XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
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();
    }
}