Forge
csharpdeeb25a2
1using System.Text.Json;
2using Avalonia.Controls;
3using Avalonia.Input;
4using Avalonia.Input.Platform;
5using Avalonia.Interactivity;
6using Avalonia.Threading;
7using Avalonia.VisualTree;
8using CascadeIDE.Features.WebAiPortal.Application;
9using CascadeIDE.ViewModels;
10
11namespace CascadeIDE.Views;
12
13public partial class WebAiPortalMfdPageView : UserControl
14{
15 private const int MaxResultChars = 2_000;
16 private const int AutoDomBridgePollMs = 1_100;
17
18 private readonly DispatcherTimer _autoDomBridgeTimer =
19 new() { Interval = TimeSpan.FromMilliseconds(AutoDomBridgePollMs) };
20
21 private int _autoDomBridgeInFlight;
22 private string? _lastAutoDomBridgeDedupKey;
23
24 public WebAiPortalMfdPageView()
25 {
26 InitializeComponent();
27 Loaded += OnLoaded;
28 Unloaded += OnUnloaded;
29 _autoDomBridgeTimer.Tick += (_, _) => OnAutoDomBridgeTimerTick();
30 }
31
32 private void OnLoaded(object? sender, RoutedEventArgs e)
33 {
34 if (WebView is null || DataContext is not MainWindowViewModel vm)
35 return;
36 TryNavigate(vm.WebAiPortalUrlText);
37 _autoDomBridgeTimer.Start();
38 }
39
40 private void OnUnloaded(object? sender, RoutedEventArgs e) => _autoDomBridgeTimer.Stop();
41
42 private void OnNavigateClick(object? sender, RoutedEventArgs e) => NavigateFromUrlBar();
43
44 private void OnWebAiPortalUrlKeyDown(object? sender, KeyEventArgs e)
45 {
46 if (e.Key != Key.Enter)
47 return;
48 e.Handled = true;
49 NavigateFromUrlBar();
50 }
51
52 private void NavigateFromUrlBar()
53 {
54 if (DataContext is not MainWindowViewModel vm)
55 return;
56 TryNavigate(vm.WebAiPortalUrlText);
57 }
58
59 private void TryNavigate(string? raw)
60 {
61 if (!WebAiPortalUrlNormalize.TryBuildNavigationUri(raw, out var uri, out var normalized))
62 {
63 if (DataContext is MainWindowViewModel vm)
64 vm.WebAiPortalLastBridgeResult = "Некорректный URL.";
65 return;
66 }
67
68 if (DataContext is MainWindowViewModel vmNavigate)
69 vmNavigate.WebAiPortalUrlText = normalized;
70
71 if (WebView is null)
72 return;
73 ArgumentNullException.ThrowIfNull(uri);
74 WebView.Navigate(uri);
75 }
76
77 private async void OnExecuteJsonCascadeFromClipboardClick(object? sender, RoutedEventArgs e)
78 {
79 if (DataContext is not MainWindowViewModel vm)
80 return;
81
82 string? jsonPayload = null;
83 var sourceLabel = "";
84
85 var top = TopLevel.GetTopLevel(this);
86 if (top?.Clipboard is { } clip)
87 {
88 try
89 {
90 var raw = await clip.TryGetTextAsync().ConfigureAwait(true);
91 if (!string.IsNullOrWhiteSpace(raw) &&
92 WebAiPortalBridgePayloadResolution.TryResolvePayload(raw, out var j, out var src))
93 {
94 jsonPayload = j;
95 sourceLabel = src == WebAiPortalBridgePayloadResolution.PayloadSourceHint.FencedMarkdown
96 ? "буфер (json-cascade)"
97 : "буфер (JSON)";
98 }
99 }
100 catch (Exception ex)
101 {
102 vm.WebAiPortalLastBridgeResult = "Буфер обмена: " + ex.Message;
103 return;
104 }
105 }
106
107 string? domError = null;
108 if (jsonPayload is null && WebView is not null)
109 {
110 try
111 {
112 var sr = await WebView
113 .InvokeScript(WebAiPortalLastCommandDomProbe.LastPayloadProbeScriptJavaScript)
114 .ConfigureAwait(true);
115 var unwrapped = WebAiPortalLastCommandDomProbe.UnwrapWrappedJsonString(sr);
116 if (!string.IsNullOrWhiteSpace(unwrapped) &&
117 WebAiPortalBridgePayloadResolution.TryResolvePayload(unwrapped, out var jd, out _))
118 {
119 jsonPayload = jd;
120 sourceLabel = "страница (последний блок)";
121 }
122 }
123 catch (Exception ex)
124 {
125 domError = ex.Message;
126 }
127 }
128
129 if (jsonPayload is null)
130 {
131 var parts = new List<string>
132 {
133 "Нет команды для моста: в буфере нет fenced json-cascade и не распознан голый JSON с command_id;",
134 "на странице в pre/code не найден последний такой объект.",
135 };
136 if (domError is not null)
137 parts.Add("Ошибка скрипта DOM: " + domError);
138 vm.WebAiPortalLastBridgeResult = string.Join(" ", parts);
139 return;
140 }
141
142 try
143 {
144 await ExecuteBridgeFromResolvedJsonAsync(vm, jsonPayload, sourceLabel).ConfigureAwait(true);
145 }
146 catch (Exception ex)
147 {
148 await Dispatcher.UIThread.InvokeAsync(() =>
149 {
150 vm.WebAiPortalLastBridgeResult = "← " + sourceLabel + " error: " + ex.Message;
151 });
152 }
153 }
154
155 private async void OnAutoDomBridgeTimerTick()
156 {
157 try
158 {
159 await TryAutoDomBridgeFromPageOnceAsync().ConfigureAwait(true);
160 }
161 catch
162 {
163 // не бомбим таймер
164 }
165 }
166
167 /// <summary>
168 /// Режим «hands-free» ADR 0108: не ждать кнопки и буфера — только poll DOM (дедуп по каноническому JSON).
169 /// </summary>
170 private async Task TryAutoDomBridgeFromPageOnceAsync()
171 {
172 if (DataContext is not MainWindowViewModel vm)
173 return;
174 if (!vm.WebAiPortalAutoDomBridgePolling)
175 return;
176 if (WebView is null)
177 return;
178 if (!vm.WebAiPortalBridge.BridgeConsented || !vm.WebAiPortalBridge.BridgeArmed)
179 return;
180 if (Interlocked.CompareExchange(ref _autoDomBridgeInFlight, 1, 0) != 0)
181 return;
182
183 try
184 {
185 string? jsonPayload = null;
186 try
187 {
188 var sr = await WebView
189 .InvokeScript(WebAiPortalLastCommandDomProbe.LastPayloadProbeScriptJavaScript)
190 .ConfigureAwait(true);
191 var unwrapped = WebAiPortalLastCommandDomProbe.UnwrapWrappedJsonString(sr);
192 if (!string.IsNullOrWhiteSpace(unwrapped) &&
193 WebAiPortalBridgePayloadResolution.TryResolvePayload(unwrapped, out var jd, out _))
194 jsonPayload = jd;
195 }
196 catch
197 {
198 return;
199 }
200
201 if (string.IsNullOrEmpty(jsonPayload))
202 return;
203
204 if (!WebAiPortalBridgePayloadDedup.TryCanonicalKey(jsonPayload, out var dedupKey) ||
205 string.IsNullOrEmpty(dedupKey))
206 return;
207 if (dedupKey == _lastAutoDomBridgeDedupKey)
208 return;
209
210 _ = WebAiPortalBridgePayloadResolution.TryGetCommandId(jsonPayload, out var invokedCmdId);
211 var text = await vm.WebAiPortalBridge.ExecuteFromWebJsonAsync(jsonPayload).ConfigureAwait(true);
212 _lastAutoDomBridgeDedupKey = dedupKey;
213 await ApplyPostBridgeDispositionAsync(vm, "← страница (авто DOM): ", invokedCmdId, text).ConfigureAwait(true);
214 }
215 catch (Exception ex)
216 {
217 await Dispatcher.UIThread.InvokeAsync(() =>
218 {
219 vm.WebAiPortalLastBridgeResult = "← авто DOM error: " + ex.Message;
220 });
221 }
222 finally
223 {
224 Interlocked.Exchange(ref _autoDomBridgeInFlight, 0);
225 }
226 }
227
228 private async Task ExecuteBridgeFromResolvedJsonAsync(MainWindowViewModel vm, string jsonPayload, string sourceLabel)
229 {
230 _ = WebAiPortalBridgePayloadResolution.TryGetCommandId(jsonPayload, out var invokedCmdId);
231 var text = await vm.WebAiPortalBridge.ExecuteFromWebJsonAsync(jsonPayload).ConfigureAwait(true);
232 await ApplyPostBridgeDispositionAsync(vm, "← " + sourceLabel + ": ", invokedCmdId, text).ConfigureAwait(true);
233 }
234
235 private void OnRevokeBridgeClick(object? sender, RoutedEventArgs e)
236 {
237 if (DataContext is MainWindowViewModel vm)
238 {
239 vm.WebAiPortalBridge.RevokeBridge();
240 vm.WebAiPortalLastBridgeResult = "Мост отозван.";
241 }
242 }
243
244 private async void OnNavigationCompletedWeb(object? sender, WebViewNavigationCompletedEventArgs e)
245 {
246 _lastAutoDomBridgeDedupKey = null;
247 await Dispatcher.UIThread.InvokeAsync(() =>
248 {
249 if (DataContext is MainWindowViewModel vm)
250 {
251 vm.WebAiPortalLastBridgeResult = e.IsSuccess
252 ? "Navigation completed."
253 : "Navigation failed.";
254 }
255 });
256 }
257
258 private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e)
259 {
260 if (DataContext is not MainWindowViewModel vm)
261 return;
262 var body = e.Body ?? "";
263 try
264 {
265 _ = WebAiPortalBridgePayloadResolution.TryGetCommandId(body, out var invokedCmdId);
266 var text = await vm.WebAiPortalBridge.ExecuteFromWebJsonAsync(body).ConfigureAwait(true);
267 await ApplyPostBridgeDispositionAsync(vm, "← bridge: ", invokedCmdId, text).ConfigureAwait(true);
268 }
269 catch (Exception ex)
270 {
271 await Dispatcher.UIThread.InvokeAsync(() =>
272 {
273 vm.WebAiPortalLastBridgeResult = "← bridge error: " + ex.Message;
274 });
275 }
276 }
277
278 /// <summary>После успешного ответа моста: текст для композитор/буфер с учётом лимита чата; опционально вставка в фокус на странице.</summary>
279 private async Task ApplyPostBridgeDispositionAsync(
280 MainWindowViewModel vm,
281 string statusPrefix,
282 string? executedCommandId,
283 string fullResponseText)
284 {
285 var mixin = WebAiPortalChatMixInFormatter.BuildForComposer(
286 vm.WebAiPortalPreferCompactComposerForChat,
287 WebAiPortalChatMixInFormatter.DefaultMaxChatCharacters,
288 executedCommandId,
289 fullResponseText);
290
291 string? injectHint = null;
292 if (vm.WebAiPortalTryInjectResultIntoFocusedComposer && WebView is not null)
293 {
294 try
295 {
296 var script = WebAiPortalComposerInjectScript.Build(mixin.TextForComposer);
297 var raw = await WebView.InvokeScript(script).ConfigureAwait(true);
298 injectHint = SummarizeComposerInject(
299 WebAiPortalLastCommandDomProbe.UnwrapWrappedJsonString(raw) ?? "");
300 }
301 catch (Exception ex)
302 {
303 injectHint = ex.Message;
304 }
305 }
306
307 var top = TopLevel.GetTopLevel(this);
308 if (vm.WebAiPortalCopyFullBridgeResultToClipboard && top?.Clipboard is { } clip)
309 await clip.SetTextAsync(mixin.TextForComposer).ConfigureAwait(true);
310
311 await Dispatcher.UIThread.InvokeAsync(() =>
312 {
313 var summary = mixin.UsedCompactMixer
314 ? $"{fullResponseText.Length} символов IDE → ниже текст для чата компактный (буфер/композитор); сырой JSON не ставили."
315 : fullResponseText;
316 var line = TruncateForUi(statusPrefix + summary);
317 if (injectHint is not null)
318 line += " · композитор: " + injectHint;
319 if (vm.WebAiPortalCopyFullBridgeResultToClipboard)
320 line += mixin.UsedCompactMixer ? " · буфер (компакт)" : " · буфер";
321 vm.WebAiPortalLastBridgeResult = line;
322 });
323 }
324
325 private static string SummarizeComposerInject(string raw)
326 {
327 if (string.IsNullOrWhiteSpace(raw))
328 return "?";
329 try
330 {
331 using var doc = JsonDocument.Parse(raw);
332 var root = doc.RootElement;
333 if (root.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True && root.TryGetProperty("tag", out var tag))
334 return "ок " + (tag.GetString() ?? "");
335
336 if (root.TryGetProperty("reason", out var reason))
337 {
338 var r = reason.GetString();
339 root.TryGetProperty("tag", out var tag2);
340 var t = tag2.ValueKind != JsonValueKind.Undefined ? tag2.ToString() : "";
341 return string.IsNullOrEmpty(t) ? (r ?? "?") : (r ?? "?") + " " + t;
342 }
343 }
344 catch
345 {
346 // строка уже обрезается статусной полосой
347 }
348
349 return raw.Length <= 64 ? raw : raw[..64] + "…";
350 }
351
352 private static string TruncateForUi(string s) =>
353 s.Length <= MaxResultChars ? s : s[..MaxResultChars] + "…";
354}
355
View only · write via MCP/CIDE