using System.Net; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Microsoft.Web.WebView2.Core; using Windows.System; using XFEWorkflow.Abstractions; using XFEWorkflow.Desktop.Sdk; namespace XFEWorkflow.Plugins.AgentBrowser.WinUI; public sealed partial class AgentBrowserPage : IWorkflowPage { private readonly Grid root = new() { ColumnSpacing = 0 }; private readonly WebView2 browser = new(), chat = new(); private readonly TextBox address = new() { PlaceholderText = "输入网址", HorizontalAlignment = HorizontalAlignment.Stretch }; private readonly TextBox composer = new() { PlaceholderText = "让 DeepSeek 帮你操作网页…", AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 72, MaxHeight = 180 }; private readonly TextBlock status = new() { Text = "自动加载 browser-debugger 技能", FontSize = 12, Opacity = .72, TextWrapping = TextWrapping.Wrap }; private readonly TextBlock modelLabel = new() { Text = "DeepSeek", FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, FontSize = 18, TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; private readonly InfoBar error = new() { IsOpen = false, Severity = InfoBarSeverity.Error, IsClosable = true }; private readonly Button send = new() { Content = "发送", MinWidth = 80 }; private readonly Button stop = new() { Content = "停止", IsEnabled = false }; private readonly Button newChat = new() { Content = new FontIcon { Glyph = "\uE710" } }; private readonly Button settingsButton = new() { Content = new FontIcon { Glyph = "\uE713" } }; private readonly ColumnDefinition chatColumn = new() { Width = new(440) }; private readonly HttpClient http = new() { Timeout = Timeout.InfiniteTimeSpan }; private readonly Queue queued = []; private AgentConversation? conversation; private IPluginPageContext host = null!; private AgentOptions options = new(); private CancellationTokenSource? active; private readonly CancellationTokenSource lifetime = new(); private Task turn = Task.CompletedTask; private Task? initialization; private bool disposed, chatReady; private string endpoint = "", targetId = ""; private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously); public FrameworkElement View => root; public Task Ready => ready.Task; public AgentBrowserPage() { root.RowDefinitions.Add(new() { Height = new(1, GridUnitType.Star) }); root.ColumnDefinitions.Add(new() { Width = new(1, GridUnitType.Star) }); root.ColumnDefinitions.Add(new() { Width = new(8) }); root.ColumnDefinitions.Add(chatColumn); var browserPanel = new Grid { RowSpacing = 10 }; browserPanel.RowDefinitions.Add(new() { Height = GridLength.Auto }); browserPanel.RowDefinitions.Add(new() { Height = new(1, GridUnitType.Star) }); var toolbar = new Grid { ColumnSpacing = 8 }; toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); toolbar.ColumnDefinitions.Add(new() { Width = new(1, GridUnitType.Star) }); toolbar.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); var nav = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; nav.Children.Add(Icon("\uE72B", "后退", () => { if (browser.CanGoBack) browser.GoBack(); })); nav.Children.Add(Icon("\uE72A", "前进", () => { if (browser.CanGoForward) browser.GoForward(); })); nav.Children.Add(Icon("\uE72C", "刷新", () => browser.Reload())); toolbar.Children.Add(nav); toolbar.Children.Add(address); Grid.SetColumn(address, 1); var go = new Button { Content = "前往" }; go.Click += (_, _) => Navigate(address.Text); toolbar.Children.Add(go); Grid.SetColumn(go, 2); address.KeyDown += (_, e) => { if (e.Key == VirtualKey.Enter) { e.Handled = true; Navigate(address.Text); } }; browserPanel.Children.Add(toolbar); var browserFrame = Card(browser); browserPanel.Children.Add(browserFrame); Grid.SetRow(browserFrame, 1); root.Children.Add(browserPanel); var side = new Grid { RowSpacing = 10, Padding = new(16) }; side.RowDefinitions.Add(new() { Height = GridLength.Auto }); side.RowDefinitions.Add(new() { Height = GridLength.Auto }); side.RowDefinitions.Add(new() { Height = new(1, GridUnitType.Star) }); side.RowDefinitions.Add(new() { Height = GridLength.Auto }); side.RowDefinitions.Add(new() { Height = GridLength.Auto }); var header = new Grid { ColumnSpacing = 8 }; header.ColumnDefinitions.Add(new() { Width = new(1, GridUnitType.Star) }); header.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); header.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); header.Children.Add(modelLabel); header.Children.Add(newChat); Grid.SetColumn(newChat, 1); header.Children.Add(settingsButton); Grid.SetColumn(settingsButton, 2); ToolTipService.SetToolTip(newChat, "新对话(保留当前浏览器和登录状态)"); ToolTipService.SetToolTip(settingsButton, "DeepSeek、凭据与技能设置"); side.Children.Add(header); side.Children.Add(error); Grid.SetRow(error, 1); side.Children.Add(chat); Grid.SetRow(chat, 2); side.Children.Add(composer); Grid.SetRow(composer, 3); var footer = new Grid { ColumnSpacing = 8 }; footer.ColumnDefinitions.Add(new() { Width = new(1, GridUnitType.Star) }); footer.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); footer.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); footer.Children.Add(status); footer.Children.Add(stop); Grid.SetColumn(stop, 1); footer.Children.Add(send); Grid.SetColumn(send, 2); side.Children.Add(footer); Grid.SetRow(footer, 4); var sideCard = Card(side); root.Children.Add(sideCard); Grid.SetColumn(sideCard, 2); send.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; send.Click += (_, _) => BeginSend(); stop.Click += (_, _) => active?.Cancel(); settingsButton.Click += async (_, _) => await GuardAsync(ShowSettingsAsync); newChat.Click += (_, _) => { if (active is not null) return; conversation?.Clear(); Post(new { type = "clear" }); status.Text = "新对话 · 当前浏览器会话已保留"; }; composer.KeyDown += (_, e) => { if (e.Key != VirtualKey.Enter || composer.Text.Length == 0) return; var shift = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); if (!shift) { e.Handled = true; BeginSend(); } }; root.Loaded += (_, _) => { initialization ??= InitializeViewsAsync(); }; root.ActualThemeChanged += (_, _) => SendTheme(); root.SizeChanged += (_, _) => { // Keep enough room for both panels on compact windows without overwriting the persisted preference. var available = Math.Max(260, root.ActualWidth - 280); if (chatColumn.Width.Value > available) chatColumn.Width = new(available); }; AutomationProperties.SetAutomationId(root, "AgentBrowserPage"); AutomationProperties.SetAutomationId(browser, "AgentBrowserWeb"); AutomationProperties.SetAutomationId(chat, "AgentChatWeb"); AutomationProperties.SetAutomationId(address, "AgentAddress"); AutomationProperties.SetAutomationId(status, "AgentStatus"); AutomationProperties.SetAutomationId(composer, "AgentComposer"); AutomationProperties.SetAutomationId(send, "AgentSend"); AutomationProperties.SetAutomationId(stop, "AgentStop"); AutomationProperties.SetAutomationId(settingsButton, "AgentSettings"); AutomationProperties.SetAutomationId(newChat, "AgentNewConversation"); } public ValueTask InitializeAsync(IPluginPageContext context, CancellationToken cancellationToken) { host = context; options = context.Settings.Deserialize(WorkflowJson.Options) ?? new(); conversation = new(http); modelLabel.Text = options.Model; ToolTipService.SetToolTip(modelLabel, "DeepSeek · " + options.Model); var splitter = context.CreateVerticalSplitter("chat", 440, 300, 900, () => chatColumn.Width.Value, x => chatColumn.Width = new(x), () => root.ActualWidth - 280); root.Children.Add(splitter); Grid.SetColumn(splitter, 1); ToolTipService.SetToolTip(status, options.SkillDirectory); if (options.Credential.Length > 0 && !context.CredentialReferences.Contains(options.Credential)) status.Text = "先在设置中保存 DeepSeek API Key"; return ValueTask.CompletedTask; } private async Task InitializeViewsAsync() { try { host.InitializeWebView(browser); host.InitializeWebView(chat); // Isolate the agent target from the chat renderer and all other app/browser profiles. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); endpoint = $"http://127.0.0.1:{port}"; var environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(host.DataDirectory, "BrowserProfile"), new CoreWebView2EnvironmentOptions { AdditionalBrowserArguments = $"--remote-debugging-address=127.0.0.1 --remote-debugging-port={port} --disable-features=CalculateNativeWinOcclusion --disable-background-timer-throttling --disable-renderer-backgrounding" }); if (disposed) return; await browser.EnsureCoreWebView2Async(environment); browser.CoreWebView2.SourceChanged += (_, _) => address.Text = browser.Source?.ToString() ?? ""; browser.CoreWebView2.NewWindowRequested += (_, args) => { args.Handled = true; Navigate(args.Uri); }; browser.CoreWebView2.DownloadStarting += (_, args) => { var downloads = Path.Combine(host.DataDirectory, "Downloads"); Directory.CreateDirectory(downloads); var name = Path.GetFileName(args.ResultFilePath); if (string.IsNullOrWhiteSpace(name)) name = "download.bin"; args.ResultFilePath = Path.Combine(downloads, Guid.NewGuid().ToString("N")[..8] + "-" + name); args.Handled = true; }; browser.CoreWebView2.NavigationStarting += (_, args) => { if (!AllowedNavigation(args.Uri)) { args.Cancel = true; ShowError("仅允许访问 HTTP(S) 网页和 about:blank"); } }; browser.CoreWebView2.ProcessFailed += (_, _) => { active?.Cancel(); ShowError("浏览器进程已退出,请重新打开插件页面"); }; var target = JsonNode.Parse(await browser.CoreWebView2.CallDevToolsProtocolMethodAsync("Target.getTargetInfo", "{}"))!; targetId = target["targetInfo"]!["targetId"]!.GetValue(); browser.Source = new Uri("about:blank"); var chatEnvironment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(host.DataDirectory, "ChatProfile"), new CoreWebView2EnvironmentOptions { AdditionalBrowserArguments = "--disable-features=CalculateNativeWinOcclusion" }); if (disposed) return; await chat.EnsureCoreWebView2Async(chatEnvironment); chat.DefaultBackgroundColor = Microsoft.UI.Colors.Transparent; chat.CoreWebView2.SetVirtualHostNameToFolderMapping("agent-chat.xfeworkflow.local", Path.Combine(host.PluginDirectory, "Chat"), CoreWebView2HostResourceAccessKind.DenyCors); chat.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; chat.CoreWebView2.Settings.AreDevToolsEnabled = false; chat.CoreWebView2.WebMessageReceived += ChatMessage; chat.CoreWebView2.NavigationStarting += (_, args) => { if (!args.Uri.StartsWith("https://agent-chat.xfeworkflow.local/", StringComparison.Ordinal)) args.Cancel = true; }; chat.Source = new Uri("https://agent-chat.xfeworkflow.local/index.html"); } catch (Exception ex) { if (!disposed) { ready.TrySetException(ex); ShowError("Agent 浏览器初始化失败:" + ex.Message); } } } private void ChatMessage(object? sender, CoreWebView2WebMessageReceivedEventArgs e) { if (disposed || !e.Source.StartsWith("https://agent-chat.xfeworkflow.local/", StringComparison.Ordinal)) return; try { var data = JsonNode.Parse(e.WebMessageAsJson)!.AsObject(); switch (data.String("type")) { case "ready": chatReady = true; SendTheme(); while (queued.TryDequeue(out var item)) Post(item); ready.TrySetResult(); break; case "openLink": Navigate(data.String("url")); break; case "copy": var package = new Windows.ApplicationModel.DataTransfer.DataPackage(); package.SetText(data.String("text")); Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package); break; } } catch (Exception ex) { ShowError(ex.Message); } } private void BeginSend() { if (active is not null || disposed) return; var prompt = composer.Text.Trim(); if (prompt.Length == 0) return; active = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); composer.Text = ""; SetBusy(true); error.IsOpen = false; Post(new { type = "user", id = Guid.NewGuid().ToString("N"), text = prompt }); turn = SendAsync(prompt, active); } private async Task SendAsync(string prompt, CancellationTokenSource cancellation) { try { await ready.Task.WaitAsync(cancellation.Token); var key = options.Credential.Length == 0 ? "" : host.GetCredential(options.Credential); var work = Path.Combine(host.DataDirectory, "Conversations", DateTime.Now.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8]); // Both inference and Node I/O stay off the WinUI synchronization context. await Task.Run(async () => { await using var runtime = new SkillBrowserRuntime(options, Path.Combine(host.PluginDirectory, "Scripts", "agent-bridge.mjs"), endpoint, targetId, work); await conversation!.RunAsync(prompt, options, key, runtime, PublishAsync, cancellation.Token); }, cancellation.Token); status.Text = "任务完成 · 浏览器会话已保留"; } catch (Exception) when (cancellation.IsCancellationRequested) { status.Text = "已停止 · 已完成的网页操作保留"; Post(new { type = "status", text = "已停止任务" }); } catch (OperationCanceledException) { ShowError("DeepSeek 请求超过等待时间,请检查服务或网络后重试"); Post(new { type = "error", text = "DeepSeek 请求超时" }); } catch (Exception ex) { ShowError(ex.Message); host.Log("Agent 浏览器:" + ex.Message, true); Post(new { type = "error", text = host.Redact(ex.Message) }); } finally { active = null; cancellation.Dispose(); if (!disposed) SetBusy(false); } } private ValueTask PublishAsync(AgentEvent item) { var text = host.Redact(item.Text); string? image = null; if (item.ImagePath is { } path && File.Exists(path) && new FileInfo(path).Length <= 8 * 1024 * 1024) image = "data:image/png;base64," + Convert.ToBase64String(File.ReadAllBytes(path)); var data = new { type = item.Kind, id = item.Id, text, name = item.Name, image }; root.DispatcherQueue.TryEnqueue(() => { if (disposed) return; if (item.Kind == "status") status.Text = text.Split('\n')[0]; Post(data); }); return ValueTask.CompletedTask; } private void SetBusy(bool busy) { send.IsEnabled = !busy; stop.IsEnabled = busy; newChat.IsEnabled = settingsButton.IsEnabled = !busy; } private void Post(object item) { if (disposed) return; if (!chatReady) { if (queued.Count < 1000) queued.Enqueue(item); return; } chat.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(item, WorkflowJson.Options)); } private void SendTheme() => Post(new { type = "theme", dark = root.ActualTheme == ElementTheme.Dark }); private static bool AllowedNavigation(string url) => url == "about:blank" || Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; private void Navigate(string url) { try { if (browser.CoreWebView2 is null) return; if (!url.Contains("://", StringComparison.Ordinal) && url != "about:blank") url = "https://" + url; if (!AllowedNavigation(url)) throw new ArgumentException("请输入 HTTP(S) 网址"); browser.Source = new Uri(url); } catch (Exception ex) { ShowError(ex.Message); } } private void ShowError(string text) { if (disposed) return; error.Message = host.Redact(text); error.IsOpen = true; status.Text = "需要处理"; } private async Task GuardAsync(Func task) { try { await task(); } catch (OperationCanceledException) { } catch (Exception ex) { ShowError(ex.Message); } } private static Button Icon(string glyph, string tip, Action click) { var button = new Button { Content = new FontIcon { Glyph = glyph }, Padding = new(10) }; ToolTipService.SetToolTip(button, tip); button.Click += (_, _) => { try { click(); } catch (InvalidOperationException) { } }; return button; } private static Border Card(UIElement child) { var card = new Border { Child = child, Style = (Style)Application.Current.Resources["ManagementCardStyle"], Padding = new(0), CornerRadius = new(10) }; return card; } public async ValueTask DisposeAsync() { if (disposed) return; disposed = true; lifetime.Cancel(); active?.Cancel(); try { await turn; } catch (OperationCanceledException) { } ready.TrySetCanceled(); http.Dispose(); browser.Close(); chat.Close(); lifetime.Dispose(); } }