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<object> 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<AgentOptions>(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<string>();
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> 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();
}
}
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<object> 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<AgentOptions>(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<string>();
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> 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();
}
}