| 1 | using System.Text.Json; |
| 2 | using Avalonia.Controls; |
| 3 | using Avalonia.Input; |
| 4 | using Avalonia.Input.Platform; |
| 5 | using Avalonia.Interactivity; |
| 6 | using Avalonia.Threading; |
| 7 | using Avalonia.VisualTree; |
| 8 | using CascadeIDE.Features.WebAiPortal.Application; |
| 9 | using CascadeIDE.ViewModels; |
| 10 | |
| 11 | namespace CascadeIDE.Views; |
| 12 | |
| 13 | public 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 | |