From 05f17447931246a92c4bea42f4e8972ba9d0cee8 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 19 Mar 2026 14:31:59 -0700 Subject: [PATCH 1/2] Add Playwright functions --- Directory.Packages.props | 19 +- inspect.csx | 19 - .../docs/ai/agent.md | 48 ++ .../docs/changelog/v2.0.0.md | 4 + .../BrowserAutomationConstants.cs | 23 + .../BrowserAutomationJson.cs | 18 + .../BrowserAutomationPage.cs | 32 ++ .../BrowserAutomationResultFactory.cs | 10 + .../BrowserAutomationService.cs | 475 ++++++++++++++++++ .../BrowserAutomationSession.cs | 54 ++ .../BrowserAutomationToolBase.cs | 235 +++++++++ .../CaptureBrowserScreenshotTool.cs | 107 ++++ .../CheckBrowserElementTool.cs | 84 ++++ .../ClearBrowserInputTool.cs | 84 ++++ .../ClickBrowserElementTool.cs | 91 ++++ .../CloseBrowserSessionTool.cs | 49 ++ .../BrowserAutomation/CloseBrowserTabTool.cs | 54 ++ .../DiagnoseBrowserPageTool.cs | 108 ++++ .../DoubleClickBrowserElementTool.cs | 88 ++++ .../BrowserAutomation/FillBrowserInputTool.cs | 91 ++++ .../GetBrowserButtonsTool.cs | 80 +++ .../GetBrowserConsoleMessagesTool.cs | 85 ++++ .../GetBrowserElementInfoTool.cs | 101 ++++ .../BrowserAutomation/GetBrowserFormsTool.cs | 94 ++++ .../GetBrowserHeadingsTool.cs | 80 +++ .../BrowserAutomation/GetBrowserLinksTool.cs | 80 +++ .../GetBrowserNetworkActivityTool.cs | 76 +++ .../GetBrowserPageContentTool.cs | 135 +++++ .../GetBrowserPageStateTool.cs | 83 +++ .../GetBrowserSessionTool.cs | 49 ++ .../BrowserAutomation/GoBackBrowserTool.cs | 85 ++++ .../BrowserAutomation/GoForwardBrowserTool.cs | 85 ++++ .../HoverBrowserElementTool.cs | 88 ++++ .../ListBrowserSessionsTool.cs | 45 ++ .../BrowserAutomation/NavigateBrowserTool.cs | 91 ++++ .../BrowserAutomation/OpenBrowserTabTool.cs | 68 +++ .../BrowserAutomation/PressBrowserKeyTool.cs | 98 ++++ .../ReloadBrowserPageTool.cs | 85 ++++ .../ScrollBrowserElementIntoViewTool.cs | 85 ++++ .../ScrollBrowserPageTool.cs | 83 +++ .../SelectBrowserOptionTool.cs | 95 ++++ .../StartBrowserSessionTool.cs | 84 ++++ .../BrowserAutomation/SwitchBrowserTabTool.cs | 55 ++ .../UncheckBrowserElementTool.cs | 84 ++++ .../UploadBrowserFilesTool.cs | 95 ++++ .../WaitForBrowserElementTool.cs | 91 ++++ .../WaitForBrowserLoadStateTool.cs | 83 +++ .../WaitForBrowserNavigationTool.cs | 96 ++++ .../BrowserAutomationStartup.cs | 275 ++++++++++ .../CrestApps.OrchardCore.AI.Agent.csproj | 1 + .../BrowserAutomationStartupTests.cs | 55 ++ 51 files changed, 4355 insertions(+), 28 deletions(-) delete mode 100644 inspect.csx create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a51356514..431f86a87 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,14 +98,15 @@ - - - - - - - - + + + + + + + + + @@ -133,4 +134,4 @@ - \ No newline at end of file + diff --git a/inspect.csx b/inspect.csx deleted file mode 100644 index 5210ac0a9..000000000 --- a/inspect.csx +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Reflection; - -var asm = Assembly.LoadFrom(@"C:\Users\mike\.nuget\packages\orchardcore.abstractions\3.0.0-preview-18934\lib\net10.0\OrchardCore.Abstractions.dll"); -foreach (var t in asm.GetExportedTypes()) -{ - if (t.Name == "ShellScope") - { - Console.WriteLine($"Type: {t.FullName}"); - foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly)) - { - if (m.Name.Contains("BeforeDispose") || m.Name.Contains("RegisterBefore") || m.Name.Contains("AddException") || m.Name.Contains("ExceptionHandler")) - { - var parms = string.Join(", ", Array.ConvertAll(m.GetParameters(), p => $"{p.ParameterType.Name} {p.Name}")); - Console.WriteLine($" {(m.IsStatic ? "static " : "")}{m.ReturnType.Name} {m.Name}({parms})"); - } - } - } -} diff --git a/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md b/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md index db0c2aed0..3c54cf684 100644 --- a/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md +++ b/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md @@ -34,6 +34,54 @@ Once the feature is enabled: This allows you to tailor each agent's abilities to suit your specific site tasks and workflows. +## Browser Automation Tools + +The AI Agent feature now includes a large **Playwright-powered browser automation** toolset so an AI chat can interact with your website through the real UI in a user-like way. These tools let the model open a browser session, navigate between pages, inspect the DOM, click buttons, fill forms, wait for UI state changes, capture screenshots, and gather troubleshooting diagnostics. + +### Tool Categories + +Browser tools are grouped in the **Capabilities** tab so you can enable the right level of browser access for each profile or chat interaction. The grouped tool picker already supports **Select All** globally and a per-category **Select All** toggle, so you can enable a whole browser logic group with one click. + +The browser capability labels are localized the same way as the rest of the AI Agent tool catalog, so category names appear consistently in Orchard Core language extraction and translation workflows. + +The browser automation set is organized into these categories: + +| Category | Purpose | +| --- | --- | +| **Browser Sessions** | Start/close sessions, list sessions, inspect sessions, and manage tabs. | +| **Browser Navigation** | Navigate to URLs, go back/forward, reload, and scroll pages or elements. | +| **Browser Inspection** | Read page state, content, links, forms, headings, buttons, and element details. | +| **Browser Interaction** | Click, double-click, hover, and send keyboard input. | +| **Browser Forms** | Fill inputs, clear fields, select options, check/uncheck controls, and upload files. | +| **Browser Waiting** | Wait for selectors, URL changes, and load states. | +| **Browser Troubleshooting** | Capture screenshots, inspect console output, inspect network activity, and diagnose broken pages. | + +### How Browser Sessions Work + +Browser automation tools are **stateful**. Start by calling `startBrowserSession`, then keep passing the returned `sessionId` to later browser tools. Session tools also return `pageId` values for tracked tabs so the model can switch tabs or target a specific page when needed. + +The tools are intentionally granular. A typical browser workflow looks like this: + +1. Start a browser session. +2. Navigate to a page. +3. Inspect the page state, links, forms, or specific elements. +4. Click, type, select, upload, or wait for UI changes. +5. Use troubleshooting tools when a page does not behave as expected. + +### Playwright Browser Installation + +The `Microsoft.Playwright` package is included with the AI Agent module, but the actual browser binaries must still be installed for the built application. After building your Orchard Core app, run the generated Playwright install script for the target output folder. For example: + +```powershell +pwsh .\src\Modules\CrestApps.OrchardCore.AI.Agent\bin\Debug\net10.0\playwright.ps1 install +``` + +If the browsers are not installed, the browser tools return a descriptive Playwright error telling you to run the install script. + +### Browser Safety and Scope + +These tools expose powerful UI automation. Only enable the browser categories on profiles or chat interactions that truly need them. In most cases, it is best to create a dedicated profile for browser-driven tasks rather than making browser automation available everywhere. + --- ## Agent Profile Type diff --git a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md index 977f72315..58edcc6ee 100644 --- a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md +++ b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md @@ -174,6 +174,10 @@ A new suite of modules for multi-channel communication: ### AI Agent (`CrestApps.OrchardCore.AI.Agent`) - Expanded toolset with 30+ built-in tools covering content management, tenant management, feature toggles, workflow automation, and communication tasks. +- **Playwright browser automation tools** — Added 30+ browser-focused AI tools that let agents operate the website through the real UI: start and manage browser sessions, open and switch tabs, navigate, inspect DOM structure, click controls, fill forms, upload files, wait for state changes, capture screenshots, inspect console and network activity, and collect troubleshooting diagnostics. +- **Grouped browser capability selection** — Browser tools are organized into dedicated capability groups such as Browser Sessions, Browser Navigation, Browser Inspection, Browser Interaction, Browser Forms, Browser Waiting, and Browser Troubleshooting so administrators can enable whole browser logic groups from the existing grouped tool picker. +- **Stateful browser sessions** — Browser automation tools use tracked Playwright sessions and page IDs so multi-step AI workflows can safely chain navigation, inspection, and interaction calls across multiple tool invocations. +- **Browser tool definition normalization** — Browser tool JSON schemas now follow the same inline `JsonSerializer.Deserialize( """ ... """ )` formatting pattern as the rest of the AI Agent tools, and browser category labels are registered with literal localizer strings so Orchard Core extraction can localize them correctly. - **Removed per-tool permission checks** — AI tools no longer perform their own authorization checks at invocation time. Permission enforcement is handled at the profile design level by `LocalToolRegistryProvider`, which verifies that the user configuring the AI Profile has `AIPermissions.AccessAITool` permission for each tool they expose. This ensures tools work correctly in anonymous contexts (e.g., public chat widgets, background tasks, post-session processing) without failing due to missing user authentication. - **`CreateOrUpdateContentTool` — Owner fallback parameters** — Added optional `ownerUsername`, `ownerUserId`, and `ownerEmail` parameters. When content is created without an authenticated user (e.g., from an anonymous chat widget), the AI model can specify who the content should be created on behalf of. The tool resolves the user and sets `contentItem.Owner` and `contentItem.Author` accordingly. diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs new file mode 100644 index 000000000..ea306d1e3 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs @@ -0,0 +1,23 @@ +using System; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +internal static class BrowserAutomationConstants +{ + public const string SessionsCategory = "Browser Sessions"; + public const string NavigationCategory = "Browser Navigation"; + public const string InspectionCategory = "Browser Inspection"; + public const string InteractionCategory = "Browser Interaction"; + public const string FormsCategory = "Browser Forms"; + public const string WaitingCategory = "Browser Waiting"; + public const string TroubleshootingCategory = "Browser Troubleshooting"; + + public const int DefaultTimeoutMs = 30_000; + public const int MaxTimeoutMs = 120_000; + public const int DefaultMaxItems = 25; + public const int MaxCollectionItems = 100; + public const int MaxStoredConsoleMessages = 200; + public const int MaxStoredNetworkEvents = 300; + public const int DefaultMaxTextLength = 4_000; + public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromMinutes(30); +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs new file mode 100644 index 000000000..733b59e8d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +internal static class BrowserAutomationJson +{ + public static JsonSerializerOptions SerializerOptions { get; } = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + }; + + public static string Serialize(object value) + => JsonSerializer.Serialize(value, SerializerOptions); + + public static JsonElement ParseJson(string json) + => JsonSerializer.Deserialize(json, SerializerOptions); +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs new file mode 100644 index 000000000..7b3181b8a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +internal sealed class BrowserAutomationPage +{ + public BrowserAutomationPage(string pageId, IPage page, DateTime createdUtc) + { + PageId = pageId; + Page = page; + CreatedUtc = createdUtc; + LastTouchedUtc = createdUtc; + } + + public string PageId { get; } + + public IPage Page { get; } + + public DateTime CreatedUtc { get; } + + public DateTime LastTouchedUtc { get; private set; } + + public ConcurrentQueue> ConsoleMessages { get; } = new(); + + public ConcurrentQueue> NetworkEvents { get; } = new(); + + public ConcurrentQueue PageErrors { get; } = new(); + + public void Touch(DateTime utc) + => LastTouchedUtc = utc; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs new file mode 100644 index 000000000..215bb9452 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs @@ -0,0 +1,10 @@ +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +internal static class BrowserAutomationResultFactory +{ + public static string Success(string action, object data) + => BrowserAutomationJson.Serialize(data); + + public static string Failure(string action, string message) + => message; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs new file mode 100644 index 000000000..48fce4f91 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs @@ -0,0 +1,475 @@ +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class BrowserAutomationService : IAsyncDisposable +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly global::OrchardCore.Modules.IClock _clock; + private readonly ILogger _logger; + + public BrowserAutomationService( + global::OrchardCore.Modules.IClock clock, + ILogger logger) + { + _clock = clock; + _logger = logger; + } + + public async Task>> ListSessionsAsync(CancellationToken cancellationToken) + { + await CleanupExpiredSessionsAsync(cancellationToken); + + var snapshots = new List>(); + + foreach (var sessionId in _sessions.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + snapshots.Add(await GetSessionSnapshotAsync(sessionId, cancellationToken)); + } + + return snapshots; + } + + public async Task> GetSessionSnapshotAsync(string sessionId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, BuildSessionSnapshotAsync, cancellationToken); + } + + public async Task> CreateSessionAsync( + string browserType, + bool headless, + string startUrl, + int? viewportWidth, + int? viewportHeight, + string locale, + string userAgent, + int timeoutMs, + CancellationToken cancellationToken) + { + await CleanupExpiredSessionsAsync(cancellationToken); + + browserType = NormalizeBrowserType(browserType); + + var createdUtc = _clock.UtcNow; + var sessionId = Guid.NewGuid().ToString("n"); + var playwright = await Playwright.CreateAsync(); + IBrowser browser = null; + IBrowserContext context = null; + + try + { + browser = await LaunchBrowserAsync(playwright, browserType, headless, timeoutMs); + + var contextOptions = new BrowserNewContextOptions(); + if (viewportWidth.HasValue && viewportHeight.HasValue) + { + contextOptions.ViewportSize = new ViewportSize + { + Width = viewportWidth.Value, + Height = viewportHeight.Value, + }; + } + + if (!string.IsNullOrWhiteSpace(locale)) + { + contextOptions.Locale = locale.Trim(); + } + + if (!string.IsNullOrWhiteSpace(userAgent)) + { + contextOptions.UserAgent = userAgent.Trim(); + } + + context = await browser.NewContextAsync(contextOptions); + + var session = new BrowserAutomationSession(sessionId, browserType, headless, playwright, browser, context, createdUtc); + _sessions[sessionId] = session; + + var page = await context.NewPageAsync(); + var trackedPage = TrackPage(session, page); + + if (!string.IsNullOrWhiteSpace(startUrl)) + { + await page.GotoAsync(startUrl.Trim(), new PageGotoOptions + { + Timeout = timeoutMs, + WaitUntil = WaitUntilState.Load, + }); + } + + return await BuildSessionSnapshotAsync(session); + } + catch (PlaywrightException) when (context is not null || browser is not null) + { + if (context is not null) + { + await context.CloseAsync(); + } + + if (browser is not null) + { + await browser.CloseAsync(); + } + + playwright.Dispose(); + _sessions.TryRemove(sessionId, out _); + throw; + } + } + + public async Task> CreatePageAsync( + string sessionId, + string url, + WaitUntilState waitUntil, + int timeoutMs, + CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var page = await session.Context.NewPageAsync(); + var trackedPage = TrackPage(session, page); + + if (!string.IsNullOrWhiteSpace(url)) + { + await page.GotoAsync(url.Trim(), new PageGotoOptions + { + Timeout = timeoutMs, + WaitUntil = waitUntil, + }); + } + + return await BuildPageSnapshotAsync(session, trackedPage); + }, cancellationToken); + } + + public async Task> SwitchActivePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + if (!session.Pages.TryGetValue(pageId, out var trackedPage)) + { + throw new InvalidOperationException($"Page '{pageId}' was not found for session '{sessionId}'."); + } + + session.ActivePageId = trackedPage.PageId; + trackedPage.Touch(_clock.UtcNow); + return await BuildPageSnapshotAsync(session, trackedPage); + }, cancellationToken); + } + + public async Task> ClosePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); + var snapshot = await BuildPageSnapshotAsync(session, trackedPage); + + session.Pages.TryRemove(trackedPage.PageId, out _); + + if (!trackedPage.Page.IsClosed) + { + await trackedPage.Page.CloseAsync(); + } + + session.ActivePageId = session.Pages.Values + .OrderByDescending(x => x.LastTouchedUtc) + .Select(x => x.PageId) + .FirstOrDefault(); + + return snapshot; + }, cancellationToken); + } + + public async Task> CloseSessionAsync(string sessionId, CancellationToken cancellationToken) + { + if (!_sessions.TryRemove(sessionId, out var session)) + { + throw new InvalidOperationException($"Browser session '{sessionId}' was not found."); + } + + await session.Gate.WaitAsync(cancellationToken); + try + { + var snapshot = await BuildSessionSnapshotAsync(session); + + foreach (var trackedPage in session.Pages.Values) + { + if (!trackedPage.Page.IsClosed) + { + await trackedPage.Page.CloseAsync(); + } + } + + await session.Context.CloseAsync(); + await session.Browser.CloseAsync(); + session.Playwright.Dispose(); + + return snapshot; + } + finally + { + session.Gate.Release(); + session.Gate.Dispose(); + } + } + + internal async Task WithSessionAsync( + string sessionId, + Func> action, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(action); + + await CleanupExpiredSessionsAsync(cancellationToken); + + if (!_sessions.TryGetValue(sessionId, out var session)) + { + throw new InvalidOperationException($"Browser session '{sessionId}' was not found."); + } + + await session.Gate.WaitAsync(cancellationToken); + try + { + session.Touch(_clock.UtcNow); + return await action(session); + } + finally + { + session.Gate.Release(); + } + } + + internal async Task WithPageAsync( + string sessionId, + string pageId, + Func> action, + CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); + trackedPage.Touch(_clock.UtcNow); + session.ActivePageId = trackedPage.PageId; + return await action(session, trackedPage); + }, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + foreach (var sessionId in _sessions.Keys.ToArray()) + { + await CloseSessionAsync(sessionId, CancellationToken.None); + } + } + + private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + var expirationCutoff = now - BrowserAutomationConstants.SessionIdleTimeout; + var expiredSessionIds = _sessions.Values + .Where(x => x.LastTouchedUtc < expirationCutoff) + .Select(x => x.SessionId) + .ToArray(); + + foreach (var sessionId in expiredSessionIds) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Closing expired browser automation session '{SessionId}'.", sessionId); + } + + await CloseSessionAsync(sessionId, cancellationToken); + } + } + + private static async Task LaunchBrowserAsync(IPlaywright playwright, string browserType, bool headless, int timeoutMs) + { + var options = new BrowserTypeLaunchOptions + { + Headless = headless, + Timeout = timeoutMs, + }; + + return browserType switch + { + "chromium" => await playwright.Chromium.LaunchAsync(options), + "firefox" => await playwright.Firefox.LaunchAsync(options), + "webkit" => await playwright.Webkit.LaunchAsync(options), + _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), + }; + } + + private BrowserAutomationPage TrackPage(BrowserAutomationSession session, IPage page) + { + var pageId = $"page-{Interlocked.Increment(ref session.PageSequence)}"; + var trackedPage = new BrowserAutomationPage(pageId, page, _clock.UtcNow); + session.Pages[pageId] = trackedPage; + session.ActivePageId = pageId; + + page.Console += (_, message) => + { + var consoleEntry = new Dictionary + { + ["pageId"] = pageId, + ["type"] = message.Type, + ["text"] = message.Text, + ["timestampUtc"] = _clock.UtcNow, + }; + + EnqueueLimited(trackedPage.ConsoleMessages, consoleEntry, BrowserAutomationConstants.MaxStoredConsoleMessages); + }; + + page.PageError += (_, error) => + { + EnqueueLimited(trackedPage.PageErrors, error, BrowserAutomationConstants.MaxStoredConsoleMessages); + EnqueueLimited(trackedPage.ConsoleMessages, new Dictionary + { + ["pageId"] = pageId, + ["type"] = "pageerror", + ["text"] = error, + ["timestampUtc"] = _clock.UtcNow, + }, BrowserAutomationConstants.MaxStoredConsoleMessages); + }; + + page.Request += (_, request) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "request", + ["method"] = request.Method, + ["url"] = request.Url, + ["resourceType"] = request.ResourceType, + ["timestampUtc"] = _clock.UtcNow, + }, BrowserAutomationConstants.MaxStoredNetworkEvents); + }; + + page.Response += (_, response) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "response", + ["url"] = response.Url, + ["status"] = response.Status, + ["ok"] = response.Ok, + ["timestampUtc"] = _clock.UtcNow, + }, BrowserAutomationConstants.MaxStoredNetworkEvents); + }; + + page.RequestFailed += (_, request) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "requestfailed", + ["method"] = request.Method, + ["url"] = request.Url, + ["resourceType"] = request.ResourceType, + ["timestampUtc"] = _clock.UtcNow, + }, BrowserAutomationConstants.MaxStoredNetworkEvents); + }; + + return trackedPage; + } + + private async Task ResolvePageAsync( + BrowserAutomationSession session, + string pageId, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(pageId) && session.Pages.TryGetValue(pageId, out var explicitPage)) + { + return explicitPage; + } + + if (!string.IsNullOrWhiteSpace(pageId)) + { + throw new InvalidOperationException($"Page '{pageId}' was not found for session '{session.SessionId}'."); + } + + if (!string.IsNullOrWhiteSpace(session.ActivePageId) && session.Pages.TryGetValue(session.ActivePageId, out var activePage)) + { + return activePage; + } + + var page = await session.Context.NewPageAsync(); + return TrackPage(session, page); + } + + private async Task> BuildSessionSnapshotAsync(BrowserAutomationSession session) + { + var pages = new List>(); + + foreach (var trackedPage in session.Pages.Values.OrderBy(x => x.CreatedUtc)) + { + pages.Add(await BuildPageSnapshotAsync(session, trackedPage)); + } + + return new Dictionary + { + ["sessionId"] = session.SessionId, + ["browserType"] = session.BrowserType, + ["headless"] = session.Headless, + ["createdUtc"] = session.CreatedUtc, + ["lastTouchedUtc"] = session.LastTouchedUtc, + ["activePageId"] = session.ActivePageId ?? string.Empty, + ["pageCount"] = pages.Count, + ["pages"] = pages, + }; + } + + private static async Task> BuildPageSnapshotAsync(BrowserAutomationSession session, BrowserAutomationPage trackedPage) + { + var snapshot = new Dictionary + { + ["sessionId"] = session.SessionId, + ["pageId"] = trackedPage.PageId, + ["isActive"] = string.Equals(session.ActivePageId, trackedPage.PageId, StringComparison.OrdinalIgnoreCase), + ["isClosed"] = trackedPage.Page.IsClosed, + ["url"] = trackedPage.Page.Url ?? string.Empty, + ["createdUtc"] = trackedPage.CreatedUtc, + ["lastTouchedUtc"] = trackedPage.LastTouchedUtc, + ["consoleMessageCount"] = trackedPage.ConsoleMessages.Count, + ["networkEventCount"] = trackedPage.NetworkEvents.Count, + ["pageErrorCount"] = trackedPage.PageErrors.Count, + }; + + if (!trackedPage.Page.IsClosed) + { + snapshot["title"] = await trackedPage.Page.TitleAsync(); + } + + return snapshot; + } + + private static void EnqueueLimited(ConcurrentQueue queue, T item, int limit) + { + queue.Enqueue(item); + while (queue.Count > limit && queue.TryDequeue(out _)) + { + } + } + + private static string NormalizeBrowserType(string browserType) + { + if (string.IsNullOrWhiteSpace(browserType)) + { + return "chromium"; + } + + browserType = browserType.Trim().ToLowerInvariant(); + return browserType switch + { + "chromium" or "chrome" => "chromium", + "firefox" => "firefox", + "webkit" => "webkit", + _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs new file mode 100644 index 000000000..186853267 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using System.Threading; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +internal sealed class BrowserAutomationSession +{ + public BrowserAutomationSession( + string sessionId, + string browserType, + bool headless, + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + DateTime createdUtc) + { + SessionId = sessionId; + BrowserType = browserType; + Headless = headless; + Playwright = playwright; + Browser = browser; + Context = context; + CreatedUtc = createdUtc; + LastTouchedUtc = createdUtc; + } + + public string SessionId { get; } + + public string BrowserType { get; } + + public bool Headless { get; } + + public IPlaywright Playwright { get; } + + public IBrowser Browser { get; } + + public IBrowserContext Context { get; } + + public ConcurrentDictionary Pages { get; } = new(StringComparer.OrdinalIgnoreCase); + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public string ActivePageId { get; set; } + + public DateTime CreatedUtc { get; } + + public DateTime LastTouchedUtc { get; private set; } + + public int PageSequence; + + public void Touch(DateTime utc) + => LastTouchedUtc = utc; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs new file mode 100644 index 000000000..96bcc15ae --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public abstract class BrowserAutomationToolBase : AIFunction + where TTool : AITool +{ + protected BrowserAutomationToolBase( + BrowserAutomationService browserAutomationService, + ILogger logger) + { + BrowserAutomationService = browserAutomationService; + Logger = logger; + } + + protected BrowserAutomationService BrowserAutomationService { get; } + + protected ILogger Logger { get; } + + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + protected static JsonElement ParseJson(string json) + => BrowserAutomationJson.ParseJson(json); + + protected static string Success(string action, object data) + => BrowserAutomationResultFactory.Success(action, data); + + protected static string Failure(string action, string message) + => BrowserAutomationResultFactory.Failure(action, message); + + protected static string GetRequiredString(AIFunctionArguments arguments, string key) + { + if (!arguments.TryGetFirstString(key, out var value)) + { + throw new InvalidOperationException($"{key} is required."); + } + + return value.Trim(); + } + + protected static string GetOptionalString(AIFunctionArguments arguments, string key) + => arguments.TryGetFirstString(key, out var value) ? value.Trim() : null; + + protected static bool GetBoolean(AIFunctionArguments arguments, string key, bool fallbackValue = false) + => arguments.TryGetFirst(key, out var value) ? value : fallbackValue; + + protected static int GetTimeout(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultTimeoutMs) + { + var timeout = arguments.TryGetFirst("timeoutMs", out var parsedTimeout) + ? parsedTimeout + : fallbackValue; + + return Math.Clamp(timeout, 1_000, BrowserAutomationConstants.MaxTimeoutMs); + } + + protected static int GetMaxItems(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultMaxItems) + { + var maxItems = arguments.TryGetFirst("maxItems", out var parsedMaxItems) + ? parsedMaxItems + : fallbackValue; + + return Math.Clamp(maxItems, 1, BrowserAutomationConstants.MaxCollectionItems); + } + + protected static int GetMaxTextLength(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultMaxTextLength) + { + var maxLength = arguments.TryGetFirst("maxLength", out var parsedMaxLength) + ? parsedMaxLength + : fallbackValue; + + return Math.Clamp(maxLength, 256, 20_000); + } + + protected static int? GetNullableInt(AIFunctionArguments arguments, string key) + => arguments.TryGetFirst(key, out var value) ? value : null; + + protected static string[] GetStringArray(AIFunctionArguments arguments, string key) + { + if (!arguments.TryGetFirst(key, out var values) || values is null || values.Length == 0) + { + throw new InvalidOperationException($"{key} is required."); + } + + var sanitizedValues = values + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .ToArray(); + + if (sanitizedValues.Length == 0) + { + throw new InvalidOperationException($"{key} is required."); + } + + return sanitizedValues; + } + + protected static string GetSessionId(AIFunctionArguments arguments) + => GetRequiredString(arguments, "sessionId"); + + protected static string GetPageId(AIFunctionArguments arguments) + => GetOptionalString(arguments, "pageId"); + + protected static WaitUntilState ParseWaitUntil(AIFunctionArguments arguments, string key = "waitUntil", WaitUntilState fallbackValue = WaitUntilState.Load) + { + var value = GetOptionalString(arguments, key); + if (string.IsNullOrWhiteSpace(value)) + { + return fallbackValue; + } + + return value.Trim().ToLowerInvariant() switch + { + "load" => WaitUntilState.Load, + "domcontentloaded" => WaitUntilState.DOMContentLoaded, + "networkidle" => WaitUntilState.NetworkIdle, + "commit" => WaitUntilState.Commit, + _ => throw new InvalidOperationException($"Unsupported waitUntil value '{value}'. Supported values are load, domcontentloaded, networkidle, and commit."), + }; + } + + protected static LoadState ParseLoadState(AIFunctionArguments arguments, string key = "state", LoadState fallbackValue = LoadState.Load) + { + var value = GetOptionalString(arguments, key); + if (string.IsNullOrWhiteSpace(value)) + { + return fallbackValue; + } + + return value.Trim().ToLowerInvariant() switch + { + "load" => LoadState.Load, + "domcontentloaded" => LoadState.DOMContentLoaded, + "networkidle" => LoadState.NetworkIdle, + _ => throw new InvalidOperationException($"Unsupported state value '{value}'. Supported values are load, domcontentloaded, and networkidle."), + }; + } + + protected static WaitForSelectorState ParseSelectorState(AIFunctionArguments arguments, string key = "state", WaitForSelectorState fallbackValue = WaitForSelectorState.Visible) + { + var value = GetOptionalString(arguments, key); + if (string.IsNullOrWhiteSpace(value)) + { + return fallbackValue; + } + + return value.Trim().ToLowerInvariant() switch + { + "attached" => WaitForSelectorState.Attached, + "detached" => WaitForSelectorState.Detached, + "hidden" => WaitForSelectorState.Hidden, + "visible" => WaitForSelectorState.Visible, + _ => throw new InvalidOperationException($"Unsupported selector state '{value}'. Supported values are attached, detached, hidden, and visible."), + }; + } + + protected static MouseButton ParseMouseButton(AIFunctionArguments arguments, string key = "button", MouseButton fallbackValue = MouseButton.Left) + { + var value = GetOptionalString(arguments, key); + if (string.IsNullOrWhiteSpace(value)) + { + return fallbackValue; + } + + return value.Trim().ToLowerInvariant() switch + { + "left" => MouseButton.Left, + "middle" => MouseButton.Middle, + "right" => MouseButton.Right, + _ => throw new InvalidOperationException($"Unsupported button value '{value}'. Supported values are left, middle, and right."), + }; + } + + protected static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } + + protected async Task ExecuteSafeAsync(string action, Func> callback) + { + try + { + if (Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug("AI browser tool '{ToolName}' invoked.", Name); + } + + var result = await callback(); + + if (Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug("AI browser tool '{ToolName}' completed.", Name); + } + + return result; + } + catch (TimeoutException exception) + { + Logger.LogWarning(exception, "AI browser tool '{ToolName}' timed out.", Name); + return Failure(action, exception.Message); + } + catch (ObjectDisposedException exception) + { + Logger.LogWarning(exception, "AI browser tool '{ToolName}' referenced a disposed browser resource.", Name); + return Failure(action, exception.Message); + } + catch (InvalidOperationException exception) + { + Logger.LogWarning(exception, "AI browser tool '{ToolName}' failed validation.", Name); + return Failure(action, exception.Message); + } + catch (PlaywrightException exception) + { + Logger.LogWarning(exception, "AI browser tool '{ToolName}' failed during Playwright execution.", Name); + + var message = exception.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase) + ? exception.Message + " Run the Playwright browser install script generated by the app build before using browser automation tools." + : exception.Message; + + return Failure(action, message); + } + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs new file mode 100644 index 000000000..7c00cda8d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System.IO; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class CaptureBrowserScreenshotTool : BrowserAutomationToolBase +{ + public const string TheName = "captureBrowserScreenshot"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "fullPage": { + "type": "boolean", + "description": "Optional. When true, captures the full page. Defaults to true." + }, + "format": { + "type": "string", + "description": "Optional screenshot format: png or jpeg. Defaults to png." + }, + "returnBase64": { + "type": "boolean", + "description": "Optional. When true, includes the screenshot content as base64 in the tool result." + }, + "path": { + "type": "string", + "description": "Optional absolute output path. When omitted, a temp path is used." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public CaptureBrowserScreenshotTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Captures a screenshot of the current page and optionally returns it as base64."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var fullPage = GetBoolean(arguments, "fullPage", true); + var returnBase64 = GetBoolean(arguments, "returnBase64"); + var format = (GetOptionalString(arguments, "format") ?? "png").Trim().ToLowerInvariant(); + var outputPath = GetOptionalString(arguments, "path"); + var extension = format == "jpeg" || format == "jpg" ? "jpg" : "png"; + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + var directory = Path.Combine(Path.GetTempPath(), "CrestApps.OrchardCore", "BrowserAutomation"); + Directory.CreateDirectory(directory); + outputPath = Path.Combine(directory, $"{sessionId}_{trackedPage.PageId}_{Guid.NewGuid():N}.{extension}"); + } + + var bytes = await trackedPage.Page.ScreenshotAsync(new PageScreenshotOptions + { + FullPage = fullPage, + Path = outputPath, + Type = extension == "jpg" ? ScreenshotType.Jpeg : ScreenshotType.Png, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + path = outputPath, + format = extension, + fullPage, + base64 = returnBase64 ? Convert.ToBase64String(bytes) : null, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs new file mode 100644 index 000000000..a8dd15a33 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class CheckBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "checkBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public CheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Checks a checkbox or radio button."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.CheckAsync(new LocatorCheckOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs new file mode 100644 index 000000000..34d3b5a0c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ClearBrowserInputTool : BrowserAutomationToolBase +{ + public const string TheName = "clearBrowserInput"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public ClearBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Clears the value of an input or textarea."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.FillAsync(string.Empty, new LocatorFillOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs new file mode 100644 index 000000000..52b3350ca --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ClickBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "clickBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public ClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Clicks an element using a Playwright selector."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var button = ParseMouseButton(arguments); + var timeout = GetTimeout(arguments); + await locator.ClickAsync(new LocatorClickOptions + { + Button = button, + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + button = button.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs new file mode 100644 index 000000000..477fc8cd7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class CloseBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "closeBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier returned by startBrowserSession." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public CloseBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Closes an existing browser session and disposes all tracked pages."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CloseSessionAsync(GetSessionId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs new file mode 100644 index 000000000..bacc75ddd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class CloseBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "closeBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public CloseBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Closes a browser tab. When pageId is omitted, closes the active tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.ClosePageAsync(GetSessionId(arguments), GetPageId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs new file mode 100644 index 000000000..c183bfafa --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System; +using System.Linq; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class DiagnoseBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "diagnoseBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of console and network entries to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public DiagnoseBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Collects a troubleshooting snapshot for the current page, including visible errors, recent console issues, and failing network requests."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 20); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"() => JSON.stringify({ + documentTitle: document.title, + readyState: document.readyState, + visibleErrors: Array.from(document.querySelectorAll('.error, .alert-danger, .validation-summary-errors, .field-validation-error, .input-validation-error, [aria-invalid=""true""]')).slice(0, 20).map((element, index) => ({ + index, + tagName: element.tagName, + text: (element.innerText || element.textContent || '').trim(), + id: element.id || '', + className: element.className || '' + })), + brokenImages: Array.from(document.images).filter(image => !image.complete || image.naturalWidth === 0).slice(0, 20).map((image, index) => ({ + index, + src: image.currentSrc || image.src || '', + alt: image.alt || '' + })) + })"); + + var failingNetwork = trackedPage.NetworkEvents + .ToArray() + .Where(x => x.TryGetValue("phase", out var phase) && (phase?.ToString() == "requestfailed" || (x.TryGetValue("status", out var status) && int.TryParse(status?.ToString(), out var code) && code >= 400))) + .TakeLast(maxItems) + .ToArray(); + + var consoleIssues = trackedPage.ConsoleMessages + .ToArray() + .Where(x => x.TryGetValue("type", out var type) && !string.Equals(type?.ToString(), "info", StringComparison.OrdinalIgnoreCase)) + .TakeLast(maxItems) + .ToArray(); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + diagnostics = ParseJson(raw), + consoleIssues, + failingNetwork, + pageErrors = trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs new file mode 100644 index 000000000..8a07f63ff --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class DoubleClickBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "doubleClickBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public DoubleClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Double-clicks an element using a Playwright selector."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.DblClickAsync(new LocatorDblClickOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs new file mode 100644 index 000000000..bcf23dfcc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class FillBrowserInputTool : BrowserAutomationToolBase +{ + public const string TheName = "fillBrowserInput"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + }, + "value": { + "type": "string", + "description": "The value to enter." + } + }, + "required": [ + "sessionId", + "selector", + "value" + ], + "additionalProperties": false + } + """); + + public FillBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Fills an input, textarea, or content-editable element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var value = GetRequiredString(arguments, "value"); + var timeout = GetTimeout(arguments); + await locator.FillAsync(value, new LocatorFillOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + value, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs new file mode 100644 index 000000000..57550631e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserButtonsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserButtons"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserButtonsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists button-like controls found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('button, input[type=button], input[type=submit], input[type=reset]')).slice(0, maxItems).map((button, index) => ({ + index, + tagName: button.tagName, type: button.getAttribute('type') || '', text: (button.innerText || button.value || button.textContent || '').trim(), disabled: !!button.disabled, id: button.id || '', name: button.getAttribute('name') || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs new file mode 100644 index 000000000..7656f32ab --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System.Linq; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserConsoleMessagesTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserConsoleMessages"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of messages to return." + }, + "includePageErrors": { + "type": "boolean", + "description": "Optional. When true, includes captured page error text. Defaults to true." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserConsoleMessagesTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns recent console messages and page errors captured for the current tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 50); + var includePageErrors = GetBoolean(arguments, "includePageErrors", true); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var consoleMessages = trackedPage.ConsoleMessages.ToArray().TakeLast(maxItems).ToArray(); + var pageErrors = includePageErrors + ? trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray() + : []; + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + consoleMessages, + pageErrors, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs new file mode 100644 index 000000000..5089845ae --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserElementInfoTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserElementInfo"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element to inspect." + }, + "maxLength": { + "type": "integer", + "description": "Optional maximum length for returned text or HTML." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public GetBrowserElementInfoTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns details about a specific element, including visibility, text, and selected attributes."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var maxLength = GetMaxTextLength(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.WaitForAsync(new LocatorWaitForOptions + { + Timeout = timeout, + }); + + var boundingBox = await locator.BoundingBoxAsync(new LocatorBoundingBoxOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + text = Truncate(await locator.InnerTextAsync(new LocatorInnerTextOptions { Timeout = timeout }), maxLength), + html = Truncate(await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions { Timeout = timeout }), maxLength), + visible = await locator.IsVisibleAsync(), + enabled = await locator.IsEnabledAsync(), + editable = await locator.IsEditableAsync(), + boundingBox, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs new file mode 100644 index 000000000..ba831b7f8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserFormsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserForms"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of forms to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserFormsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists forms and their visible fields on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.forms).slice(0, maxItems).map((form, index) => ({ + index, + id: form.id || '', + name: form.getAttribute('name') || '', + method: form.getAttribute('method') || 'get', + action: form.getAttribute('action') || window.location.href, + fields: Array.from(form.elements).slice(0, 20).map((field, fieldIndex) => ({ + index: fieldIndex, + tagName: field.tagName, + type: field.getAttribute('type') || '', + name: field.getAttribute('name') || '', + id: field.id || '', + placeholder: field.getAttribute('placeholder') || '', + required: !!field.required, + disabled: !!field.disabled, + value: field.type === 'password' ? '' : (field.value || '') + })) + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + forms = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs new file mode 100644 index 000000000..b7d6b2e2b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserHeadingsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserHeadings"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserHeadingsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists headings found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).slice(0, maxItems).map((heading, index) => ({ + index, + level: heading.tagName, text: (heading.innerText || heading.textContent || '').trim(), id: heading.id || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs new file mode 100644 index 000000000..133bd395f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserLinksTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserLinks"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserLinksTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists anchor elements found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('a')).slice(0, maxItems).map((anchor, index) => ({ + index, + text: (anchor.innerText || anchor.textContent || '').trim(), href: anchor.href || anchor.getAttribute('href') || '', target: anchor.target || '', rel: anchor.rel || '', ariaLabel: anchor.getAttribute('aria-label') || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs new file mode 100644 index 000000000..9479ba13c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System.Linq; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserNetworkActivityTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserNetworkActivity"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of network events to return." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserNetworkActivityTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns recent network requests, responses, and failed requests captured for the current tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 50); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var events = trackedPage.NetworkEvents.ToArray().TakeLast(maxItems).ToArray(); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + events, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs new file mode 100644 index 000000000..49ec5c18c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserPageContentTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserPageContent"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "Optional Playwright selector. When omitted, returns the full page content." + }, + "includeText": { + "type": "boolean", + "description": "Optional. Include text content. Defaults to true." + }, + "includeHtml": { + "type": "boolean", + "description": "Optional. Include HTML content. Defaults to false." + }, + "maxLength": { + "type": "integer", + "description": "Optional maximum length for returned text or HTML." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds for selector lookup." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserPageContentTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Retrieves text and/or HTML from the full page or from a specific element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetOptionalString(arguments, "selector"); + var includeText = GetBoolean(arguments, "includeText", true); + var includeHtml = GetBoolean(arguments, "includeHtml", false); + var maxLength = GetMaxTextLength(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + string text = null; + string html = null; + + if (string.IsNullOrWhiteSpace(selector)) + { + if (includeText) + { + text = await trackedPage.Page.EvaluateAsync("() => document.body ? document.body.innerText : ''"); + } + + if (includeHtml) + { + html = await trackedPage.Page.ContentAsync(); + } + } + else + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.WaitForAsync(new LocatorWaitForOptions + { + Timeout = timeout, + }); + + if (includeText) + { + text = await locator.InnerTextAsync(new LocatorInnerTextOptions + { + Timeout = timeout, + }); + } + + if (includeHtml) + { + html = await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions + { + Timeout = timeout, + }); + } + } + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + text = includeText ? Truncate(text, maxLength) : null, + html = includeHtml ? Truncate(html, maxLength) : null, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs new file mode 100644 index 000000000..2ec933b95 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserPageStateTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserPageState"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserPageStateTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns high-level state about the current page, including title, ready state, scroll position, and element counts."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"() => JSON.stringify({ + readyState: document.readyState, + location: window.location.href, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + historyLength: window.history.length, + linkCount: document.querySelectorAll('a').length, + buttonCount: document.querySelectorAll('button, input[type=button], input[type=submit]').length, + formCount: document.forms.length, + headingCount: document.querySelectorAll('h1, h2, h3, h4, h5, h6').length + })"); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + state = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs new file mode 100644 index 000000000..269703cf9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GetBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier to inspect." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GetBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns details about a specific browser session, including tracked tabs."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.GetSessionSnapshotAsync(GetSessionId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs new file mode 100644 index 000000000..1fb777294 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GoBackBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "goBackBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GoBackBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the tab backward in browser history."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GoBackAsync(new PageGoBackOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs new file mode 100644 index 000000000..f169eb681 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class GoForwardBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "goForwardBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public GoForwardBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the tab forward in browser history."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GoForwardAsync(new PageGoForwardOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs new file mode 100644 index 000000000..ff3bc0dab --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class HoverBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "hoverBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public HoverBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Moves the mouse over an element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.HoverAsync(new LocatorHoverOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs new file mode 100644 index 000000000..438ea1613 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ListBrowserSessionsTool : BrowserAutomationToolBase +{ + public const string TheName = "listBrowserSessions"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """); + + public ListBrowserSessionsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists the currently tracked Playwright browser sessions."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessions = await BrowserAutomationService.ListSessionsAsync(cancellationToken); + return Success(TheName, new + { + count = sessions.Count, + sessions, + }); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs new file mode 100644 index 000000000..dca02b7b7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class NavigateBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "navigateBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "url": { + "type": "string", + "description": "The target URL to navigate to." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "url" + ], + "additionalProperties": false + } + """); + + public NavigateBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the active tab or a specified tab to a URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var url = GetRequiredString(arguments, "url"); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GotoAsync(url, new PageGotoOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs new file mode 100644 index 000000000..acc65f2ac --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class OpenBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "openBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "url": { + "type": "string", + "description": "Optional URL to open in the new tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public OpenBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Opens a new tab in an existing browser session and can optionally navigate it to a URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CreatePageAsync( + GetSessionId(arguments), + GetOptionalString(arguments, "url"), + ParseWaitUntil(arguments), + GetTimeout(arguments), + cancellationToken); + + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs new file mode 100644 index 000000000..c9919c7bd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class PressBrowserKeyTool : BrowserAutomationToolBase +{ + public const string TheName = "pressBrowserKey"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "key": { + "type": "string", + "description": "The key or shortcut to press, such as Enter, Escape, Tab, or Control+A." + }, + "selector": { + "type": "string", + "description": "Optional selector to focus before pressing the key." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "key" + ], + "additionalProperties": false + } + """); + + public PressBrowserKeyTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Sends a keyboard shortcut or key press to the page or to a focused element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var key = GetRequiredString(arguments, "key"); + var selector = GetOptionalString(arguments, "selector"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + if (!string.IsNullOrWhiteSpace(selector)) + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.PressAsync(key, new LocatorPressOptions + { + Timeout = timeout, + }); + } + else + { + await trackedPage.Page.Keyboard.PressAsync(key, new KeyboardPressOptions()); + } + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + key, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs new file mode 100644 index 000000000..d6139b25d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ReloadBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "reloadBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public ReloadBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Reloads the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.ReloadAsync(new PageReloadOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs new file mode 100644 index 000000000..f20dc9d4c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ScrollBrowserElementIntoViewTool : BrowserAutomationToolBase +{ + public const string TheName = "scrollBrowserElementIntoView"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element to scroll into view." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public ScrollBrowserElementIntoViewTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Scrolls an element into view using Playwright locator semantics."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.ScrollIntoViewIfNeededAsync(new LocatorScrollIntoViewIfNeededOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs new file mode 100644 index 000000000..d33d50f48 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class ScrollBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "scrollBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "deltaX": { + "type": "integer", + "description": "Optional horizontal scroll offset. Defaults to 0." + }, + "deltaY": { + "type": "integer", + "description": "Optional vertical scroll offset. Defaults to 400." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public ScrollBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Scrolls the current page by the provided offsets."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var deltaX = arguments.TryGetFirst("deltaX", out var parsedDeltaX) ? parsedDeltaX : 0; + var deltaY = arguments.TryGetFirst("deltaY", out var parsedDeltaY) ? parsedDeltaY : 400; + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(scroll) => { + window.scrollBy(scroll.deltaX, scroll.deltaY); + return JSON.stringify({ x: window.scrollX, y: window.scrollY }); + }", + new { deltaX, deltaY }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + scrollPosition = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs new file mode 100644 index 000000000..3bdb1002c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class SelectBrowserOptionTool : BrowserAutomationToolBase +{ + public const string TheName = "selectBrowserOption"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the select element." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "One or more option values to select." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector", + "values" + ], + "additionalProperties": false + } + """); + + public SelectBrowserOptionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Selects one or more values in a select element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var values = GetStringArray(arguments, "values"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var selected = await locator.SelectOptionAsync(values, new LocatorSelectOptionOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + values = selected, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs new file mode 100644 index 000000000..8690c55b2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class StartBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "startBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "browserType": { + "type": "string", + "description": "Optional browser engine: chromium, firefox, or webkit." + }, + "headless": { + "type": "boolean", + "description": "Optional. When true, launches the browser in headless mode. Defaults to true." + }, + "startUrl": { + "type": "string", + "description": "Optional. The initial URL to open after the browser session starts." + }, + "viewportWidth": { + "type": "integer", + "description": "Optional viewport width in pixels." + }, + "viewportHeight": { + "type": "integer", + "description": "Optional viewport height in pixels." + }, + "locale": { + "type": "string", + "description": "Optional browser locale, such as en-US." + }, + "userAgent": { + "type": "string", + "description": "Optional custom user agent string." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional browser launch and initial navigation timeout in milliseconds." + } + }, + "additionalProperties": false + } + """); + + public StartBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Creates a Playwright browser session and optionally navigates to an initial URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CreateSessionAsync( + GetOptionalString(arguments, "browserType"), + GetBoolean(arguments, "headless", true), + GetOptionalString(arguments, "startUrl"), + GetNullableInt(arguments, "viewportWidth"), + GetNullableInt(arguments, "viewportHeight"), + GetOptionalString(arguments, "locale"), + GetOptionalString(arguments, "userAgent"), + GetTimeout(arguments), + cancellationToken); + + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs new file mode 100644 index 000000000..8c56c3abe --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class SwitchBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "switchBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "The page identifier to activate." + } + }, + "required": [ + "sessionId", + "pageId" + ], + "additionalProperties": false + } + """); + + public SwitchBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Marks a specific browser tab as the active tab for future actions."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.SwitchActivePageAsync(GetSessionId(arguments), GetRequiredString(arguments, "pageId"), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs new file mode 100644 index 000000000..404daa430 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class UncheckBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "uncheckBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public UncheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Unchecks a checkbox."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.UncheckAsync(new LocatorUncheckOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs new file mode 100644 index 000000000..caf76bcbe --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class UploadBrowserFilesTool : BrowserAutomationToolBase +{ + public const string TheName = "uploadBrowserFiles"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the file input." + }, + "filePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Absolute file paths to upload." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector", + "filePaths" + ], + "additionalProperties": false + } + """); + + public UploadBrowserFilesTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Uploads one or more files into a file input element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var filePaths = GetStringArray(arguments, "filePaths"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.SetInputFilesAsync(filePaths, new LocatorSetInputFilesOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + filePaths, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs new file mode 100644 index 000000000..9c8d37a6a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class WaitForBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector to wait for." + }, + "state": { + "type": "string", + "description": "Optional selector state: attached, detached, hidden, or visible." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId", + "selector" + ], + "additionalProperties": false + } + """); + + public WaitForBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for a selector to reach a requested state."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var state = ParseSelectorState(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + await trackedPage.Page.WaitForSelectorAsync(selector, new PageWaitForSelectorOptions + { + State = state, + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + state = state.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs new file mode 100644 index 000000000..02d0e0b7c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class WaitForBrowserLoadStateTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserLoadState"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "state": { + "type": "string", + "description": "Optional load state: load, domcontentloaded, or networkidle." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public WaitForBrowserLoadStateTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for the page to reach a specific load state."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var state = ParseLoadState(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + await trackedPage.Page.WaitForLoadStateAsync(state, new PageWaitForLoadStateOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + state = state.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs new file mode 100644 index 000000000..8849d4355 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using System.Diagnostics; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +public sealed class WaitForBrowserNavigationTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserNavigation"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "urlContains": { + "type": "string", + "description": "Optional URL fragment that must appear in the current URL before the wait completes." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + """); + + public WaitForBrowserNavigationTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for the page URL to change or to contain the requested fragment."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var urlContains = GetOptionalString(arguments, "urlContains"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var stopwatch = Stopwatch.StartNew(); + var initialUrl = trackedPage.Page.Url; + + while (stopwatch.ElapsedMilliseconds < timeout) + { + var currentUrl = trackedPage.Page.Url; + var changed = !string.Equals(initialUrl, currentUrl, StringComparison.Ordinal); + var matches = string.IsNullOrWhiteSpace(urlContains) || currentUrl.Contains(urlContains, StringComparison.OrdinalIgnoreCase); + + if (changed && matches) + { + return new + { + sessionId, + pageId = trackedPage.PageId, + initialUrl, + url = currentUrl, + title = await trackedPage.Page.TitleAsync(), + }; + } + + await Task.Delay(250, cancellationToken); + } + + throw new TimeoutException($"Navigation did not reach the expected URL within {timeout} ms."); + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs new file mode 100644 index 000000000..b9bf3534f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs @@ -0,0 +1,275 @@ +using CrestApps.OrchardCore.AI; +using CrestApps.OrchardCore.AI.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; + +[Feature(AIConstants.Feature.OrchardCoreAIAgent)] +public sealed class BrowserAutomationStartup : StartupBase +{ + internal readonly IStringLocalizer S; + + public BrowserAutomationStartup(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + RegisterSessionTools(services); + RegisterNavigationTools(services); + RegisterInspectionTools(services); + RegisterInteractionTools(services); + RegisterFormTools(services); + RegisterWaitingTools(services); + RegisterTroubleshootingTools(services); + } + + private void RegisterSessionTools(IServiceCollection services) + { + services.AddAITool(StartBrowserSessionTool.TheName) + .WithTitle(S["Start Browser Session"]) + .WithDescription(S["Launches a Playwright browser session and optionally opens an initial page."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(CloseBrowserSessionTool.TheName) + .WithTitle(S["Close Browser Session"]) + .WithDescription(S["Closes a tracked Playwright browser session and disposes its tabs."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(ListBrowserSessionsTool.TheName) + .WithTitle(S["List Browser Sessions"]) + .WithDescription(S["Lists tracked Playwright browser sessions and their tabs."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(GetBrowserSessionTool.TheName) + .WithTitle(S["Get Browser Session"]) + .WithDescription(S["Retrieves details about a tracked Playwright browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(OpenBrowserTabTool.TheName) + .WithTitle(S["Open Browser Tab"]) + .WithDescription(S["Opens a new tab in a tracked browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(CloseBrowserTabTool.TheName) + .WithTitle(S["Close Browser Tab"]) + .WithDescription(S["Closes a tab in a tracked browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(SwitchBrowserTabTool.TheName) + .WithTitle(S["Switch Browser Tab"]) + .WithDescription(S["Marks a browser tab as the active tab for subsequent actions."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + } + + private void RegisterNavigationTools(IServiceCollection services) + { + services.AddAITool(NavigateBrowserTool.TheName) + .WithTitle(S["Navigate Browser"]) + .WithDescription(S["Navigates a tab to a URL."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(GoBackBrowserTool.TheName) + .WithTitle(S["Go Back"]) + .WithDescription(S["Navigates backward in browser history."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(GoForwardBrowserTool.TheName) + .WithTitle(S["Go Forward"]) + .WithDescription(S["Navigates forward in browser history."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ReloadBrowserPageTool.TheName) + .WithTitle(S["Reload Page"]) + .WithDescription(S["Reloads the current page."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ScrollBrowserPageTool.TheName) + .WithTitle(S["Scroll Page"]) + .WithDescription(S["Scrolls the current page vertically or horizontally."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ScrollBrowserElementIntoViewTool.TheName) + .WithTitle(S["Scroll Element Into View"]) + .WithDescription(S["Scrolls a specific element into the viewport."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + } + + private void RegisterInspectionTools(IServiceCollection services) + { + services.AddAITool(GetBrowserPageStateTool.TheName) + .WithTitle(S["Get Page State"]) + .WithDescription(S["Returns high-level state for the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserPageContentTool.TheName) + .WithTitle(S["Get Page Content"]) + .WithDescription(S["Returns page text and HTML for the full page or a selected element."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserLinksTool.TheName) + .WithTitle(S["Get Page Links"]) + .WithDescription(S["Lists the links found on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserFormsTool.TheName) + .WithTitle(S["Get Page Forms"]) + .WithDescription(S["Lists forms and their fields on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserHeadingsTool.TheName) + .WithTitle(S["Get Page Headings"]) + .WithDescription(S["Lists headings found on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserButtonsTool.TheName) + .WithTitle(S["Get Page Buttons"]) + .WithDescription(S["Lists button-like controls found on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserElementInfoTool.TheName) + .WithTitle(S["Get Element Info"]) + .WithDescription(S["Returns detailed information about a selected element."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + } + + private void RegisterInteractionTools(IServiceCollection services) + { + services.AddAITool(ClickBrowserElementTool.TheName) + .WithTitle(S["Click Element"]) + .WithDescription(S["Clicks a page element."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(DoubleClickBrowserElementTool.TheName) + .WithTitle(S["Double Click Element"]) + .WithDescription(S["Double-clicks a page element."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(HoverBrowserElementTool.TheName) + .WithTitle(S["Hover Element"]) + .WithDescription(S["Moves the mouse over a page element."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(PressBrowserKeyTool.TheName) + .WithTitle(S["Press Key"]) + .WithDescription(S["Sends a keyboard key or shortcut to the page."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + } + + private void RegisterFormTools(IServiceCollection services) + { + services.AddAITool(FillBrowserInputTool.TheName) + .WithTitle(S["Fill Input"]) + .WithDescription(S["Fills an input, textarea, or editable element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(ClearBrowserInputTool.TheName) + .WithTitle(S["Clear Input"]) + .WithDescription(S["Clears an input or textarea value."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(SelectBrowserOptionTool.TheName) + .WithTitle(S["Select Option"]) + .WithDescription(S["Selects one or more values in a select element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(CheckBrowserElementTool.TheName) + .WithTitle(S["Check Element"]) + .WithDescription(S["Checks a checkbox or radio button."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(UncheckBrowserElementTool.TheName) + .WithTitle(S["Uncheck Element"]) + .WithDescription(S["Unchecks a checkbox."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(UploadBrowserFilesTool.TheName) + .WithTitle(S["Upload Files"]) + .WithDescription(S["Uploads local files into a file input element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + } + + private void RegisterWaitingTools(IServiceCollection services) + { + services.AddAITool(WaitForBrowserElementTool.TheName) + .WithTitle(S["Wait For Element"]) + .WithDescription(S["Waits for a selector to reach a requested state."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + + services.AddAITool(WaitForBrowserNavigationTool.TheName) + .WithTitle(S["Wait For Navigation"]) + .WithDescription(S["Waits for navigation or a URL change."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + + services.AddAITool(WaitForBrowserLoadStateTool.TheName) + .WithTitle(S["Wait For Load State"]) + .WithDescription(S["Waits for a page to reach a specific load state."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + } + + private void RegisterTroubleshootingTools(IServiceCollection services) + { + services.AddAITool(CaptureBrowserScreenshotTool.TheName) + .WithTitle(S["Capture Screenshot"]) + .WithDescription(S["Captures a screenshot of the current page."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(GetBrowserConsoleMessagesTool.TheName) + .WithTitle(S["Get Console Messages"]) + .WithDescription(S["Returns recent console messages and page errors."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(GetBrowserNetworkActivityTool.TheName) + .WithTitle(S["Get Network Activity"]) + .WithDescription(S["Returns recent network requests and responses."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(DiagnoseBrowserPageTool.TheName) + .WithTitle(S["Diagnose Page"]) + .WithDescription(S["Collects a troubleshooting snapshot for the current page."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/CrestApps.OrchardCore.AI.Agent.csproj b/src/Modules/CrestApps.OrchardCore.AI.Agent/CrestApps.OrchardCore.AI.Agent.csproj index 678b3b90e..580544406 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/CrestApps.OrchardCore.AI.Agent.csproj +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/CrestApps.OrchardCore.AI.Agent.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs new file mode 100644 index 000000000..88b89ca2b --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs @@ -0,0 +1,55 @@ +using CrestApps.OrchardCore.AI; +using CrestApps.OrchardCore.AI.Agent.BrowserAutomation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace CrestApps.OrchardCore.Tests.Agent.BrowserAutomation; + +public sealed class BrowserAutomationStartupTests +{ + [Fact] + public void ConfigureServices_RegistersExpectedSelectableBrowserTools() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + + var startup = new BrowserAutomationStartup(new PassthroughStringLocalizer()); + + startup.ConfigureServices(services); + + using var serviceProvider = services.BuildServiceProvider(); + var definitions = serviceProvider.GetRequiredService>().Value; + var selectableTools = definitions.Tools + .Where(x => !x.Value.IsSystemTool) + .ToDictionary(x => x.Key, x => x.Value); + + Assert.Equal(37, selectableTools.Count); + + Assert.Equal(7, selectableTools.Count(x => x.Value.Category == "Browser Sessions")); + Assert.Equal(6, selectableTools.Count(x => x.Value.Category == "Browser Navigation")); + Assert.Equal(7, selectableTools.Count(x => x.Value.Category == "Browser Inspection")); + Assert.Equal(4, selectableTools.Count(x => x.Value.Category == "Browser Interaction")); + Assert.Equal(6, selectableTools.Count(x => x.Value.Category == "Browser Forms")); + Assert.Equal(3, selectableTools.Count(x => x.Value.Category == "Browser Waiting")); + Assert.Equal(4, selectableTools.Count(x => x.Value.Category == "Browser Troubleshooting")); + + Assert.Contains(StartBrowserSessionTool.TheName, selectableTools.Keys); + Assert.Contains(NavigateBrowserTool.TheName, selectableTools.Keys); + Assert.Contains(WaitForBrowserElementTool.TheName, selectableTools.Keys); + Assert.Contains(DiagnoseBrowserPageTool.TheName, selectableTools.Keys); + } + + private sealed class PassthroughStringLocalizer : IStringLocalizer + { + public LocalizedString this[string name] + => new(name, name); + + public LocalizedString this[string name, params object[] arguments] + => new(name, string.Format(name, arguments)); + + public IEnumerable GetAllStrings(bool includeParentCultures) + => []; + } +} From 895369676ff4a08948f2515459530e5440fd0425 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 20 Mar 2026 08:01:38 -0700 Subject: [PATCH 2/2] move the playwright automation into a new feature --- .../AIInvocationItemKeys.cs | 7 + .../LivePageContextPromptBuilder.cs | 270 ++++ .../Hubs/AIChatHub.cs | 71 +- .../Hubs/IChatHubClient.cs | 2 + .../Hubs/ChatInteractionHub.cs | 62 +- .../AIConstants.cs | 2 + .../Models/DefaultAIOptions.cs | 2 +- .../docs/ai/agent.md | 12 +- .../docs/changelog/v2.0.0.md | 7 + ...tomationConstants.cs => AgentConstants.cs} | 48 +- .../BrowserAutomationStartup.cs | 275 ---- .../Manifest.cs | 11 + .../Properties/AssemblyInfo.cs | 3 + .../BrowserAutomationJson.cs | 2 +- .../BrowserAutomationPage.cs | 64 +- .../BrowserAutomationResultFactory.cs | 2 +- .../BrowserAutomationService.cs | 1143 ++++++++++------- .../BrowserAutomationSession.cs | 107 +- .../CrestApps.OrchardCore.AI.Agent/Startup.cs | 299 ++++- .../Analytics/QueryChatSessionMetricsTool.cs | 4 +- .../BrowserAutomationScripts.cs | 86 ++ .../BrowserAutomationToolBase.cs | 42 +- .../BrowserNavigationPathParser.cs | 22 + .../CaptureBrowserScreenshotTool.cs | 211 ++- .../CheckBrowserElementTool.cs | 168 +-- .../ClearBrowserInputTool.cs | 168 +-- .../ClickBrowserElementTool.cs | 182 +-- .../CloseBrowserSessionTool.cs | 97 +- .../BrowserAutomation/CloseBrowserTabTool.cs | 107 +- .../DiagnoseBrowserPageTool.cs | 215 ++-- .../DoubleClickBrowserElementTool.cs | 176 +-- .../BrowserAutomation/FillBrowserInputTool.cs | 181 ++- .../GetBrowserButtonsTool.cs | 159 ++- .../GetBrowserConsoleMessagesTool.cs | 169 ++- .../GetBrowserElementInfoTool.cs | 202 +-- .../BrowserAutomation/GetBrowserFormsTool.cs | 185 ++- .../GetBrowserHeadingsTool.cs | 159 ++- .../BrowserAutomation/GetBrowserLinksTool.cs | 159 ++- .../GetBrowserNetworkActivityTool.cs | 151 ++- .../GetBrowserPageContentTool.cs | 269 ++-- .../GetBrowserPageStateTool.cs | 165 ++- .../GetBrowserSessionTool.cs | 97 +- .../BrowserAutomation/GoBackBrowserTool.cs | 169 ++- .../BrowserAutomation/GoForwardBrowserTool.cs | 169 ++- .../HoverBrowserElementTool.cs | 175 ++- .../ListBrowserSessionsTool.cs | 91 +- .../NavigateBrowserMenuTool.cs | 201 +++ .../BrowserAutomation/NavigateBrowserTool.cs | 182 +-- .../BrowserAutomation/OpenBrowserTabTool.cs | 135 +- .../BrowserAutomation/PressBrowserKeyTool.cs | 196 +-- .../ReloadBrowserPageTool.cs | 169 ++- .../ScrollBrowserElementIntoViewTool.cs | 170 +-- .../ScrollBrowserPageTool.cs | 165 ++- .../SelectBrowserOptionTool.cs | 190 +-- .../StartBrowserSessionTool.cs | 169 +-- .../BrowserAutomation/SwitchBrowserTabTool.cs | 110 +- .../UncheckBrowserElementTool.cs | 168 +-- .../UploadBrowserFilesTool.cs | 190 +-- .../WaitForBrowserElementTool.cs | 182 +-- .../WaitForBrowserLoadStateTool.cs | 165 ++- .../WaitForBrowserNavigationTool.cs | 191 ++- .../Communications/SendEmailTool.cs | 6 +- .../Communications/SendNotificationTool.cs | 6 +- .../{ => Tools}/Communications/SendSmsTool.cs | 6 +- ...reateOrUpdateContentTypeDefinitionsTool.cs | 4 +- .../GetContentPartDefinitionsTool.cs | 4 +- .../GetContentTypeDefinitionsTool.cs | 4 +- .../ContentTypes/ListContentFieldsTool.cs | 4 +- .../ListContentPartsDefinitionsTool.cs | 4 +- .../ListContentTypesDefinitionsTool.cs | 4 +- .../RemoveContentPartDefinitionsTool.cs | 4 +- .../RemoveContentTypeDefinitionsTool.cs | 4 +- .../{ => Tools}/Contents/CloneContentTool.cs | 4 +- .../Contents/CreateOrUpdateContentTool.cs | 4 +- .../{ => Tools}/Contents/DeleteContentTool.cs | 5 +- .../Contents/GetContentItemLinkTool.cs | 4 +- .../Contents/GetContentItemSchemaTool.cs | 4 +- .../{ => Tools}/Contents/GetContentTool.cs | 4 +- .../Contents/PublishContentTool.cs | 4 +- .../Contents/SearchForContentsTool.cs | 6 +- .../Contents/UnpublishContentTool.cs | 4 +- .../Features/DisableFeatureTool.cs | 6 +- .../{ => Tools}/Features/EnableFeatureTool.cs | 6 +- .../{ => Tools}/Features/GetFeatureTool.cs | 6 +- .../{ => Tools}/Features/ListFeaturesTool.cs | 4 +- .../Features/SearchFeaturesTool.cs | 6 +- .../Profiles/ListAIProfilesTool.cs | 4 +- .../{ => Tools}/Profiles/ViewAIProfileTool.cs | 2 +- .../Recipes/ExecuteStartupRecipesTool.cs | 4 +- .../Recipes/GetRecipeJsonSchemaTool.cs | 4 +- .../{ => Tools}/Recipes/ImportOrchardTool.cs | 2 +- .../Recipes/ListNonStartupRecipesTool.cs | 4 +- .../Recipes/ListRecipeStepsAndSchemasTool.cs | 4 +- .../Recipes/ListStartupRecipesTool.cs | 4 +- .../{ => Tools}/Roles/GetRoleTool.cs | 6 +- .../System/ApplySystemSettingsTool.cs | 4 +- .../{ => Tools}/System/ListTimeZoneTool.cs | 4 +- .../{ => Tools}/Tenants/CreateTenantTool.cs | 89 +- .../{ => Tools}/Tenants/DisableTenantTool.cs | 24 +- .../{ => Tools}/Tenants/EnableTenantTool.cs | 24 +- .../{ => Tools}/Tenants/GetTenantTool.cs | 22 +- .../{ => Tools}/Tenants/ListTenantTool.cs | 4 +- .../{ => Tools}/Tenants/ReloadTenantTool.cs | 24 +- .../{ => Tools}/Tenants/RemoveTenantTool.cs | 24 +- .../{ => Tools}/Tenants/SetupTenantTool.cs | 131 +- .../{ => Tools}/Users/GetUserInfoTool.cs | 6 +- .../{ => Tools}/Users/SearchForUsersTool.cs | 6 +- .../Workflows/CreateOrUpdateWorkflowTool.cs | 4 +- .../Workflows/GetWorkflowTypesTool.cs | 6 +- .../Workflows/ListWorkflowActivitiesTool.cs | 10 +- .../Workflows/ListWorkflowTypesTool.cs | 6 +- .../Assets/js/chat-interaction.js | 113 +- .../wwwroot/scripts/chat-interaction.js | 93 +- .../wwwroot/scripts/chat-interaction.min.js | 2 +- .../Assets/js/ai-chat-widget.js | 34 + .../Assets/js/ai-chat.js | 193 ++- .../wwwroot/scripts/ai-chat-widget.js | 26 + .../wwwroot/scripts/ai-chat-widget.min.js | 2 +- .../wwwroot/scripts/ai-chat.js | 161 ++- .../wwwroot/scripts/ai-chat.min.js | 2 +- .../BrowserAutomationServiceTests.cs | 88 ++ .../BrowserAutomationStartupTests.cs | 34 +- .../BrowserNavigationPathParserTests.cs | 28 + .../LivePageContextPromptBuilderTests.cs | 50 + .../Communications/SendEmailToolTests.cs | 2 +- .../Contents/GetContentItemLinkToolTests.cs | 2 +- 126 files changed, 6016 insertions(+), 4303 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/AIInvocationItemKeys.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/LivePageContextPromptBuilder.cs rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation/BrowserAutomationConstants.cs => AgentConstants.cs} (75%) delete mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/Properties/AssemblyInfo.cs rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation => Services}/BrowserAutomationJson.cs (88%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation => Services}/BrowserAutomationPage.cs (89%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation => Services}/BrowserAutomationResultFactory.cs (80%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation => Services}/BrowserAutomationService.cs (66%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{BrowserAutomation => Services}/BrowserAutomationSession.cs (90%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Analytics/QueryChatSessionMetricsTool.cs (98%) create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationScripts.cs rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/BrowserAutomationToolBase.cs (87%) create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserNavigationPathParser.cs rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/CaptureBrowserScreenshotTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/CheckBrowserElementTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ClearBrowserInputTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ClickBrowserElementTool.cs (71%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/CloseBrowserSessionTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/CloseBrowserTabTool.cs (68%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/DiagnoseBrowserPageTool.cs (84%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/DoubleClickBrowserElementTool.cs (70%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/FillBrowserInputTool.cs (68%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserButtonsTool.cs (77%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserConsoleMessagesTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserElementInfoTool.cs (76%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserFormsTool.cs (78%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserHeadingsTool.cs (76%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserLinksTool.cs (76%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserNetworkActivityTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserPageContentTool.cs (76%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserPageStateTool.cs (81%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GetBrowserSessionTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GoBackBrowserTool.cs (71%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/GoForwardBrowserTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/HoverBrowserElementTool.cs (68%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ListBrowserSessionsTool.cs (81%) create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserMenuTool.cs rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/NavigateBrowserTool.cs (67%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/OpenBrowserTabTool.cs (65%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/PressBrowserKeyTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ReloadBrowserPageTool.cs (71%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/ScrollBrowserPageTool.cs (73%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/SelectBrowserOptionTool.cs (69%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/StartBrowserSessionTool.cs (65%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/SwitchBrowserTabTool.cs (65%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/UncheckBrowserElementTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/UploadBrowserFilesTool.cs (67%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/WaitForBrowserElementTool.cs (71%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/WaitForBrowserLoadStateTool.cs (72%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/BrowserAutomation/WaitForBrowserNavigationTool.cs (77%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Communications/SendEmailTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Communications/SendNotificationTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Communications/SendSmsTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs (93%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/GetContentPartDefinitionsTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/GetContentTypeDefinitionsTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/ListContentFieldsTool.cs (95%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/ListContentPartsDefinitionsTool.cs (95%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/ListContentTypesDefinitionsTool.cs (95%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/RemoveContentPartDefinitionsTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/ContentTypes/RemoveContentTypeDefinitionsTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/CloneContentTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/CreateOrUpdateContentTool.cs (99%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/DeleteContentTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/GetContentItemLinkTool.cs (98%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/GetContentItemSchemaTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/GetContentTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/PublishContentTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/SearchForContentsTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Contents/UnpublishContentTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Features/DisableFeatureTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Features/EnableFeatureTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Features/GetFeatureTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Features/ListFeaturesTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Features/SearchFeaturesTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Profiles/ListAIProfilesTool.cs (98%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Profiles/ViewAIProfileTool.cs (99%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/ExecuteStartupRecipesTool.cs (98%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/GetRecipeJsonSchemaTool.cs (98%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/ImportOrchardTool.cs (84%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/ListNonStartupRecipesTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/ListRecipeStepsAndSchemasTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Recipes/ListStartupRecipesTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Roles/GetRoleTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/System/ApplySystemSettingsTool.cs (93%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/System/ListTimeZoneTool.cs (95%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/CreateTenantTool.cs (75%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/DisableTenantTool.cs (87%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/EnableTenantTool.cs (87%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/GetTenantTool.cs (83%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/ListTenantTool.cs (95%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/ReloadTenantTool.cs (86%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/RemoveTenantTool.cs (89%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Tenants/SetupTenantTool.cs (79%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Users/GetUserInfoTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Users/SearchForUsersTool.cs (97%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Workflows/CreateOrUpdateWorkflowTool.cs (93%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Workflows/GetWorkflowTypesTool.cs (96%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Workflows/ListWorkflowActivitiesTool.cs (84%) rename src/Modules/CrestApps.OrchardCore.AI.Agent/{ => Tools}/Workflows/ListWorkflowTypesTool.cs (97%) create mode 100644 tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserNavigationPathParserTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/LivePageContextPromptBuilderTests.cs diff --git a/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/AIInvocationItemKeys.cs b/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/AIInvocationItemKeys.cs new file mode 100644 index 000000000..c837aec26 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/AIInvocationItemKeys.cs @@ -0,0 +1,7 @@ +namespace CrestApps.OrchardCore.AI; + +public static class AIInvocationItemKeys +{ + public const string LiveNavigationUrl = "LiveNavigationUrl"; + public const string LivePageContextJson = "LivePageContextJson"; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/LivePageContextPromptBuilder.cs b/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/LivePageContextPromptBuilder.cs new file mode 100644 index 000000000..037e1fc01 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/LivePageContextPromptBuilder.cs @@ -0,0 +1,270 @@ +using System.Text; +using System.Text.Json; + +namespace CrestApps.OrchardCore.AI; + +public static class LivePageContextPromptBuilder +{ + public static string Append(string prompt, AIInvocationContext invocationContext) + { + if (string.IsNullOrWhiteSpace(prompt) || invocationContext is null) + { + return prompt; + } + + if (!invocationContext.Items.TryGetValue(AIInvocationItemKeys.LivePageContextJson, out var rawContext) || + rawContext is not string contextJson || + string.IsNullOrWhiteSpace(contextJson)) + { + return prompt; + } + + var summary = BuildSummary(contextJson); + if (string.IsNullOrWhiteSpace(summary)) + { + return prompt; + } + + return $"{prompt}\n\n[Current visible page context]\n{summary}\n[/Current visible page context]"; + } + + public static void Store(AIInvocationContext invocationContext, string contextJson) + { + if (invocationContext is null || string.IsNullOrWhiteSpace(contextJson)) + { + return; + } + + try + { + using var document = JsonDocument.Parse(contextJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return; + } + + invocationContext.Items[AIInvocationItemKeys.LivePageContextJson] = contextJson; + } + catch (JsonException) + { + } + } + + internal static string BuildSummary(string contextJson) + { + if (string.IsNullOrWhiteSpace(contextJson)) + { + return null; + } + + using var document = JsonDocument.Parse(contextJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + var builder = new StringBuilder(); + AppendLine(builder, "URL", GetString(root, "url")); + AppendLine(builder, "Title", GetString(root, "title")); + AppendLine(builder, "Frame context", GetBoolean(root, "isParentContext") ? "parent page" : "current page"); + AppendList(builder, "Headings", GetStringArray(root, "headings"), 12, 120); + AppendLinks(builder, root); + AppendList(builder, "Visible buttons", GetObjectStringArray(root, "buttons", "text"), 20, 120); + AppendLine(builder, "Visible text preview", Truncate(GetString(root, "textPreview"), 1500)); + + return builder.Length == 0 ? null : builder.ToString().TrimEnd(); + } + + private static void AppendLinks(StringBuilder builder, JsonElement root) + { + if (!root.TryGetProperty("links", out var linksElement) || linksElement.ValueKind != JsonValueKind.Array) + { + return; + } + + var count = 0; + foreach (var link in linksElement.EnumerateArray()) + { + if (count >= 40) + { + break; + } + + var text = Truncate(GetString(link, "text"), 120); + var href = Truncate(GetString(link, "href"), 240); + if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(href)) + { + continue; + } + + if (count == 0) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.AppendLine("Visible links:"); + } + + builder.Append("- "); + if (!string.IsNullOrWhiteSpace(text)) + { + builder.Append(text); + } + else + { + builder.Append("[no text]"); + } + + if (!string.IsNullOrWhiteSpace(href)) + { + builder.Append(" -> "); + builder.Append(href); + } + + var context = Truncate(GetString(link, "context"), 160); + if (!string.IsNullOrWhiteSpace(context)) + { + builder.Append(" (context: "); + builder.Append(context); + builder.Append(')'); + } + + builder.AppendLine(); + count++; + } + } + + private static void AppendList(StringBuilder builder, string label, IEnumerable values, int maxItems, int maxLength) + { + if (values is null) + { + return; + } + + var appendedAny = false; + var count = 0; + + foreach (var value in values) + { + var normalizedValue = Truncate(value?.Trim(), maxLength); + if (string.IsNullOrWhiteSpace(normalizedValue)) + { + continue; + } + + if (!appendedAny) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.AppendLine($"{label}:"); + appendedAny = true; + } + + builder.Append("- "); + builder.AppendLine(normalizedValue); + count++; + + if (count >= maxItems) + { + break; + } + } + } + + private static void AppendLine(StringBuilder builder, string label, string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(label); + builder.Append(": "); + builder.Append(value.Trim()); + } + + private static string GetString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + return null; + } + + return property.GetString(); + } + + private static bool GetBoolean(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.True and not JsonValueKind.False) + { + return false; + } + + return property.GetBoolean(); + } + + private static IEnumerable GetStringArray(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) + { + yield break; + } + + foreach (var item in property.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + yield return item.GetString(); + } + } + } + + private static IEnumerable GetObjectStringArray(JsonElement element, string propertyName, string nestedPropertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) + { + yield break; + } + + foreach (var item in property.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + var value = GetString(item, nestedPropertyName); + if (!string.IsNullOrWhiteSpace(value)) + { + yield return value; + } + } + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + value = value.Trim(); + if (value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } +} diff --git a/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs b/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs index 95d8f5df9..7c1df4e3b 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs @@ -43,14 +43,14 @@ public AIChatHub( protected override ChatContextType GetChatType() => ChatContextType.AIChatSession; - public ChannelReader SendMessage(string profileId, string prompt, string sessionId, string sessionProfileId, CancellationToken cancellationToken) + public ChannelReader SendMessage(string profileId, string prompt, string sessionId, string sessionProfileId, string clientPageContextJson, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(); // Create a child scope for proper ISession/IDocumentStore lifecycle. _ = ShellScope.UsingChildScopeAsync(async scope => { - await HandlePromptAsync(channel.Writer, scope.ServiceProvider, profileId, prompt, sessionId, sessionProfileId, cancellationToken); + await HandlePromptAsync(channel.Writer, scope.ServiceProvider, profileId, prompt, sessionId, sessionProfileId, clientPageContextJson, cancellationToken); }); return channel.Reader; @@ -245,11 +245,12 @@ await ShellScope.UsingChildScopeAsync(async scope => }); } - private async Task HandlePromptAsync(ChannelWriter writer, IServiceProvider services, string profileId, string prompt, string sessionId, string sessionProfileId, CancellationToken cancellationToken) + private async Task HandlePromptAsync(ChannelWriter writer, IServiceProvider services, string profileId, string prompt, string sessionId, string sessionProfileId, string clientPageContextJson, CancellationToken cancellationToken) { try { using var invocationScope = AIInvocationScope.Begin(); + LivePageContextPromptBuilder.Store(invocationScope.Context, clientPageContextJson); if (string.IsNullOrWhiteSpace(profileId)) { @@ -287,12 +288,8 @@ private async Task HandlePromptAsync(ChannelWriter wri } await ProcessUtilityAsync(writer, services, profile, prompt.Trim(), cancellationToken); - - // We don't need to save the session for utility profiles. - return; } - - if (profile.Type == AIProfileType.TemplatePrompt) + else if (profile.Type == AIProfileType.TemplatePrompt) { if (string.IsNullOrWhiteSpace(sessionProfileId)) { @@ -317,6 +314,8 @@ private async Task HandlePromptAsync(ChannelWriter wri // At this point, we are dealing with a chat profile. await ProcessChatPromptAsync(writer, services, profile, sessionId, prompt?.Trim(), cancellationToken); } + + await NavigateCallerIfRequestedAsync(invocationScope.Context); } catch (Exception ex) { @@ -424,6 +423,7 @@ private async Task ProcessChatPromptAsync(ChannelWriter>(); var citationCollector = services.GetRequiredService(); var clock = services.GetRequiredService(); + var effectivePrompt = LivePageContextPromptBuilder.Append(prompt, AIInvocationScope.Current); (var chatSession, var isNew) = await GetSessionAsync(services, sessionId, profile, prompt); @@ -474,6 +474,8 @@ private async Task ProcessChatPromptAsync(ChannelWriter !x.IsGeneratedPrompt) .Select(prompt => new ChatMessage(prompt.Role, prompt.Content))); + ReplaceLatestUserMessage(conversationHistory, effectivePrompt); + // Resolve the chat response handler for this session. // In conversation mode, always use the AI handler for TTS/STT integration. var chatMode = profile.TryGetSettings(out var chatModeSettings) @@ -483,7 +485,7 @@ private async Task ProcessChatPromptAsync(ChannelWriter(); var completionService = services.GetRequiredService(); + var effectivePrompt = LivePageContextPromptBuilder.Append(prompt, AIInvocationScope.Current); var messageId = IdGenerator.GenerateId(); @@ -661,7 +664,7 @@ private static async Task ProcessUtilityAsync(ChannelWriter(); - await foreach (var chunk in completionService.CompleteStreamingAsync(profile.Source, [new ChatMessage(ChatRole.User, prompt)], completionContext, cancellationToken)) + await foreach (var chunk in completionService.CompleteStreamingAsync(profile.Source, [new ChatMessage(ChatRole.User, effectivePrompt)], completionContext, cancellationToken)) { if (string.IsNullOrEmpty(chunk.Text)) { @@ -679,6 +682,52 @@ private static async Task ProcessUtilityAsync(ChannelWriter conversationHistory, string effectivePrompt) + { + if (conversationHistory.Count == 0 || string.IsNullOrWhiteSpace(effectivePrompt)) + { + return; + } + + var latestMessage = conversationHistory[^1]; + if (latestMessage.Role != ChatRole.User) + { + return; + } + + conversationHistory[^1] = new ChatMessage(ChatRole.User, effectivePrompt); + } + + private static bool TryGetRequestedNavigationUrl(AIInvocationContext invocationContext, out string url) + { + url = null; + + if (invocationContext is null) + { + return false; + } + + if (!invocationContext.Items.TryGetValue(AIInvocationItemKeys.LiveNavigationUrl, out var requestedUrl) || + requestedUrl is not string stringUrl || + string.IsNullOrWhiteSpace(stringUrl)) + { + return false; + } + + url = stringUrl; + return true; + } + private static object CreateSessionPayload(AIChatSession chatSession, AIProfile profile, IReadOnlyList prompts) => new { @@ -1068,7 +1117,7 @@ private async Task ProcessConversationPromptAsync( var channel = Channel.CreateUnbounded(); - var handleTask = HandlePromptAsync(channel.Writer, services, profile.ItemId, prompt, sessionId, null, cancellationToken); + var handleTask = HandlePromptAsync(channel.Writer, services, profile.ItemId, prompt, sessionId, null, null, cancellationToken); var sentenceChannel = Channel.CreateUnbounded(); var effectiveSessionId = sessionId; diff --git a/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/IChatHubClient.cs b/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/IChatHubClient.cs index c064e47d9..4717ddd93 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/IChatHubClient.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/IChatHubClient.cs @@ -7,6 +7,8 @@ namespace CrestApps.OrchardCore.AI.Chat.Core.Hubs; /// public interface IChatHubClient { + Task NavigateTo(string url); + Task ReceiveError(string error); Task ReceiveTranscript(string identifier, string text, bool isFinal); diff --git a/src/Core/CrestApps.OrchardCore.AI.Chat.Interactions.Core/Hubs/ChatInteractionHub.cs b/src/Core/CrestApps.OrchardCore.AI.Chat.Interactions.Core/Hubs/ChatInteractionHub.cs index a6a5e3c39..ffa9d5b14 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Chat.Interactions.Core/Hubs/ChatInteractionHub.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Chat.Interactions.Core/Hubs/ChatInteractionHub.cs @@ -43,14 +43,14 @@ public ChatInteractionHub( protected override ChatContextType GetChatType() => ChatContextType.ChatInteraction; - public ChannelReader SendMessage(string itemId, string prompt, CancellationToken cancellationToken) + public ChannelReader SendMessage(string itemId, string prompt, string clientPageContextJson, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(); // Create a child scope for proper ISession/IDocumentStore lifecycle. _ = ShellScope.UsingChildScopeAsync(async scope => { - await HandlePromptAsync(channel.Writer, scope.ServiceProvider, itemId, prompt, cancellationToken); + await HandlePromptAsync(channel.Writer, scope.ServiceProvider, itemId, prompt, clientPageContextJson, cancellationToken); }); return channel.Reader; @@ -367,11 +367,12 @@ await ShellScope.UsingChildScopeAsync(async scope => }); } - private async Task HandlePromptAsync(ChannelWriter writer, IServiceProvider services, string itemId, string prompt, CancellationToken cancellationToken) + private async Task HandlePromptAsync(ChannelWriter writer, IServiceProvider services, string itemId, string prompt, string clientPageContextJson, CancellationToken cancellationToken) { try { using var invocationScope = AIInvocationScope.Begin(); + LivePageContextPromptBuilder.Store(invocationScope.Context, clientPageContextJson); if (string.IsNullOrWhiteSpace(itemId)) { @@ -414,6 +415,7 @@ private async Task HandlePromptAsync(ChannelWriter wri var handlerResolver = services.GetRequiredService(); var citationCollector = services.GetRequiredService(); var clock = services.GetRequiredService(); + var effectivePrompt = LivePageContextPromptBuilder.Append(prompt, AIInvocationScope.Current); // Create and save user prompt var userPrompt = new ChatInteractionPrompt @@ -441,6 +443,8 @@ private async Task HandlePromptAsync(ChannelWriter wri .Select(p => new ChatMessage(p.Role, p.Text)) .ToList(); + ReplaceLatestUserMessage(conversationHistory, effectivePrompt); + // Resolve the chat response handler for this interaction. // In conversation mode, always use the AI handler for TTS/STT integration. var siteService = services.GetRequiredService(); @@ -450,7 +454,7 @@ private async Task HandlePromptAsync(ChannelWriter wri var handlerContext = new ChatResponseHandlerContext { - Prompt = prompt, + Prompt = effectivePrompt, ConnectionId = Context.ConnectionId, SessionId = interaction.ItemId, ChatType = ChatContextType.ChatInteraction, @@ -545,6 +549,8 @@ private async Task HandlePromptAsync(ChannelWriter wri { await interactionManager.UpdateAsync(interaction); } + + await NavigateCallerIfRequestedAsync(invocationScope.Context); } catch (Exception ex) { @@ -578,6 +584,52 @@ private async Task HandlePromptAsync(ChannelWriter wri } } + private async Task NavigateCallerIfRequestedAsync(AIInvocationContext invocationContext) + { + if (!TryGetRequestedNavigationUrl(invocationContext, out var url)) + { + return; + } + + await Clients.Caller.NavigateTo(url); + } + + private static void ReplaceLatestUserMessage(List conversationHistory, string effectivePrompt) + { + if (conversationHistory.Count == 0 || string.IsNullOrWhiteSpace(effectivePrompt)) + { + return; + } + + var latestMessage = conversationHistory[^1]; + if (latestMessage.Role != ChatRole.User) + { + return; + } + + conversationHistory[^1] = new ChatMessage(ChatRole.User, effectivePrompt); + } + + private static bool TryGetRequestedNavigationUrl(AIInvocationContext invocationContext, out string url) + { + url = null; + + if (invocationContext is null) + { + return false; + } + + if (!invocationContext.Items.TryGetValue(AIInvocationItemKeys.LiveNavigationUrl, out var requestedUrl) || + requestedUrl is not string stringUrl || + string.IsNullOrWhiteSpace(stringUrl)) + { + return false; + } + + url = stringUrl; + return true; + } + public async Task StartConversation(string itemId, IAsyncEnumerable audioChunks, string audioFormat = null, string language = null) { if (string.IsNullOrWhiteSpace(itemId)) @@ -907,7 +959,7 @@ private async Task ProcessConversationPromptAsync( var channel = Channel.CreateUnbounded(); - var handleTask = HandlePromptAsync(channel.Writer, services, itemId, prompt, cancellationToken); + var handleTask = HandlePromptAsync(channel.Writer, services, itemId, prompt, null, cancellationToken); var sentenceChannel = Channel.CreateUnbounded(); string messageId = null; diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/AIConstants.cs b/src/Core/CrestApps.OrchardCore.AI.Core/AIConstants.cs index c0f1809a2..79fbf25ec 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Core/AIConstants.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Core/AIConstants.cs @@ -26,6 +26,8 @@ public static class Feature public const string OrchardCoreAIAgent = "CrestApps.OrchardCore.AI.Agent"; + public const string OrchardCoreAIAgentBrowserAutomation = "CrestApps.OrchardCore.AI.Agent.BrowserAutomation"; + public const string ChatCore = "CrestApps.OrchardCore.AI.Chat.Core"; public const string Chat = "CrestApps.OrchardCore.AI.Chat"; diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/Models/DefaultAIOptions.cs b/src/Core/CrestApps.OrchardCore.AI.Core/Models/DefaultAIOptions.cs index 133219885..503fe1faf 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Core/Models/DefaultAIOptions.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Core/Models/DefaultAIOptions.cs @@ -14,7 +14,7 @@ public sealed class DefaultAIOptions public int PastMessagesCount { get; set; } = 10; - public int MaximumIterationsPerRequest { get; set; } = 10; + public int MaximumIterationsPerRequest { get; set; } = 20; public bool EnableOpenTelemetry { get; set; } diff --git a/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md b/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md index 3c54cf684..ded0a5727 100644 --- a/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md +++ b/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md @@ -56,9 +56,19 @@ The browser automation set is organized into these categories: | **Browser Waiting** | Wait for selectors, URL changes, and load states. | | **Browser Troubleshooting** | Capture screenshots, inspect console output, inspect network activity, and diagnose broken pages. | +The built-in browser tools also ship with normalized JSON schema metadata so AI providers receive compact parameter definitions without extra spacer lines between schema entries. + +For navigation-heavy admin tasks, the browser set also includes a dedicated menu-navigation tool that can follow nested labels such as `Search >> Indexes` instead of relying only on direct URLs or generic link inspection. + ### How Browser Sessions Work -Browser automation tools are **stateful**. Start by calling `startBrowserSession`, then keep passing the returned `sessionId` to later browser tools. Session tools also return `pageId` values for tracked tabs so the model can switch tabs or target a specific page when needed. +Browser automation tools are **stateful** and now live behind the optional `CrestApps.OrchardCore.AI.Agent.BrowserAutomation` feature so tenants can enable the core AI Agent without automatically enabling Playwright-based browser control. Start by calling `startBrowserSession`, then keep passing the returned `sessionId` to later browser tools when you want to pin a specific session. Most browser tools also accept the special `default` session alias, which resolves to the most recently used live browser session, so the model does not need an explicit `sessionId` for common single-session navigation flows. + +When no tracked session exists yet, the `default` alias now attempts to auto-start a Playwright session from the current AI Chat page URL. If the chat is rendered inside an iframe widget, the widget passes the parent page URL when available so browser navigation can start from the host page instead of the iframe shell. For same-origin pages, the current request cookies are also copied into the Playwright context so authenticated admin navigation can reuse the active Orchard Core sign-in session. + +For direct page navigation requests, the chat clients also listen for a live `NavigateTo` SignalR command. When a browser navigation tool resolves a same-origin destination, the current page now redirects in the user browser as well, so commands like `go to Search >> Indexes` can move the visible Orchard Core admin page instead of only updating the mirrored Playwright session. + +The chat clients now also capture a compact summary of the real visible page DOM when a prompt is sent, including the current URL, title, headings, visible links, visible buttons, and a short text preview. That live page summary is appended only to the model-facing prompt for the current invocation, so the AI can reason about the page you are actually looking at without polluting the saved user transcript. The tools are intentionally granular. A typical browser workflow looks like this: diff --git a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md index 1834fd276..682c972e9 100644 --- a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md +++ b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md @@ -169,6 +169,13 @@ A new suite of modules for multi-channel communication: - **Chat UI Notifications** — New extensible notification system that allows C# code to send transient system messages to the chat interface via SignalR — no JavaScript required. Built-in notifications include typing indicators ("Mike is typing…"), transfer status with estimated wait times and cancel buttons, agent-connected indicators, and conversation/session ended indicators. The `IChatNotificationSender` interface provides `SendAsync`, `UpdateAsync`, and `RemoveAsync` methods. Well-known notification types are available via `ChatNotificationTypes` and action names via `ChatNotificationActionNames`. Notifications are created using `new ChatNotification("type")` with the `Type` serving as the sole identifier — e.g., `new ChatNotification(ChatNotificationTypes.Typing)`. All user-facing strings accept `IStringLocalizer` for full localization support. `ChatNotification` requires a `type` parameter in its constructor and the `Type` setter is private. Notification system messages support action buttons that trigger server-side `IChatNotificationActionHandler` callbacks (registered as keyed services). Built-in action handlers: `cancel-transfer` (resets handler to AI) and `end-session` (closes the session). The system uses an extensible transport architecture — `IChatNotificationTransport` implementations are registered as keyed services by `ChatContextType`, allowing third-party modules to add notification support for custom hubs. Custom notification types with custom actions and styling are fully supported. See the [Chat UI Notifications documentation](../ai/chat-notifications.md) for details. - **`SpeechTextSanitizer` Utility** — Extracted `SanitizeForSpeech` from both hub classes into a shared `SpeechTextSanitizer.Sanitize()` static method in `CrestApps.OrchardCore.AI.Core.Services`. This utility strips markdown formatting, code blocks, emoji, and other non-speech elements from text before passing it to a text-to-speech engine. Available for reuse by any module. - **`CrestApps.OrchardCore.AI.Chat.Core` Library** — Introduced a new core library (`src/Core/CrestApps.OrchardCore.AI.Chat.Core`) containing `AIChatHub`, `ChatInteractionHub`, and shared hub infrastructure. Both hubs now inherit from a common `ChatHubBase` base class that provides shared text-to-speech streaming, conversation stop, and sentence-level speech synthesis methods. Client interfaces (`IAIChatHubClient`, `IChatInteractionHubClient`) extend a shared `IChatHubClient` interface. External modules can reference the Core library to resolve `IHubContext` or `IHubContext` for sending deferred messages without depending on the module projects directly. Static helper methods `AIChatHub.GetSessionGroupName()` and `ChatInteractionHub.GetInteractionGroupName()` are public for use in webhook endpoints. +- **AI Agent tool schema cleanup** — Built-in AI Agent functions now emit compact JSON schema raw strings without extra blank spacer lines, improving consistency across providers and making browser automation tool definitions easier for models to consume. The `listWorkflowActivities` tool was also corrected so it no longer rejects calls by requiring an unused `workflowTypeId` argument. +- **Browser menu navigation** — The AI Agent browser toolset now includes a dedicated nested-menu navigation tool for Orchard Core admin and site navigation paths such as `Search >> Indexes`. Browser automation registrations were also consolidated into the main AI Agent startup path so the browser service and tools are registered together from `Startup.cs`. +- **Browser default session alias** — Browser tools now treat `default` as an alias for the most recently used live browser session. When no session exists, the returned error now explicitly tells callers to run `startBrowserSession` first instead of failing with an opaque missing-session message. +- **Browser session auto-bootstrap from chat context** — When browser tools are invoked through AI Chat without an existing session, the `default` alias now auto-starts a Playwright session from the current chat page URL. Embedded widgets also forward the parent page URL when available, and same-origin request cookies are copied into the Playwright context so admin navigation can follow the active Orchard Core user session more reliably. +- **Live browser redirects for AI navigation** — AI Chat and Chat Interaction clients now support a `NavigateTo` SignalR command. When browser navigation tools resolve a same-origin destination, the caller browser is redirected to that page (including parent-page redirects for iframe widgets) so the visible UI can follow the AI navigation request instead of leaving the user on the original page while only the server-side Playwright mirror moves. +- **Live DOM bridge for AI prompts** — AI Chat and Chat Interaction now send a compact snapshot of the real visible page with each prompt, including the URL, title, headings, visible links, visible buttons, and a text preview. The server stores that snapshot in invocation scope and appends it only to the model-facing prompt, letting the AI reason about the DOM the user is currently seeing without altering the saved chat transcript. +- **Optional AI Agent browser automation feature** — Playwright-based browser session management and browser automation tools now live in a dedicated Orchard Core feature, `CrestApps.OrchardCore.AI.Agent.BrowserAutomation`, so tenants can enable the AI Agent without also enabling browser control. Browser tool schemas were also updated so `sessionId` is no longer advertised as required when the `default` session alias is supported, preventing the model from unnecessarily asking for a session identifier during normal navigation flows. ### MCP (`CrestApps.OrchardCore.AI.Mcp`) diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/AgentConstants.cs similarity index 75% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/AgentConstants.cs index ea306d1e3..f47645b00 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationConstants.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/AgentConstants.cs @@ -1,23 +1,25 @@ -using System; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -internal static class BrowserAutomationConstants -{ - public const string SessionsCategory = "Browser Sessions"; - public const string NavigationCategory = "Browser Navigation"; - public const string InspectionCategory = "Browser Inspection"; - public const string InteractionCategory = "Browser Interaction"; - public const string FormsCategory = "Browser Forms"; - public const string WaitingCategory = "Browser Waiting"; - public const string TroubleshootingCategory = "Browser Troubleshooting"; - - public const int DefaultTimeoutMs = 30_000; - public const int MaxTimeoutMs = 120_000; - public const int DefaultMaxItems = 25; - public const int MaxCollectionItems = 100; - public const int MaxStoredConsoleMessages = 200; - public const int MaxStoredNetworkEvents = 300; - public const int DefaultMaxTextLength = 4_000; - public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromMinutes(30); -} +namespace CrestApps.OrchardCore.AI.Agent; + +internal static class AgentConstants +{ + public const string DefaultSessionId = "default"; + public const string BrowserPageUrlQueryKey = "browserPageUrl"; + public const string BrowserParentPageUrlQueryKey = "browserParentPageUrl"; + + public const string SessionsCategory = "Browser Sessions"; + public const string NavigationCategory = "Browser Navigation"; + public const string InspectionCategory = "Browser Inspection"; + public const string InteractionCategory = "Browser Interaction"; + public const string FormsCategory = "Browser Forms"; + public const string WaitingCategory = "Browser Waiting"; + public const string TroubleshootingCategory = "Browser Troubleshooting"; + + public const int DefaultTimeoutMs = 30_000; + public const int MaxTimeoutMs = 120_000; + public const int DefaultMaxItems = 25; + public const int MaxCollectionItems = 100; + public const int MaxStoredConsoleMessages = 200; + public const int MaxStoredNetworkEvents = 300; + public const int DefaultMaxTextLength = 4_000; + public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromMinutes(30); +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs deleted file mode 100644 index b9bf3534f..000000000 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomationStartup.cs +++ /dev/null @@ -1,275 +0,0 @@ -using CrestApps.OrchardCore.AI; -using CrestApps.OrchardCore.AI.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; -using OrchardCore.Modules; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -[Feature(AIConstants.Feature.OrchardCoreAIAgent)] -public sealed class BrowserAutomationStartup : StartupBase -{ - internal readonly IStringLocalizer S; - - public BrowserAutomationStartup(IStringLocalizer stringLocalizer) - { - S = stringLocalizer; - } - - public override void ConfigureServices(IServiceCollection services) - { - services.AddSingleton(); - - RegisterSessionTools(services); - RegisterNavigationTools(services); - RegisterInspectionTools(services); - RegisterInteractionTools(services); - RegisterFormTools(services); - RegisterWaitingTools(services); - RegisterTroubleshootingTools(services); - } - - private void RegisterSessionTools(IServiceCollection services) - { - services.AddAITool(StartBrowserSessionTool.TheName) - .WithTitle(S["Start Browser Session"]) - .WithDescription(S["Launches a Playwright browser session and optionally opens an initial page."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(CloseBrowserSessionTool.TheName) - .WithTitle(S["Close Browser Session"]) - .WithDescription(S["Closes a tracked Playwright browser session and disposes its tabs."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(ListBrowserSessionsTool.TheName) - .WithTitle(S["List Browser Sessions"]) - .WithDescription(S["Lists tracked Playwright browser sessions and their tabs."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(GetBrowserSessionTool.TheName) - .WithTitle(S["Get Browser Session"]) - .WithDescription(S["Retrieves details about a tracked Playwright browser session."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(OpenBrowserTabTool.TheName) - .WithTitle(S["Open Browser Tab"]) - .WithDescription(S["Opens a new tab in a tracked browser session."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(CloseBrowserTabTool.TheName) - .WithTitle(S["Close Browser Tab"]) - .WithDescription(S["Closes a tab in a tracked browser session."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - - services.AddAITool(SwitchBrowserTabTool.TheName) - .WithTitle(S["Switch Browser Tab"]) - .WithDescription(S["Marks a browser tab as the active tab for subsequent actions."]) - .WithCategory(S["Browser Sessions"]) - .Selectable(); - } - - private void RegisterNavigationTools(IServiceCollection services) - { - services.AddAITool(NavigateBrowserTool.TheName) - .WithTitle(S["Navigate Browser"]) - .WithDescription(S["Navigates a tab to a URL."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - - services.AddAITool(GoBackBrowserTool.TheName) - .WithTitle(S["Go Back"]) - .WithDescription(S["Navigates backward in browser history."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - - services.AddAITool(GoForwardBrowserTool.TheName) - .WithTitle(S["Go Forward"]) - .WithDescription(S["Navigates forward in browser history."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - - services.AddAITool(ReloadBrowserPageTool.TheName) - .WithTitle(S["Reload Page"]) - .WithDescription(S["Reloads the current page."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - - services.AddAITool(ScrollBrowserPageTool.TheName) - .WithTitle(S["Scroll Page"]) - .WithDescription(S["Scrolls the current page vertically or horizontally."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - - services.AddAITool(ScrollBrowserElementIntoViewTool.TheName) - .WithTitle(S["Scroll Element Into View"]) - .WithDescription(S["Scrolls a specific element into the viewport."]) - .WithCategory(S["Browser Navigation"]) - .Selectable(); - } - - private void RegisterInspectionTools(IServiceCollection services) - { - services.AddAITool(GetBrowserPageStateTool.TheName) - .WithTitle(S["Get Page State"]) - .WithDescription(S["Returns high-level state for the current page."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserPageContentTool.TheName) - .WithTitle(S["Get Page Content"]) - .WithDescription(S["Returns page text and HTML for the full page or a selected element."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserLinksTool.TheName) - .WithTitle(S["Get Page Links"]) - .WithDescription(S["Lists the links found on the current page."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserFormsTool.TheName) - .WithTitle(S["Get Page Forms"]) - .WithDescription(S["Lists forms and their fields on the current page."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserHeadingsTool.TheName) - .WithTitle(S["Get Page Headings"]) - .WithDescription(S["Lists headings found on the current page."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserButtonsTool.TheName) - .WithTitle(S["Get Page Buttons"]) - .WithDescription(S["Lists button-like controls found on the current page."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - - services.AddAITool(GetBrowserElementInfoTool.TheName) - .WithTitle(S["Get Element Info"]) - .WithDescription(S["Returns detailed information about a selected element."]) - .WithCategory(S["Browser Inspection"]) - .Selectable(); - } - - private void RegisterInteractionTools(IServiceCollection services) - { - services.AddAITool(ClickBrowserElementTool.TheName) - .WithTitle(S["Click Element"]) - .WithDescription(S["Clicks a page element."]) - .WithCategory(S["Browser Interaction"]) - .Selectable(); - - services.AddAITool(DoubleClickBrowserElementTool.TheName) - .WithTitle(S["Double Click Element"]) - .WithDescription(S["Double-clicks a page element."]) - .WithCategory(S["Browser Interaction"]) - .Selectable(); - - services.AddAITool(HoverBrowserElementTool.TheName) - .WithTitle(S["Hover Element"]) - .WithDescription(S["Moves the mouse over a page element."]) - .WithCategory(S["Browser Interaction"]) - .Selectable(); - - services.AddAITool(PressBrowserKeyTool.TheName) - .WithTitle(S["Press Key"]) - .WithDescription(S["Sends a keyboard key or shortcut to the page."]) - .WithCategory(S["Browser Interaction"]) - .Selectable(); - } - - private void RegisterFormTools(IServiceCollection services) - { - services.AddAITool(FillBrowserInputTool.TheName) - .WithTitle(S["Fill Input"]) - .WithDescription(S["Fills an input, textarea, or editable element."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - - services.AddAITool(ClearBrowserInputTool.TheName) - .WithTitle(S["Clear Input"]) - .WithDescription(S["Clears an input or textarea value."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - - services.AddAITool(SelectBrowserOptionTool.TheName) - .WithTitle(S["Select Option"]) - .WithDescription(S["Selects one or more values in a select element."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - - services.AddAITool(CheckBrowserElementTool.TheName) - .WithTitle(S["Check Element"]) - .WithDescription(S["Checks a checkbox or radio button."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - - services.AddAITool(UncheckBrowserElementTool.TheName) - .WithTitle(S["Uncheck Element"]) - .WithDescription(S["Unchecks a checkbox."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - - services.AddAITool(UploadBrowserFilesTool.TheName) - .WithTitle(S["Upload Files"]) - .WithDescription(S["Uploads local files into a file input element."]) - .WithCategory(S["Browser Forms"]) - .Selectable(); - } - - private void RegisterWaitingTools(IServiceCollection services) - { - services.AddAITool(WaitForBrowserElementTool.TheName) - .WithTitle(S["Wait For Element"]) - .WithDescription(S["Waits for a selector to reach a requested state."]) - .WithCategory(S["Browser Waiting"]) - .Selectable(); - - services.AddAITool(WaitForBrowserNavigationTool.TheName) - .WithTitle(S["Wait For Navigation"]) - .WithDescription(S["Waits for navigation or a URL change."]) - .WithCategory(S["Browser Waiting"]) - .Selectable(); - - services.AddAITool(WaitForBrowserLoadStateTool.TheName) - .WithTitle(S["Wait For Load State"]) - .WithDescription(S["Waits for a page to reach a specific load state."]) - .WithCategory(S["Browser Waiting"]) - .Selectable(); - } - - private void RegisterTroubleshootingTools(IServiceCollection services) - { - services.AddAITool(CaptureBrowserScreenshotTool.TheName) - .WithTitle(S["Capture Screenshot"]) - .WithDescription(S["Captures a screenshot of the current page."]) - .WithCategory(S["Browser Troubleshooting"]) - .Selectable(); - - services.AddAITool(GetBrowserConsoleMessagesTool.TheName) - .WithTitle(S["Get Console Messages"]) - .WithDescription(S["Returns recent console messages and page errors."]) - .WithCategory(S["Browser Troubleshooting"]) - .Selectable(); - - services.AddAITool(GetBrowserNetworkActivityTool.TheName) - .WithTitle(S["Get Network Activity"]) - .WithDescription(S["Returns recent network requests and responses."]) - .WithCategory(S["Browser Troubleshooting"]) - .Selectable(); - - services.AddAITool(DiagnoseBrowserPageTool.TheName) - .WithTitle(S["Diagnose Page"]) - .WithDescription(S["Collects a troubleshooting snapshot for the current page."]) - .WithCategory(S["Browser Troubleshooting"]) - .Selectable(); - } -} - diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Manifest.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Manifest.cs index 6ffca3600..d4ce6975d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Manifest.cs @@ -16,3 +16,14 @@ "CrestApps.OrchardCore.Recipes", ] )] + +[assembly: Feature( + Id = AIConstants.Feature.OrchardCoreAIAgentBrowserAutomation, + Name = "AI Agent Browser Automation", + Description = "Provides optional Playwright-powered browser automation tools for the AI Agent so tenants can enable browser control separately from the core AI Agent tools.", + Category = "Artificial Intelligence", + Dependencies = + [ + AIConstants.Feature.OrchardCoreAIAgent, + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Properties/AssemblyInfo.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..2a0d8854c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CrestApps.OrchardCore.Tests")] diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationJson.cs similarity index 88% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationJson.cs index 733b59e8d..c66b2dc19 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationJson.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationJson.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; +namespace CrestApps.OrchardCore.AI.Agent.Services; internal static class BrowserAutomationJson { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationPage.cs similarity index 89% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationPage.cs index 7b3181b8a..cd57586d9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationPage.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationPage.cs @@ -1,32 +1,32 @@ -using System.Collections.Concurrent; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -internal sealed class BrowserAutomationPage -{ - public BrowserAutomationPage(string pageId, IPage page, DateTime createdUtc) - { - PageId = pageId; - Page = page; - CreatedUtc = createdUtc; - LastTouchedUtc = createdUtc; - } - - public string PageId { get; } - - public IPage Page { get; } - - public DateTime CreatedUtc { get; } - - public DateTime LastTouchedUtc { get; private set; } - - public ConcurrentQueue> ConsoleMessages { get; } = new(); - - public ConcurrentQueue> NetworkEvents { get; } = new(); - - public ConcurrentQueue PageErrors { get; } = new(); - - public void Touch(DateTime utc) - => LastTouchedUtc = utc; -} +using System.Collections.Concurrent; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Services; + +internal sealed class BrowserAutomationPage +{ + public BrowserAutomationPage(string pageId, IPage page, DateTime createdUtc) + { + PageId = pageId; + Page = page; + CreatedUtc = createdUtc; + LastTouchedUtc = createdUtc; + } + + public string PageId { get; } + + public IPage Page { get; } + + public DateTime CreatedUtc { get; } + + public DateTime LastTouchedUtc { get; private set; } + + public ConcurrentQueue> ConsoleMessages { get; } = new(); + + public ConcurrentQueue> NetworkEvents { get; } = new(); + + public ConcurrentQueue PageErrors { get; } = new(); + + public void Touch(DateTime utc) + => LastTouchedUtc = utc; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationResultFactory.cs similarity index 80% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationResultFactory.cs index 215bb9452..01761f034 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationResultFactory.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationResultFactory.cs @@ -1,4 +1,4 @@ -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; +namespace CrestApps.OrchardCore.AI.Agent.Services; internal static class BrowserAutomationResultFactory { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationService.cs similarity index 66% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationService.cs index 48fce4f91..79b1db81d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationService.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationService.cs @@ -1,475 +1,668 @@ -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using OrchardCore.Modules; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class BrowserAutomationService : IAsyncDisposable -{ - private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); - private readonly global::OrchardCore.Modules.IClock _clock; - private readonly ILogger _logger; - - public BrowserAutomationService( - global::OrchardCore.Modules.IClock clock, - ILogger logger) - { - _clock = clock; - _logger = logger; - } - - public async Task>> ListSessionsAsync(CancellationToken cancellationToken) - { - await CleanupExpiredSessionsAsync(cancellationToken); - - var snapshots = new List>(); - - foreach (var sessionId in _sessions.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - { - snapshots.Add(await GetSessionSnapshotAsync(sessionId, cancellationToken)); - } - - return snapshots; - } - - public async Task> GetSessionSnapshotAsync(string sessionId, CancellationToken cancellationToken) - { - return await WithSessionAsync(sessionId, BuildSessionSnapshotAsync, cancellationToken); - } - - public async Task> CreateSessionAsync( - string browserType, - bool headless, - string startUrl, - int? viewportWidth, - int? viewportHeight, - string locale, - string userAgent, - int timeoutMs, - CancellationToken cancellationToken) - { - await CleanupExpiredSessionsAsync(cancellationToken); - - browserType = NormalizeBrowserType(browserType); - - var createdUtc = _clock.UtcNow; - var sessionId = Guid.NewGuid().ToString("n"); - var playwright = await Playwright.CreateAsync(); - IBrowser browser = null; - IBrowserContext context = null; - - try - { - browser = await LaunchBrowserAsync(playwright, browserType, headless, timeoutMs); - - var contextOptions = new BrowserNewContextOptions(); - if (viewportWidth.HasValue && viewportHeight.HasValue) - { - contextOptions.ViewportSize = new ViewportSize - { - Width = viewportWidth.Value, - Height = viewportHeight.Value, - }; - } - - if (!string.IsNullOrWhiteSpace(locale)) - { - contextOptions.Locale = locale.Trim(); - } - - if (!string.IsNullOrWhiteSpace(userAgent)) - { - contextOptions.UserAgent = userAgent.Trim(); - } - - context = await browser.NewContextAsync(contextOptions); - - var session = new BrowserAutomationSession(sessionId, browserType, headless, playwright, browser, context, createdUtc); - _sessions[sessionId] = session; - - var page = await context.NewPageAsync(); - var trackedPage = TrackPage(session, page); - - if (!string.IsNullOrWhiteSpace(startUrl)) - { - await page.GotoAsync(startUrl.Trim(), new PageGotoOptions - { - Timeout = timeoutMs, - WaitUntil = WaitUntilState.Load, - }); - } - - return await BuildSessionSnapshotAsync(session); - } - catch (PlaywrightException) when (context is not null || browser is not null) - { - if (context is not null) - { - await context.CloseAsync(); - } - - if (browser is not null) - { - await browser.CloseAsync(); - } - - playwright.Dispose(); - _sessions.TryRemove(sessionId, out _); - throw; - } - } - - public async Task> CreatePageAsync( - string sessionId, - string url, - WaitUntilState waitUntil, - int timeoutMs, - CancellationToken cancellationToken) - { - return await WithSessionAsync(sessionId, async session => - { - var page = await session.Context.NewPageAsync(); - var trackedPage = TrackPage(session, page); - - if (!string.IsNullOrWhiteSpace(url)) - { - await page.GotoAsync(url.Trim(), new PageGotoOptions - { - Timeout = timeoutMs, - WaitUntil = waitUntil, - }); - } - - return await BuildPageSnapshotAsync(session, trackedPage); - }, cancellationToken); - } - - public async Task> SwitchActivePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) - { - return await WithSessionAsync(sessionId, async session => - { - if (!session.Pages.TryGetValue(pageId, out var trackedPage)) - { - throw new InvalidOperationException($"Page '{pageId}' was not found for session '{sessionId}'."); - } - - session.ActivePageId = trackedPage.PageId; - trackedPage.Touch(_clock.UtcNow); - return await BuildPageSnapshotAsync(session, trackedPage); - }, cancellationToken); - } - - public async Task> ClosePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) - { - return await WithSessionAsync(sessionId, async session => - { - var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); - var snapshot = await BuildPageSnapshotAsync(session, trackedPage); - - session.Pages.TryRemove(trackedPage.PageId, out _); - - if (!trackedPage.Page.IsClosed) - { - await trackedPage.Page.CloseAsync(); - } - - session.ActivePageId = session.Pages.Values - .OrderByDescending(x => x.LastTouchedUtc) - .Select(x => x.PageId) - .FirstOrDefault(); - - return snapshot; - }, cancellationToken); - } - - public async Task> CloseSessionAsync(string sessionId, CancellationToken cancellationToken) - { - if (!_sessions.TryRemove(sessionId, out var session)) - { - throw new InvalidOperationException($"Browser session '{sessionId}' was not found."); - } - - await session.Gate.WaitAsync(cancellationToken); - try - { - var snapshot = await BuildSessionSnapshotAsync(session); - - foreach (var trackedPage in session.Pages.Values) - { - if (!trackedPage.Page.IsClosed) - { - await trackedPage.Page.CloseAsync(); - } - } - - await session.Context.CloseAsync(); - await session.Browser.CloseAsync(); - session.Playwright.Dispose(); - - return snapshot; - } - finally - { - session.Gate.Release(); - session.Gate.Dispose(); - } - } - - internal async Task WithSessionAsync( - string sessionId, - Func> action, - CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - ArgumentNullException.ThrowIfNull(action); - - await CleanupExpiredSessionsAsync(cancellationToken); - - if (!_sessions.TryGetValue(sessionId, out var session)) - { - throw new InvalidOperationException($"Browser session '{sessionId}' was not found."); - } - - await session.Gate.WaitAsync(cancellationToken); - try - { - session.Touch(_clock.UtcNow); - return await action(session); - } - finally - { - session.Gate.Release(); - } - } - - internal async Task WithPageAsync( - string sessionId, - string pageId, - Func> action, - CancellationToken cancellationToken) - { - return await WithSessionAsync(sessionId, async session => - { - var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); - trackedPage.Touch(_clock.UtcNow); - session.ActivePageId = trackedPage.PageId; - return await action(session, trackedPage); - }, cancellationToken); - } - - public async ValueTask DisposeAsync() - { - foreach (var sessionId in _sessions.Keys.ToArray()) - { - await CloseSessionAsync(sessionId, CancellationToken.None); - } - } - - private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken) - { - var now = _clock.UtcNow; - var expirationCutoff = now - BrowserAutomationConstants.SessionIdleTimeout; - var expiredSessionIds = _sessions.Values - .Where(x => x.LastTouchedUtc < expirationCutoff) - .Select(x => x.SessionId) - .ToArray(); - - foreach (var sessionId in expiredSessionIds) - { - if (_logger.IsEnabled(LogLevel.Information)) - { - _logger.LogInformation("Closing expired browser automation session '{SessionId}'.", sessionId); - } - - await CloseSessionAsync(sessionId, cancellationToken); - } - } - - private static async Task LaunchBrowserAsync(IPlaywright playwright, string browserType, bool headless, int timeoutMs) - { - var options = new BrowserTypeLaunchOptions - { - Headless = headless, - Timeout = timeoutMs, - }; - - return browserType switch - { - "chromium" => await playwright.Chromium.LaunchAsync(options), - "firefox" => await playwright.Firefox.LaunchAsync(options), - "webkit" => await playwright.Webkit.LaunchAsync(options), - _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), - }; - } - - private BrowserAutomationPage TrackPage(BrowserAutomationSession session, IPage page) - { - var pageId = $"page-{Interlocked.Increment(ref session.PageSequence)}"; - var trackedPage = new BrowserAutomationPage(pageId, page, _clock.UtcNow); - session.Pages[pageId] = trackedPage; - session.ActivePageId = pageId; - - page.Console += (_, message) => - { - var consoleEntry = new Dictionary - { - ["pageId"] = pageId, - ["type"] = message.Type, - ["text"] = message.Text, - ["timestampUtc"] = _clock.UtcNow, - }; - - EnqueueLimited(trackedPage.ConsoleMessages, consoleEntry, BrowserAutomationConstants.MaxStoredConsoleMessages); - }; - - page.PageError += (_, error) => - { - EnqueueLimited(trackedPage.PageErrors, error, BrowserAutomationConstants.MaxStoredConsoleMessages); - EnqueueLimited(trackedPage.ConsoleMessages, new Dictionary - { - ["pageId"] = pageId, - ["type"] = "pageerror", - ["text"] = error, - ["timestampUtc"] = _clock.UtcNow, - }, BrowserAutomationConstants.MaxStoredConsoleMessages); - }; - - page.Request += (_, request) => - { - EnqueueLimited(trackedPage.NetworkEvents, new Dictionary - { - ["pageId"] = pageId, - ["phase"] = "request", - ["method"] = request.Method, - ["url"] = request.Url, - ["resourceType"] = request.ResourceType, - ["timestampUtc"] = _clock.UtcNow, - }, BrowserAutomationConstants.MaxStoredNetworkEvents); - }; - - page.Response += (_, response) => - { - EnqueueLimited(trackedPage.NetworkEvents, new Dictionary - { - ["pageId"] = pageId, - ["phase"] = "response", - ["url"] = response.Url, - ["status"] = response.Status, - ["ok"] = response.Ok, - ["timestampUtc"] = _clock.UtcNow, - }, BrowserAutomationConstants.MaxStoredNetworkEvents); - }; - - page.RequestFailed += (_, request) => - { - EnqueueLimited(trackedPage.NetworkEvents, new Dictionary - { - ["pageId"] = pageId, - ["phase"] = "requestfailed", - ["method"] = request.Method, - ["url"] = request.Url, - ["resourceType"] = request.ResourceType, - ["timestampUtc"] = _clock.UtcNow, - }, BrowserAutomationConstants.MaxStoredNetworkEvents); - }; - - return trackedPage; - } - - private async Task ResolvePageAsync( - BrowserAutomationSession session, - string pageId, - CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(pageId) && session.Pages.TryGetValue(pageId, out var explicitPage)) - { - return explicitPage; - } - - if (!string.IsNullOrWhiteSpace(pageId)) - { - throw new InvalidOperationException($"Page '{pageId}' was not found for session '{session.SessionId}'."); - } - - if (!string.IsNullOrWhiteSpace(session.ActivePageId) && session.Pages.TryGetValue(session.ActivePageId, out var activePage)) - { - return activePage; - } - - var page = await session.Context.NewPageAsync(); - return TrackPage(session, page); - } - - private async Task> BuildSessionSnapshotAsync(BrowserAutomationSession session) - { - var pages = new List>(); - - foreach (var trackedPage in session.Pages.Values.OrderBy(x => x.CreatedUtc)) - { - pages.Add(await BuildPageSnapshotAsync(session, trackedPage)); - } - - return new Dictionary - { - ["sessionId"] = session.SessionId, - ["browserType"] = session.BrowserType, - ["headless"] = session.Headless, - ["createdUtc"] = session.CreatedUtc, - ["lastTouchedUtc"] = session.LastTouchedUtc, - ["activePageId"] = session.ActivePageId ?? string.Empty, - ["pageCount"] = pages.Count, - ["pages"] = pages, - }; - } - - private static async Task> BuildPageSnapshotAsync(BrowserAutomationSession session, BrowserAutomationPage trackedPage) - { - var snapshot = new Dictionary - { - ["sessionId"] = session.SessionId, - ["pageId"] = trackedPage.PageId, - ["isActive"] = string.Equals(session.ActivePageId, trackedPage.PageId, StringComparison.OrdinalIgnoreCase), - ["isClosed"] = trackedPage.Page.IsClosed, - ["url"] = trackedPage.Page.Url ?? string.Empty, - ["createdUtc"] = trackedPage.CreatedUtc, - ["lastTouchedUtc"] = trackedPage.LastTouchedUtc, - ["consoleMessageCount"] = trackedPage.ConsoleMessages.Count, - ["networkEventCount"] = trackedPage.NetworkEvents.Count, - ["pageErrorCount"] = trackedPage.PageErrors.Count, - }; - - if (!trackedPage.Page.IsClosed) - { - snapshot["title"] = await trackedPage.Page.TitleAsync(); - } - - return snapshot; - } - - private static void EnqueueLimited(ConcurrentQueue queue, T item, int limit) - { - queue.Enqueue(item); - while (queue.Count > limit && queue.TryDequeue(out _)) - { - } - } - - private static string NormalizeBrowserType(string browserType) - { - if (string.IsNullOrWhiteSpace(browserType)) - { - return "chromium"; - } - - browserType = browserType.Trim().ToLowerInvariant(); - return browserType switch - { - "chromium" or "chrome" => "chromium", - "firefox" => "firefox", - "webkit" => "webkit", - _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), - }; - } -} +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Services; + +public sealed class BrowserAutomationService : IAsyncDisposable +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); + private readonly global::OrchardCore.Modules.IClock _clock; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public BrowserAutomationService( + global::OrchardCore.Modules.IClock clock, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _clock = clock; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task>> ListSessionsAsync(CancellationToken cancellationToken) + { + await CleanupExpiredSessionsAsync(cancellationToken); + + var snapshots = new List>(); + + foreach (var sessionId in _sessions.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + snapshots.Add(await GetSessionSnapshotAsync(sessionId, cancellationToken)); + } + + return snapshots; + } + + public async Task> GetSessionSnapshotAsync(string sessionId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, BuildSessionSnapshotAsync, cancellationToken); + } + + public async Task> CreateSessionAsync( + string browserType, + bool headless, + string startUrl, + int? viewportWidth, + int? viewportHeight, + string locale, + string userAgent, + int timeoutMs, + CancellationToken cancellationToken) + { + await CleanupExpiredSessionsAsync(cancellationToken); + + browserType = NormalizeBrowserType(browserType); + + var createdUtc = _clock.UtcNow; + var sessionId = Guid.NewGuid().ToString("n"); + var playwright = await Playwright.CreateAsync(); + IBrowser browser = null; + IBrowserContext context = null; + + try + { + browser = await LaunchBrowserAsync(playwright, browserType, headless, timeoutMs); + + var contextOptions = new BrowserNewContextOptions(); + if (viewportWidth.HasValue && viewportHeight.HasValue) + { + contextOptions.ViewportSize = new ViewportSize + { + Width = viewportWidth.Value, + Height = viewportHeight.Value, + }; + } + + if (!string.IsNullOrWhiteSpace(locale)) + { + contextOptions.Locale = locale.Trim(); + } + + if (!string.IsNullOrWhiteSpace(userAgent)) + { + contextOptions.UserAgent = userAgent.Trim(); + } + + context = await browser.NewContextAsync(contextOptions); + await CopyCurrentRequestCookiesAsync(context, startUrl); + + var session = new BrowserAutomationSession(sessionId, browserType, headless, playwright, browser, context, createdUtc); + _sessions[sessionId] = session; + + var page = await context.NewPageAsync(); + var trackedPage = TrackPage(session, page); + + if (!string.IsNullOrWhiteSpace(startUrl)) + { + await page.GotoAsync(startUrl.Trim(), new PageGotoOptions + { + Timeout = timeoutMs, + WaitUntil = WaitUntilState.Load, + }); + } + + return await BuildSessionSnapshotAsync(session); + } + catch (PlaywrightException) when (context is not null || browser is not null) + { + if (context is not null) + { + await context.CloseAsync(); + } + + if (browser is not null) + { + await browser.CloseAsync(); + } + + playwright.Dispose(); + _sessions.TryRemove(sessionId, out _); + throw; + } + } + + public async Task> CreatePageAsync( + string sessionId, + string url, + WaitUntilState waitUntil, + int timeoutMs, + CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var page = await session.Context.NewPageAsync(); + var trackedPage = TrackPage(session, page); + + if (!string.IsNullOrWhiteSpace(url)) + { + await page.GotoAsync(url.Trim(), new PageGotoOptions + { + Timeout = timeoutMs, + WaitUntil = waitUntil, + }); + } + + return await BuildPageSnapshotAsync(session, trackedPage); + }, cancellationToken); + } + + public async Task> SwitchActivePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + if (!session.Pages.TryGetValue(pageId, out var trackedPage)) + { + throw new InvalidOperationException($"Page '{pageId}' was not found for session '{sessionId}'."); + } + + session.ActivePageId = trackedPage.PageId; + trackedPage.Touch(_clock.UtcNow); + return await BuildPageSnapshotAsync(session, trackedPage); + }, cancellationToken); + } + + public async Task> ClosePageAsync(string sessionId, string pageId, CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); + var snapshot = await BuildPageSnapshotAsync(session, trackedPage); + + session.Pages.TryRemove(trackedPage.PageId, out _); + + if (!trackedPage.Page.IsClosed) + { + await trackedPage.Page.CloseAsync(); + } + + session.ActivePageId = session.Pages.Values + .OrderByDescending(x => x.LastTouchedUtc) + .Select(x => x.PageId) + .FirstOrDefault(); + + return snapshot; + }, cancellationToken); + } + + public async Task> CloseSessionAsync(string sessionId, CancellationToken cancellationToken) + { + await CleanupExpiredSessionsAsync(cancellationToken); + + var resolvedSessionId = ResolveRequestedSessionId(sessionId, _sessions.Values); + if (!_sessions.TryRemove(resolvedSessionId, out var session)) + { + throw new InvalidOperationException($"Browser session '{resolvedSessionId}' was not found."); + } + + await session.Gate.WaitAsync(cancellationToken); + try + { + var snapshot = await BuildSessionSnapshotAsync(session); + + foreach (var trackedPage in session.Pages.Values) + { + if (!trackedPage.Page.IsClosed) + { + await trackedPage.Page.CloseAsync(); + } + } + + await session.Context.CloseAsync(); + await session.Browser.CloseAsync(); + session.Playwright.Dispose(); + + return snapshot; + } + finally + { + session.Gate.Release(); + session.Gate.Dispose(); + } + } + + internal async Task WithSessionAsync( + string sessionId, + Func> action, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(action); + + await CleanupExpiredSessionsAsync(cancellationToken); + + var resolvedSessionId = await ResolveRequestedSessionIdAsync(sessionId, cancellationToken); + + if (!_sessions.TryGetValue(resolvedSessionId, out var session)) + { + throw new InvalidOperationException($"Browser session '{resolvedSessionId}' was not found."); + } + + await session.Gate.WaitAsync(cancellationToken); + try + { + session.Touch(_clock.UtcNow); + return await action(session); + } + finally + { + session.Gate.Release(); + } + } + + internal static string ResolveBootstrapUrl(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (TryGetAbsoluteHttpUrl(httpContext.Request.Query[AgentConstants.BrowserParentPageUrlQueryKey], out var parentPageUrl)) + { + return parentPageUrl; + } + + if (TryGetAbsoluteHttpUrl(httpContext.Request.Query[AgentConstants.BrowserPageUrlQueryKey], out var pageUrl)) + { + return pageUrl; + } + + if (TryGetAbsoluteHttpUrl(httpContext.Request.Headers.Referer, out var refererUrl)) + { + return refererUrl; + } + + return null; + } + + internal static string ResolveRequestedSessionId(string sessionId, IEnumerable sessions) + { + var normalizedSessionId = string.IsNullOrWhiteSpace(sessionId) + ? AgentConstants.DefaultSessionId + : sessionId.Trim(); + + if (!string.Equals(normalizedSessionId, AgentConstants.DefaultSessionId, StringComparison.OrdinalIgnoreCase)) + { + return normalizedSessionId; + } + + var resolvedSessionId = sessions + .OrderByDescending(session => session.LastTouchedUtc) + .Select(session => session.SessionId) + .FirstOrDefault(); + + if (resolvedSessionId is null) + { + throw new InvalidOperationException($"No active browser session was found. Call 'startBrowserSession' first, then reuse the returned sessionId or use the '{AgentConstants.DefaultSessionId}' alias."); + } + + return resolvedSessionId; + } + + internal async Task WithPageAsync( + string sessionId, + string pageId, + Func> action, + CancellationToken cancellationToken) + { + return await WithSessionAsync(sessionId, async session => + { + var trackedPage = await ResolvePageAsync(session, pageId, cancellationToken); + trackedPage.Touch(_clock.UtcNow); + session.ActivePageId = trackedPage.PageId; + return await action(session, trackedPage); + }, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + foreach (var sessionId in _sessions.Keys.ToArray()) + { + await CloseSessionAsync(sessionId, CancellationToken.None); + } + } + + private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + var expirationCutoff = now - AgentConstants.SessionIdleTimeout; + var expiredSessionIds = _sessions.Values + .Where(x => x.LastTouchedUtc < expirationCutoff) + .Select(x => x.SessionId) + .ToArray(); + + foreach (var sessionId in expiredSessionIds) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Closing expired browser automation session '{SessionId}'.", sessionId); + } + + await CloseSessionAsync(sessionId, cancellationToken); + } + } + + private async Task ResolveRequestedSessionIdAsync(string sessionId, CancellationToken cancellationToken) + { + var normalizedSessionId = string.IsNullOrWhiteSpace(sessionId) + ? AgentConstants.DefaultSessionId + : sessionId.Trim(); + + if (!string.Equals(normalizedSessionId, AgentConstants.DefaultSessionId, StringComparison.OrdinalIgnoreCase)) + { + return normalizedSessionId; + } + + var existingSessionId = _sessions.Values + .OrderByDescending(session => session.LastTouchedUtc) + .Select(session => session.SessionId) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(existingSessionId)) + { + return existingSessionId; + } + + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null) + { + throw new InvalidOperationException($"No active browser session was found. Call 'startBrowserSession' first, then reuse the returned sessionId or use the '{AgentConstants.DefaultSessionId}' alias."); + } + + var bootstrapUrl = ResolveBootstrapUrl(httpContext); + if (string.IsNullOrWhiteSpace(bootstrapUrl)) + { + throw new InvalidOperationException($"No active browser session was found. Call 'startBrowserSession' first, then reuse the returned sessionId or use the '{AgentConstants.DefaultSessionId}' alias."); + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Auto-starting browser automation session for '{StartUrl}' because no active session was found for the default alias.", bootstrapUrl); + } + + var snapshot = await CreateSessionAsync( + "chromium", + true, + bootstrapUrl, + null, + null, + null, + null, + AgentConstants.DefaultTimeoutMs, + cancellationToken); + + if (!snapshot.TryGetValue("sessionId", out var sessionIdValue) || + sessionIdValue is not string createdSessionId || + string.IsNullOrWhiteSpace(createdSessionId)) + { + throw new InvalidOperationException("The browser automation session was created, but its session identifier was missing from the snapshot."); + } + + return createdSessionId; + } + + private static async Task LaunchBrowserAsync(IPlaywright playwright, string browserType, bool headless, int timeoutMs) + { + var options = new BrowserTypeLaunchOptions + { + Headless = headless, + Timeout = timeoutMs, + }; + + return browserType switch + { + "chromium" => await playwright.Chromium.LaunchAsync(options), + "firefox" => await playwright.Firefox.LaunchAsync(options), + "webkit" => await playwright.Webkit.LaunchAsync(options), + _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), + }; + } + + private async Task CopyCurrentRequestCookiesAsync(IBrowserContext context, string startUrl) + { + if (string.IsNullOrWhiteSpace(startUrl)) + { + return; + } + + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null) + { + return; + } + + if (!Uri.TryCreate(startUrl.Trim(), UriKind.Absolute, out var startUri) || + !string.Equals(startUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(startUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var request = httpContext.Request; + if (request.Cookies.Count == 0) + { + return; + } + + if (!string.Equals(request.Host.Host, startUri.Host, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var requestPort = request.Host.Port ?? (string.Equals(request.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80); + var startPort = startUri.IsDefaultPort ? (string.Equals(startUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80) : startUri.Port; + if (!string.Equals(request.Scheme, startUri.Scheme, StringComparison.OrdinalIgnoreCase) || requestPort != startPort) + { + return; + } + + var cookieUrl = startUri.GetLeftPart(UriPartial.Authority); + var cookies = request.Cookies + .Select(entry => new Cookie + { + Name = entry.Key, + Value = entry.Value, + Url = cookieUrl, + Secure = string.Equals(startUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase), + }) + .ToArray(); + + if (cookies.Length == 0) + { + return; + } + + await context.AddCookiesAsync(cookies); + } + + private static bool TryGetAbsoluteHttpUrl(string value, out string normalizedUrl) + { + normalizedUrl = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (!Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri)) + { + return false; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + normalizedUrl = uri.ToString(); + return true; + } + + private BrowserAutomationPage TrackPage(BrowserAutomationSession session, IPage page) + { + var pageId = $"page-{Interlocked.Increment(ref session.PageSequence)}"; + var trackedPage = new BrowserAutomationPage(pageId, page, _clock.UtcNow); + session.Pages[pageId] = trackedPage; + session.ActivePageId = pageId; + + page.Console += (_, message) => + { + var consoleEntry = new Dictionary + { + ["pageId"] = pageId, + ["type"] = message.Type, + ["text"] = message.Text, + ["timestampUtc"] = _clock.UtcNow, + }; + + EnqueueLimited(trackedPage.ConsoleMessages, consoleEntry, AgentConstants.MaxStoredConsoleMessages); + }; + + page.PageError += (_, error) => + { + EnqueueLimited(trackedPage.PageErrors, error, AgentConstants.MaxStoredConsoleMessages); + EnqueueLimited(trackedPage.ConsoleMessages, new Dictionary + { + ["pageId"] = pageId, + ["type"] = "pageerror", + ["text"] = error, + ["timestampUtc"] = _clock.UtcNow, + }, AgentConstants.MaxStoredConsoleMessages); + }; + + page.Request += (_, request) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "request", + ["method"] = request.Method, + ["url"] = request.Url, + ["resourceType"] = request.ResourceType, + ["timestampUtc"] = _clock.UtcNow, + }, AgentConstants.MaxStoredNetworkEvents); + }; + + page.Response += (_, response) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "response", + ["url"] = response.Url, + ["status"] = response.Status, + ["ok"] = response.Ok, + ["timestampUtc"] = _clock.UtcNow, + }, AgentConstants.MaxStoredNetworkEvents); + }; + + page.RequestFailed += (_, request) => + { + EnqueueLimited(trackedPage.NetworkEvents, new Dictionary + { + ["pageId"] = pageId, + ["phase"] = "requestfailed", + ["method"] = request.Method, + ["url"] = request.Url, + ["resourceType"] = request.ResourceType, + ["timestampUtc"] = _clock.UtcNow, + }, AgentConstants.MaxStoredNetworkEvents); + }; + + return trackedPage; + } + + private async Task ResolvePageAsync( + BrowserAutomationSession session, + string pageId, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(pageId) && session.Pages.TryGetValue(pageId, out var explicitPage)) + { + return explicitPage; + } + + if (!string.IsNullOrWhiteSpace(pageId)) + { + throw new InvalidOperationException($"Page '{pageId}' was not found for session '{session.SessionId}'."); + } + + if (!string.IsNullOrWhiteSpace(session.ActivePageId) && session.Pages.TryGetValue(session.ActivePageId, out var activePage)) + { + return activePage; + } + + var page = await session.Context.NewPageAsync(); + return TrackPage(session, page); + } + + private async Task> BuildSessionSnapshotAsync(BrowserAutomationSession session) + { + var pages = new List>(); + + foreach (var trackedPage in session.Pages.Values.OrderBy(x => x.CreatedUtc)) + { + pages.Add(await BuildPageSnapshotAsync(session, trackedPage)); + } + + return new Dictionary + { + ["sessionId"] = session.SessionId, + ["browserType"] = session.BrowserType, + ["headless"] = session.Headless, + ["createdUtc"] = session.CreatedUtc, + ["lastTouchedUtc"] = session.LastTouchedUtc, + ["activePageId"] = session.ActivePageId ?? string.Empty, + ["pageCount"] = pages.Count, + ["pages"] = pages, + }; + } + + private static async Task> BuildPageSnapshotAsync(BrowserAutomationSession session, BrowserAutomationPage trackedPage) + { + var snapshot = new Dictionary + { + ["sessionId"] = session.SessionId, + ["pageId"] = trackedPage.PageId, + ["isActive"] = string.Equals(session.ActivePageId, trackedPage.PageId, StringComparison.OrdinalIgnoreCase), + ["isClosed"] = trackedPage.Page.IsClosed, + ["url"] = trackedPage.Page.Url ?? string.Empty, + ["createdUtc"] = trackedPage.CreatedUtc, + ["lastTouchedUtc"] = trackedPage.LastTouchedUtc, + ["consoleMessageCount"] = trackedPage.ConsoleMessages.Count, + ["networkEventCount"] = trackedPage.NetworkEvents.Count, + ["pageErrorCount"] = trackedPage.PageErrors.Count, + }; + + if (!trackedPage.Page.IsClosed) + { + snapshot["title"] = await trackedPage.Page.TitleAsync(); + } + + return snapshot; + } + + private static void EnqueueLimited(ConcurrentQueue queue, T item, int limit) + { + queue.Enqueue(item); + while (queue.Count > limit && queue.TryDequeue(out _)) + { + } + } + + private static string NormalizeBrowserType(string browserType) + { + if (string.IsNullOrWhiteSpace(browserType)) + { + return "chromium"; + } + + browserType = browserType.Trim().ToLowerInvariant(); + return browserType switch + { + "chromium" or "chrome" => "chromium", + "firefox" => "firefox", + "webkit" => "webkit", + _ => throw new InvalidOperationException($"Unsupported browser type '{browserType}'. Supported values are chromium, firefox, and webkit."), + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationSession.cs similarity index 90% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationSession.cs index 186853267..8de8ce40e 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationSession.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationSession.cs @@ -1,54 +1,53 @@ -using System.Collections.Concurrent; -using System.Threading; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -internal sealed class BrowserAutomationSession -{ - public BrowserAutomationSession( - string sessionId, - string browserType, - bool headless, - IPlaywright playwright, - IBrowser browser, - IBrowserContext context, - DateTime createdUtc) - { - SessionId = sessionId; - BrowserType = browserType; - Headless = headless; - Playwright = playwright; - Browser = browser; - Context = context; - CreatedUtc = createdUtc; - LastTouchedUtc = createdUtc; - } - - public string SessionId { get; } - - public string BrowserType { get; } - - public bool Headless { get; } - - public IPlaywright Playwright { get; } - - public IBrowser Browser { get; } - - public IBrowserContext Context { get; } - - public ConcurrentDictionary Pages { get; } = new(StringComparer.OrdinalIgnoreCase); - - public SemaphoreSlim Gate { get; } = new(1, 1); - - public string ActivePageId { get; set; } - - public DateTime CreatedUtc { get; } - - public DateTime LastTouchedUtc { get; private set; } - - public int PageSequence; - - public void Touch(DateTime utc) - => LastTouchedUtc = utc; -} +using System.Collections.Concurrent; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Services; + +internal sealed class BrowserAutomationSession +{ + public BrowserAutomationSession( + string sessionId, + string browserType, + bool headless, + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + DateTime createdUtc) + { + SessionId = sessionId; + BrowserType = browserType; + Headless = headless; + Playwright = playwright; + Browser = browser; + Context = context; + CreatedUtc = createdUtc; + LastTouchedUtc = createdUtc; + } + + public string SessionId { get; } + + public string BrowserType { get; } + + public bool Headless { get; } + + public IPlaywright Playwright { get; } + + public IBrowser Browser { get; } + + public IBrowserContext Context { get; } + + public ConcurrentDictionary Pages { get; } = new(StringComparer.OrdinalIgnoreCase); + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public string ActivePageId { get; set; } + + public DateTime CreatedUtc { get; } + + public DateTime LastTouchedUtc { get; private set; } + + public int PageSequence; + + public void Touch(DateTime utc) + => LastTouchedUtc = utc; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Startup.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Startup.cs index d052db583..62da8bf06 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Startup.cs @@ -1,16 +1,17 @@ -using CrestApps.OrchardCore.AI.Agent.Analytics; -using CrestApps.OrchardCore.AI.Agent.Communications; -using CrestApps.OrchardCore.AI.Agent.Contents; -using CrestApps.OrchardCore.AI.Agent.ContentTypes; -using CrestApps.OrchardCore.AI.Agent.Features; -using CrestApps.OrchardCore.AI.Agent.Profiles; -using CrestApps.OrchardCore.AI.Agent.Recipes; -using CrestApps.OrchardCore.AI.Agent.Roles; using CrestApps.OrchardCore.AI.Agent.Services; -using CrestApps.OrchardCore.AI.Agent.System; -using CrestApps.OrchardCore.AI.Agent.Tenants; -using CrestApps.OrchardCore.AI.Agent.Users; -using CrestApps.OrchardCore.AI.Agent.Workflows; +using CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; +using CrestApps.OrchardCore.AI.Agent.Tools.Analytics; +using CrestApps.OrchardCore.AI.Agent.Tools.Communications; +using CrestApps.OrchardCore.AI.Agent.Tools.Contents; +using CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; +using CrestApps.OrchardCore.AI.Agent.Tools.Features; +using CrestApps.OrchardCore.AI.Agent.Tools.Profiles; +using CrestApps.OrchardCore.AI.Agent.Tools.Recipes; +using CrestApps.OrchardCore.AI.Agent.Tools.Roles; +using CrestApps.OrchardCore.AI.Agent.Tools.System; +using CrestApps.OrchardCore.AI.Agent.Tools.Tenants; +using CrestApps.OrchardCore.AI.Agent.Tools.Users; +using CrestApps.OrchardCore.AI.Agent.Tools.Workflows; using CrestApps.OrchardCore.AI.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; @@ -38,6 +39,280 @@ public override void ConfigureServices(IServiceCollection services) } } +[Feature(AIConstants.Feature.OrchardCoreAIAgentBrowserAutomation)] +public sealed class BrowserAutomationFeatureStartup : StartupBase +{ + internal readonly IStringLocalizer S; + + public BrowserAutomationFeatureStartup(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddSingleton(); + + RegisterBrowserSessionTools(services); + RegisterBrowserNavigationTools(services); + RegisterBrowserInspectionTools(services); + RegisterBrowserInteractionTools(services); + RegisterBrowserFormTools(services); + RegisterBrowserWaitingTools(services); + RegisterBrowserTroubleshootingTools(services); + } + + private void RegisterBrowserSessionTools(IServiceCollection services) + { + services.AddAITool(StartBrowserSessionTool.TheName) + .WithTitle(S["Start Browser Session"]) + .WithDescription(S["Launches a real Playwright browser session so the AI can visit pages, inspect navigation, and interact with the website UI. Start with this before using the other browser tools."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(CloseBrowserSessionTool.TheName) + .WithTitle(S["Close Browser Session"]) + .WithDescription(S["Closes a tracked Playwright browser session and disposes its tabs."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(ListBrowserSessionsTool.TheName) + .WithTitle(S["List Browser Sessions"]) + .WithDescription(S["Lists tracked Playwright browser sessions and their tabs."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(GetBrowserSessionTool.TheName) + .WithTitle(S["Get Browser Session"]) + .WithDescription(S["Retrieves details about a tracked Playwright browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(OpenBrowserTabTool.TheName) + .WithTitle(S["Open Browser Tab"]) + .WithDescription(S["Opens a new tab in a tracked browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(CloseBrowserTabTool.TheName) + .WithTitle(S["Close Browser Tab"]) + .WithDescription(S["Closes a tab in a tracked browser session."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + + services.AddAITool(SwitchBrowserTabTool.TheName) + .WithTitle(S["Switch Browser Tab"]) + .WithDescription(S["Marks a browser tab as the active tab for subsequent actions."]) + .WithCategory(S["Browser Sessions"]) + .Selectable(); + } + + private void RegisterBrowserNavigationTools(IServiceCollection services) + { + services.AddAITool(NavigateBrowserTool.TheName) + .WithTitle(S["Navigate Browser"]) + .WithDescription(S["Visits a specific URL in the real browser. Use this when the destination URL is known."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(NavigateBrowserMenuTool.TheName) + .WithTitle(S["Navigate Menu"]) + .WithDescription(S["Opens a page from visible site navigation or Orchard Core admin sidebar labels, including nested paths like 'Search >> Indexes' or 'Content Management >> Content Definitions'."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(GoBackBrowserTool.TheName) + .WithTitle(S["Go Back"]) + .WithDescription(S["Navigates backward in browser history."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(GoForwardBrowserTool.TheName) + .WithTitle(S["Go Forward"]) + .WithDescription(S["Navigates forward in browser history."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ReloadBrowserPageTool.TheName) + .WithTitle(S["Reload Page"]) + .WithDescription(S["Reloads the current page."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ScrollBrowserPageTool.TheName) + .WithTitle(S["Scroll Page"]) + .WithDescription(S["Scrolls the current page vertically or horizontally."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + + services.AddAITool(ScrollBrowserElementIntoViewTool.TheName) + .WithTitle(S["Scroll Element Into View"]) + .WithDescription(S["Scrolls a specific element into the viewport."]) + .WithCategory(S["Browser Navigation"]) + .Selectable(); + } + + private void RegisterBrowserInspectionTools(IServiceCollection services) + { + services.AddAITool(GetBrowserPageStateTool.TheName) + .WithTitle(S["Get Page State"]) + .WithDescription(S["Returns high-level state for the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserPageContentTool.TheName) + .WithTitle(S["Get Page Content"]) + .WithDescription(S["Returns page text and HTML for the full page or a selected element."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserLinksTool.TheName) + .WithTitle(S["Get Page Links"]) + .WithDescription(S["Lists visible links and navigation items found on the current page, including sidebar or menu entries."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserFormsTool.TheName) + .WithTitle(S["Get Page Forms"]) + .WithDescription(S["Lists forms and their fields on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserHeadingsTool.TheName) + .WithTitle(S["Get Page Headings"]) + .WithDescription(S["Lists headings found on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserButtonsTool.TheName) + .WithTitle(S["Get Page Buttons"]) + .WithDescription(S["Lists button-like controls found on the current page."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + + services.AddAITool(GetBrowserElementInfoTool.TheName) + .WithTitle(S["Get Element Info"]) + .WithDescription(S["Returns detailed information about a selected element."]) + .WithCategory(S["Browser Inspection"]) + .Selectable(); + } + + private void RegisterBrowserInteractionTools(IServiceCollection services) + { + services.AddAITool(ClickBrowserElementTool.TheName) + .WithTitle(S["Click Element"]) + .WithDescription(S["Clicks a visible page element, such as a link, button, or menu item."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(DoubleClickBrowserElementTool.TheName) + .WithTitle(S["Double Click Element"]) + .WithDescription(S["Double-clicks a page element."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(HoverBrowserElementTool.TheName) + .WithTitle(S["Hover Element"]) + .WithDescription(S["Moves the mouse over a page element."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + + services.AddAITool(PressBrowserKeyTool.TheName) + .WithTitle(S["Press Key"]) + .WithDescription(S["Sends a keyboard key or shortcut to the page."]) + .WithCategory(S["Browser Interaction"]) + .Selectable(); + } + + private void RegisterBrowserFormTools(IServiceCollection services) + { + services.AddAITool(FillBrowserInputTool.TheName) + .WithTitle(S["Fill Input"]) + .WithDescription(S["Fills an input, textarea, or editable element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(ClearBrowserInputTool.TheName) + .WithTitle(S["Clear Input"]) + .WithDescription(S["Clears an input or textarea value."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(SelectBrowserOptionTool.TheName) + .WithTitle(S["Select Option"]) + .WithDescription(S["Selects one or more values in a select element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(CheckBrowserElementTool.TheName) + .WithTitle(S["Check Element"]) + .WithDescription(S["Checks a checkbox or radio button."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(UncheckBrowserElementTool.TheName) + .WithTitle(S["Uncheck Element"]) + .WithDescription(S["Unchecks a checkbox."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + + services.AddAITool(UploadBrowserFilesTool.TheName) + .WithTitle(S["Upload Files"]) + .WithDescription(S["Uploads local files into a file input element."]) + .WithCategory(S["Browser Forms"]) + .Selectable(); + } + + private void RegisterBrowserWaitingTools(IServiceCollection services) + { + services.AddAITool(WaitForBrowserElementTool.TheName) + .WithTitle(S["Wait For Element"]) + .WithDescription(S["Waits for a selector to reach a requested state."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + + services.AddAITool(WaitForBrowserNavigationTool.TheName) + .WithTitle(S["Wait For Navigation"]) + .WithDescription(S["Waits for navigation or a URL change."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + + services.AddAITool(WaitForBrowserLoadStateTool.TheName) + .WithTitle(S["Wait For Load State"]) + .WithDescription(S["Waits for a page to reach a specific load state."]) + .WithCategory(S["Browser Waiting"]) + .Selectable(); + } + + private void RegisterBrowserTroubleshootingTools(IServiceCollection services) + { + services.AddAITool(CaptureBrowserScreenshotTool.TheName) + .WithTitle(S["Capture Screenshot"]) + .WithDescription(S["Captures a screenshot of the current page."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(GetBrowserConsoleMessagesTool.TheName) + .WithTitle(S["Get Console Messages"]) + .WithDescription(S["Returns recent console messages and page errors."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(GetBrowserNetworkActivityTool.TheName) + .WithTitle(S["Get Network Activity"]) + .WithDescription(S["Returns recent network requests and responses."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + + services.AddAITool(DiagnoseBrowserPageTool.TheName) + .WithTitle(S["Diagnose Page"]) + .WithDescription(S["Collects a troubleshooting snapshot for the current page."]) + .WithCategory(S["Browser Troubleshooting"]) + .Selectable(); + } +} + [RequireFeatures(AIConstants.Feature.OrchardCoreAIAgent, "OrchardCore.Recipes.Core")] public sealed class RecipesStartup : StartupBase { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Analytics/QueryChatSessionMetricsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Analytics/QueryChatSessionMetricsTool.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Analytics/QueryChatSessionMetricsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Analytics/QueryChatSessionMetricsTool.cs index 59f043ede..e919541aa 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Analytics/QueryChatSessionMetricsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Analytics/QueryChatSessionMetricsTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core; using CrestApps.OrchardCore.AI.Core.Extensions; using CrestApps.OrchardCore.AI.Core.Indexes; @@ -9,7 +9,7 @@ using YesSql.Services; using ISession = YesSql.ISession; -namespace CrestApps.OrchardCore.AI.Agent.Analytics; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Analytics; public sealed class QueryChatSessionMetricsTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationScripts.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationScripts.cs new file mode 100644 index 000000000..d4b0d3395 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationScripts.cs @@ -0,0 +1,86 @@ +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +internal static class BrowserAutomationScripts +{ + public const string FindNavigationItem = + """ + (input) => { + const normalize = (value) => (value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const target = normalize(input.segment); + const containers = Array.from(document.querySelectorAll('nav, aside, [role="navigation"], .ta-navbar-nav, .admin-menu, .menu')); + const scopes = containers.length > 0 ? containers : [document.body]; + const seen = new Set(); + const candidates = []; + + for (const scope of scopes) { + const elements = scope.querySelectorAll('a, button, [role="menuitem"], [aria-expanded]'); + for (const element of elements) { + if (!(element instanceof HTMLElement) || seen.has(element)) { + continue; + } + + seen.add(element); + + const texts = [ + normalize(element.innerText || element.textContent), + normalize(element.getAttribute('aria-label')), + normalize(element.getAttribute('title')) + ].filter(Boolean); + + if (texts.length === 0) { + continue; + } + + const exact = texts.some((text) => text === target); + const contains = texts.some((text) => text.includes(target) || target.includes(text)); + + if (!exact && !contains) { + continue; + } + + const score = + (exact ? 100 : 50) + + (isVisible(element) ? 25 : 0) + + (element.closest('nav, aside, [role="navigation"]') ? 20 : 0) + + (element.getAttribute('aria-expanded') === 'false' ? 5 : 0); + + candidates.push({ + element, + score, + text: (element.innerText || element.textContent || '').replace(/\s+/g, ' ').trim(), + href: element.getAttribute('href') || '', + tagName: element.tagName.toLowerCase(), + ariaExpanded: element.getAttribute('aria-expanded') || '', + }); + } + } + + candidates.sort((left, right) => right.score - left.score); + + const match = candidates[0]; + if (!match) { + return null; + } + + match.element.setAttribute('data-ai-nav-match', input.marker); + + return JSON.stringify({ + text: match.text, + href: match.href, + tagName: match.tagName, + ariaExpanded: match.ariaExpanded, + }); + } + """; + + public const string RemoveNavigationMarker = + """ + (marker) => { + const element = document.querySelector('[data-ai-nav-match="' + marker + '"]'); + if (element) { + element.removeAttribute('data-ai-nav-match'); + } + } + """; +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationToolBase.cs similarity index 87% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationToolBase.cs index 96bcc15ae..0e2e419d2 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/BrowserAutomationToolBase.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationToolBase.cs @@ -1,10 +1,11 @@ using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Playwright; -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; public abstract class BrowserAutomationToolBase : AIFunction where TTool : AITool @@ -51,25 +52,25 @@ protected static string GetOptionalString(AIFunctionArguments arguments, string protected static bool GetBoolean(AIFunctionArguments arguments, string key, bool fallbackValue = false) => arguments.TryGetFirst(key, out var value) ? value : fallbackValue; - protected static int GetTimeout(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultTimeoutMs) + protected static int GetTimeout(AIFunctionArguments arguments, int fallbackValue = AgentConstants.DefaultTimeoutMs) { var timeout = arguments.TryGetFirst("timeoutMs", out var parsedTimeout) ? parsedTimeout : fallbackValue; - return Math.Clamp(timeout, 1_000, BrowserAutomationConstants.MaxTimeoutMs); + return Math.Clamp(timeout, 1_000, AgentConstants.MaxTimeoutMs); } - protected static int GetMaxItems(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultMaxItems) + protected static int GetMaxItems(AIFunctionArguments arguments, int fallbackValue = AgentConstants.DefaultMaxItems) { var maxItems = arguments.TryGetFirst("maxItems", out var parsedMaxItems) ? parsedMaxItems : fallbackValue; - return Math.Clamp(maxItems, 1, BrowserAutomationConstants.MaxCollectionItems); + return Math.Clamp(maxItems, 1, AgentConstants.MaxCollectionItems); } - protected static int GetMaxTextLength(AIFunctionArguments arguments, int fallbackValue = BrowserAutomationConstants.DefaultMaxTextLength) + protected static int GetMaxTextLength(AIFunctionArguments arguments, int fallbackValue = AgentConstants.DefaultMaxTextLength) { var maxLength = arguments.TryGetFirst("maxLength", out var parsedMaxLength) ? parsedMaxLength @@ -102,7 +103,7 @@ protected static string[] GetStringArray(AIFunctionArguments arguments, string k } protected static string GetSessionId(AIFunctionArguments arguments) - => GetRequiredString(arguments, "sessionId"); + => GetOptionalString(arguments, "sessionId") ?? AgentConstants.DefaultSessionId; protected static string GetPageId(AIFunctionArguments arguments) => GetOptionalString(arguments, "pageId"); @@ -187,6 +188,33 @@ protected static string Truncate(string value, int maxLength) return value[..maxLength]; } + protected static void RequestLiveNavigation(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var invocationContext = AIInvocationScope.Current; + if (invocationContext is null) + { + return; + } + + invocationContext.Items[AIInvocationItemKeys.LiveNavigationUrl] = uri.ToString(); + } + protected async Task ExecuteSafeAsync(string action, Func> callback) { try diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserNavigationPathParser.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserNavigationPathParser.cs new file mode 100644 index 000000000..0a1d48779 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserNavigationPathParser.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +internal static class BrowserNavigationPathParser +{ + public static string[] Split(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return []; + } + + return Regex.Split(path, @"\s*(?:>>|>|/|\\|→|»)\s*") + .Select(NormalizeSegment) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .ToArray(); + } + + public static string NormalizeSegment(string value) + => Regex.Replace(value ?? string.Empty, @"\s+", " ").Trim(); +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CaptureBrowserScreenshotTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CaptureBrowserScreenshotTool.cs index 7c00cda8d..2fbd582b7 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CaptureBrowserScreenshotTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CaptureBrowserScreenshotTool.cs @@ -1,107 +1,104 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System.IO; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class CaptureBrowserScreenshotTool : BrowserAutomationToolBase -{ - public const string TheName = "captureBrowserScreenshot"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "fullPage": { - "type": "boolean", - "description": "Optional. When true, captures the full page. Defaults to true." - }, - "format": { - "type": "string", - "description": "Optional screenshot format: png or jpeg. Defaults to png." - }, - "returnBase64": { - "type": "boolean", - "description": "Optional. When true, includes the screenshot content as base64 in the tool result." - }, - "path": { - "type": "string", - "description": "Optional absolute output path. When omitted, a temp path is used." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public CaptureBrowserScreenshotTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Captures a screenshot of the current page and optionally returns it as base64."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var fullPage = GetBoolean(arguments, "fullPage", true); - var returnBase64 = GetBoolean(arguments, "returnBase64"); - var format = (GetOptionalString(arguments, "format") ?? "png").Trim().ToLowerInvariant(); - var outputPath = GetOptionalString(arguments, "path"); - var extension = format == "jpeg" || format == "jpg" ? "jpg" : "png"; - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - if (string.IsNullOrWhiteSpace(outputPath)) - { - var directory = Path.Combine(Path.GetTempPath(), "CrestApps.OrchardCore", "BrowserAutomation"); - Directory.CreateDirectory(directory); - outputPath = Path.Combine(directory, $"{sessionId}_{trackedPage.PageId}_{Guid.NewGuid():N}.{extension}"); - } - - var bytes = await trackedPage.Page.ScreenshotAsync(new PageScreenshotOptions - { - FullPage = fullPage, - Path = outputPath, - Type = extension == "jpg" ? ScreenshotType.Jpeg : ScreenshotType.Png, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - path = outputPath, - format = extension, - fullPage, - base64 = returnBase64 ? Convert.ToBase64String(bytes) : null, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class CaptureBrowserScreenshotTool : BrowserAutomationToolBase +{ + public const string TheName = "captureBrowserScreenshot"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "fullPage": { + "type": "boolean", + "description": "Optional. When true, captures the full page. Defaults to true." + }, + "format": { + "type": "string", + "description": "Optional screenshot format: png or jpeg. Defaults to png." + }, + "returnBase64": { + "type": "boolean", + "description": "Optional. When true, includes the screenshot content as base64 in the tool result." + }, + "path": { + "type": "string", + "description": "Optional absolute output path. When omitted, a temp path is used." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public CaptureBrowserScreenshotTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Captures a screenshot of the current page and optionally returns it as base64."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var fullPage = GetBoolean(arguments, "fullPage", true); + var returnBase64 = GetBoolean(arguments, "returnBase64"); + var format = (GetOptionalString(arguments, "format") ?? "png").Trim().ToLowerInvariant(); + var outputPath = GetOptionalString(arguments, "path"); + var extension = format == "jpeg" || format == "jpg" ? "jpg" : "png"; + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + if (string.IsNullOrWhiteSpace(outputPath)) + { + var directory = Path.Combine(Path.GetTempPath(), "CrestApps.OrchardCore", "BrowserAutomation"); + Directory.CreateDirectory(directory); + outputPath = Path.Combine(directory, $"{sessionId}_{trackedPage.PageId}_{Guid.NewGuid():N}.{extension}"); + } + + var bytes = await trackedPage.Page.ScreenshotAsync(new PageScreenshotOptions + { + FullPage = fullPage, + Path = outputPath, + Type = extension == "jpg" ? ScreenshotType.Jpeg : ScreenshotType.Png, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + path = outputPath, + format = extension, + fullPage, + base64 = returnBase64 ? Convert.ToBase64String(bytes) : null, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CheckBrowserElementTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CheckBrowserElementTool.cs index a8dd15a33..0b1c79856 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CheckBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CheckBrowserElementTool.cs @@ -1,84 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class CheckBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "checkBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the target control." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public CheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Checks a checkbox or radio button."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var timeout = GetTimeout(arguments); - await locator.CheckAsync(new LocatorCheckOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class CheckBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "checkBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public CheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Checks a checkbox or radio button."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.CheckAsync(new LocatorCheckOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClearBrowserInputTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClearBrowserInputTool.cs index 34d3b5a0c..79d1e3c90 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClearBrowserInputTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClearBrowserInputTool.cs @@ -1,84 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ClearBrowserInputTool : BrowserAutomationToolBase -{ - public const string TheName = "clearBrowserInput"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the target control." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public ClearBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Clears the value of an input or textarea."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var timeout = GetTimeout(arguments); - await locator.FillAsync(string.Empty, new LocatorFillOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ClearBrowserInputTool : BrowserAutomationToolBase +{ + public const string TheName = "clearBrowserInput"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public ClearBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Clears the value of an input or textarea."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.FillAsync(string.Empty, new LocatorFillOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClickBrowserElementTool.cs similarity index 71% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClickBrowserElementTool.cs index 52b3350ca..447cb18f9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ClickBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ClickBrowserElementTool.cs @@ -1,91 +1,91 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ClickBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "clickBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the element." - }, - "button": { - "type": "string", - "description": "Optional mouse button for click actions: left, middle, or right." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public ClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Clicks an element using a Playwright selector."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var button = ParseMouseButton(arguments); - var timeout = GetTimeout(arguments); - await locator.ClickAsync(new LocatorClickOptions - { - Button = button, - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - button = button.ToString(), - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ClickBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "clickBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public ClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Clicks an element using a Playwright selector."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var button = ParseMouseButton(arguments); + var timeout = GetTimeout(arguments); + await locator.ClickAsync(new LocatorClickOptions + { + Button = button, + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + button = button.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserSessionTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserSessionTool.cs index 477fc8cd7..483c50d1d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserSessionTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserSessionTool.cs @@ -1,49 +1,48 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class CloseBrowserSessionTool : BrowserAutomationToolBase -{ - public const string TheName = "closeBrowserSession"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier returned by startBrowserSession." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public CloseBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Closes an existing browser session and disposes all tracked pages."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.CloseSessionAsync(GetSessionId(arguments), cancellationToken); - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class CloseBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "closeBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier returned by startBrowserSession." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public CloseBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Closes an existing browser session and disposes all tracked pages."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CloseSessionAsync(GetSessionId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserTabTool.cs similarity index 68% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserTabTool.cs index bacc75ddd..2f61ec5dd 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/CloseBrowserTabTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/CloseBrowserTabTool.cs @@ -1,54 +1,53 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class CloseBrowserTabTool : BrowserAutomationToolBase -{ - public const string TheName = "closeBrowserTab"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public CloseBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Closes a browser tab. When pageId is omitted, closes the active tab."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.ClosePageAsync(GetSessionId(arguments), GetPageId(arguments), cancellationToken); - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class CloseBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "closeBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public CloseBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Closes a browser tab. When pageId is omitted, closes the active tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.ClosePageAsync(GetSessionId(arguments), GetPageId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DiagnoseBrowserPageTool.cs similarity index 84% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DiagnoseBrowserPageTool.cs index c183bfafa..6670000c7 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DiagnoseBrowserPageTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DiagnoseBrowserPageTool.cs @@ -1,108 +1,107 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System; -using System.Linq; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class DiagnoseBrowserPageTool : BrowserAutomationToolBase -{ - public const string TheName = "diagnoseBrowserPage"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of console and network entries to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public DiagnoseBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Collects a troubleshooting snapshot for the current page, including visible errors, recent console issues, and failing network requests."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments, 20); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"() => JSON.stringify({ - documentTitle: document.title, - readyState: document.readyState, - visibleErrors: Array.from(document.querySelectorAll('.error, .alert-danger, .validation-summary-errors, .field-validation-error, .input-validation-error, [aria-invalid=""true""]')).slice(0, 20).map((element, index) => ({ - index, - tagName: element.tagName, - text: (element.innerText || element.textContent || '').trim(), - id: element.id || '', - className: element.className || '' - })), - brokenImages: Array.from(document.images).filter(image => !image.complete || image.naturalWidth === 0).slice(0, 20).map((image, index) => ({ - index, - src: image.currentSrc || image.src || '', - alt: image.alt || '' - })) - })"); - - var failingNetwork = trackedPage.NetworkEvents - .ToArray() - .Where(x => x.TryGetValue("phase", out var phase) && (phase?.ToString() == "requestfailed" || (x.TryGetValue("status", out var status) && int.TryParse(status?.ToString(), out var code) && code >= 400))) - .TakeLast(maxItems) - .ToArray(); - - var consoleIssues = trackedPage.ConsoleMessages - .ToArray() - .Where(x => x.TryGetValue("type", out var type) && !string.Equals(type?.ToString(), "info", StringComparison.OrdinalIgnoreCase)) - .TakeLast(maxItems) - .ToArray(); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - diagnostics = ParseJson(raw), - consoleIssues, - failingNetwork, - pageErrors = trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System; +using System.Linq; +using CrestApps.OrchardCore.AI.Agent.Services; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class DiagnoseBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "diagnoseBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of console and network entries to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public DiagnoseBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Collects a troubleshooting snapshot for the current page, including visible errors, recent console issues, and failing network requests."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 20); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"() => JSON.stringify({ + documentTitle: document.title, + readyState: document.readyState, + visibleErrors: Array.from(document.querySelectorAll('.error, .alert-danger, .validation-summary-errors, .field-validation-error, .input-validation-error, [aria-invalid=""true""]')).slice(0, 20).map((element, index) => ({ + index, + tagName: element.tagName, + text: (element.innerText || element.textContent || '').trim(), + id: element.id || '', + className: element.className || '' + })), + brokenImages: Array.from(document.images).filter(image => !image.complete || image.naturalWidth === 0).slice(0, 20).map((image, index) => ({ + index, + src: image.currentSrc || image.src || '', + alt: image.alt || '' + })) + })"); + + var failingNetwork = trackedPage.NetworkEvents + .ToArray() + .Where(x => x.TryGetValue("phase", out var phase) && (phase?.ToString() == "requestfailed" || (x.TryGetValue("status", out var status) && int.TryParse(status?.ToString(), out var code) && code >= 400))) + .TakeLast(maxItems) + .ToArray(); + + var consoleIssues = trackedPage.ConsoleMessages + .ToArray() + .Where(x => x.TryGetValue("type", out var type) && !string.Equals(type?.ToString(), "info", StringComparison.OrdinalIgnoreCase)) + .TakeLast(maxItems) + .ToArray(); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + diagnostics = ParseJson(raw), + consoleIssues, + failingNetwork, + pageErrors = trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DoubleClickBrowserElementTool.cs similarity index 70% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DoubleClickBrowserElementTool.cs index 8a07f63ff..29fde2218 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/DoubleClickBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/DoubleClickBrowserElementTool.cs @@ -1,88 +1,88 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class DoubleClickBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "doubleClickBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the element." - }, - "button": { - "type": "string", - "description": "Optional mouse button for click actions: left, middle, or right." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public DoubleClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Double-clicks an element using a Playwright selector."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var timeout = GetTimeout(arguments); - await locator.DblClickAsync(new LocatorDblClickOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class DoubleClickBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "doubleClickBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public DoubleClickBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Double-clicks an element using a Playwright selector."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.DblClickAsync(new LocatorDblClickOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/FillBrowserInputTool.cs similarity index 68% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/FillBrowserInputTool.cs index bcf23dfcc..100de7a62 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/FillBrowserInputTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/FillBrowserInputTool.cs @@ -1,91 +1,90 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class FillBrowserInputTool : BrowserAutomationToolBase -{ - public const string TheName = "fillBrowserInput"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the target control." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - }, - "value": { - "type": "string", - "description": "The value to enter." - } - }, - "required": [ - "sessionId", - "selector", - "value" - ], - "additionalProperties": false - } - """); - - public FillBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Fills an input, textarea, or content-editable element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var value = GetRequiredString(arguments, "value"); - var timeout = GetTimeout(arguments); - await locator.FillAsync(value, new LocatorFillOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - value, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class FillBrowserInputTool : BrowserAutomationToolBase +{ + public const string TheName = "fillBrowserInput"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + }, + "value": { + "type": "string", + "description": "The value to enter." + } + }, + "required": [ + "selector", + "value" + ], + "additionalProperties": false + } + """); + + public FillBrowserInputTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Fills an input, textarea, or content-editable element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var value = GetRequiredString(arguments, "value"); + var timeout = GetTimeout(arguments); + await locator.FillAsync(value, new LocatorFillOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + value, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserButtonsTool.cs similarity index 77% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserButtonsTool.cs index 57550631e..c788c0ff2 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserButtonsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserButtonsTool.cs @@ -1,80 +1,79 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserButtonsTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserButtons"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of items to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserButtonsTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Lists button-like controls found on the current page."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('button, input[type=button], input[type=submit], input[type=reset]')).slice(0, maxItems).map((button, index) => ({ - index, - tagName: button.tagName, type: button.getAttribute('type') || '', text: (button.innerText || button.value || button.textContent || '').trim(), disabled: !!button.disabled, id: button.id || '', name: button.getAttribute('name') || '' - })))", - maxItems); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - items = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserButtonsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserButtons"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserButtonsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists button-like controls found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('button, input[type=button], input[type=submit], input[type=reset]')).slice(0, maxItems).map((button, index) => ({ + index, + tagName: button.tagName, type: button.getAttribute('type') || '', text: (button.innerText || button.value || button.textContent || '').trim(), disabled: !!button.disabled, id: button.id || '', name: button.getAttribute('name') || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserConsoleMessagesTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserConsoleMessagesTool.cs index 7656f32ab..6cdbf5766 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserConsoleMessagesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserConsoleMessagesTool.cs @@ -1,85 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System.Linq; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserConsoleMessagesTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserConsoleMessages"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of messages to return." - }, - "includePageErrors": { - "type": "boolean", - "description": "Optional. When true, includes captured page error text. Defaults to true." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserConsoleMessagesTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Returns recent console messages and page errors captured for the current tab."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments, 50); - var includePageErrors = GetBoolean(arguments, "includePageErrors", true); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var consoleMessages = trackedPage.ConsoleMessages.ToArray().TakeLast(maxItems).ToArray(); - var pageErrors = includePageErrors - ? trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray() - : []; - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - consoleMessages, - pageErrors, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System.Linq; +using CrestApps.OrchardCore.AI.Agent.Services; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserConsoleMessagesTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserConsoleMessages"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of messages to return." + }, + "includePageErrors": { + "type": "boolean", + "description": "Optional. When true, includes captured page error text. Defaults to true." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserConsoleMessagesTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns recent console messages and page errors captured for the current tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 50); + var includePageErrors = GetBoolean(arguments, "includePageErrors", true); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var consoleMessages = trackedPage.ConsoleMessages.ToArray().TakeLast(maxItems).ToArray(); + var pageErrors = includePageErrors + ? trackedPage.PageErrors.ToArray().TakeLast(maxItems).ToArray() + : []; + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + consoleMessages, + pageErrors, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserElementInfoTool.cs similarity index 76% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserElementInfoTool.cs index 5089845ae..f91d66453 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserElementInfoTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserElementInfoTool.cs @@ -1,101 +1,101 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserElementInfoTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserElementInfo"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the element to inspect." - }, - "maxLength": { - "type": "integer", - "description": "Optional maximum length for returned text or HTML." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public GetBrowserElementInfoTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Returns details about a specific element, including visibility, text, and selected attributes."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - var maxLength = GetMaxTextLength(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - await locator.WaitForAsync(new LocatorWaitForOptions - { - Timeout = timeout, - }); - - var boundingBox = await locator.BoundingBoxAsync(new LocatorBoundingBoxOptions - { - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - text = Truncate(await locator.InnerTextAsync(new LocatorInnerTextOptions { Timeout = timeout }), maxLength), - html = Truncate(await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions { Timeout = timeout }), maxLength), - visible = await locator.IsVisibleAsync(), - enabled = await locator.IsEnabledAsync(), - editable = await locator.IsEditableAsync(), - boundingBox, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserElementInfoTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserElementInfo"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element to inspect." + }, + "maxLength": { + "type": "integer", + "description": "Optional maximum length for returned text or HTML." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public GetBrowserElementInfoTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns details about a specific element, including visibility, text, and selected attributes."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var maxLength = GetMaxTextLength(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.WaitForAsync(new LocatorWaitForOptions + { + Timeout = timeout, + }); + + var boundingBox = await locator.BoundingBoxAsync(new LocatorBoundingBoxOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + text = Truncate(await locator.InnerTextAsync(new LocatorInnerTextOptions { Timeout = timeout }), maxLength), + html = Truncate(await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions { Timeout = timeout }), maxLength), + visible = await locator.IsVisibleAsync(), + enabled = await locator.IsEnabledAsync(), + editable = await locator.IsEditableAsync(), + boundingBox, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserFormsTool.cs similarity index 78% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserFormsTool.cs index ba831b7f8..42c6f906e 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserFormsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserFormsTool.cs @@ -1,94 +1,91 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserFormsTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserForms"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of forms to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserFormsTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Lists forms and their visible fields on the current page."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"(maxItems) => JSON.stringify(Array.from(document.forms).slice(0, maxItems).map((form, index) => ({ - index, - id: form.id || '', - name: form.getAttribute('name') || '', - method: form.getAttribute('method') || 'get', - action: form.getAttribute('action') || window.location.href, - fields: Array.from(form.elements).slice(0, 20).map((field, fieldIndex) => ({ - index: fieldIndex, - tagName: field.tagName, - type: field.getAttribute('type') || '', - name: field.getAttribute('name') || '', - id: field.id || '', - placeholder: field.getAttribute('placeholder') || '', - required: !!field.required, - disabled: !!field.disabled, - value: field.type === 'password' ? '' : (field.value || '') - })) - })))", - maxItems); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - forms = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserFormsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserForms"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of forms to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserFormsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists forms and their visible fields on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.forms).slice(0, maxItems).map((form, index) => ({ + index, + id: form.id || '', + name: form.getAttribute('name') || '', + method: form.getAttribute('method') || 'get', + action: form.getAttribute('action') || window.location.href, + fields: Array.from(form.elements).slice(0, 20).map((field, fieldIndex) => ({ + index: fieldIndex, + tagName: field.tagName, + type: field.getAttribute('type') || '', + name: field.getAttribute('name') || '', + id: field.id || '', + placeholder: field.getAttribute('placeholder') || '', + required: !!field.required, + disabled: !!field.disabled, + value: field.type === 'password' ? '' : (field.value || '') + })) + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + forms = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserHeadingsTool.cs similarity index 76% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserHeadingsTool.cs index b7d6b2e2b..33524f473 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserHeadingsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserHeadingsTool.cs @@ -1,80 +1,79 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserHeadingsTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserHeadings"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of items to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserHeadingsTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Lists headings found on the current page."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).slice(0, maxItems).map((heading, index) => ({ - index, - level: heading.tagName, text: (heading.innerText || heading.textContent || '').trim(), id: heading.id || '' - })))", - maxItems); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - items = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserHeadingsTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserHeadings"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserHeadingsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists headings found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).slice(0, maxItems).map((heading, index) => ({ + index, + level: heading.tagName, text: (heading.innerText || heading.textContent || '').trim(), id: heading.id || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserLinksTool.cs similarity index 76% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserLinksTool.cs index 133bd395f..547c17b0a 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserLinksTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserLinksTool.cs @@ -1,80 +1,79 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserLinksTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserLinks"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of items to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserLinksTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Lists anchor elements found on the current page."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('a')).slice(0, maxItems).map((anchor, index) => ({ - index, - text: (anchor.innerText || anchor.textContent || '').trim(), href: anchor.href || anchor.getAttribute('href') || '', target: anchor.target || '', rel: anchor.rel || '', ariaLabel: anchor.getAttribute('aria-label') || '' - })))", - maxItems); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - items = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserLinksTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserLinks"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of items to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserLinksTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists anchor elements found on the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(maxItems) => JSON.stringify(Array.from(document.querySelectorAll('a')).slice(0, maxItems).map((anchor, index) => ({ + index, + text: (anchor.innerText || anchor.textContent || '').trim(), href: anchor.href || anchor.getAttribute('href') || '', target: anchor.target || '', rel: anchor.rel || '', ariaLabel: anchor.getAttribute('aria-label') || '' + })))", + maxItems); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + items = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserNetworkActivityTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserNetworkActivityTool.cs index 9479ba13c..7e8f3f3e3 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserNetworkActivityTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserNetworkActivityTool.cs @@ -1,76 +1,75 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; -using System.Linq; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserNetworkActivityTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserNetworkActivity"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "maxItems": { - "type": "integer", - "description": "Optional maximum number of network events to return." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserNetworkActivityTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Returns recent network requests, responses, and failed requests captured for the current tab."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var maxItems = GetMaxItems(arguments, 50); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var events = trackedPage.NetworkEvents.ToArray().TakeLast(maxItems).ToArray(); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - events, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using System.Linq; +using CrestApps.OrchardCore.AI.Agent.Services; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserNetworkActivityTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserNetworkActivity"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "maxItems": { + "type": "integer", + "description": "Optional maximum number of network events to return." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserNetworkActivityTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns recent network requests, responses, and failed requests captured for the current tab."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var maxItems = GetMaxItems(arguments, 50); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var events = trackedPage.NetworkEvents.ToArray().TakeLast(maxItems).ToArray(); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + events, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageContentTool.cs similarity index 76% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageContentTool.cs index 49ec5c18c..d76710844 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageContentTool.cs @@ -1,135 +1,134 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserPageContentTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserPageContent"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "Optional Playwright selector. When omitted, returns the full page content." - }, - "includeText": { - "type": "boolean", - "description": "Optional. Include text content. Defaults to true." - }, - "includeHtml": { - "type": "boolean", - "description": "Optional. Include HTML content. Defaults to false." - }, - "maxLength": { - "type": "integer", - "description": "Optional maximum length for returned text or HTML." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds for selector lookup." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserPageContentTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Retrieves text and/or HTML from the full page or from a specific element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetOptionalString(arguments, "selector"); - var includeText = GetBoolean(arguments, "includeText", true); - var includeHtml = GetBoolean(arguments, "includeHtml", false); - var maxLength = GetMaxTextLength(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - string text = null; - string html = null; - - if (string.IsNullOrWhiteSpace(selector)) - { - if (includeText) - { - text = await trackedPage.Page.EvaluateAsync("() => document.body ? document.body.innerText : ''"); - } - - if (includeHtml) - { - html = await trackedPage.Page.ContentAsync(); - } - } - else - { - var locator = trackedPage.Page.Locator(selector).First; - await locator.WaitForAsync(new LocatorWaitForOptions - { - Timeout = timeout, - }); - - if (includeText) - { - text = await locator.InnerTextAsync(new LocatorInnerTextOptions - { - Timeout = timeout, - }); - } - - if (includeHtml) - { - html = await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions - { - Timeout = timeout, - }); - } - } - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - text = includeText ? Truncate(text, maxLength) : null, - html = includeHtml ? Truncate(html, maxLength) : null, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserPageContentTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserPageContent"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "Optional Playwright selector. When omitted, returns the full page content." + }, + "includeText": { + "type": "boolean", + "description": "Optional. Include text content. Defaults to true." + }, + "includeHtml": { + "type": "boolean", + "description": "Optional. Include HTML content. Defaults to false." + }, + "maxLength": { + "type": "integer", + "description": "Optional maximum length for returned text or HTML." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds for selector lookup." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserPageContentTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Retrieves text and/or HTML from the full page or from a specific element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetOptionalString(arguments, "selector"); + var includeText = GetBoolean(arguments, "includeText", true); + var includeHtml = GetBoolean(arguments, "includeHtml", false); + var maxLength = GetMaxTextLength(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + string text = null; + string html = null; + + if (string.IsNullOrWhiteSpace(selector)) + { + if (includeText) + { + text = await trackedPage.Page.EvaluateAsync("() => document.body ? document.body.innerText : ''"); + } + + if (includeHtml) + { + html = await trackedPage.Page.ContentAsync(); + } + } + else + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.WaitForAsync(new LocatorWaitForOptions + { + Timeout = timeout, + }); + + if (includeText) + { + text = await locator.InnerTextAsync(new LocatorInnerTextOptions + { + Timeout = timeout, + }); + } + + if (includeHtml) + { + html = await locator.InnerHTMLAsync(new LocatorInnerHTMLOptions + { + Timeout = timeout, + }); + } + } + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + text = includeText ? Truncate(text, maxLength) : null, + html = includeHtml ? Truncate(html, maxLength) : null, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageStateTool.cs similarity index 81% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageStateTool.cs index 2ec933b95..5b4ebf767 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserPageStateTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserPageStateTool.cs @@ -1,83 +1,82 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserPageStateTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserPageState"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserPageStateTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Returns high-level state about the current page, including title, ready state, scroll position, and element counts."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"() => JSON.stringify({ - readyState: document.readyState, - location: window.location.href, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - scrollX: window.scrollX, - scrollY: window.scrollY, - historyLength: window.history.length, - linkCount: document.querySelectorAll('a').length, - buttonCount: document.querySelectorAll('button, input[type=button], input[type=submit]').length, - formCount: document.forms.length, - headingCount: document.querySelectorAll('h1, h2, h3, h4, h5, h6').length - })"); - - return new - { - sessionId, - pageId = trackedPage.PageId, - title = await trackedPage.Page.TitleAsync(), - url = trackedPage.Page.Url, - state = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserPageStateTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserPageState"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserPageStateTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns high-level state about the current page, including title, ready state, scroll position, and element counts."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"() => JSON.stringify({ + readyState: document.readyState, + location: window.location.href, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + historyLength: window.history.length, + linkCount: document.querySelectorAll('a').length, + buttonCount: document.querySelectorAll('button, input[type=button], input[type=submit]').length, + formCount: document.forms.length, + headingCount: document.querySelectorAll('h1, h2, h3, h4, h5, h6').length + })"); + + return new + { + sessionId, + pageId = trackedPage.PageId, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + state = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserSessionTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserSessionTool.cs index 269703cf9..b36c35d67 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GetBrowserSessionTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GetBrowserSessionTool.cs @@ -1,49 +1,48 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GetBrowserSessionTool : BrowserAutomationToolBase -{ - public const string TheName = "getBrowserSession"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier to inspect." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GetBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Returns details about a specific browser session, including tracked tabs."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.GetSessionSnapshotAsync(GetSessionId(arguments), cancellationToken); - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GetBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "getBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier to inspect." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GetBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Returns details about a specific browser session, including tracked tabs."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.GetSessionSnapshotAsync(GetSessionId(arguments), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoBackBrowserTool.cs similarity index 71% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoBackBrowserTool.cs index 1fb777294..7b602f728 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoBackBrowserTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoBackBrowserTool.cs @@ -1,85 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GoBackBrowserTool : BrowserAutomationToolBase -{ - public const string TheName = "goBackBrowser"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "waitUntil": { - "type": "string", - "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional navigation timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GoBackBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Navigates the tab backward in browser history."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var waitUntil = ParseWaitUntil(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var response = await trackedPage.Page.GoBackAsync(new PageGoBackOptions - { - Timeout = timeout, - WaitUntil = waitUntil, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - status = response?.Status, - ok = response?.Ok, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GoBackBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "goBackBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GoBackBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the tab backward in browser history."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GoBackAsync(new PageGoBackOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoForwardBrowserTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoForwardBrowserTool.cs index f169eb681..9b104f3f8 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/GoForwardBrowserTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/GoForwardBrowserTool.cs @@ -1,85 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class GoForwardBrowserTool : BrowserAutomationToolBase -{ - public const string TheName = "goForwardBrowser"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "waitUntil": { - "type": "string", - "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional navigation timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public GoForwardBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Navigates the tab forward in browser history."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var waitUntil = ParseWaitUntil(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var response = await trackedPage.Page.GoForwardAsync(new PageGoForwardOptions - { - Timeout = timeout, - WaitUntil = waitUntil, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - status = response?.Status, - ok = response?.Ok, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class GoForwardBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "goForwardBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public GoForwardBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the tab forward in browser history."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GoForwardAsync(new PageGoForwardOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/HoverBrowserElementTool.cs similarity index 68% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/HoverBrowserElementTool.cs index ff3bc0dab..3d4728862 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/HoverBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/HoverBrowserElementTool.cs @@ -1,88 +1,87 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class HoverBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "hoverBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the element." - }, - "button": { - "type": "string", - "description": "Optional mouse button for click actions: left, middle, or right." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public HoverBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Moves the mouse over an element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var timeout = GetTimeout(arguments); - await locator.HoverAsync(new LocatorHoverOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class HoverBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "hoverBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element." + }, + "button": { + "type": "string", + "description": "Optional mouse button for click actions: left, middle, or right." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public HoverBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Moves the mouse over an element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.HoverAsync(new LocatorHoverOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ListBrowserSessionsTool.cs similarity index 81% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ListBrowserSessionsTool.cs index 438ea1613..c76473c37 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ListBrowserSessionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ListBrowserSessionsTool.cs @@ -1,45 +1,46 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ListBrowserSessionsTool : BrowserAutomationToolBase -{ - public const string TheName = "listBrowserSessions"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": {}, - "additionalProperties": false - } - """); - - public ListBrowserSessionsTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Lists the currently tracked Playwright browser sessions."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessions = await BrowserAutomationService.ListSessionsAsync(cancellationToken); - return Success(TheName, new - { - count = sessions.Count, - sessions, - }); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ListBrowserSessionsTool : BrowserAutomationToolBase +{ + public const string TheName = "listBrowserSessions"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """); + + public ListBrowserSessionsTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Lists the currently tracked Playwright browser sessions."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessions = await BrowserAutomationService.ListSessionsAsync(cancellationToken); + return Success(TheName, new + { + count = sessions.Count, + sessions, + }); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserMenuTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserMenuTool.cs new file mode 100644 index 000000000..c1b355d91 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserMenuTool.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class NavigateBrowserMenuTool : BrowserAutomationToolBase +{ + public const string TheName = "navigateBrowserMenu"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "path": { + "type": "string", + "description": "A menu label path to follow, such as 'Search >> Indexes' or 'Content Definitions'." + }, + "pathSegments": { + "type": "array", + "description": "Optional explicit menu path segments, such as ['Search', 'Indexes'].", + "items": { + "type": "string" + } + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public NavigateBrowserMenuTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates visible site or Orchard Core admin menus by label, including nested paths such as 'Search >> Indexes'. Use this when the user asks to open, visit, or go to a page from the UI navigation."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var timeout = GetTimeout(arguments); + var pathSegments = GetPathSegments(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var matchedItems = new List(); + + foreach (var pathSegment in pathSegments) + { + var match = await ClickNavigationItemAsync(trackedPage.Page, pathSegment, timeout); + matchedItems.Add(match); + } + + return new + { + sessionId, + pageId = trackedPage.PageId, + pathSegments, + matchedItems, + title = await trackedPage.Page.TitleAsync(), + url = trackedPage.Page.Url, + }; + }, cancellationToken); + + RequestLiveNavigation(result.url); + return Success(TheName, result); + }); + } + + private static string[] GetPathSegments(AIFunctionArguments arguments) + { + if (arguments.TryGetFirst("pathSegments", out var pathSegments) && pathSegments is { Length: > 0 }) + { + var sanitizedSegments = pathSegments + .Select(BrowserNavigationPathParser.NormalizeSegment) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .ToArray(); + + if (sanitizedSegments.Length > 0) + { + return sanitizedSegments; + } + } + + var path = GetOptionalString(arguments, "path"); + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException("path or pathSegments is required."); + } + + var parsedSegments = BrowserNavigationPathParser.Split(path); + if (parsedSegments.Length == 0) + { + throw new InvalidOperationException("path or pathSegments is required."); + } + + return parsedSegments; + } + + private static async Task ClickNavigationItemAsync(IPage page, string pathSegment, int timeout) + { + var marker = $"ai-nav-{Guid.NewGuid():N}"; + var rawMatch = await page.EvaluateAsync(BrowserAutomationScripts.FindNavigationItem, new + { + segment = pathSegment, + marker, + }); + + if (string.IsNullOrWhiteSpace(rawMatch)) + { + throw new InvalidOperationException($"Could not find a navigation item matching '{pathSegment}'."); + } + + var match = JsonSerializer.Deserialize(rawMatch); + if (match is null) + { + throw new InvalidOperationException($"Could not resolve the navigation item '{pathSegment}'."); + } + + var previousUrl = page.Url; + var locator = page.Locator($"[data-ai-nav-match='{marker}']").First; + + await locator.ScrollIntoViewIfNeededAsync(new LocatorScrollIntoViewIfNeededOptions + { + Timeout = timeout, + }); + + await locator.ClickAsync(new LocatorClickOptions + { + Timeout = timeout, + }); + + await page.EvaluateAsync(BrowserAutomationScripts.RemoveNavigationMarker, marker); + + if (!string.IsNullOrWhiteSpace(match.Href) && !match.Href.StartsWith('#')) + { + await WaitForUrlChangeAsync(page, previousUrl, Math.Min(timeout, 5_000)); + } + else + { + await page.WaitForTimeoutAsync(300); + } + + match.PathSegment = pathSegment; + + return match; + } + + private static async Task WaitForUrlChangeAsync(IPage page, string previousUrl, int timeoutMs) + { + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + if (!string.Equals(page.Url, previousUrl, StringComparison.Ordinal)) + { + return; + } + + await page.WaitForTimeoutAsync(100); + } + } + + private sealed class NavigationMatch + { + public string PathSegment { get; set; } + + public string Text { get; set; } + + public string Href { get; set; } + + public string TagName { get; set; } + + public string AriaExpanded { get; set; } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserTool.cs similarity index 67% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserTool.cs index dca02b7b7..ee9d1ac62 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/NavigateBrowserTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/NavigateBrowserTool.cs @@ -1,91 +1,91 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class NavigateBrowserTool : BrowserAutomationToolBase -{ - public const string TheName = "navigateBrowser"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "url": { - "type": "string", - "description": "The target URL to navigate to." - }, - "waitUntil": { - "type": "string", - "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional navigation timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "url" - ], - "additionalProperties": false - } - """); - - public NavigateBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Navigates the active tab or a specified tab to a URL."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var url = GetRequiredString(arguments, "url"); - var waitUntil = ParseWaitUntil(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var response = await trackedPage.Page.GotoAsync(url, new PageGotoOptions - { - Timeout = timeout, - WaitUntil = waitUntil, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - status = response?.Status, - ok = response?.Ok, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class NavigateBrowserTool : BrowserAutomationToolBase +{ + public const string TheName = "navigateBrowser"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "url": { + "type": "string", + "description": "The target URL to navigate to." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + """); + + public NavigateBrowserTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Navigates the active tab or a specified tab to a URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var url = GetRequiredString(arguments, "url"); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.GotoAsync(url, new PageGotoOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + RequestLiveNavigation(result.url); + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/OpenBrowserTabTool.cs similarity index 65% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/OpenBrowserTabTool.cs index acc65f2ac..41f4e0b3b 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/OpenBrowserTabTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/OpenBrowserTabTool.cs @@ -1,68 +1,67 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class OpenBrowserTabTool : BrowserAutomationToolBase -{ - public const string TheName = "openBrowserTab"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "url": { - "type": "string", - "description": "Optional URL to open in the new tab." - }, - "waitUntil": { - "type": "string", - "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional navigation timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public OpenBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Opens a new tab in an existing browser session and can optionally navigate it to a URL."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.CreatePageAsync( - GetSessionId(arguments), - GetOptionalString(arguments, "url"), - ParseWaitUntil(arguments), - GetTimeout(arguments), - cancellationToken); - - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class OpenBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "openBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "url": { + "type": "string", + "description": "Optional URL to open in the new tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public OpenBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Opens a new tab in an existing browser session and can optionally navigate it to a URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CreatePageAsync( + GetSessionId(arguments), + GetOptionalString(arguments, "url"), + ParseWaitUntil(arguments), + GetTimeout(arguments), + cancellationToken); + + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/PressBrowserKeyTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/PressBrowserKeyTool.cs index c9919c7bd..cef541ead 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/PressBrowserKeyTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/PressBrowserKeyTool.cs @@ -1,98 +1,98 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class PressBrowserKeyTool : BrowserAutomationToolBase -{ - public const string TheName = "pressBrowserKey"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "key": { - "type": "string", - "description": "The key or shortcut to press, such as Enter, Escape, Tab, or Control+A." - }, - "selector": { - "type": "string", - "description": "Optional selector to focus before pressing the key." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "key" - ], - "additionalProperties": false - } - """); - - public PressBrowserKeyTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Sends a keyboard shortcut or key press to the page or to a focused element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var key = GetRequiredString(arguments, "key"); - var selector = GetOptionalString(arguments, "selector"); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - if (!string.IsNullOrWhiteSpace(selector)) - { - var locator = trackedPage.Page.Locator(selector).First; - await locator.PressAsync(key, new LocatorPressOptions - { - Timeout = timeout, - }); - } - else - { - await trackedPage.Page.Keyboard.PressAsync(key, new KeyboardPressOptions()); - } - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - key, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class PressBrowserKeyTool : BrowserAutomationToolBase +{ + public const string TheName = "pressBrowserKey"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "key": { + "type": "string", + "description": "The key or shortcut to press, such as Enter, Escape, Tab, or Control+A." + }, + "selector": { + "type": "string", + "description": "Optional selector to focus before pressing the key." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "key" + ], + "additionalProperties": false + } + """); + + public PressBrowserKeyTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Sends a keyboard shortcut or key press to the page or to a focused element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var key = GetRequiredString(arguments, "key"); + var selector = GetOptionalString(arguments, "selector"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + if (!string.IsNullOrWhiteSpace(selector)) + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.PressAsync(key, new LocatorPressOptions + { + Timeout = timeout, + }); + } + else + { + await trackedPage.Page.Keyboard.PressAsync(key, new KeyboardPressOptions()); + } + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + key, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ReloadBrowserPageTool.cs similarity index 71% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ReloadBrowserPageTool.cs index d6139b25d..24aac7298 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ReloadBrowserPageTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ReloadBrowserPageTool.cs @@ -1,85 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ReloadBrowserPageTool : BrowserAutomationToolBase -{ - public const string TheName = "reloadBrowserPage"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "waitUntil": { - "type": "string", - "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional navigation timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public ReloadBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Reloads the current page."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var waitUntil = ParseWaitUntil(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var response = await trackedPage.Page.ReloadAsync(new PageReloadOptions - { - Timeout = timeout, - WaitUntil = waitUntil, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - status = response?.Status, - ok = response?.Ok, - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ReloadBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "reloadBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "waitUntil": { + "type": "string", + "description": "Optional navigation wait strategy: load, domcontentloaded, networkidle, or commit." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional navigation timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public ReloadBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Reloads the current page."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var waitUntil = ParseWaitUntil(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var response = await trackedPage.Page.ReloadAsync(new PageReloadOptions + { + Timeout = timeout, + WaitUntil = waitUntil, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + status = response?.Status, + ok = response?.Ok, + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs index f20dc9d4c..3211f8940 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserElementIntoViewTool.cs @@ -1,85 +1,85 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ScrollBrowserElementIntoViewTool : BrowserAutomationToolBase -{ - public const string TheName = "scrollBrowserElementIntoView"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the element to scroll into view." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public ScrollBrowserElementIntoViewTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Scrolls an element into view using Playwright locator semantics."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - await locator.ScrollIntoViewIfNeededAsync(new LocatorScrollIntoViewIfNeededOptions - { - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ScrollBrowserElementIntoViewTool : BrowserAutomationToolBase +{ + public const string TheName = "scrollBrowserElementIntoView"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the element to scroll into view." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public ScrollBrowserElementIntoViewTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Scrolls an element into view using Playwright locator semantics."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.ScrollIntoViewIfNeededAsync(new LocatorScrollIntoViewIfNeededOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserPageTool.cs similarity index 73% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserPageTool.cs index d33d50f48..458f3d55e 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/ScrollBrowserPageTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/ScrollBrowserPageTool.cs @@ -1,83 +1,82 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class ScrollBrowserPageTool : BrowserAutomationToolBase -{ - public const string TheName = "scrollBrowserPage"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "deltaX": { - "type": "integer", - "description": "Optional horizontal scroll offset. Defaults to 0." - }, - "deltaY": { - "type": "integer", - "description": "Optional vertical scroll offset. Defaults to 400." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public ScrollBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Scrolls the current page by the provided offsets."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var deltaX = arguments.TryGetFirst("deltaX", out var parsedDeltaX) ? parsedDeltaX : 0; - var deltaY = arguments.TryGetFirst("deltaY", out var parsedDeltaY) ? parsedDeltaY : 400; - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var raw = await trackedPage.Page.EvaluateAsync( - @"(scroll) => { - window.scrollBy(scroll.deltaX, scroll.deltaY); - return JSON.stringify({ x: window.scrollX, y: window.scrollY }); - }", - new { deltaX, deltaY }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - scrollPosition = ParseJson(raw), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class ScrollBrowserPageTool : BrowserAutomationToolBase +{ + public const string TheName = "scrollBrowserPage"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "deltaX": { + "type": "integer", + "description": "Optional horizontal scroll offset. Defaults to 0." + }, + "deltaY": { + "type": "integer", + "description": "Optional vertical scroll offset. Defaults to 400." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public ScrollBrowserPageTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Scrolls the current page by the provided offsets."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var deltaX = arguments.TryGetFirst("deltaX", out var parsedDeltaX) ? parsedDeltaX : 0; + var deltaY = arguments.TryGetFirst("deltaY", out var parsedDeltaY) ? parsedDeltaY : 400; + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var raw = await trackedPage.Page.EvaluateAsync( + @"(scroll) => { + window.scrollBy(scroll.deltaX, scroll.deltaY); + return JSON.stringify({ x: window.scrollX, y: window.scrollY }); + }", + new { deltaX, deltaY }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + scrollPosition = ParseJson(raw), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SelectBrowserOptionTool.cs similarity index 69% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SelectBrowserOptionTool.cs index 3bdb1002c..9698691c8 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SelectBrowserOptionTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SelectBrowserOptionTool.cs @@ -1,95 +1,95 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class SelectBrowserOptionTool : BrowserAutomationToolBase -{ - public const string TheName = "selectBrowserOption"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the select element." - }, - "values": { - "type": "array", - "items": { - "type": "string" - }, - "description": "One or more option values to select." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector", - "values" - ], - "additionalProperties": false - } - """); - - public SelectBrowserOptionTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Selects one or more values in a select element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - var values = GetStringArray(arguments, "values"); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var selected = await locator.SelectOptionAsync(values, new LocatorSelectOptionOptions - { - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - values = selected, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class SelectBrowserOptionTool : BrowserAutomationToolBase +{ + public const string TheName = "selectBrowserOption"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the select element." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "One or more option values to select." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector", + "values" + ], + "additionalProperties": false + } + """); + + public SelectBrowserOptionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Selects one or more values in a select element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var values = GetStringArray(arguments, "values"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var selected = await locator.SelectOptionAsync(values, new LocatorSelectOptionOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + values = selected, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/StartBrowserSessionTool.cs similarity index 65% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/StartBrowserSessionTool.cs index 8690c55b2..9f5ad6435 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/StartBrowserSessionTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/StartBrowserSessionTool.cs @@ -1,84 +1,85 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class StartBrowserSessionTool : BrowserAutomationToolBase -{ - public const string TheName = "startBrowserSession"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "browserType": { - "type": "string", - "description": "Optional browser engine: chromium, firefox, or webkit." - }, - "headless": { - "type": "boolean", - "description": "Optional. When true, launches the browser in headless mode. Defaults to true." - }, - "startUrl": { - "type": "string", - "description": "Optional. The initial URL to open after the browser session starts." - }, - "viewportWidth": { - "type": "integer", - "description": "Optional viewport width in pixels." - }, - "viewportHeight": { - "type": "integer", - "description": "Optional viewport height in pixels." - }, - "locale": { - "type": "string", - "description": "Optional browser locale, such as en-US." - }, - "userAgent": { - "type": "string", - "description": "Optional custom user agent string." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional browser launch and initial navigation timeout in milliseconds." - } - }, - "additionalProperties": false - } - """); - - public StartBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Creates a Playwright browser session and optionally navigates to an initial URL."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.CreateSessionAsync( - GetOptionalString(arguments, "browserType"), - GetBoolean(arguments, "headless", true), - GetOptionalString(arguments, "startUrl"), - GetNullableInt(arguments, "viewportWidth"), - GetNullableInt(arguments, "viewportHeight"), - GetOptionalString(arguments, "locale"), - GetOptionalString(arguments, "userAgent"), - GetTimeout(arguments), - cancellationToken); - - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class StartBrowserSessionTool : BrowserAutomationToolBase +{ + public const string TheName = "startBrowserSession"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "browserType": { + "type": "string", + "description": "Optional browser engine: chromium, firefox, or webkit." + }, + "headless": { + "type": "boolean", + "description": "Optional. When true, launches the browser in headless mode. Defaults to true." + }, + "startUrl": { + "type": "string", + "description": "Optional. The initial URL to open after the browser session starts." + }, + "viewportWidth": { + "type": "integer", + "description": "Optional viewport width in pixels." + }, + "viewportHeight": { + "type": "integer", + "description": "Optional viewport height in pixels." + }, + "locale": { + "type": "string", + "description": "Optional browser locale, such as en-US." + }, + "userAgent": { + "type": "string", + "description": "Optional custom user agent string." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional browser launch and initial navigation timeout in milliseconds." + } + }, + "additionalProperties": false + } + """); + + public StartBrowserSessionTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Creates a Playwright browser session and optionally navigates to an initial URL."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.CreateSessionAsync( + GetOptionalString(arguments, "browserType"), + GetBoolean(arguments, "headless", true), + GetOptionalString(arguments, "startUrl"), + GetNullableInt(arguments, "viewportWidth"), + GetNullableInt(arguments, "viewportHeight"), + GetOptionalString(arguments, "locale"), + GetOptionalString(arguments, "userAgent"), + GetTimeout(arguments), + cancellationToken); + + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SwitchBrowserTabTool.cs similarity index 65% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SwitchBrowserTabTool.cs index 8c56c3abe..62927190d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/SwitchBrowserTabTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/SwitchBrowserTabTool.cs @@ -1,55 +1,55 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class SwitchBrowserTabTool : BrowserAutomationToolBase -{ - public const string TheName = "switchBrowserTab"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "The page identifier to activate." - } - }, - "required": [ - "sessionId", - "pageId" - ], - "additionalProperties": false - } - """); - - public SwitchBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Marks a specific browser tab as the active tab for future actions."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var snapshot = await BrowserAutomationService.SwitchActivePageAsync(GetSessionId(arguments), GetRequiredString(arguments, "pageId"), cancellationToken); - return Success(TheName, snapshot); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class SwitchBrowserTabTool : BrowserAutomationToolBase +{ + public const string TheName = "switchBrowserTab"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "The page identifier to activate." + } + }, + "required": [ + "pageId" + ], + "additionalProperties": false + } + """); + + public SwitchBrowserTabTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Marks a specific browser tab as the active tab for future actions."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var snapshot = await BrowserAutomationService.SwitchActivePageAsync(GetSessionId(arguments), GetRequiredString(arguments, "pageId"), cancellationToken); + return Success(TheName, snapshot); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UncheckBrowserElementTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UncheckBrowserElementTool.cs index 404daa430..820c49bee 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UncheckBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UncheckBrowserElementTool.cs @@ -1,84 +1,84 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class UncheckBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "uncheckBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the target control." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public UncheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Unchecks a checkbox."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - var timeout = GetTimeout(arguments); - await locator.UncheckAsync(new LocatorUncheckOptions - { - Timeout = timeout, - }); - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class UncheckBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "uncheckBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the target control." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public UncheckBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Unchecks a checkbox."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + var timeout = GetTimeout(arguments); + await locator.UncheckAsync(new LocatorUncheckOptions + { + Timeout = timeout, + }); + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UploadBrowserFilesTool.cs similarity index 67% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UploadBrowserFilesTool.cs index caf76bcbe..ae1e4123e 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/UploadBrowserFilesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/UploadBrowserFilesTool.cs @@ -1,95 +1,95 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class UploadBrowserFilesTool : BrowserAutomationToolBase -{ - public const string TheName = "uploadBrowserFiles"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector for the file input." - }, - "filePaths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Absolute file paths to upload." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector", - "filePaths" - ], - "additionalProperties": false - } - """); - - public UploadBrowserFilesTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Uploads one or more files into a file input element."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - var filePaths = GetStringArray(arguments, "filePaths"); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var locator = trackedPage.Page.Locator(selector).First; - await locator.SetInputFilesAsync(filePaths, new LocatorSetInputFilesOptions - { - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - filePaths, - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class UploadBrowserFilesTool : BrowserAutomationToolBase +{ + public const string TheName = "uploadBrowserFiles"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector for the file input." + }, + "filePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Absolute file paths to upload." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector", + "filePaths" + ], + "additionalProperties": false + } + """); + + public UploadBrowserFilesTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Uploads one or more files into a file input element."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var filePaths = GetStringArray(arguments, "filePaths"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var locator = trackedPage.Page.Locator(selector).First; + await locator.SetInputFilesAsync(filePaths, new LocatorSetInputFilesOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + filePaths, + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserElementTool.cs similarity index 71% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserElementTool.cs index 9c8d37a6a..c0f2fd4a4 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserElementTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserElementTool.cs @@ -1,91 +1,91 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class WaitForBrowserElementTool : BrowserAutomationToolBase -{ - public const string TheName = "waitForBrowserElement"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "selector": { - "type": "string", - "description": "The Playwright selector to wait for." - }, - "state": { - "type": "string", - "description": "Optional selector state: attached, detached, hidden, or visible." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId", - "selector" - ], - "additionalProperties": false - } - """); - - public WaitForBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Waits for a selector to reach a requested state."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var selector = GetRequiredString(arguments, "selector"); - var state = ParseSelectorState(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - await trackedPage.Page.WaitForSelectorAsync(selector, new PageWaitForSelectorOptions - { - State = state, - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - selector, - state = state.ToString(), - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class WaitForBrowserElementTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserElement"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "selector": { + "type": "string", + "description": "The Playwright selector to wait for." + }, + "state": { + "type": "string", + "description": "Optional selector state: attached, detached, hidden, or visible." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + } + """); + + public WaitForBrowserElementTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for a selector to reach a requested state."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var selector = GetRequiredString(arguments, "selector"); + var state = ParseSelectorState(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + await trackedPage.Page.WaitForSelectorAsync(selector, new PageWaitForSelectorOptions + { + State = state, + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + selector, + state = state.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserLoadStateTool.cs similarity index 72% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserLoadStateTool.cs index 02d0e0b7c..9e73bb892 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserLoadStateTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserLoadStateTool.cs @@ -1,83 +1,82 @@ -using System.Text.Json; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class WaitForBrowserLoadStateTool : BrowserAutomationToolBase -{ - public const string TheName = "waitForBrowserLoadState"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "state": { - "type": "string", - "description": "Optional load state: load, domcontentloaded, or networkidle." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public WaitForBrowserLoadStateTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Waits for the page to reach a specific load state."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var state = ParseLoadState(arguments); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - await trackedPage.Page.WaitForLoadStateAsync(state, new PageWaitForLoadStateOptions - { - Timeout = timeout, - }); - - return new - { - sessionId, - pageId = trackedPage.PageId, - state = state.ToString(), - url = trackedPage.Page.Url, - title = await trackedPage.Page.TitleAsync(), - }; - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using CrestApps.OrchardCore.AI.Agent.Services; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class WaitForBrowserLoadStateTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserLoadState"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "state": { + "type": "string", + "description": "Optional load state: load, domcontentloaded, or networkidle." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public WaitForBrowserLoadStateTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for the page to reach a specific load state."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var state = ParseLoadState(arguments); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + await trackedPage.Page.WaitForLoadStateAsync(state, new PageWaitForLoadStateOptions + { + Timeout = timeout, + }); + + return new + { + sessionId, + pageId = trackedPage.PageId, + state = state.ToString(), + url = trackedPage.Page.Url, + title = await trackedPage.Page.TitleAsync(), + }; + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserNavigationTool.cs similarity index 77% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserNavigationTool.cs index 8849d4355..85997fff5 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/BrowserAutomation/WaitForBrowserNavigationTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/WaitForBrowserNavigationTool.cs @@ -1,96 +1,95 @@ -using System.Text.Json; -using System.Diagnostics; -using CrestApps.OrchardCore.AI.Core.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Playwright; - -namespace CrestApps.OrchardCore.AI.Agent.BrowserAutomation; - -public sealed class WaitForBrowserNavigationTool : BrowserAutomationToolBase -{ - public const string TheName = "waitForBrowserNavigation"; - - private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "The browser session identifier." - }, - "pageId": { - "type": "string", - "description": "Optional page identifier. Defaults to the active tab." - }, - "urlContains": { - "type": "string", - "description": "Optional URL fragment that must appear in the current URL before the wait completes." - }, - "timeoutMs": { - "type": "integer", - "description": "Optional timeout in milliseconds." - } - }, - "required": [ - "sessionId" - ], - "additionalProperties": false - } - """); - - public WaitForBrowserNavigationTool(BrowserAutomationService browserAutomationService, ILogger logger) - : base(browserAutomationService, logger) - { - } - - public override string Name => TheName; - - public override string Description => "Waits for the page URL to change or to contain the requested fragment."; - - public override JsonElement JsonSchema => _jsonSchema; - - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) - { - return await ExecuteSafeAsync(TheName, async () => - { - var sessionId = GetSessionId(arguments); - var pageId = GetPageId(arguments); - var urlContains = GetOptionalString(arguments, "urlContains"); - var timeout = GetTimeout(arguments); - - var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => - { - var stopwatch = Stopwatch.StartNew(); - var initialUrl = trackedPage.Page.Url; - - while (stopwatch.ElapsedMilliseconds < timeout) - { - var currentUrl = trackedPage.Page.Url; - var changed = !string.Equals(initialUrl, currentUrl, StringComparison.Ordinal); - var matches = string.IsNullOrWhiteSpace(urlContains) || currentUrl.Contains(urlContains, StringComparison.OrdinalIgnoreCase); - - if (changed && matches) - { - return new - { - sessionId, - pageId = trackedPage.PageId, - initialUrl, - url = currentUrl, - title = await trackedPage.Page.TitleAsync(), - }; - } - - await Task.Delay(250, cancellationToken); - } - - throw new TimeoutException($"Navigation did not reach the expected URL within {timeout} ms."); - }, cancellationToken); - - return Success(TheName, result); - }); - } -} - +using System.Text.Json; +using System.Diagnostics; +using CrestApps.OrchardCore.AI.Core.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using CrestApps.OrchardCore.AI.Agent.Services; + +namespace CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +public sealed class WaitForBrowserNavigationTool : BrowserAutomationToolBase +{ + public const string TheName = "waitForBrowserNavigation"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The browser session identifier." + }, + "pageId": { + "type": "string", + "description": "Optional page identifier. Defaults to the active tab." + }, + "urlContains": { + "type": "string", + "description": "Optional URL fragment that must appear in the current URL before the wait completes." + }, + "timeoutMs": { + "type": "integer", + "description": "Optional timeout in milliseconds." + } + }, + "required": [], + "additionalProperties": false + } + """); + + public WaitForBrowserNavigationTool(BrowserAutomationService browserAutomationService, ILogger logger) + : base(browserAutomationService, logger) + { + } + + public override string Name => TheName; + + public override string Description => "Waits for the page URL to change or to contain the requested fragment."; + + public override JsonElement JsonSchema => _jsonSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await ExecuteSafeAsync(TheName, async () => + { + var sessionId = GetSessionId(arguments); + var pageId = GetPageId(arguments); + var urlContains = GetOptionalString(arguments, "urlContains"); + var timeout = GetTimeout(arguments); + + var result = await BrowserAutomationService.WithPageAsync(sessionId, pageId, async (_, trackedPage) => + { + var stopwatch = Stopwatch.StartNew(); + var initialUrl = trackedPage.Page.Url; + + while (stopwatch.ElapsedMilliseconds < timeout) + { + var currentUrl = trackedPage.Page.Url; + var changed = !string.Equals(initialUrl, currentUrl, StringComparison.Ordinal); + var matches = string.IsNullOrWhiteSpace(urlContains) || currentUrl.Contains(urlContains, StringComparison.OrdinalIgnoreCase); + + if (changed && matches) + { + return new + { + sessionId, + pageId = trackedPage.PageId, + initialUrl, + url = currentUrl, + title = await trackedPage.Page.TitleAsync(), + }; + } + + await Task.Delay(250, cancellationToken); + } + + throw new TimeoutException($"Navigation did not reach the expected URL within {timeout} ms."); + }, cancellationToken); + + return Success(TheName, result); + }); + } +} + diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendEmailTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendEmailTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendEmailTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendEmailTool.cs index 1dcf8bafe..a25047e05 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendEmailTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendEmailTool.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CrestApps.OrchardCore.AI; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -8,14 +9,14 @@ using OrchardCore.Email; using OrchardCore.Users; -namespace CrestApps.OrchardCore.AI.Agent.Communications; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Communications; public sealed class SendEmailTool : AIFunction { public const string TheName = "sendEmail"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { @@ -77,7 +78,6 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a return "No EmailService is registered. Can't send emails using this tool."; } - if (!arguments.TryGetFirstString("to", out var to)) { logger.LogWarning("AI tool '{ToolName}' missing required argument '{ArgumentName}'.", Name, "to"); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendNotificationTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendNotificationTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendNotificationTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendNotificationTool.cs index 710d845c4..9f62d64b0 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendNotificationTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendNotificationTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.AI; @@ -8,14 +8,14 @@ using OrchardCore.Notifications.Models; using OrchardCore.Users; -namespace CrestApps.OrchardCore.AI.Agent.Communications; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Communications; public sealed class SendNotificationTool : AIFunction { public const string TheName = "sendNotification"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendSmsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendSmsTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendSmsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendSmsTool.cs index 47867ed3e..26cd014bc 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Communications/SendSmsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Communications/SendSmsTool.cs @@ -1,18 +1,18 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Sms; -namespace CrestApps.OrchardCore.AI.Agent.Communications; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Communications; public sealed class SendSmsTool : AIFunction { public const string TheName = "sendSmsMessage"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs similarity index 93% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs index 7e4a9373f..f9460c35d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/CreateOrUpdateContentTypeDefinitionsTool.cs @@ -1,10 +1,10 @@ -using CrestApps.OrchardCore.AI.Core; +using CrestApps.OrchardCore.AI.Core; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class CreateOrUpdateContentTypeDefinitionsTool : ImportRecipeBaseTool { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentPartDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentPartDefinitionsTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentPartDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentPartDefinitionsTool.cs index a3aba3d59..9094c1797 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentPartDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentPartDefinitionsTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class GetContentPartDefinitionsTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentTypeDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentTypeDefinitionsTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentTypeDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentTypeDefinitionsTool.cs index 3df74c0db..9e7e08ab1 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/GetContentTypeDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/GetContentTypeDefinitionsTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class GetContentTypeDefinitionsTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentFieldsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentFieldsTool.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentFieldsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentFieldsTool.cs index b8b77a635..ce83cc188 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentFieldsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentFieldsTool.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Agent.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class ListContentFieldsTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentPartsDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentPartsDefinitionsTool.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentPartsDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentPartsDefinitionsTool.cs index 694e398d0..cbee3ccb8 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentPartsDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentPartsDefinitionsTool.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class ListContentPartsDefinitionsTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentTypesDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentTypesDefinitionsTool.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentTypesDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentTypesDefinitionsTool.cs index 2e9c5603a..a24b02fcb 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/ListContentTypesDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/ListContentTypesDefinitionsTool.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class ListContentTypesDefinitionsTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentPartDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentPartDefinitionsTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentPartDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentPartDefinitionsTool.cs index bda9ec378..2cc2374a9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentPartDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentPartDefinitionsTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using CrestApps.OrchardCore.AI.Core.Extensions; using CrestApps.OrchardCore.Recipes.Core.Services; @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class RemoveContentPartDefinitionsTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentTypeDefinitionsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentTypeDefinitionsTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentTypeDefinitionsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentTypeDefinitionsTool.cs index a23502058..39ac524b5 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/ContentTypes/RemoveContentTypeDefinitionsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/ContentTypes/RemoveContentTypeDefinitionsTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using CrestApps.OrchardCore.AI.Core.Extensions; using CrestApps.OrchardCore.Recipes.Core.Services; @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement.Metadata; -namespace CrestApps.OrchardCore.AI.Agent.ContentTypes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.ContentTypes; public sealed class RemoveContentTypeDefinitionsTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CloneContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CloneContentTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CloneContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CloneContentTool.cs index 703d4abda..b452737e9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CloneContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CloneContentTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class CloneContentTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CreateOrUpdateContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CreateOrUpdateContentTool.cs similarity index 99% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CreateOrUpdateContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CreateOrUpdateContentTool.cs index a19b9fdaf..37b250bfb 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/CreateOrUpdateContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/CreateOrUpdateContentTool.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.Json; using System.Text.Json.Settings; using CrestApps.OrchardCore.AI.Core.Extensions; @@ -12,7 +12,7 @@ using OrchardCore.ContentManagement.Metadata; using Usr = OrchardCore.Users; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class CreateOrUpdateContentTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/DeleteContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/DeleteContentTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/DeleteContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/DeleteContentTool.cs index 27f293244..2ce1f8b09 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/DeleteContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/DeleteContentTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class DeleteContentTool: AIFunction { @@ -24,7 +24,6 @@ public sealed class DeleteContentTool: AIFunction "required": ["contentItemId"], "additionalProperties": false } - """); public override string Name => TheName; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemLinkTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemLinkTool.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemLinkTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemLinkTool.cs index bbc3b106f..1e1b532fd 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemLinkTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemLinkTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class GetContentItemLinkTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemSchemaTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemSchemaTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemSchemaTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemSchemaTool.cs index 446c2351d..105cf2621 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentItemSchemaTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentItemSchemaTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +8,7 @@ using OrchardCore.ContentManagement.Metadata; using OrchardCore.Json; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class GetContentItemSchemaTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentTool.cs index 317b882bd..4687937a9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/GetContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/GetContentTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -7,7 +7,7 @@ using OrchardCore.ContentManagement; using OrchardCore.Json; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class GetContentTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/PublishContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/PublishContentTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/PublishContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/PublishContentTool.cs index 4d64f4616..074a86909 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/PublishContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/PublishContentTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class PublishContentTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/SearchForContentsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/SearchForContentsTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/SearchForContentsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/SearchForContentsTool.cs index c5747aa0e..676b5fe54 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/SearchForContentsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/SearchForContentsTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +13,7 @@ using YesSql.Filters.Query; using YesSql.Filters.Query.Services; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class SearchForContentsTool : AIFunction { @@ -36,7 +36,7 @@ public sealed class SearchForContentsTool : AIFunction }, "required": ["term"], "additionalProperties": false - } + } """); public override string Name => TheName; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/UnpublishContentTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/UnpublishContentTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/UnpublishContentTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/UnpublishContentTool.cs index ad693d804..2b902f6a2 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Contents/UnpublishContentTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Contents/UnpublishContentTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.ContentManagement; -namespace CrestApps.OrchardCore.AI.Agent.Contents; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Contents; public sealed class UnpublishContentTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/DisableFeatureTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/DisableFeatureTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Features/DisableFeatureTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/DisableFeatureTool.cs index aa1350306..3bf927262 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/DisableFeatureTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/DisableFeatureTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,14 +6,14 @@ using OrchardCore.DisplayManagement.Extensions; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Features; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Features; internal sealed class DisableFeatureTool : AIFunction { public const string TheName = "disableSiteFeature"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/EnableFeatureTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/EnableFeatureTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Features/EnableFeatureTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/EnableFeatureTool.cs index 90dbcb331..6ce3860ae 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/EnableFeatureTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/EnableFeatureTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,14 +6,14 @@ using OrchardCore.DisplayManagement.Extensions; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Features; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Features; public sealed class EnableFeatureTool : AIFunction { public const string TheName = "enableSiteFeature"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/GetFeatureTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/GetFeatureTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Features/GetFeatureTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/GetFeatureTool.cs index b55de7ef3..24846d90b 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/GetFeatureTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/GetFeatureTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,14 +6,14 @@ using OrchardCore.DisplayManagement.Extensions; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Features; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Features; public sealed class GetFeatureTool : AIFunction { public const string TheName = "getSiteFeature"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/ListFeaturesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/ListFeaturesTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Features/ListFeaturesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/ListFeaturesTool.cs index db2d50063..1a89d3141 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/ListFeaturesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/ListFeaturesTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.DisplayManagement.Extensions; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Features; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Features; public sealed class ListFeaturesTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/SearchFeaturesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/SearchFeaturesTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Features/SearchFeaturesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/SearchFeaturesTool.cs index b489f5094..f55203e0d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Features/SearchFeaturesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Features/SearchFeaturesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,14 +6,14 @@ using OrchardCore.DisplayManagement.Extensions; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Features; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Features; public sealed class FeaturesSearchTool : AIFunction { public const string TheName = "searchSiteFeature"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ListAIProfilesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ListAIProfilesTool.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ListAIProfilesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ListAIProfilesTool.cs index 0826ad3d4..d731f0bc6 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ListAIProfilesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ListAIProfilesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using CrestApps.OrchardCore.AI.Core.Extensions; using CrestApps.OrchardCore.AI.Models; @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.Entities; -namespace CrestApps.OrchardCore.AI.Agent.Profiles; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Profiles; public sealed class ListAIProfilesTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ViewAIProfileTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ViewAIProfileTool.cs similarity index 99% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ViewAIProfileTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ViewAIProfileTool.cs index 6f25ca9ab..28a26246b 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Profiles/ViewAIProfileTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Profiles/ViewAIProfileTool.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.Entities; -namespace CrestApps.OrchardCore.AI.Agent.Profiles; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Profiles; public sealed class ViewAIProfileTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ExecuteStartupRecipesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ExecuteStartupRecipesTool.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ExecuteStartupRecipesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ExecuteStartupRecipesTool.cs index c9d393356..1ccc87ad3 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ExecuteStartupRecipesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ExecuteStartupRecipesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -9,7 +9,7 @@ using OrchardCore.Recipes.Models; using OrchardCore.Recipes.Services; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class ExecuteStartupRecipesTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/GetRecipeJsonSchemaTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/GetRecipeJsonSchemaTool.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/GetRecipeJsonSchemaTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/GetRecipeJsonSchemaTool.cs index 8b2643837..f12ca92ec 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/GetRecipeJsonSchemaTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/GetRecipeJsonSchemaTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using CrestApps.OrchardCore.Recipes.Core; using CrestApps.OrchardCore.Recipes.Core.Services; @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class GetRecipeJsonSchemaTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ImportOrchardTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ImportOrchardTool.cs similarity index 84% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ImportOrchardTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ImportOrchardTool.cs index 2373293b2..a7ed4f12f 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ImportOrchardTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ImportOrchardTool.cs @@ -1,6 +1,6 @@ using CrestApps.OrchardCore.AI.Core; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class ImportOrchardTool : ImportRecipeBaseTool { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListNonStartupRecipesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListNonStartupRecipesTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListNonStartupRecipesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListNonStartupRecipesTool.cs index 05ce79624..b83840e52 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListNonStartupRecipesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListNonStartupRecipesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,7 +7,7 @@ using OrchardCore.Recipes.Models; using OrchardCore.Recipes.Services; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class ListNonStartupRecipesTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListRecipeStepsAndSchemasTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListRecipeStepsAndSchemasTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListRecipeStepsAndSchemasTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListRecipeStepsAndSchemasTool.cs index 504b5d3c8..69c4f2963 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListRecipeStepsAndSchemasTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListRecipeStepsAndSchemasTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.Recipes.Core; using CrestApps.OrchardCore.Recipes.Core.Services; using Json.Schema; @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class ListRecipeStepsAndSchemasTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListStartupRecipesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListStartupRecipesTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListStartupRecipesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListStartupRecipesTool.cs index 1ab6b91b4..80863c35e 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Recipes/ListStartupRecipesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Recipes/ListStartupRecipesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,7 +7,7 @@ using OrchardCore.Recipes.Models; using OrchardCore.Recipes.Services; -namespace CrestApps.OrchardCore.AI.Agent.Recipes; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Recipes; public sealed class ListStartupRecipesTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Roles/GetRoleTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Roles/GetRoleTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Roles/GetRoleTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Roles/GetRoleTool.cs index 9242d7196..479498f84 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Roles/GetRoleTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Roles/GetRoleTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.AI; @@ -6,14 +6,14 @@ using Microsoft.Extensions.Logging; using OrchardCore.Security; -namespace CrestApps.OrchardCore.AI.Agent.Roles; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Roles; internal sealed class GetRoleTool : AIFunction { public const string TheName = "getRoleInfo"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/System/ApplySystemSettingsTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ApplySystemSettingsTool.cs similarity index 93% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/System/ApplySystemSettingsTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ApplySystemSettingsTool.cs index ac9fb5217..c84ac5cc6 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/System/ApplySystemSettingsTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ApplySystemSettingsTool.cs @@ -1,10 +1,10 @@ -using CrestApps.OrchardCore.AI.Core; +using CrestApps.OrchardCore.AI.Core; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.System; +namespace CrestApps.OrchardCore.AI.Agent.Tools.System; public sealed class ApplySystemSettingsTool : ImportRecipeBaseTool { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/System/ListTimeZoneTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ListTimeZoneTool.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/System/ListTimeZoneTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ListTimeZoneTool.cs index a6b741678..c4ca08893 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/System/ListTimeZoneTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/System/ListTimeZoneTool.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Modules; -namespace CrestApps.OrchardCore.AI.Agent.System; +namespace CrestApps.OrchardCore.AI.Agent.Tools.System; public sealed class ListTimeZoneTool : AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/CreateTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/CreateTenantTool.cs similarity index 75% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/CreateTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/CreateTenantTool.cs index af80ab281..ad160453c 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/CreateTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/CreateTenantTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,56 +6,57 @@ using OrchardCore.Data; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class CreateTenantTool : AIFunction { public const string TheName = "createTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ - { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - }, - "databaseProvider": { - "type": "string", - "description": "The database provider to use.", - "enum": [ - "SqlConnection", - "MySql", - "Sqlite", - "Postgres" - ] - }, - "requestUrlPrefix": { - "type": "string", - "description": "A URI prefix to use." - }, - "requestUrlHost": { - "type": "string", - "description": "One or more qualified domain to use with this tenant." - }, - "connectionString": { - "type": "string", - "description": "The connection string to use when setting up the tenant." - }, - "tablePrefix": { - "type": "string", - "description": "A SQL table prefix to use for every table." - }, - "recipeName": { - "type": "string", - "description": "The name of the startup recipe to use during setup." - } + """ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + }, + "databaseProvider": { + "type": "string", + "description": "The database provider to use.", + "enum": [ + "SqlConnection", + "MySql", + "Sqlite", + "Postgres" + ] + }, + "requestUrlPrefix": { + "type": "string", + "description": "A URI prefix to use." + }, + "requestUrlHost": { + "type": "string", + "description": "One or more qualified domain to use with this tenant." + }, + "connectionString": { + "type": "string", + "description": "The connection string to use when setting up the tenant." + }, + "tablePrefix": { + "type": "string", + "description": "A SQL table prefix to use for every table." }, - "additionalProperties": false, - "required": [ - "name", - "recipeName"] + "recipeName": { + "type": "string", + "description": "The name of the startup recipe to use during setup." + } + }, + "additionalProperties": false, + "required": [ + "name", + "recipeName" + ] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/DisableTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/DisableTenantTool.cs similarity index 87% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/DisableTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/DisableTenantTool.cs index 3846aada1..71f603fa2 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/DisableTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/DisableTenantTool.cs @@ -1,28 +1,28 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class DisableTenantTool: AIFunction { public const string TheName = "disableTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - } - }, - "additionalProperties": false, - "required": ["name"] + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + } + }, + "additionalProperties": false, + "required": ["name"] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/EnableTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/EnableTenantTool.cs similarity index 87% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/EnableTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/EnableTenantTool.cs index a549ecf7e..a6f1ea023 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/EnableTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/EnableTenantTool.cs @@ -1,28 +1,28 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class EnableTenantTool: AIFunction { public const string TheName = "enableTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - } - }, - "additionalProperties": false, - "required": ["name"] + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + } + }, + "additionalProperties": false, + "required": ["name"] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/GetTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/GetTenantTool.cs similarity index 83% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/GetTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/GetTenantTool.cs index c46e2e5ce..8ad87b573 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/GetTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/GetTenantTool.cs @@ -1,11 +1,11 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class GetTenantTool: AIFunction { @@ -14,15 +14,15 @@ public sealed class GetTenantTool: AIFunction private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - } - }, - "additionalProperties": false, - "required": ["name"] + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + } + }, + "additionalProperties": false, + "required": ["name"] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ListTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ListTenantTool.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ListTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ListTenantTool.cs index 11fba7cc9..a91239a4d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ListTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ListTenantTool.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class ListTenantTool: AIFunction { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ReloadTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ReloadTenantTool.cs similarity index 86% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ReloadTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ReloadTenantTool.cs index 097f26e85..1f34d3a0d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/ReloadTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/ReloadTenantTool.cs @@ -1,28 +1,28 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class ReloadTenantTool: AIFunction { public const string TheName = "reloadTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - } - }, - "additionalProperties": false, - "required": ["name"] + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + } + }, + "additionalProperties": false, + "required": ["name"] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/RemoveTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/RemoveTenantTool.cs similarity index 89% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/RemoveTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/RemoveTenantTool.cs index 91d6bea67..787e99c56 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/RemoveTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/RemoveTenantTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,24 +6,24 @@ using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Removing; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class RemoveTenantTool: AIFunction { public const string TheName = "removeTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - } - }, - "additionalProperties": false, - "required": ["name"] + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." + } + }, + "additionalProperties": false, + "required": ["name"] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/SetupTenantTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/SetupTenantTool.cs similarity index 79% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/SetupTenantTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/SetupTenantTool.cs index 3f23d4e02..c0138292a 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Tenants/SetupTenantTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Tenants/SetupTenantTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Cysharp.Text; using Microsoft.AspNetCore.Identity; @@ -12,78 +12,79 @@ using OrchardCore.Modules; using OrchardCore.Setup.Services; -namespace CrestApps.OrchardCore.AI.Agent.Tenants; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Tenants; public sealed class SetupTenantTool : AIFunction { public const string TheName = "setupTenant"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "A unique name for the tenant to be used as identifier." - }, - "username": { - "type": "string", - "description": "The username for the super user to setup the site with." - }, - "email": { - "type": "string", - "description": "A valid email for the super user to setup the site with." - }, - "password": { - "type": "string", - "description": "The password for the super user to setup the site with." - }, - "title": { - "type": "string", - "description": "A title for the site." - }, - "timeZoneId": { - "type": "string", - "description": "The Unix TimeZone id." - }, - "databaseProvider": { - "type": "string", - "description": "The database provider to use.", - "enum": [ - "SqlConnection", - "MySql", - "Sqlite", - "Postgres" - ] - }, - "requestUrlPrefix": { - "type": "string", - "description": "A URI prefix to use." - }, - "requestUrlHost": { - "type": "string", - "description": "One or more qualified domain to use with this tenant." - }, - "connectionString": { - "type": "string", - "description": "The connection string to use when setting up the tenant." - }, - "tablePrefix": { - "type": "string", - "description": "A SQL table prefix to use for every table." - }, - "recipeName": { - "type": "string", - "description": "The name of the startup recipe to use during setup." - } + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A unique name for the tenant to be used as identifier." }, - "additionalProperties": false, - "required": [ - "name", - "username", - "email", - "password"] + "username": { + "type": "string", + "description": "The username for the super user to setup the site with." + }, + "email": { + "type": "string", + "description": "A valid email for the super user to setup the site with." + }, + "password": { + "type": "string", + "description": "The password for the super user to setup the site with." + }, + "title": { + "type": "string", + "description": "A title for the site." + }, + "timeZoneId": { + "type": "string", + "description": "The Unix TimeZone id." + }, + "databaseProvider": { + "type": "string", + "description": "The database provider to use.", + "enum": [ + "SqlConnection", + "MySql", + "Sqlite", + "Postgres" + ] + }, + "requestUrlPrefix": { + "type": "string", + "description": "A URI prefix to use." + }, + "requestUrlHost": { + "type": "string", + "description": "One or more qualified domain to use with this tenant." + }, + "connectionString": { + "type": "string", + "description": "The connection string to use when setting up the tenant." + }, + "tablePrefix": { + "type": "string", + "description": "A SQL table prefix to use for every table." + }, + "recipeName": { + "type": "string", + "description": "The name of the startup recipe to use during setup." + } + }, + "additionalProperties": false, + "required": [ + "name", + "username", + "email", + "password" + ] } """); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Users/GetUserInfoTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/GetUserInfoTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Users/GetUserInfoTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/GetUserInfoTool.cs index 24646a66b..40dae7f25 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Users/GetUserInfoTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/GetUserInfoTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.AI; @@ -7,14 +7,14 @@ using OrchardCore.Users; using OrchardCore.Users.Models; -namespace CrestApps.OrchardCore.AI.Agent.Users; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Users; internal sealed class GetUserInfoTool : AIFunction { public const string TheName = "getUserInfo"; private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( - """ + """ { "type": "object", "properties": { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Users/SearchForUsersTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/SearchForUsersTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Users/SearchForUsersTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/SearchForUsersTool.cs index c6cabb3be..1967c069d 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Users/SearchForUsersTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Users/SearchForUsersTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +13,7 @@ using YesSql.Filters.Query; using YesSql.Filters.Query.Services; -namespace CrestApps.OrchardCore.AI.Agent.Users; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Users; public sealed class SearchForUsersTool : AIFunction { @@ -36,7 +36,7 @@ public sealed class SearchForUsersTool : AIFunction }, "required": ["term"], "additionalProperties": false - } + } """); public override string Name => TheName; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/CreateOrUpdateWorkflowTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/CreateOrUpdateWorkflowTool.cs similarity index 93% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/CreateOrUpdateWorkflowTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/CreateOrUpdateWorkflowTool.cs index 2a509900c..48479f5ef 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/CreateOrUpdateWorkflowTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/CreateOrUpdateWorkflowTool.cs @@ -1,10 +1,10 @@ -using CrestApps.OrchardCore.AI.Core; +using CrestApps.OrchardCore.AI.Core; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace CrestApps.OrchardCore.AI.Agent.Workflows; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Workflows; public sealed class CreateOrUpdateWorkflowTool : ImportRecipeBaseTool { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/GetWorkflowTypesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/GetWorkflowTypesTool.cs similarity index 96% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/GetWorkflowTypesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/GetWorkflowTypesTool.cs index 5242f044b..f54b873a9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/GetWorkflowTypesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/GetWorkflowTypesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -7,7 +7,7 @@ using OrchardCore.Json; using OrchardCore.Workflows.Services; -namespace CrestApps.OrchardCore.AI.Agent.Workflows; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Workflows; public sealed class GetWorkflowTypesTool : AIFunction { @@ -25,7 +25,7 @@ public sealed class GetWorkflowTypesTool : AIFunction }, "required": ["workflowTypeId"], "additionalProperties": false - } + } """); public override string Name => TheName; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowActivitiesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowActivitiesTool.cs similarity index 84% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowActivitiesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowActivitiesTool.cs index dcef56ba8..d0931f4a1 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowActivitiesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowActivitiesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -6,7 +6,7 @@ using OrchardCore.Workflows.Activities; using OrchardCore.Workflows.Services; -namespace CrestApps.OrchardCore.AI.Agent.Workflows; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Workflows; public sealed class ListWorkflowActivitiesTool : AIFunction { @@ -45,12 +45,6 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a var activityLibrary = arguments.Services.GetRequiredService(); - if (!arguments.TryGetFirst("workflowTypeId", out var workflowTypeId)) - { - logger.LogWarning("AI tool '{ToolName}' missing required argument '{ArgumentName}'.", Name, "workflowTypeId"); - return "Unable to find a workflowTypeId argument in the function arguments."; - } - var activities = activityLibrary.ListActivities(); if (!activities.Any()) diff --git a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowTypesTool.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowTypesTool.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowTypesTool.cs rename to src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowTypesTool.cs index 93bf41f87..415213b3b 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Agent/Workflows/ListWorkflowTypesTool.cs +++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/Workflows/ListWorkflowTypesTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using CrestApps.OrchardCore.AI.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +8,7 @@ using OrchardCore.Navigation; using OrchardCore.Workflows.Services; -namespace CrestApps.OrchardCore.AI.Agent.Workflows; +namespace CrestApps.OrchardCore.AI.Agent.Tools.Workflows; public sealed class ListWorkflowTypesTool : AIFunction { @@ -31,7 +31,7 @@ public sealed class ListWorkflowTypesTool : AIFunction }, "required": ["term"], "additionalProperties": false - } + } """); public override string Name => TheName; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/Assets/js/chat-interaction.js b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/Assets/js/chat-interaction.js index 5357d51ae..afe8cf214 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/Assets/js/chat-interaction.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/Assets/js/chat-interaction.js @@ -79,6 +79,112 @@ window.chatInteractionManager = function () { return span.innerHTML; } + function getSameOriginNavigationTarget(url) { + if (!url) return null; + + var targetUrl = new URL(url, window.location.href); + if (targetUrl.origin !== window.location.origin) { + return null; + } + + return targetUrl; + } + + function navigateLivePage(url) { + var targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + console.warn('Ignored live navigation to a non same-origin URL.', url); + return; + } + + window.setTimeout(function () { + window.location.assign(targetUrl.toString()); + }, 150); + } + + function getBridgeWindow() { + if (window.parent && window.parent !== window) { + try { + if (window.parent.location && window.parent.document) { + return window.parent; + } + } catch { + } + } + + return window; + } + + function getBridgeDocument() { + return getBridgeWindow().document; + } + + function normalizeContextText(text, maxLength) { + if (!text) return ''; + + var normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? normalized.substring(0, maxLength) : normalized; + } + + function isVisibleBridgeElement(element, bridgeWindow) { + if (!element) return false; + if (element.closest('.ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction')) return false; + + var style = bridgeWindow.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') return false; + + var rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function captureLivePageContext() { + var bridgeWindow = getBridgeWindow(); + var bridgeDocument = getBridgeDocument(); + var links = []; + var buttons = []; + var headings = []; + var url = bridgeWindow.location.href; + + bridgeDocument.querySelectorAll('h1, h2, h3').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || headings.length >= 12) return; + + var text = normalizeContextText(element.innerText || element.textContent, 120); + if (text) headings.push(text); + }); + + bridgeDocument.querySelectorAll('a[href]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || links.length >= 40) return; + + var text = normalizeContextText(element.innerText || element.textContent || element.getAttribute('aria-label'), 120); + var href = element.href ? new URL(element.href, url).toString() : ''; + var container = element.closest('tr, li, article, section, .card, .list-group-item, .content-item'); + var context = normalizeContextText((container || element).innerText || (container || element).textContent, 160); + + if (!text && !href) return; + + links.push({ text: text, href: href, context: context }); + }); + + bridgeDocument.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || buttons.length >= 20) return; + + var text = normalizeContextText(element.innerText || element.textContent || element.value || element.getAttribute('aria-label'), 120); + if (text) buttons.push({ text: text }); + }); + + var textPreview = normalizeContextText((bridgeDocument.body && bridgeDocument.body.innerText) || '', 1500); + + return JSON.stringify({ + url: url, + title: bridgeDocument.title || '', + isParentContext: bridgeWindow !== window, + headings: headings, + links: links, + buttons: buttons, + textPreview: textPreview + }); + } + const renderer = new marked.Renderer(); // Modify the link rendering to open in a new tab @@ -415,6 +521,10 @@ window.chatInteractionManager = function () { } }); + this.connection.on("NavigateTo", (url) => { + navigateLivePage(url); + }); + this.connection.on("ReceiveError", (error) => { console.error("SignalR Error: ", error); @@ -730,8 +840,9 @@ window.chatInteractionManager = function () { var messageIndex = this.messages.length; var currentItemId = this.getItemId(); + var livePageContextJson = captureLivePageContext(); - this.stream = this.connection.stream("SendMessage", currentItemId, trimmedPrompt) + this.stream = this.connection.stream("SendMessage", currentItemId, trimmedPrompt, livePageContextJson) .subscribe({ next: (chunk) => { let message = this.messages[messageIndex]; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.js b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.js index 83ce23482..3fb9f2c02 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.js @@ -56,6 +56,93 @@ window.chatInteractionManager = function () { span.textContent = text; return span.innerHTML; } + function getSameOriginNavigationTarget(url) { + if (!url) return null; + var targetUrl = new URL(url, window.location.href); + if (targetUrl.origin !== window.location.origin) { + return null; + } + return targetUrl; + } + function navigateLivePage(url) { + var targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + console.warn('Ignored live navigation to a non same-origin URL.', url); + return; + } + window.setTimeout(function () { + window.location.assign(targetUrl.toString()); + }, 150); + } + function getBridgeWindow() { + if (window.parent && window.parent !== window) { + try { + if (window.parent.location && window.parent.document) { + return window.parent; + } + } catch (_unused) {} + } + return window; + } + function getBridgeDocument() { + return getBridgeWindow().document; + } + function normalizeContextText(text, maxLength) { + if (!text) return ''; + var normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? normalized.substring(0, maxLength) : normalized; + } + function isVisibleBridgeElement(element, bridgeWindow) { + if (!element) return false; + if (element.closest('.ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction')) return false; + var style = bridgeWindow.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') return false; + var rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function captureLivePageContext() { + var bridgeWindow = getBridgeWindow(); + var bridgeDocument = getBridgeDocument(); + var links = []; + var buttons = []; + var headings = []; + var url = bridgeWindow.location.href; + bridgeDocument.querySelectorAll('h1, h2, h3').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || headings.length >= 12) return; + var text = normalizeContextText(element.innerText || element.textContent, 120); + if (text) headings.push(text); + }); + bridgeDocument.querySelectorAll('a[href]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || links.length >= 40) return; + var text = normalizeContextText(element.innerText || element.textContent || element.getAttribute('aria-label'), 120); + var href = element.href ? new URL(element.href, url).toString() : ''; + var container = element.closest('tr, li, article, section, .card, .list-group-item, .content-item'); + var context = normalizeContextText((container || element).innerText || (container || element).textContent, 160); + if (!text && !href) return; + links.push({ + text: text, + href: href, + context: context + }); + }); + bridgeDocument.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || buttons.length >= 20) return; + var text = normalizeContextText(element.innerText || element.textContent || element.value || element.getAttribute('aria-label'), 120); + if (text) buttons.push({ + text: text + }); + }); + var textPreview = normalizeContextText(bridgeDocument.body && bridgeDocument.body.innerText || '', 1500); + return JSON.stringify({ + url: url, + title: bridgeDocument.title || '', + isParentContext: bridgeWindow !== window, + headings: headings, + links: links, + buttons: buttons, + textPreview: textPreview + }); + } var renderer = new marked.Renderer(); // Modify the link rendering to open in a new tab @@ -368,6 +455,9 @@ window.chatInteractionManager = function () { historyItem.textContent = title || config.untitledText; } }); + _this.connection.on("NavigateTo", function (url) { + navigateLivePage(url); + }); _this.connection.on("ReceiveError", function (error) { console.error("SignalR Error: ", error); if (_this.isRecording) { @@ -727,7 +817,8 @@ window.chatInteractionManager = function () { var lastResponseId = null; var messageIndex = this.messages.length; var currentItemId = this.getItemId(); - this.stream = this.connection.stream("SendMessage", currentItemId, trimmedPrompt).subscribe({ + var livePageContextJson = captureLivePageContext(); + this.stream = this.connection.stream("SendMessage", currentItemId, trimmedPrompt, livePageContextJson).subscribe({ next: function next(chunk) { var message = _this5.messages[messageIndex]; if (!message) { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.min.js b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.min.js index 4bd52933f..90e164a27 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.min.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat.Interactions/wwwroot/scripts/chat-interaction.min.js @@ -1 +1 @@ -function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var i,o,a,r,s=[],c=!0,l=!1;try{if(a=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=a.call(n)).done)&&(s.push(i.value),s.length!==t);c=!0);}catch(e){l=!0,o=e}finally{try{if(!c&&null!=n.return&&(r=n.return(),Object(r)!==r))return}finally{if(l)throw o}}return s}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _regenerator(){/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */var e,t,n="function"==typeof Symbol?Symbol:{},i=n.iterator||"@@iterator",o=n.toStringTag||"@@toStringTag";function a(n,i,o,a){var c=i&&i.prototype instanceof s?i:s,l=Object.create(c.prototype);return _regeneratorDefine2(l,"_invoke",function(n,i,o){var a,s,c,l=0,u=o||[],d=!1,h={p:0,n:0,v:e,a:f,f:f.bind(e,4),d:function(t,n){return a=t,s=0,c=e,h.n=n,r}};function f(n,i){for(s=n,c=i,t=0;!d&&l&&!o&&t3?(o=m===i)&&(c=a[(s=a[4])?5:(s=3,3)],a[4]=a[5]=e):a[0]<=f&&((o=n<2&&fi||i>m)&&(a[4]=n,a[5]=i,h.n=m,s=0))}if(o||n>1)return r;throw d=!0,i}return function(o,u,m){if(l>1)throw TypeError("Generator is already running");for(d&&1===u&&f(u,m),s=u,c=m;(t=s<2?e:c)||!d;){a||(s?s<3?(s>1&&(h.n=-1),f(s,c)):h.n=c:h.v=c);try{if(l=2,a){if(s||(o="next"),t=a[o]){if(!(t=t.call(a,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,s<2&&(s=0)}else 1===s&&(t=a.return)&&t.call(a),s<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),s=1);a=e}else if((t=(d=h.n<0)?c:n.call(i,h))!==r)break}catch(t){a=e,s=1,c=t}finally{l=1}}return{value:t,done:d}}}(n,o,a),!0),l}var r={};function s(){}function c(){}function l(){}t=Object.getPrototypeOf;var u=[][i]?t(t([][i]())):(_regeneratorDefine2(t={},i,function(){return this}),t),d=l.prototype=s.prototype=Object.create(u);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,l):(e.__proto__=l,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return c.prototype=l,_regeneratorDefine2(d,"constructor",l),_regeneratorDefine2(l,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(l,o,"GeneratorFunction"),_regeneratorDefine2(d),_regeneratorDefine2(d,o,"Generator"),_regeneratorDefine2(d,i,function(){return this}),_regeneratorDefine2(d,"toString",function(){return"[object Generator]"}),(_regenerator=function(){return{w:a,m:h}})()}function _regeneratorDefine2(e,t,n,i){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,t,n,i){function a(t,n){_regeneratorDefine2(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!i,configurable:!i,writable:!i}):e[t]=n:(a("next",0),a("throw",1),a("return",2))},_regeneratorDefine2(e,t,n,i)}function asyncGeneratorStep(e,t,n,i,o,a,r){try{var s=e[a](r),c=s.value}catch(e){return void n(e)}s.done?t(c):Promise.resolve(c).then(i,o)}function _asyncToGenerator(e){return function(){var t=this,n=arguments;return new Promise(function(i,o){var a=e.apply(t,n);function r(e){asyncGeneratorStep(a,i,o,r,s,"next",e)}function s(e){asyncGeneratorStep(a,i,o,r,s,"throw",e)}r(void 0)})}}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,r=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return r=e.done,e},e:function(e){s=!0,a=e},f:function(){try{r||null==n.return||n.return()}finally{if(s)throw a}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=Array(t);n\n
\n
\n
You
\n
\n \n Assistant\n
\n
\n

{{ message.title }}

\n
\n \n \n \n
\n
\n
\n
\n
\n \n {{ notification.content }}\n \n
\n
\n \n
\n
\n \n ',indicatorTemplate:'\n
\n \n Assistant\n
\n ',untitledText:"Untitled",clearHistoryTitle:"Clear History",clearHistoryMessage:"Are you sure you want to clear the chat history? This action cannot be undone. Your documents, parameters, and tools will be preserved.",clearHistoryOkText:"Yes",clearHistoryCancelText:"Cancel"};function t(e){if(!e)return"";var t=e.trim();return/^javascript:/i.test(t)||/^vbscript:/i.test(t)||/^data:text\/html/i.test(t)?"":e}function n(e){var t=document.createElement("span");return t.textContent=e,t.innerHTML}var i=new marked.Renderer;i.link=function(e){var n=t(e.href);return n?'').concat(e.text,""):e.text||""},i.code=function(e){var t=e.text||"",i=(e.lang||"").trim(),o=t;if("undefined"!=typeof hljs)if(i&&hljs.getLanguage(i))try{o=hljs.highlight(t,{language:i}).value}catch(e){}else try{o=hljs.highlightAuto(t).value}catch(e){}else o=n(t);var a=i?n(i):"code";return'
'.concat(a,'
').concat(o,"
")},i.image=function(n){var i=t(n.href);if(!i)return"";var o=n.text||e.generatedImageAltText,a=e.generatedImageMaxWidth;return'
\n ').concat(o,'\n
\n \n \n \n
\n
')};var o=0,a=[];function r(e){if(e&&e._pendingCharts&&e._pendingCharts.length){var t,n=_createForOfIteratorHelper(e._pendingCharts);try{for(n.s();!(t=n.n()).done;){var i=t.value,o=document.getElementById(i.chartId);if(o)if("undefined"!=typeof Chart)try{var a;o._chartInstance&&o._chartInstance.destroy();var r="string"==typeof i.config?JSON.parse(i.config):i.config;null!==(a=r.options)&&void 0!==a||(r.options={}),r.options.responsive=!0,r.options.maintainAspectRatio=!1,o._chartInstance=new Chart(o,r)}catch(e){console.error("Error creating chart:",e)}else console.error("Chart.js is not available on the page.")}}catch(e){n.e(e)}finally{n.f()}e._pendingCharts=[]}}function s(e,t){a=[];var n=marked.parse(e,{renderer:i});return t._pendingCharts=a.length>0?_toConsumableArray(a):[],DOMPurify.sanitize(n,{ADD_ATTR:["target"]})}marked.use({extensions:[{name:"chart",level:"block",start:function(e){var t=e.indexOf("[chart:");return t>=0?t:void 0},tokenizer:function(e){var t=function(e){var t="[chart:",n=e.indexOf(t);if(n<0)return null;var i=n+t.length,o=i;for(;o=e.length||"{"!==e[o])return null;for(var a=0,r=!1,s=!1;o')+'')+'
'+'
";var n,i}}]});return{initialize:function(t){var n=Object.assign({},e,t);if(e=n,n.signalRHubUrl)if(n.appElementSelector)if(n.chatContainerElementSelector)if(n.inputElementSelector){if(n.sendButtonElementSelector)return Vue.createApp({data:function(){return{inputElement:null,buttonElement:null,chatContainer:null,placeholder:null,isInteractionStarted:!1,isPlaceholderVisible:!0,isStreaming:!1,isNavigatingAway:!1,autoScroll:!0,stream:null,messages:[],prompt:"",initialFieldValues:new Map,settingsDirty:!1,saveSettingsTimeout:null,isRecording:!1,mediaRecorder:null,preRecordingPrompt:"",micButton:null,speechToTextEnabled:"AudioInput"===n.chatMode||"Conversation"===n.chatMode,textToSpeechEnabled:"Conversation"===n.chatMode,ttsVoiceName:n.ttsVoiceName||null,audioChunks:[],audioPlayQueue:[],isPlayingAudio:!1,currentAudioElement:null,conversationModeEnabled:"Conversation"===n.chatMode,conversationButton:null,isConversationMode:!1,notifications:[]}},computed:{lastAssistantIndex:function(){for(var e=this.messages.length-1;e>=0;e--)if("assistant"===this.messages[e].role)return e;return-1}},methods:{handleBeforeUnload:function(){this.isNavigatingAway=!0},startConnection:function(){var e=this;return _asyncToGenerator(_regenerator().m(function t(){var i;return _regenerator().w(function(t){for(;;)switch(t.p=t.n){case 0:return e.connection=(new signalR.HubConnectionBuilder).withUrl(n.signalRHubUrl).withAutomaticReconnect().build(),e.connection.serverTimeoutInMilliseconds=6e5,e.connection.keepAliveIntervalInMilliseconds=15e3,e.connection.on("LoadInteraction",function(t){var n;e.initializeInteraction(t.itemId,!0),e.messages=[];var i=document.querySelector('input[name="ChatInteraction.Title"]');i&&t.title&&(i.value=t.title),(null!==(n=t.messages)&&void 0!==n?n:[]).forEach(function(t){e.addMessage(t),e.$nextTick(function(){r(t)})})}),e.connection.on("SettingsSaved",function(e,t){var i=document.querySelector('.chat-interaction-history-item[data-interaction-id="'.concat(e,'"]'));i&&(i.textContent=t||n.untitledText)}),e.connection.on("ReceiveError",function(t){console.error("SignalR Error: ",t),e.isRecording&&e.stopRecording()}),e.connection.on("ReceiveTranscript",function(t,n,i){if(e.isConversationMode){if(!i&&n){e._conversationPartialTranscript=n;var o='

'+n.replace(/&/g,"&").replace(//g,">")+"

";e._conversationPartialMessage?(e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent=o):(e.hidePlaceholder(),e._conversationPartialMessage={role:"user",content:n,htmlContent:o,isPartial:!0},e.messages.push(e._conversationPartialMessage)),e.scrollToBottom()}}else n&&!e._audioInputSent&&(e.prompt=e.preRecordingPrompt+n,e.inputElement&&(e.inputElement.value=e.prompt,e.inputElement.dispatchEvent(new Event("input"))))}),e.connection.on("ReceiveConversationUserMessage",function(t,n){if(n){if(e.stopAudio(),e._conversationAssistantMessage){var i=e.messages[e._conversationAssistantMessage.index];i&&(i.isStreaming=!1),e._conversationAssistantMessage=null}if(e._conversationPartialMessage){var o=n.replace(/&/g,"&").replace(//g,">");e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent="

"+o+"

",e._conversationPartialMessage.isPartial=!1,e._conversationPartialMessage=null}else e.addMessage({role:"user",content:n});e.scrollToBottom()}}),e.connection.on("ReceiveConversationAssistantToken",function(t,n,i,o){if(!e._conversationAssistantMessage){e.stopAudio(),e.hideTypingIndicator();for(var a=0;a".concat(g._displayIndex,""))}}catch(e){m.e(e)}finally{m.f()}n=n.replaceAll("",","),n+="

";var v,p=_createForOfIteratorHelper(i);try{for(p.s();!(v=p.n()).done;){var y=_slicedToArray(v.value,2),b=(y[0],y[1]),S=b.text||"[doc:".concat(b.index,"]");n+=b.link?"**".concat(b._displayIndex,"**. [").concat(S,"](").concat(b.link,")
"):"**".concat(b._displayIndex,"**. ").concat(S,"
")}}catch(e){p.e(e)}finally{p.f()}}}e.content=n,e.htmlContent=s(n,e)}this.addMessageInternal(e),this.hidePlaceholder(),this.$nextTick(function(){r(e),t.scrollToBottom()})},hidePlaceholder:function(){this.placeholder&&this.placeholder.classList.add("d-none"),this.isPlaceholderVisible=!1},showPlaceholder:function(){this.placeholder&&this.placeholder.classList.remove("d-none"),this.isPlaceholderVisible=!0},fireEvent:function(e){document.dispatchEvent(e)},isIndicator:function(e){return"indicator"===e.role},sendMessage:function(){var e=this;return _asyncToGenerator(_regenerator().m(function t(){var n,i;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:if(n=e.prompt.trim()){t.n=1;break}return t.a(2);case 1:return e.isRecording&&e.stopRecording(),e._audioInputSent=!0,t.n=2,e.flushPendingSave();case 2:e.addMessage({role:"user",content:n}),(i=document.getElementById("clearHistoryBtn"))&&i.classList.remove("d-none"),e.streamMessage(n),e.inputElement.value="",e.prompt="";case 3:return t.a(2)}},t)}))()},streamMessage:function(e){var t=this;this.stream&&(this.stream.dispose(),this.stream=null),this.streamingStarted(),this.showTypingIndicator(),this.autoScroll=!0;var n="",i={},o=null,a=this.messages.length,c=this.getItemId();this.stream=this.connection.stream("SendMessage",c,e).subscribe({next:function(e){var l=t.messages[a];if(!l){e.sessionId&&!c&&t.setItemId(e.sessionId),t.hideTypingIndicator(),a=t.messages.length;var u={role:"assistant",content:"",htmlContent:"",isStreaming:!0};t.messages.push(u),l=u}if(e.references&&"object"===_typeof(e.references)&&Object.keys(e.references).length)for(var d=0,h=Object.entries(e.references);d".concat(A.index,"
"))}n+=v.replaceAll("",",")}l.content=n,l.htmlContent=s(n,l),t.messages[a]=l,t.$nextTick(function(){r(l),t.scrollToBottom()})},complete:function(){var e;t.processReferences(i,a),t.streamingFinished();var n=t.messages[a];n&&(n.isStreaming=!1),n&&n.content||t.hideTypingIndicator(),t.isConversationMode&&t.textToSpeechEnabled&&n&&n.content&&t.synthesizeSpeech(n.content),null===(e=t.stream)||void 0===e||e.dispose(),t.stream=null},error:function(e){var n;t.processReferences(i,a),t.streamingFinished();var o=t.messages[a];o&&(o.isStreaming=!1),t.hideTypingIndicator(),t.isNavigatingAway||t.addMessage(t.getServiceDownMessage()),null===(n=t.stream)||void 0===n||n.dispose(),t.stream=null,console.error("Stream error:",e)}})},getServiceDownMessage:function(){return{role:"assistant",content:"Our service is currently unavailable. Please try again later. We apologize for the inconvenience.",htmlContent:""}},processReferences:function(e,t){if(Object.keys(e).length){var n=this.messages[t],i=n.content||"",o=Object.entries(e).filter(function(e){var t=_slicedToArray(e,2),n=t[0],o=t[1];return i.includes(n)||i.includes("".concat(o.index,""))});if(!o.length)return;o.sort(function(e,t){var n=_slicedToArray(e,2)[1],i=_slicedToArray(t,2)[1];return n.index-i.index});var a,r=i.trim(),c=1,l=_createForOfIteratorHelper(o);try{for(l.s();!(a=l.n()).done;){var u=_slicedToArray(a.value,2),d=u[0],h=u[1],f="__CITE_".concat(h.index,"__");r=(r=r.replaceAll(d,f)).replaceAll("".concat(h.index,""),f),h._displayIndex=c++,h._placeholder=f}}catch(e){l.e(e)}finally{l.f()}var m,g=_createForOfIteratorHelper(o);try{for(g.s();!(m=g.n()).done;){var v=_slicedToArray(m.value,2)[1];r=r.replaceAll(v._placeholder,"".concat(v._displayIndex,""))}}catch(e){g.e(e)}finally{g.f()}r=r.replaceAll("",","),r+="

";var p,y=_createForOfIteratorHelper(o);try{for(y.s();!(p=y.n()).done;){var b=_slicedToArray(p.value,2),S=(b[0],b[1]),A=S.text||"[doc:".concat(S.index,"]");r+=S.link?"**".concat(S._displayIndex,"**. [").concat(A,"](").concat(S.link,")
"):"**".concat(S._displayIndex,"**. ").concat(A,"
")}}catch(e){y.e(e)}finally{y.f()}n.content=r,n.htmlContent=s(r,n),this.messages[t]=n,this.scrollToBottom()}},streamingStarted:function(){var e=this.buttonElement.getAttribute("data-stop-icon");e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0}))},streamingFinished:function(){var e=this.buttonElement.getAttribute("data-start-icon");if(e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.chatContainer)for(var t=this.chatContainer.querySelectorAll(".ai-streaming-icon"),n=0;n0){var e=this.audioPlayQueue.shift();this.playAudioBlob(e)}else this.isPlayingAudio=!1,this.conversationModeOnAudioEnded()},stopAudio:function(){this.currentAudioElement&&(this.currentAudioElement.pause(),this.currentAudioElement.currentTime=0,this.currentAudioElement=null),this.audioChunks=[],this.audioPlayQueue=[],this.isPlayingAudio=!1},toggleConversationMode:function(){this.isConversationMode?this.stopConversationMode():this.startConversationMode()},startConversationMode:function(){var e=this;this.conversationModeEnabled&&!this.isConversationMode&&this.connection&&(this.isConversationMode=!0,this.updateConversationButton(),this._conversationPartialTranscript="",this._conversationAssistantMessage=null,this._conversationPartialMessage=null,this.removeNotification("conversation-ended"),navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e._conversationSubject=new signalR.Subject,e._conversationStream=t;var i=window.AudioContext||window.webkitAudioContext;i&&(e._conversationAudioCtx=new i,e._conversationAnalyser=e._conversationAudioCtx.createAnalyser(),e._conversationAnalyser.fftSize=256,e._conversationAudioCtx.createMediaStreamSource(t).connect(e._conversationAnalyser));var o=Promise.resolve(),a=e._conversationAnalyser;e.mediaRecorder.addEventListener("dataavailable",function(t){if(t.data&&t.data.size>0){if(e.isPlayingAudio&&a){var n=new Uint8Array(a.frequencyBinCount);a.getByteFrequencyData(n);for(var i=0,r=0;r=30&&e.stopAudio()}o=o.then(_asyncToGenerator(_regenerator().m(function n(){var i,o,a,r;return _regenerator().w(function(n){for(;;)switch(n.n){case 0:return n.n=1,t.data.arrayBuffer();case 1:i=n.v,o=new Uint8Array(i),a=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),r=btoa(a);try{e._conversationSubject.next(r)}catch(e){}case 2:return n.a(2)}},n)})))}}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),o.then(function(){try{e._conversationSubject.complete()}catch(e){}})});var r=e.getItemId(),s=document.documentElement.lang||"en-US";e.connection.send("StartConversation",r,e._conversationSubject,n,s),e.mediaRecorder.start(1e3),e.isRecording=!0}).catch(function(t){console.error("Microphone access denied:",t),e.isConversationMode=!1,e.updateConversationButton()}))},stopConversationMode:function(){if(this.isConversationMode){if(this.isConversationMode=!1,this.updateConversationButton(),this.connection&&this.connection.invoke("StopConversation").catch(function(){}),this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1),this.stopAudio(),this._conversationPartialTranscript="",this._conversationPartialMessage=null,this._conversationAudioCtx&&(this._conversationAudioCtx.close().catch(function(){}),this._conversationAudioCtx=null,this._conversationAnalyser=null),this._conversationAssistantMessage){var e=this.messages[this._conversationAssistantMessage.index];e&&(e.isStreaming=!1),this._conversationAssistantMessage=null}for(var t=0;t=0?this.notifications.splice(t,1,e):this.notifications.push(e),this.scrollToBottom()},updateNotification:function(e){var t=this.notifications.findIndex(function(t){return t.type===e.type});t>=0&&(this.notifications.splice(t,1,e),this.scrollToBottom())},removeNotification:function(e){this.notifications=this.notifications.filter(function(t){return t.type!==e})},dismissNotification:function(e){this.removeNotification(e)},handleNotificationAction:function(e,t){if(this.connection){var n=this.getItemId();this.connection.invoke("HandleNotificationAction",n,e,t).catch(function(e){return console.error("Failed to handle notification action:",e)})}},setItemId:function(e){this.inputElement.setAttribute("data-interaction-id",e||"")},resetInteraction:function(){this.setItemId(""),this.isInteractionStarted=!1,this.messages=[],this.showPlaceholder()},initializeApp:function(){var e=this;this.inputElement=document.querySelector(n.inputElementSelector),this.buttonElement=document.querySelector(n.sendButtonElementSelector),this.chatContainer=document.querySelector(n.chatContainerElementSelector),this.placeholder=document.querySelector(n.placeholderElementSelector);var t=this.getItemId();t&&this.loadInteraction(t),this.chatContainer.addEventListener("scroll",function(){if(e.stream){var t=e.chatContainer.scrollHeight-e.chatContainer.clientHeight-e.chatContainer.scrollTop<=30;e.autoScroll=t}}),this.inputElement.addEventListener("keyup",function(t){null==e.stream&&("Enter"!==t.key||t.shiftKey||e.buttonElement.click())}),this.inputElement.addEventListener("input",function(t){e.handleUserInput(t),t.target.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)}),this.inputElement.addEventListener("paste",function(t){setTimeout(function(){e.prompt=e.inputElement.value,e.inputElement.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)},0)}),this.buttonElement.addEventListener("click",function(){if(null==e.stream)e.sendMessage();else if(e.stream.dispose(),e.stream=null,e.streamingFinished(),e.hideTypingIndicator(),e.messages.length>0){var t=e.messages[e.messages.length-1];"assistant"!==t.role||t.content?t.isStreaming&&(t.isStreaming=!1):e.messages.pop()}});for(var i=document.getElementsByClassName("chat-interaction-history-item"),o=0;o '+a,setTimeout(function(){t.innerHTML=''},2e3)}}}}),document.querySelectorAll('input[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]), select[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]), textarea[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["])').forEach(function(t){var n="checkbox"===t.type,i="SELECT"===t.tagName;n||i?t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()}):(t.addEventListener("focus",function(){e.initialFieldValues.set(t,t.value)}),t.addEventListener("blur",function(){var n=e.initialFieldValues.get(t);void 0!==n&&t.value!==n&&(e.settingsDirty=!0,e.debouncedSaveSettings()),e.initialFieldValues.delete(t)}))}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Tools["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"].group-toggle').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Connections["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Agents["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})});var r=document.querySelector(".ci-agent-global-toggle");r&&r.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()});var s=document.getElementById("clearHistoryBtn");s&&s.addEventListener("click",function(){var t=s.getAttribute("data-interaction-id");t&&e.clearHistory(t)}),this.speechToTextEnabled&&n.micButtonElementSelector&&(this.micButton=document.querySelector(n.micButtonElementSelector),this.micButton&&(this.micButton.style.display="",this.micButton.addEventListener("click",function(){e.toggleRecording()}))),this.conversationModeEnabled&&n.conversationButtonElementSelector&&(this.conversationButton=document.querySelector(n.conversationButtonElementSelector),this.conversationButton&&this.conversationButton.addEventListener("click",function(){e.toggleConversationMode()}))},loadInteraction:function(e){this.connection.invoke("LoadInteraction",e).catch(function(e){return console.error(e)})},clearHistory:function(e){var t=this;confirmDialog({title:n.clearHistoryTitle,message:n.clearHistoryMessage,okText:n.clearHistoryOkText,cancelText:n.clearHistoryCancelText,callback:function(n){n&&(t.stream&&(t.stream.dispose(),t.stream=null,t.hideTypingIndicator(),t.streamingFinished()),t.connection.invoke("ClearHistory",e).catch(function(e){return console.error("Error clearing history:",e)}))}})},debouncedSaveSettings:function(){var e=this;this.saveSettingsTimeout&&clearTimeout(this.saveSettingsTimeout),this.stream||(this.saveSettingsTimeout=setTimeout(function(){e.settingsDirty&&(e.saveSettings(),e.settingsDirty=!1),e.saveSettingsTimeout=null},850))},getSelectedToolNames:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Tools["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},getSelectedMcpConnectionIds:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Connections["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},getSelectedAgentNames:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Agents["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},saveSettings:function(){var e=this.getItemId();if(!e)return Promise.resolve();var t={};return document.querySelectorAll('input[name^="ChatInteraction."]:not([type="hidden"]):not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["]), select[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["]), textarea[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["])').forEach(function(e){var n=e.name.replace("ChatInteraction.",""),i=n.charAt(0).toLowerCase()+n.slice(1);"checkbox"===e.type?t[i]=e.checked:"number"===e.type?t[i]=e.value?parseFloat(e.value):null:t[i]=e.value||null}),t.toolNames=this.getSelectedToolNames(),t.mcpConnectionIds=this.getSelectedMcpConnectionIds(),t.agentNames=this.getSelectedAgentNames(),this.connection.invoke("SaveSettings",e,t).catch(function(e){return console.error("Error saving settings:",e)})},flushPendingSave:function(){return this.saveSettingsTimeout&&(clearTimeout(this.saveSettingsTimeout),this.saveSettingsTimeout=null),this.settingsDirty?(this.settingsDirty=!1,this.saveSettings()):Promise.resolve()},initializeInteraction:function(e,t){this.isInteractionStarted&&!t||(this.fireEvent(new CustomEvent("initializingChatInteraction",{detail:{itemId:e}})),this.setItemId(e),this.isInteractionStarted=!0)},copyResponse:function(e){navigator.clipboard.writeText(e)},startRecording:function(){var e=this;!this.isRecording&&this.connection&&navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e.preRecordingPrompt=e.prompt,e._audioInputSent=!1;var i=new signalR.Subject,o=e.getItemId(),a=Promise.resolve();e.mediaRecorder.addEventListener("dataavailable",function(e){e.data&&e.data.size>0&&(a=a.then(_asyncToGenerator(_regenerator().m(function t(){var n,o,a,r;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.data.arrayBuffer();case 1:n=t.v,o=new Uint8Array(n),a=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),r=btoa(a),i.next(r);case 2:return t.a(2)}},t)}))))}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),a.then(function(){return i.complete()})});var r=document.documentElement.lang||"en-US";e.connection.send("SendAudioStream",o,i,n,r),e.mediaRecorder.start(1e3),e.isRecording=!0,e.updateMicButton()}).catch(function(e){console.error("Microphone access denied:",e)})},stopRecording:function(){this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1,this.updateMicButton())},toggleRecording:function(){this.isRecording?this.stopRecording():this.startRecording()},updateMicButton:function(){this.micButton&&(this.isRecording?(this.micButton.classList.add("stt-recording"),this.micButton.innerHTML=''):(this.micButton.classList.remove("stt-recording"),this.micButton.innerHTML=''))}},watch:{isPlayingAudio:function(){},isConversationMode:function(e){this.micButton&&(this.micButton.style.display=e?"none":this.speechToTextEnabled?"":"none"),this.buttonElement&&(this.buttonElement.style.display=e?"none":""),this.inputElement&&(this.inputElement.disabled=e,e&&(this.inputElement.placeholder=""))}},mounted:function(){var e=this;_asyncToGenerator(_regenerator().m(function t(){return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.startConnection();case 1:e.initializeApp();case 2:return t.a(2)}},t)}))(),window.addEventListener("beforeunload",this.handleBeforeUnload)},beforeUnmount:function(){window.removeEventListener("beforeunload",this.handleBeforeUnload),this.stream&&(this.stream.dispose(),this.stream=null),this.connection&&this.connection.stop()},template:n.messageTemplate}).mount(n.appElementSelector);console.error("The sendButtonElementSelector is required.")}else console.error("The inputElementSelector is required.");else console.error("The chatContainerElementSelector is required.");else console.error("The appElementSelector is required.");else console.error("The signalRHubUrl is required.")}}}(),window.downloadChart=function(e){var t=document.getElementById(e);if(t){var n=document.createElement("a");n.download="chart-"+e+".png",n.href=t.toDataURL("image/png"),n.click()}else console.error("Chart canvas not found:",e)},document.addEventListener("click",function(e){var t=e.target.closest(".ai-download-image");if(t){var n=t.closest(".generated-image-container"),i=null==n?void 0:n.querySelector("img");if(i){var o=i.src;o&&o.startsWith("data:")&&(e.preventDefault(),fetch(o).then(function(e){return e.blob()}).then(function(e){var n=URL.createObjectURL(e),i=document.createElement("a");i.href=n,i.download=t.getAttribute("download")||"generated-image.png",document.body.appendChild(i),i.click(),document.body.removeChild(i),setTimeout(function(){URL.revokeObjectURL(n)},100)}).catch(function(e){console.error("Failed to download image:",e)}))}}}); +function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var i,o,r,a,s=[],c=!0,l=!1;try{if(r=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=r.call(n)).done)&&(s.push(i.value),s.length!==t);c=!0);}catch(e){l=!0,o=e}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _regenerator(){/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */var e,t,n="function"==typeof Symbol?Symbol:{},i=n.iterator||"@@iterator",o=n.toStringTag||"@@toStringTag";function r(n,i,o,r){var c=i&&i.prototype instanceof s?i:s,l=Object.create(c.prototype);return _regeneratorDefine2(l,"_invoke",function(n,i,o){var r,s,c,l=0,u=o||[],d=!1,h={p:0,n:0,v:e,a:f,f:f.bind(e,4),d:function(t,n){return r=t,s=0,c=e,h.n=n,a}};function f(n,i){for(s=n,c=i,t=0;!d&&l&&!o&&t3?(o=m===i)&&(c=r[(s=r[4])?5:(s=3,3)],r[4]=r[5]=e):r[0]<=f&&((o=n<2&&fi||i>m)&&(r[4]=n,r[5]=i,h.n=m,s=0))}if(o||n>1)return a;throw d=!0,i}return function(o,u,m){if(l>1)throw TypeError("Generator is already running");for(d&&1===u&&f(u,m),s=u,c=m;(t=s<2?e:c)||!d;){r||(s?s<3?(s>1&&(h.n=-1),f(s,c)):h.n=c:h.v=c);try{if(l=2,r){if(s||(o="next"),t=r[o]){if(!(t=t.call(r,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,s<2&&(s=0)}else 1===s&&(t=r.return)&&t.call(r),s<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),s=1);r=e}else if((t=(d=h.n<0)?c:n.call(i,h))!==a)break}catch(t){r=e,s=1,c=t}finally{l=1}}return{value:t,done:d}}}(n,o,r),!0),l}var a={};function s(){}function c(){}function l(){}t=Object.getPrototypeOf;var u=[][i]?t(t([][i]())):(_regeneratorDefine2(t={},i,function(){return this}),t),d=l.prototype=s.prototype=Object.create(u);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,l):(e.__proto__=l,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return c.prototype=l,_regeneratorDefine2(d,"constructor",l),_regeneratorDefine2(l,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(l,o,"GeneratorFunction"),_regeneratorDefine2(d),_regeneratorDefine2(d,o,"Generator"),_regeneratorDefine2(d,i,function(){return this}),_regeneratorDefine2(d,"toString",function(){return"[object Generator]"}),(_regenerator=function(){return{w:r,m:h}})()}function _regeneratorDefine2(e,t,n,i){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,t,n,i){function r(t,n){_regeneratorDefine2(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!i,configurable:!i,writable:!i}):e[t]=n:(r("next",0),r("throw",1),r("return",2))},_regeneratorDefine2(e,t,n,i)}function asyncGeneratorStep(e,t,n,i,o,r,a){try{var s=e[r](a),c=s.value}catch(e){return void n(e)}s.done?t(c):Promise.resolve(c).then(i,o)}function _asyncToGenerator(e){return function(){var t=this,n=arguments;return new Promise(function(i,o){var r=e.apply(t,n);function a(e){asyncGeneratorStep(r,i,o,a,s,"next",e)}function s(e){asyncGeneratorStep(r,i,o,a,s,"throw",e)}a(void 0)})}}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,a=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){s=!0,r=e},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw r}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=Array(t);n\n
\n
\n
You
\n
\n \n Assistant\n
\n
\n

{{ message.title }}

\n
\n \n \n \n
\n
\n
\n
\n
\n \n {{ notification.content }}\n \n
\n
\n \n
\n
\n \n ',indicatorTemplate:'\n
\n \n Assistant\n
\n ',untitledText:"Untitled",clearHistoryTitle:"Clear History",clearHistoryMessage:"Are you sure you want to clear the chat history? This action cannot be undone. Your documents, parameters, and tools will be preserved.",clearHistoryOkText:"Yes",clearHistoryCancelText:"Cancel"};function t(e){if(!e)return"";var t=e.trim();return/^javascript:/i.test(t)||/^vbscript:/i.test(t)||/^data:text\/html/i.test(t)?"":e}function n(e){var t=document.createElement("span");return t.textContent=e,t.innerHTML}function i(e){var t=function(e){if(!e)return null;var t=new URL(e,window.location.href);return t.origin!==window.location.origin?null:t}(e);t?window.setTimeout(function(){window.location.assign(t.toString())},150):console.warn("Ignored live navigation to a non same-origin URL.",e)}function o(){if(window.parent&&window.parent!==window)try{if(window.parent.location&&window.parent.document)return window.parent}catch(e){}return window}function r(e,t){if(!e)return"";var n=e.replace(/\s+/g," ").trim();return n.length>t?n.substring(0,t):n}function a(e,t){if(!e)return!1;if(e.closest(".ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction"))return!1;var n=t.getComputedStyle(e);if("none"===n.display||"hidden"===n.visibility)return!1;var i=e.getBoundingClientRect();return i.width>0&&i.height>0}function s(){var e=o(),t=o().document,n=[],i=[],s=[],c=e.location.href;t.querySelectorAll("h1, h2, h3").forEach(function(t){if(a(t,e)&&!(s.length>=12)){var n=r(t.innerText||t.textContent,120);n&&s.push(n)}}),t.querySelectorAll("a[href]").forEach(function(t){if(a(t,e)&&!(n.length>=40)){var i=r(t.innerText||t.textContent||t.getAttribute("aria-label"),120),o=t.href?new URL(t.href,c).toString():"",s=t.closest("tr, li, article, section, .card, .list-group-item, .content-item"),l=r((s||t).innerText||(s||t).textContent,160);(i||o)&&n.push({text:i,href:o,context:l})}}),t.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach(function(t){if(a(t,e)&&!(i.length>=20)){var n=r(t.innerText||t.textContent||t.value||t.getAttribute("aria-label"),120);n&&i.push({text:n})}});var l=r(t.body&&t.body.innerText||"",1500);return JSON.stringify({url:c,title:t.title||"",isParentContext:e!==window,headings:s,links:n,buttons:i,textPreview:l})}var c=new marked.Renderer;c.link=function(e){var n=t(e.href);return n?'').concat(e.text,""):e.text||""},c.code=function(e){var t=e.text||"",i=(e.lang||"").trim(),o=t;if("undefined"!=typeof hljs)if(i&&hljs.getLanguage(i))try{o=hljs.highlight(t,{language:i}).value}catch(e){}else try{o=hljs.highlightAuto(t).value}catch(e){}else o=n(t);var r=i?n(i):"code";return'
'.concat(r,'
').concat(o,"
")},c.image=function(n){var i=t(n.href);if(!i)return"";var o=n.text||e.generatedImageAltText,r=e.generatedImageMaxWidth;return'
\n ').concat(o,'\n
\n \n \n \n
\n
')};var l=0,u=[];function d(e){if(e&&e._pendingCharts&&e._pendingCharts.length){var t,n=_createForOfIteratorHelper(e._pendingCharts);try{for(n.s();!(t=n.n()).done;){var i=t.value,o=document.getElementById(i.chartId);if(o)if("undefined"!=typeof Chart)try{var r;o._chartInstance&&o._chartInstance.destroy();var a="string"==typeof i.config?JSON.parse(i.config):i.config;null!==(r=a.options)&&void 0!==r||(a.options={}),a.options.responsive=!0,a.options.maintainAspectRatio=!1,o._chartInstance=new Chart(o,a)}catch(e){console.error("Error creating chart:",e)}else console.error("Chart.js is not available on the page.")}}catch(e){n.e(e)}finally{n.f()}e._pendingCharts=[]}}function h(e,t){u=[];var n=marked.parse(e,{renderer:c});return t._pendingCharts=u.length>0?_toConsumableArray(u):[],DOMPurify.sanitize(n,{ADD_ATTR:["target"]})}marked.use({extensions:[{name:"chart",level:"block",start:function(e){var t=e.indexOf("[chart:");return t>=0?t:void 0},tokenizer:function(e){var t=function(e){var t="[chart:",n=e.indexOf(t);if(n<0)return null;var i=n+t.length,o=i;for(;o=e.length||"{"!==e[o])return null;for(var r=0,a=!1,s=!1;o')+'')+'
'+'
";var n,i}}]});return{initialize:function(t){var n=Object.assign({},e,t);if(e=n,n.signalRHubUrl)if(n.appElementSelector)if(n.chatContainerElementSelector)if(n.inputElementSelector){if(n.sendButtonElementSelector)return Vue.createApp({data:function(){return{inputElement:null,buttonElement:null,chatContainer:null,placeholder:null,isInteractionStarted:!1,isPlaceholderVisible:!0,isStreaming:!1,isNavigatingAway:!1,autoScroll:!0,stream:null,messages:[],prompt:"",initialFieldValues:new Map,settingsDirty:!1,saveSettingsTimeout:null,isRecording:!1,mediaRecorder:null,preRecordingPrompt:"",micButton:null,speechToTextEnabled:"AudioInput"===n.chatMode||"Conversation"===n.chatMode,textToSpeechEnabled:"Conversation"===n.chatMode,ttsVoiceName:n.ttsVoiceName||null,audioChunks:[],audioPlayQueue:[],isPlayingAudio:!1,currentAudioElement:null,conversationModeEnabled:"Conversation"===n.chatMode,conversationButton:null,isConversationMode:!1,notifications:[]}},computed:{lastAssistantIndex:function(){for(var e=this.messages.length-1;e>=0;e--)if("assistant"===this.messages[e].role)return e;return-1}},methods:{handleBeforeUnload:function(){this.isNavigatingAway=!0},startConnection:function(){var e=this;return _asyncToGenerator(_regenerator().m(function t(){var o;return _regenerator().w(function(t){for(;;)switch(t.p=t.n){case 0:return e.connection=(new signalR.HubConnectionBuilder).withUrl(n.signalRHubUrl).withAutomaticReconnect().build(),e.connection.serverTimeoutInMilliseconds=6e5,e.connection.keepAliveIntervalInMilliseconds=15e3,e.connection.on("LoadInteraction",function(t){var n;e.initializeInteraction(t.itemId,!0),e.messages=[];var i=document.querySelector('input[name="ChatInteraction.Title"]');i&&t.title&&(i.value=t.title),(null!==(n=t.messages)&&void 0!==n?n:[]).forEach(function(t){e.addMessage(t),e.$nextTick(function(){d(t)})})}),e.connection.on("SettingsSaved",function(e,t){var i=document.querySelector('.chat-interaction-history-item[data-interaction-id="'.concat(e,'"]'));i&&(i.textContent=t||n.untitledText)}),e.connection.on("NavigateTo",function(e){i(e)}),e.connection.on("ReceiveError",function(t){console.error("SignalR Error: ",t),e.isRecording&&e.stopRecording()}),e.connection.on("ReceiveTranscript",function(t,n,i){if(e.isConversationMode){if(!i&&n){e._conversationPartialTranscript=n;var o='

'+n.replace(/&/g,"&").replace(//g,">")+"

";e._conversationPartialMessage?(e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent=o):(e.hidePlaceholder(),e._conversationPartialMessage={role:"user",content:n,htmlContent:o,isPartial:!0},e.messages.push(e._conversationPartialMessage)),e.scrollToBottom()}}else n&&!e._audioInputSent&&(e.prompt=e.preRecordingPrompt+n,e.inputElement&&(e.inputElement.value=e.prompt,e.inputElement.dispatchEvent(new Event("input"))))}),e.connection.on("ReceiveConversationUserMessage",function(t,n){if(n){if(e.stopAudio(),e._conversationAssistantMessage){var i=e.messages[e._conversationAssistantMessage.index];i&&(i.isStreaming=!1),e._conversationAssistantMessage=null}if(e._conversationPartialMessage){var o=n.replace(/&/g,"&").replace(//g,">");e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent="

"+o+"

",e._conversationPartialMessage.isPartial=!1,e._conversationPartialMessage=null}else e.addMessage({role:"user",content:n});e.scrollToBottom()}}),e.connection.on("ReceiveConversationAssistantToken",function(t,n,i,o){if(!e._conversationAssistantMessage){e.stopAudio(),e.hideTypingIndicator();for(var r=0;r".concat(g._displayIndex,"
"))}}catch(e){m.e(e)}finally{m.f()}n=n.replaceAll("",","),n+="

";var v,p=_createForOfIteratorHelper(i);try{for(p.s();!(v=p.n()).done;){var y=_slicedToArray(v.value,2),b=(y[0],y[1]),S=b.text||"[doc:".concat(b.index,"]");n+=b.link?"**".concat(b._displayIndex,"**. [").concat(S,"](").concat(b.link,")
"):"**".concat(b._displayIndex,"**. ").concat(S,"
")}}catch(e){p.e(e)}finally{p.f()}}}e.content=n,e.htmlContent=h(n,e)}this.addMessageInternal(e),this.hidePlaceholder(),this.$nextTick(function(){d(e),t.scrollToBottom()})},hidePlaceholder:function(){this.placeholder&&this.placeholder.classList.add("d-none"),this.isPlaceholderVisible=!1},showPlaceholder:function(){this.placeholder&&this.placeholder.classList.remove("d-none"),this.isPlaceholderVisible=!0},fireEvent:function(e){document.dispatchEvent(e)},isIndicator:function(e){return"indicator"===e.role},sendMessage:function(){var e=this;return _asyncToGenerator(_regenerator().m(function t(){var n,i;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:if(n=e.prompt.trim()){t.n=1;break}return t.a(2);case 1:return e.isRecording&&e.stopRecording(),e._audioInputSent=!0,t.n=2,e.flushPendingSave();case 2:e.addMessage({role:"user",content:n}),(i=document.getElementById("clearHistoryBtn"))&&i.classList.remove("d-none"),e.streamMessage(n),e.inputElement.value="",e.prompt="";case 3:return t.a(2)}},t)}))()},streamMessage:function(e){var t=this;this.stream&&(this.stream.dispose(),this.stream=null),this.streamingStarted(),this.showTypingIndicator(),this.autoScroll=!0;var n="",i={},o=null,r=this.messages.length,a=this.getItemId(),c=s();this.stream=this.connection.stream("SendMessage",a,e,c).subscribe({next:function(e){var s=t.messages[r];if(!s){e.sessionId&&!a&&t.setItemId(e.sessionId),t.hideTypingIndicator(),r=t.messages.length;var c={role:"assistant",content:"",htmlContent:"",isStreaming:!0};t.messages.push(c),s=c}if(e.references&&"object"===_typeof(e.references)&&Object.keys(e.references).length)for(var l=0,u=Object.entries(e.references);l".concat(A.index,"
"))}n+=v.replaceAll("",",")}s.content=n,s.htmlContent=h(n,s),t.messages[r]=s,t.$nextTick(function(){d(s),t.scrollToBottom()})},complete:function(){var e;t.processReferences(i,r),t.streamingFinished();var n=t.messages[r];n&&(n.isStreaming=!1),n&&n.content||t.hideTypingIndicator(),t.isConversationMode&&t.textToSpeechEnabled&&n&&n.content&&t.synthesizeSpeech(n.content),null===(e=t.stream)||void 0===e||e.dispose(),t.stream=null},error:function(e){var n;t.processReferences(i,r),t.streamingFinished();var o=t.messages[r];o&&(o.isStreaming=!1),t.hideTypingIndicator(),t.isNavigatingAway||t.addMessage(t.getServiceDownMessage()),null===(n=t.stream)||void 0===n||n.dispose(),t.stream=null,console.error("Stream error:",e)}})},getServiceDownMessage:function(){return{role:"assistant",content:"Our service is currently unavailable. Please try again later. We apologize for the inconvenience.",htmlContent:""}},processReferences:function(e,t){if(Object.keys(e).length){var n=this.messages[t],i=n.content||"",o=Object.entries(e).filter(function(e){var t=_slicedToArray(e,2),n=t[0],o=t[1];return i.includes(n)||i.includes("".concat(o.index,""))});if(!o.length)return;o.sort(function(e,t){var n=_slicedToArray(e,2)[1],i=_slicedToArray(t,2)[1];return n.index-i.index});var r,a=i.trim(),s=1,c=_createForOfIteratorHelper(o);try{for(c.s();!(r=c.n()).done;){var l=_slicedToArray(r.value,2),u=l[0],d=l[1],f="__CITE_".concat(d.index,"__");a=(a=a.replaceAll(u,f)).replaceAll("".concat(d.index,""),f),d._displayIndex=s++,d._placeholder=f}}catch(e){c.e(e)}finally{c.f()}var m,g=_createForOfIteratorHelper(o);try{for(g.s();!(m=g.n()).done;){var v=_slicedToArray(m.value,2)[1];a=a.replaceAll(v._placeholder,"".concat(v._displayIndex,""))}}catch(e){g.e(e)}finally{g.f()}a=a.replaceAll("",","),a+="

";var p,y=_createForOfIteratorHelper(o);try{for(y.s();!(p=y.n()).done;){var b=_slicedToArray(p.value,2),S=(b[0],b[1]),A=S.text||"[doc:".concat(S.index,"]");a+=S.link?"**".concat(S._displayIndex,"**. [").concat(A,"](").concat(S.link,")
"):"**".concat(S._displayIndex,"**. ").concat(A,"
")}}catch(e){y.e(e)}finally{y.f()}n.content=a,n.htmlContent=h(a,n),this.messages[t]=n,this.scrollToBottom()}},streamingStarted:function(){var e=this.buttonElement.getAttribute("data-stop-icon");e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0}))},streamingFinished:function(){var e=this.buttonElement.getAttribute("data-start-icon");if(e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.chatContainer)for(var t=this.chatContainer.querySelectorAll(".ai-streaming-icon"),n=0;n0){var e=this.audioPlayQueue.shift();this.playAudioBlob(e)}else this.isPlayingAudio=!1,this.conversationModeOnAudioEnded()},stopAudio:function(){this.currentAudioElement&&(this.currentAudioElement.pause(),this.currentAudioElement.currentTime=0,this.currentAudioElement=null),this.audioChunks=[],this.audioPlayQueue=[],this.isPlayingAudio=!1},toggleConversationMode:function(){this.isConversationMode?this.stopConversationMode():this.startConversationMode()},startConversationMode:function(){var e=this;this.conversationModeEnabled&&!this.isConversationMode&&this.connection&&(this.isConversationMode=!0,this.updateConversationButton(),this._conversationPartialTranscript="",this._conversationAssistantMessage=null,this._conversationPartialMessage=null,this.removeNotification("conversation-ended"),navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e._conversationSubject=new signalR.Subject,e._conversationStream=t;var i=window.AudioContext||window.webkitAudioContext;i&&(e._conversationAudioCtx=new i,e._conversationAnalyser=e._conversationAudioCtx.createAnalyser(),e._conversationAnalyser.fftSize=256,e._conversationAudioCtx.createMediaStreamSource(t).connect(e._conversationAnalyser));var o=Promise.resolve(),r=e._conversationAnalyser;e.mediaRecorder.addEventListener("dataavailable",function(t){if(t.data&&t.data.size>0){if(e.isPlayingAudio&&r){var n=new Uint8Array(r.frequencyBinCount);r.getByteFrequencyData(n);for(var i=0,a=0;a=30&&e.stopAudio()}o=o.then(_asyncToGenerator(_regenerator().m(function n(){var i,o,r,a;return _regenerator().w(function(n){for(;;)switch(n.n){case 0:return n.n=1,t.data.arrayBuffer();case 1:i=n.v,o=new Uint8Array(i),r=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),a=btoa(r);try{e._conversationSubject.next(a)}catch(e){}case 2:return n.a(2)}},n)})))}}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),o.then(function(){try{e._conversationSubject.complete()}catch(e){}})});var a=e.getItemId(),s=document.documentElement.lang||"en-US";e.connection.send("StartConversation",a,e._conversationSubject,n,s),e.mediaRecorder.start(1e3),e.isRecording=!0}).catch(function(t){console.error("Microphone access denied:",t),e.isConversationMode=!1,e.updateConversationButton()}))},stopConversationMode:function(){if(this.isConversationMode){if(this.isConversationMode=!1,this.updateConversationButton(),this.connection&&this.connection.invoke("StopConversation").catch(function(){}),this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1),this.stopAudio(),this._conversationPartialTranscript="",this._conversationPartialMessage=null,this._conversationAudioCtx&&(this._conversationAudioCtx.close().catch(function(){}),this._conversationAudioCtx=null,this._conversationAnalyser=null),this._conversationAssistantMessage){var e=this.messages[this._conversationAssistantMessage.index];e&&(e.isStreaming=!1),this._conversationAssistantMessage=null}for(var t=0;t=0?this.notifications.splice(t,1,e):this.notifications.push(e),this.scrollToBottom()},updateNotification:function(e){var t=this.notifications.findIndex(function(t){return t.type===e.type});t>=0&&(this.notifications.splice(t,1,e),this.scrollToBottom())},removeNotification:function(e){this.notifications=this.notifications.filter(function(t){return t.type!==e})},dismissNotification:function(e){this.removeNotification(e)},handleNotificationAction:function(e,t){if(this.connection){var n=this.getItemId();this.connection.invoke("HandleNotificationAction",n,e,t).catch(function(e){return console.error("Failed to handle notification action:",e)})}},setItemId:function(e){this.inputElement.setAttribute("data-interaction-id",e||"")},resetInteraction:function(){this.setItemId(""),this.isInteractionStarted=!1,this.messages=[],this.showPlaceholder()},initializeApp:function(){var e=this;this.inputElement=document.querySelector(n.inputElementSelector),this.buttonElement=document.querySelector(n.sendButtonElementSelector),this.chatContainer=document.querySelector(n.chatContainerElementSelector),this.placeholder=document.querySelector(n.placeholderElementSelector);var t=this.getItemId();t&&this.loadInteraction(t),this.chatContainer.addEventListener("scroll",function(){if(e.stream){var t=e.chatContainer.scrollHeight-e.chatContainer.clientHeight-e.chatContainer.scrollTop<=30;e.autoScroll=t}}),this.inputElement.addEventListener("keyup",function(t){null==e.stream&&("Enter"!==t.key||t.shiftKey||e.buttonElement.click())}),this.inputElement.addEventListener("input",function(t){e.handleUserInput(t),t.target.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)}),this.inputElement.addEventListener("paste",function(t){setTimeout(function(){e.prompt=e.inputElement.value,e.inputElement.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)},0)}),this.buttonElement.addEventListener("click",function(){if(null==e.stream)e.sendMessage();else if(e.stream.dispose(),e.stream=null,e.streamingFinished(),e.hideTypingIndicator(),e.messages.length>0){var t=e.messages[e.messages.length-1];"assistant"!==t.role||t.content?t.isStreaming&&(t.isStreaming=!1):e.messages.pop()}});for(var i=document.getElementsByClassName("chat-interaction-history-item"),o=0;o '+r,setTimeout(function(){t.innerHTML=''},2e3)}}}}),document.querySelectorAll('input[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]), select[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]), textarea[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["])').forEach(function(t){var n="checkbox"===t.type,i="SELECT"===t.tagName;n||i?t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()}):(t.addEventListener("focus",function(){e.initialFieldValues.set(t,t.value)}),t.addEventListener("blur",function(){var n=e.initialFieldValues.get(t);void 0!==n&&t.value!==n&&(e.settingsDirty=!0,e.debouncedSaveSettings()),e.initialFieldValues.delete(t)}))}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Tools["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"].group-toggle').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Connections["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})}),document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Agents["]').forEach(function(t){t.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()})});var a=document.querySelector(".ci-agent-global-toggle");a&&a.addEventListener("change",function(){e.settingsDirty=!0,e.debouncedSaveSettings()});var s=document.getElementById("clearHistoryBtn");s&&s.addEventListener("click",function(){var t=s.getAttribute("data-interaction-id");t&&e.clearHistory(t)}),this.speechToTextEnabled&&n.micButtonElementSelector&&(this.micButton=document.querySelector(n.micButtonElementSelector),this.micButton&&(this.micButton.style.display="",this.micButton.addEventListener("click",function(){e.toggleRecording()}))),this.conversationModeEnabled&&n.conversationButtonElementSelector&&(this.conversationButton=document.querySelector(n.conversationButtonElementSelector),this.conversationButton&&this.conversationButton.addEventListener("click",function(){e.toggleConversationMode()}))},loadInteraction:function(e){this.connection.invoke("LoadInteraction",e).catch(function(e){return console.error(e)})},clearHistory:function(e){var t=this;confirmDialog({title:n.clearHistoryTitle,message:n.clearHistoryMessage,okText:n.clearHistoryOkText,cancelText:n.clearHistoryCancelText,callback:function(n){n&&(t.stream&&(t.stream.dispose(),t.stream=null,t.hideTypingIndicator(),t.streamingFinished()),t.connection.invoke("ClearHistory",e).catch(function(e){return console.error("Error clearing history:",e)}))}})},debouncedSaveSettings:function(){var e=this;this.saveSettingsTimeout&&clearTimeout(this.saveSettingsTimeout),this.stream||(this.saveSettingsTimeout=setTimeout(function(){e.settingsDirty&&(e.saveSettings(),e.settingsDirty=!1),e.saveSettingsTimeout=null},850))},getSelectedToolNames:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Tools["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},getSelectedMcpConnectionIds:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Connections["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},getSelectedAgentNames:function(){var e=[];return document.querySelectorAll('input[type="checkbox"][name$="].IsSelected"][name^="ChatInteraction.Agents["]:checked').forEach(function(t){var n=t.name.replace(".IsSelected",".ItemId"),i=document.querySelector('input[type="hidden"][name="'.concat(n,'"]'));i&&i.value&&e.push(i.value)}),e},saveSettings:function(){var e=this.getItemId();if(!e)return Promise.resolve();var t={};return document.querySelectorAll('input[name^="ChatInteraction."]:not([type="hidden"]):not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["]), select[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["]), textarea[name^="ChatInteraction."]:not([name*=".Tools["]):not([name*=".Connections["]):not([name*=".Agents["])').forEach(function(e){var n=e.name.replace("ChatInteraction.",""),i=n.charAt(0).toLowerCase()+n.slice(1);"checkbox"===e.type?t[i]=e.checked:"number"===e.type?t[i]=e.value?parseFloat(e.value):null:t[i]=e.value||null}),t.toolNames=this.getSelectedToolNames(),t.mcpConnectionIds=this.getSelectedMcpConnectionIds(),t.agentNames=this.getSelectedAgentNames(),this.connection.invoke("SaveSettings",e,t).catch(function(e){return console.error("Error saving settings:",e)})},flushPendingSave:function(){return this.saveSettingsTimeout&&(clearTimeout(this.saveSettingsTimeout),this.saveSettingsTimeout=null),this.settingsDirty?(this.settingsDirty=!1,this.saveSettings()):Promise.resolve()},initializeInteraction:function(e,t){this.isInteractionStarted&&!t||(this.fireEvent(new CustomEvent("initializingChatInteraction",{detail:{itemId:e}})),this.setItemId(e),this.isInteractionStarted=!0)},copyResponse:function(e){navigator.clipboard.writeText(e)},startRecording:function(){var e=this;!this.isRecording&&this.connection&&navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e.preRecordingPrompt=e.prompt,e._audioInputSent=!1;var i=new signalR.Subject,o=e.getItemId(),r=Promise.resolve();e.mediaRecorder.addEventListener("dataavailable",function(e){e.data&&e.data.size>0&&(r=r.then(_asyncToGenerator(_regenerator().m(function t(){var n,o,r,a;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.data.arrayBuffer();case 1:n=t.v,o=new Uint8Array(n),r=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),a=btoa(r),i.next(a);case 2:return t.a(2)}},t)}))))}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),r.then(function(){return i.complete()})});var a=document.documentElement.lang||"en-US";e.connection.send("SendAudioStream",o,i,n,a),e.mediaRecorder.start(1e3),e.isRecording=!0,e.updateMicButton()}).catch(function(e){console.error("Microphone access denied:",e)})},stopRecording:function(){this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1,this.updateMicButton())},toggleRecording:function(){this.isRecording?this.stopRecording():this.startRecording()},updateMicButton:function(){this.micButton&&(this.isRecording?(this.micButton.classList.add("stt-recording"),this.micButton.innerHTML=''):(this.micButton.classList.remove("stt-recording"),this.micButton.innerHTML=''))}},watch:{isPlayingAudio:function(){},isConversationMode:function(e){this.micButton&&(this.micButton.style.display=e?"none":this.speechToTextEnabled?"":"none"),this.buttonElement&&(this.buttonElement.style.display=e?"none":""),this.inputElement&&(this.inputElement.disabled=e,e&&(this.inputElement.placeholder=""))}},mounted:function(){var e=this;_asyncToGenerator(_regenerator().m(function t(){return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.startConnection();case 1:e.initializeApp();case 2:return t.a(2)}},t)}))(),window.addEventListener("beforeunload",this.handleBeforeUnload)},beforeUnmount:function(){window.removeEventListener("beforeunload",this.handleBeforeUnload),this.stream&&(this.stream.dispose(),this.stream=null),this.connection&&this.connection.stop()},template:n.messageTemplate}).mount(n.appElementSelector);console.error("The sendButtonElementSelector is required.")}else console.error("The inputElementSelector is required.");else console.error("The chatContainerElementSelector is required.");else console.error("The appElementSelector is required.");else console.error("The signalRHubUrl is required.")}}}(),window.downloadChart=function(e){var t=document.getElementById(e);if(t){var n=document.createElement("a");n.download="chart-"+e+".png",n.href=t.toDataURL("image/png"),n.click()}else console.error("Chart canvas not found:",e)},document.addEventListener("click",function(e){var t=e.target.closest(".ai-download-image");if(t){var n=t.closest(".generated-image-container"),i=null==n?void 0:n.querySelector("img");if(i){var o=i.src;o&&o.startsWith("data:")&&(e.preventDefault(),fetch(o).then(function(e){return e.blob()}).then(function(e){var n=URL.createObjectURL(e),i=document.createElement("a");i.href=n,i.download=t.getAttribute("download")||"generated-image.png",document.body.appendChild(i),i.click(),document.body.removeChild(i),setTimeout(function(){URL.revokeObjectURL(n)},100)}).catch(function(e){console.error("Failed to download image:",e)}))}}}); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat-widget.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat-widget.js index e5a77433f..c1294677b 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat-widget.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat-widget.js @@ -6,6 +6,7 @@ window.aiChatAdminWidget = (function () { var STATE_STORAGE_KEY_SUFFIX = '-ai-admin-widget-state'; var SESSION_STORAGE_KEY_SUFFIX = '-ai-admin-widget-session'; var RESIZED_KEY_SUFFIX = '-ai-admin-widget-resized'; + var NAVIGATION_MESSAGE_TYPE = 'crestapps-ai-chat:navigate'; function initialize(config) { if (!config || !config.containerSelector || !config.toggleSelector) { @@ -94,6 +95,39 @@ window.aiChatAdminWidget = (function () { }; window.openAIChatManager.initialize(config.chatConfig); } + + registerNavigationRelay(); + } + + function registerNavigationRelay() { + if (window._aiChatNavigationRelayRegistered) { + return; + } + + window._aiChatNavigationRelayRegistered = true; + window.addEventListener('message', function (event) { + if (!event || !event.data || event.data.type !== NAVIGATION_MESSAGE_TYPE) { + return; + } + + if (event.origin !== window.location.origin) { + return; + } + + var targetUrl; + + try { + targetUrl = new URL(event.data.url, window.location.href); + } catch { + return; + } + + if (targetUrl.origin !== window.location.origin) { + return; + } + + window.location.assign(targetUrl.toString()); + }); } function setupAutoGrow(textarea) { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat.js index f5876aeb2..b2e5bccb1 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets/js/ai-chat.js @@ -306,6 +306,190 @@ window.openAIChatManager = function () { return DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }); } + function tryGetParentPageUrl() { + if (!window.parent || window.parent === window) { + return null; + } + + try { + return window.parent.location.href || null; + } catch { + return document.referrer || null; + } + } + + function buildHubUrlWithBrowserContext(hubUrl) { + const url = new URL(hubUrl, window.location.href); + const pageUrl = window.location.href; + const parentPageUrl = tryGetParentPageUrl(); + + if (pageUrl) { + url.searchParams.set('browserPageUrl', pageUrl); + } + + if (parentPageUrl) { + url.searchParams.set('browserParentPageUrl', parentPageUrl); + } + + return url.toString(); + } + + function getSameOriginNavigationTarget(url) { + if (!url) { + return null; + } + + const targetUrl = new URL(url, window.location.href); + if (targetUrl.origin !== window.location.origin) { + return null; + } + + return targetUrl; + } + + function requestParentNavigation(url) { + if (!window.parent || window.parent === window) { + return false; + } + + const targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + return false; + } + + try { + window.parent.location.assign(targetUrl.toString()); + return true; + } catch { + window.parent.postMessage({ + type: 'crestapps-ai-chat:navigate', + url: targetUrl.toString() + }, window.location.origin); + + return true; + } + } + + function navigateLivePage(url) { + const targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + console.warn('Ignored live navigation to a non same-origin URL.', url); + return; + } + + window.setTimeout(() => { + if (requestParentNavigation(targetUrl.toString())) { + return; + } + + window.location.assign(targetUrl.toString()); + }, 150); + } + + function getBridgeWindow() { + if (window.parent && window.parent !== window) { + try { + if (window.parent.location && window.parent.document) { + return window.parent; + } + } catch { + } + } + + return window; + } + + function getBridgeDocument() { + return getBridgeWindow().document; + } + + function normalizeContextText(text, maxLength) { + if (!text) { + return ''; + } + + const normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? normalized.substring(0, maxLength) : normalized; + } + + function isVisibleBridgeElement(element, bridgeWindow) { + if (!element) { + return false; + } + + if (element.closest('.ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction')) { + return false; + } + + const style = bridgeWindow.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function captureLivePageContext() { + const bridgeWindow = getBridgeWindow(); + const bridgeDocument = getBridgeDocument(); + const links = []; + const buttons = []; + const headings = []; + const url = bridgeWindow.location.href; + + bridgeDocument.querySelectorAll('h1, h2, h3').forEach((element) => { + if (!isVisibleBridgeElement(element, bridgeWindow) || headings.length >= 12) { + return; + } + + const text = normalizeContextText(element.innerText || element.textContent, 120); + if (text) { + headings.push(text); + } + }); + + bridgeDocument.querySelectorAll('a[href]').forEach((element) => { + if (!isVisibleBridgeElement(element, bridgeWindow) || links.length >= 40) { + return; + } + + const text = normalizeContextText(element.innerText || element.textContent || element.getAttribute('aria-label'), 120); + const href = element.href ? new URL(element.href, url).toString() : ''; + const container = element.closest('tr, li, article, section, .card, .list-group-item, .content-item'); + const context = normalizeContextText((container || element).innerText || (container || element).textContent, 160); + + if (!text && !href) { + return; + } + + links.push({ text, href, context }); + }); + + bridgeDocument.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach((element) => { + if (!isVisibleBridgeElement(element, bridgeWindow) || buttons.length >= 20) { + return; + } + + const text = normalizeContextText(element.innerText || element.textContent || element.value || element.getAttribute('aria-label'), 120); + if (text) { + buttons.push({ text }); + } + }); + + const textPreview = normalizeContextText((bridgeDocument.body && bridgeDocument.body.innerText) || '', 1500); + + return JSON.stringify({ + url, + title: bridgeDocument.title || '', + isParentContext: bridgeWindow !== window, + headings, + links, + buttons, + textPreview + }); + } + const initialize = (instanceConfig) => { const config = Object.assign({}, defaultConfig, instanceConfig); @@ -666,7 +850,7 @@ window.openAIChatManager = function () { }, async startConnection() { this.connection = new signalR.HubConnectionBuilder() - .withUrl(config.signalRHubUrl) + .withUrl(buildHubUrlWithBrowserContext(config.signalRHubUrl)) .withAutomaticReconnect() .build(); @@ -701,6 +885,10 @@ window.openAIChatManager = function () { } }); + this.connection.on("NavigateTo", (url) => { + navigateLivePage(url); + }); + this.connection.on("ReceiveError", (error) => { console.error("SignalR Error: ", error); @@ -1118,8 +1306,9 @@ window.openAIChatManager = function () { // Get the index after showing typing indicator. var messageIndex = this.messages.length; var currentSessionId = this.getSessionId(); + var livePageContextJson = captureLivePageContext(); - this.stream = this.connection.stream("SendMessage", profileId, trimmedPrompt, currentSessionId, sessionProfileId) + this.stream = this.connection.stream("SendMessage", profileId, trimmedPrompt, currentSessionId, sessionProfileId, livePageContextJson) .subscribe({ next: (chunk) => { let message = this.messages[messageIndex]; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.js index ec5a94b88..a9ba07baf 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.js @@ -11,6 +11,7 @@ window.aiChatAdminWidget = function () { var STATE_STORAGE_KEY_SUFFIX = '-ai-admin-widget-state'; var SESSION_STORAGE_KEY_SUFFIX = '-ai-admin-widget-session'; var RESIZED_KEY_SUFFIX = '-ai-admin-widget-resized'; + var NAVIGATION_MESSAGE_TYPE = 'crestapps-ai-chat:navigate'; function initialize(config) { if (!config || !config.containerSelector || !config.toggleSelector) { console.error('aiChatAdminWidget: containerSelector and toggleSelector are required.'); @@ -94,6 +95,31 @@ window.aiChatAdminWidget = function () { }; window.openAIChatManager.initialize(config.chatConfig); } + registerNavigationRelay(); + } + function registerNavigationRelay() { + if (window._aiChatNavigationRelayRegistered) { + return; + } + window._aiChatNavigationRelayRegistered = true; + window.addEventListener('message', function (event) { + if (!event || !event.data || event.data.type !== NAVIGATION_MESSAGE_TYPE) { + return; + } + if (event.origin !== window.location.origin) { + return; + } + var targetUrl; + try { + targetUrl = new URL(event.data.url, window.location.href); + } catch (_unused) { + return; + } + if (targetUrl.origin !== window.location.origin) { + return; + } + window.location.assign(targetUrl.toString()); + }); } function setupAutoGrow(textarea) { var lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 20; diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.min.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.min.js index fbb1523ef..53cc52092 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.min.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat-widget.min.js @@ -1 +1 @@ -window.aiChatAdminWidget=function(){"use strict";function t(t,e){try{var o=t.getBoundingClientRect(),n={left:o.left,top:o.top,width:t.offsetWidth,height:t.offsetHeight};localStorage.setItem(e,JSON.stringify(n))}catch(t){}}function e(t){return t.touches&&t.touches.length>0?{x:t.touches[0].clientX,y:t.touches[0].clientY}:{x:t.clientX,y:t.clientY}}return{initialize:function(o){if(o&&o.containerSelector&&o.toggleSelector){var n=document.querySelector(o.containerSelector),i=document.querySelector(o.toggleSelector);if(n&&i){var a=n.querySelector(".ai-admin-widget-header"),s=n.querySelector(".ai-admin-widget-resize-handle"),r=n.querySelector(o.restoreSizeSelector),l=n.querySelector(o.promptSelector),d=o.storagePrefix||"default",c=d+"-ai-admin-widget-pos",h=d+"-ai-admin-widget-toggle-pos",u=d+"-ai-admin-widget-state",g=d+"-ai-admin-widget-session",f=d+"-ai-admin-widget-resized";!function(t,e){try{var o=localStorage.getItem(e);if(!o)return;var n=JSON.parse(o),i=window.innerWidth,a=window.innerHeight;n.left>=0&&n.top>=0&&n.left=288&&(t.style.width=Math.min(n.width,i-16)+"px"),n.height&&n.height>=320&&(t.style.height=Math.min(n.height,a-16)+"px")}catch(t){}}(n,c),function(t,e){try{var o=localStorage.getItem(e);if(!o)return;var n=JSON.parse(o),i=window.innerWidth,a=window.innerHeight;n.left>=0&&n.top>=0&&n.lefte?"auto":"hidden"}t.addEventListener("input",o),o()}(l),o.chatConfig&&window.openAIChatManager&&(o.chatConfig.widget={showChatButton:o.toggleSelector,newChatButton:o.newChatButtonSelector,chatWidgetContainer:o.containerSelector,chatHistorySection:o.historyPanelSelector,closeHistoryButton:o.closeHistorySelector,closeChatButton:o.closeButtonSelector,showHistoryButton:o.showHistorySelector,chatWidgetStateName:g},window.openAIChatManager.initialize(o.chatConfig))}}else console.error("aiChatAdminWidget: containerSelector and toggleSelector are required.")}}}(); +window.aiChatAdminWidget=function(){"use strict";var t="crestapps-ai-chat:navigate";function e(t,e){try{var i=t.getBoundingClientRect(),n={left:i.left,top:i.top,width:t.offsetWidth,height:t.offsetHeight};localStorage.setItem(e,JSON.stringify(n))}catch(t){}}function i(t){return t.touches&&t.touches.length>0?{x:t.touches[0].clientX,y:t.touches[0].clientY}:{x:t.clientX,y:t.clientY}}return{initialize:function(n){if(n&&n.containerSelector&&n.toggleSelector){var o=document.querySelector(n.containerSelector),a=document.querySelector(n.toggleSelector);if(o&&a){var s=o.querySelector(".ai-admin-widget-header"),r=o.querySelector(".ai-admin-widget-resize-handle"),d=o.querySelector(n.restoreSizeSelector),l=o.querySelector(n.promptSelector),c=n.storagePrefix||"default",h=c+"-ai-admin-widget-pos",u=c+"-ai-admin-widget-toggle-pos",g=c+"-ai-admin-widget-state",f=c+"-ai-admin-widget-session",m=c+"-ai-admin-widget-resized";!function(t,e){try{var i=localStorage.getItem(e);if(!i)return;var n=JSON.parse(i),o=window.innerWidth,a=window.innerHeight;n.left>=0&&n.top>=0&&n.left=288&&(t.style.width=Math.min(n.width,o-16)+"px"),n.height&&n.height>=320&&(t.style.height=Math.min(n.height,a-16)+"px")}catch(t){}}(o,h),function(t,e){try{var i=localStorage.getItem(e);if(!i)return;var n=JSON.parse(i),o=window.innerWidth,a=window.innerHeight;n.left>=0&&n.top>=0&&n.lefte?"auto":"hidden"}t.addEventListener("input",i),i()}(l),n.chatConfig&&window.openAIChatManager&&(n.chatConfig.widget={showChatButton:n.toggleSelector,newChatButton:n.newChatButtonSelector,chatWidgetContainer:n.containerSelector,chatHistorySection:n.historyPanelSelector,closeHistoryButton:n.closeHistorySelector,closeChatButton:n.closeButtonSelector,showHistoryButton:n.showHistorySelector,chatWidgetStateName:f},window.openAIChatManager.initialize(n.chatConfig)),function(){if(window._aiChatNavigationRelayRegistered)return;window._aiChatNavigationRelayRegistered=!0,window.addEventListener("message",function(e){if(e&&e.data&&e.data.type===t&&e.origin===window.location.origin){var i;try{i=new URL(e.data.url,window.location.href)}catch(t){return}i.origin===window.location.origin&&window.location.assign(i.toString())}})}()}}else console.error("aiChatAdminWidget: containerSelector and toggleSelector are required.")}}}(); diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.js index 910ce7ec4..36e04584c 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.js @@ -258,6 +258,159 @@ window.openAIChatManager = function () { ADD_ATTR: ['target'] }); } + function tryGetParentPageUrl() { + if (!window.parent || window.parent === window) { + return null; + } + try { + return window.parent.location.href || null; + } catch (_unused) { + return document.referrer || null; + } + } + function buildHubUrlWithBrowserContext(hubUrl) { + var url = new URL(hubUrl, window.location.href); + var pageUrl = window.location.href; + var parentPageUrl = tryGetParentPageUrl(); + if (pageUrl) { + url.searchParams.set('browserPageUrl', pageUrl); + } + if (parentPageUrl) { + url.searchParams.set('browserParentPageUrl', parentPageUrl); + } + return url.toString(); + } + function getSameOriginNavigationTarget(url) { + if (!url) { + return null; + } + var targetUrl = new URL(url, window.location.href); + if (targetUrl.origin !== window.location.origin) { + return null; + } + return targetUrl; + } + function requestParentNavigation(url) { + if (!window.parent || window.parent === window) { + return false; + } + var targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + return false; + } + try { + window.parent.location.assign(targetUrl.toString()); + return true; + } catch (_unused2) { + window.parent.postMessage({ + type: 'crestapps-ai-chat:navigate', + url: targetUrl.toString() + }, window.location.origin); + return true; + } + } + function navigateLivePage(url) { + var targetUrl = getSameOriginNavigationTarget(url); + if (!targetUrl) { + console.warn('Ignored live navigation to a non same-origin URL.', url); + return; + } + window.setTimeout(function () { + if (requestParentNavigation(targetUrl.toString())) { + return; + } + window.location.assign(targetUrl.toString()); + }, 150); + } + function getBridgeWindow() { + if (window.parent && window.parent !== window) { + try { + if (window.parent.location && window.parent.document) { + return window.parent; + } + } catch (_unused3) {} + } + return window; + } + function getBridgeDocument() { + return getBridgeWindow().document; + } + function normalizeContextText(text, maxLength) { + if (!text) { + return ''; + } + var normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? normalized.substring(0, maxLength) : normalized; + } + function isVisibleBridgeElement(element, bridgeWindow) { + if (!element) { + return false; + } + if (element.closest('.ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction')) { + return false; + } + var style = bridgeWindow.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') { + return false; + } + var rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function captureLivePageContext() { + var bridgeWindow = getBridgeWindow(); + var bridgeDocument = getBridgeDocument(); + var links = []; + var buttons = []; + var headings = []; + var url = bridgeWindow.location.href; + bridgeDocument.querySelectorAll('h1, h2, h3').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || headings.length >= 12) { + return; + } + var text = normalizeContextText(element.innerText || element.textContent, 120); + if (text) { + headings.push(text); + } + }); + bridgeDocument.querySelectorAll('a[href]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || links.length >= 40) { + return; + } + var text = normalizeContextText(element.innerText || element.textContent || element.getAttribute('aria-label'), 120); + var href = element.href ? new URL(element.href, url).toString() : ''; + var container = element.closest('tr, li, article, section, .card, .list-group-item, .content-item'); + var context = normalizeContextText((container || element).innerText || (container || element).textContent, 160); + if (!text && !href) { + return; + } + links.push({ + text: text, + href: href, + context: context + }); + }); + bridgeDocument.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach(function (element) { + if (!isVisibleBridgeElement(element, bridgeWindow) || buttons.length >= 20) { + return; + } + var text = normalizeContextText(element.innerText || element.textContent || element.value || element.getAttribute('aria-label'), 120); + if (text) { + buttons.push({ + text: text + }); + } + }); + var textPreview = normalizeContextText(bridgeDocument.body && bridgeDocument.body.innerText || '', 1500); + return JSON.stringify({ + url: url, + title: bridgeDocument.title || '', + isParentContext: bridgeWindow !== window, + headings: headings, + links: links, + buttons: buttons, + textPreview: textPreview + }); + } var initialize = function initialize(instanceConfig) { var config = Object.assign({}, defaultConfig, instanceConfig); // Keep defaultConfig in sync so renderers use overridden values @@ -668,7 +821,7 @@ window.openAIChatManager = function () { return _regenerator().w(function (_context3) { while (1) switch (_context3.p = _context3.n) { case 0: - _this3.connection = new signalR.HubConnectionBuilder().withUrl(config.signalRHubUrl).withAutomaticReconnect().build(); + _this3.connection = new signalR.HubConnectionBuilder().withUrl(buildHubUrlWithBrowserContext(config.signalRHubUrl)).withAutomaticReconnect().build(); // Allow long-running operations (e.g., multi-step MCP tool calls) // without the client disconnecting prematurely. @@ -698,6 +851,9 @@ window.openAIChatManager = function () { _this3.sendMessage(); } }); + _this3.connection.on("NavigateTo", function (url) { + navigateLivePage(url); + }); _this3.connection.on("ReceiveError", function (error) { console.error("SignalR Error: ", error); if (_this3.isRecording) { @@ -1152,7 +1308,8 @@ window.openAIChatManager = function () { // Get the index after showing typing indicator. var messageIndex = this.messages.length; var currentSessionId = this.getSessionId(); - this.stream = this.connection.stream("SendMessage", profileId, trimmedPrompt, currentSessionId, sessionProfileId).subscribe({ + var livePageContextJson = captureLivePageContext(); + this.stream = this.connection.stream("SendMessage", profileId, trimmedPrompt, currentSessionId, sessionProfileId, livePageContextJson).subscribe({ next: function next(chunk) { var message = _this8.messages[messageIndex]; if (!message) { diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.min.js b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.min.js index 41b0c0c47..84194a9b8 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.min.js +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/scripts/ai-chat.min.js @@ -1 +1 @@ -function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var i,o,a,s,r=[],c=!0,l=!1;try{if(a=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=a.call(n)).done)&&(r.push(i.value),r.length!==t);c=!0);}catch(e){l=!0,o=e}finally{try{if(!c&&null!=n.return&&(s=n.return(),Object(s)!==s))return}finally{if(l)throw o}}return r}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _regenerator(){/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */var e,t,n="function"==typeof Symbol?Symbol:{},i=n.iterator||"@@iterator",o=n.toStringTag||"@@toStringTag";function a(n,i,o,a){var c=i&&i.prototype instanceof r?i:r,l=Object.create(c.prototype);return _regeneratorDefine2(l,"_invoke",function(n,i,o){var a,r,c,l=0,d=o||[],u=!1,h={p:0,n:0,v:e,a:f,f:f.bind(e,4),d:function(t,n){return a=t,r=0,c=e,h.n=n,s}};function f(n,i){for(r=n,c=i,t=0;!u&&l&&!o&&t3?(o=m===i)&&(c=a[(r=a[4])?5:(r=3,3)],a[4]=a[5]=e):a[0]<=f&&((o=n<2&&fi||i>m)&&(a[4]=n,a[5]=i,h.n=m,r=0))}if(o||n>1)return s;throw u=!0,i}return function(o,d,m){if(l>1)throw TypeError("Generator is already running");for(u&&1===d&&f(d,m),r=d,c=m;(t=r<2?e:c)||!u;){a||(r?r<3?(r>1&&(h.n=-1),f(r,c)):h.n=c:h.v=c);try{if(l=2,a){if(r||(o="next"),t=a[o]){if(!(t=t.call(a,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,r<2&&(r=0)}else 1===r&&(t=a.return)&&t.call(a),r<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),r=1);a=e}else if((t=(u=h.n<0)?c:n.call(i,h))!==s)break}catch(t){a=e,r=1,c=t}finally{l=1}}return{value:t,done:u}}}(n,o,a),!0),l}var s={};function r(){}function c(){}function l(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(_regeneratorDefine2(t={},i,function(){return this}),t),u=l.prototype=r.prototype=Object.create(d);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,l):(e.__proto__=l,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(u),e}return c.prototype=l,_regeneratorDefine2(u,"constructor",l),_regeneratorDefine2(l,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(l,o,"GeneratorFunction"),_regeneratorDefine2(u),_regeneratorDefine2(u,o,"Generator"),_regeneratorDefine2(u,i,function(){return this}),_regeneratorDefine2(u,"toString",function(){return"[object Generator]"}),(_regenerator=function(){return{w:a,m:h}})()}function _regeneratorDefine2(e,t,n,i){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,t,n,i){function a(t,n){_regeneratorDefine2(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!i,configurable:!i,writable:!i}):e[t]=n:(a("next",0),a("throw",1),a("return",2))},_regeneratorDefine2(e,t,n,i)}function asyncGeneratorStep(e,t,n,i,o,a,s){try{var r=e[a](s),c=r.value}catch(e){return void n(e)}r.done?t(c):Promise.resolve(c).then(i,o)}function _asyncToGenerator(e){return function(){var t=this,n=arguments;return new Promise(function(i,o){var a=e.apply(t,n);function s(e){asyncGeneratorStep(a,i,o,s,r,"next",e)}function r(e){asyncGeneratorStep(a,i,o,s,r,"throw",e)}s(void 0)})}}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,r=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){r=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(r)throw a}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=Array(t);n\n
\n
\n
{{ userLabel }}
\n
\n \n {{ assistantLabel }}\n
\n
\n

{{ message.title }}

\n
\n \n \n \n \n
\n
\n
\n
\n
\n \n {{ notification.content }}\n \n
\n
\n \n
\n
\n \n ',indicatorTemplate:'\n
\n \n Assistant\n
\n '};function t(e){if(!e)return"";var t=e.trim();return/^javascript:/i.test(t)||/^vbscript:/i.test(t)||/^data:text\/html/i.test(t)?"":e}function n(e){var t=document.createElement("span");return t.textContent=e,t.innerHTML}var i=new marked.Renderer;i.link=function(e){var n=t(e.href);return n?'').concat(e.text,""):e.text||""},i.code=function(e){var t=e.text||"",i=(e.lang||"").trim(),o=t;if("undefined"!=typeof hljs)if(i&&hljs.getLanguage(i))try{o=hljs.highlight(t,{language:i}).value}catch(e){}else try{o=hljs.highlightAuto(t).value}catch(e){}else o=n(t);var a=i?n(i):"code";return'
'.concat(a,'
').concat(o,"
")},i.image=function(n){var i=t(n.href);if(!i)return"";var o=n.text||e.generatedImageAltText,a=e.generatedImageMaxWidth;return'
\n ').concat(o,'\n
\n \n \n \n
\n
')};var o=0,a=[];function s(e){if(e&&e._pendingCharts&&e._pendingCharts.length){var t,n=_createForOfIteratorHelper(e._pendingCharts);try{for(n.s();!(t=n.n()).done;){var i=t.value,o=document.getElementById(i.chartId);if(o)if("undefined"!=typeof Chart)try{var a;o._chartInstance&&o._chartInstance.destroy();var s="string"==typeof i.config?JSON.parse(i.config):i.config;null!==(a=s.options)&&void 0!==a||(s.options={}),s.options.responsive=!0,s.options.maintainAspectRatio=!1,o._chartInstance=new Chart(o,s)}catch(e){console.error("Error creating chart:",e)}else console.error("Chart.js is not available on the page.")}}catch(e){n.e(e)}finally{n.f()}e._pendingCharts=[]}}function r(e,t){a=[];var n=marked.parse(e,{renderer:i});return t._pendingCharts=a.length>0?_toConsumableArray(a):[],DOMPurify.sanitize(n,{ADD_ATTR:["target"]})}marked.use({extensions:[{name:"chart",level:"block",start:function(e){var t=e.indexOf("[chart:");return t>=0?t:void 0},tokenizer:function(e){var t=function(e){var t="[chart:",n=e.indexOf(t);if(n<0)return null;var i=n+t.length,o=i;for(;o=e.length||"{"!==e[o])return null;for(var a=0,s=!1,r=!1;o')+'')+'
'+'
";var n,i}}]});return{initialize:function(t){var n=Object.assign({},e,t);if(e=n,n.signalRHubUrl)if(n.appElementSelector)if(n.chatContainerElementSelector)if(n.inputElementSelector){if(n.sendButtonElementSelector)return Vue.createApp({data:function(){return{inputElement:null,buttonElement:null,chatContainer:null,placeholder:null,isSessionStarted:!1,isPlaceholderVisible:!0,chatWidgetStateName:null,chatWidgetStateSession:null,chatHistorySection:null,widgetIsInitialized:!1,isStreaming:!1,isNavigatingAway:!1,autoScroll:!0,stream:null,messages:[],notifications:[],prompt:"",documents:n.existingDocuments||[],isUploading:!1,uploadErrors:[],isDragOver:!1,documentBar:null,metricsEnabled:!!n.metricsEnabled,userLabel:n.userLabel,assistantLabel:n.assistantLabel,thumbsUpTitle:n.thumbsUpTitle,thumbsDownTitle:n.thumbsDownTitle,copyTitle:n.copyTitle,isRecording:!1,mediaRecorder:null,preRecordingPrompt:"",micButton:null,speechToTextEnabled:"AudioInput"===n.chatMode||"Conversation"===n.chatMode,textToSpeechEnabled:"Conversation"===n.chatMode,ttsVoiceName:n.ttsVoiceName||null,audioChunks:[],audioPlayQueue:[],isPlayingAudio:!1,currentAudioElement:null,ttsButton:null,conversationModeEnabled:"Conversation"===n.chatMode,conversationButton:null,isConversationMode:!1,selectedResponseHandler:"",responseHandlers:n.responseHandlers||[]}},computed:{lastAssistantIndex:function(){for(var e=this.messages.length-1;e>=0;e--)if("assistant"===this.messages[e].role)return e;return-1}},methods:{handleBeforeUnload:function(){this.isNavigatingAway=!0},handleDragOver:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!0;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.add("ai-chat-drag-over")}},handleDragLeave:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!1;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.remove("ai-chat-drag-over")}},handleDrop:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!1;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.remove("ai-chat-drag-over"),e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length>0&&this.uploadFiles(e.dataTransfer.files)}},triggerFileInput:function(){if(n.sessionDocumentsEnabled){var e=document.getElementById("ai-chat-doc-input");e&&e.click()}},handleFileInputChange:function(e){var t=e.target.files;t&&t.length>0&&this.uploadFiles(t),e.target.value=""},uploadFiles:function(e){var t=this;return _asyncToGenerator(_regenerator().m(function i(){var o,a,s,r,c,l,d,u,h;return _regenerator().w(function(i){for(;;)switch(i.p=i.n){case 0:if(n.uploadDocumentUrl){i.n=1;break}return i.a(2);case 1:if(o=t.getSessionId(),a=t.getProfileId(),o||a){i.n=2;break}return console.warn("Cannot upload documents without a session or profile."),i.a(2);case 2:for(t.isUploading=!0,t.uploadErrors=[],t.renderDocumentBar(),i.p=3,s=new FormData,o?s.append("sessionId",o):s.append("profileId",a),r=0;r0)for(u=0;u0&&(t.uploadErrors=d.failed),i.n=9;break;case 8:i.p=8,h=i.v,console.error("Upload error:",h),t.uploadErrors=[{fileName:"",error:"Upload failed. Please try again."}];case 9:return i.p=9,t.isUploading=!1,t.renderDocumentBar(),i.f(9);case 10:return i.a(2)}},i,null,[[3,8,9,10]])}))()},removeDocument:function(e){var t=this;return _asyncToGenerator(_regenerator().m(function i(){var o,a,s,r,c;return _regenerator().w(function(i){for(;;)switch(i.p=i.n){case 0:if(n.removeDocumentUrl){i.n=1;break}return i.a(2);case 1:return i.p=1,o=t.getSessionId(),i.n=2,fetch(n.removeDocumentUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({itemId:o,documentId:e.documentId})});case 2:if(!(a=i.v).ok){i.n=3;break}(s=t.documents.indexOf(e))>-1&&t.documents.splice(s,1),i.n=5;break;case 3:return i.n=4,a.text();case 4:r=i.v,console.error("Failed to remove document:",a.status,r);case 5:i.n=7;break;case 6:i.p=6,c=i.v,console.error("Remove document error:",c);case 7:return i.a(2)}},i,null,[[1,6]])}))()},formatFileSize:function(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":(e/1048576).toFixed(1)+" MB"},renderHandlerSelector:function(){if(this.placeholder&&0!==this.responseHandlers.length&&!this.placeholder.querySelector(".ai-chat-handler-selector")){var e=document.createElement("div");e.className="ai-chat-handler-selector mt-2",e.style.cssText="font-size: 0.85rem;";var t=document.createElement("label");t.className="form-label mb-1",t.style.fontSize="0.8rem",t.textContent="Response Handler";var n=document.createElement("select");n.className="form-select form-select-sm";var i=document.createElement("option");i.value="",i.textContent="Default (AI)",n.appendChild(i);for(var o=0;o20&&(o=o.substring(0,17)+"..."),e+='',e+=' ',e+=this.escapeHtml(o),e+=' ',e+=""}for(var a=0;a15&&(r=r.substring(0,12)+"..."),e+='',e+=' ',e+=this.escapeHtml(r),e+=' ',e+=""}this.isUploading&&(e+='',e+=' Uploading...',e+=""),e+='",e+="",this.documentBar.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0}));for(var l=this,d=this.documentBar.querySelectorAll("[data-doc-index]"),u=0;u/g,">")+"

";e._conversationPartialMessage?(e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent=o):(e.hidePlaceholder(),e._conversationPartialMessage={role:"user",content:n,htmlContent:o,isPartial:!0},e.messages.push(e._conversationPartialMessage)),e.scrollToBottom()}}else n&&!e._audioInputSent&&(e.prompt=e.preRecordingPrompt+n,e.inputElement&&(e.inputElement.value=e.prompt,e.inputElement.dispatchEvent(new Event("input"))))}),e.connection.on("ReceiveConversationUserMessage",function(t,n){if(n){if(e.stopAudio(),e._conversationAssistantMessage){var i=e.messages[e._conversationAssistantMessage.index];i&&(i.isStreaming=!1),e._conversationAssistantMessage=null}if(e._conversationPartialMessage){var o=n.replace(/&/g,"&").replace(//g,">");e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent="

"+o+"

",e._conversationPartialMessage.isPartial=!1,e._conversationPartialMessage=null}else e.addMessage({role:"user",content:n});e.scrollToBottom()}}),e.connection.on("ReceiveConversationAssistantToken",function(t,n,i,o){if(!e._conversationAssistantMessage){e.stopAudio(),e.hideTypingIndicator();for(var a=0;a".concat(g._displayIndex,"
"))}}catch(e){m.e(e)}finally{m.f()}n=n.replaceAll("",","),n+="

";var p,v=_createForOfIteratorHelper(i);try{for(v.s();!(p=v.n()).done;){var y=_slicedToArray(p.value,2),b=y[0],S=y[1],A=S.text||b;n+=S.link?"**".concat(S._displayIndex,"**. [").concat(A,"](").concat(S.link,")
"):"**".concat(S._displayIndex,"**. ").concat(A,"
")}}catch(e){v.e(e)}finally{v.f()}}}e.content=n,e.htmlContent=r(n,e)}this.addMessageInternal(e),this.hidePlaceholder(),this.$nextTick(function(){s(e),t.scrollToBottom()})},addMessages:function(e){for(var t=this,n=0;n0&&(s=s.then(_asyncToGenerator(_regenerator().m(function t(){var n,o,a,s;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.data.arrayBuffer();case 1:n=t.v,o=new Uint8Array(n),a=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),s=btoa(a),i.next(s);case 2:return t.a(2)}},t)}))))}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),s.then(function(){return i.complete()})});var r=document.documentElement.lang||"en-US";e.connection.send("SendAudioStream",o,a,i,n,r),e.mediaRecorder.start(1e3),e.isRecording=!0,e.updateMicButton()}).catch(function(e){console.error("Microphone access denied:",e)})},stopRecording:function(){this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1,this.updateMicButton())},toggleRecording:function(){this.isRecording?this.stopRecording():this.startRecording()},updateMicButton:function(){this.micButton&&(this.isRecording?(this.micButton.classList.add("stt-recording"),this.micButton.innerHTML=''):(this.micButton.classList.remove("stt-recording"),this.micButton.innerHTML=''))},streamMessage:function(e,t,n){var i=this;this.stream&&(this.stream.dispose(),this.stream=null),this.streamingStarted(),this.showTypingIndicator(),this.autoScroll=!0;var o="",a={},c=null,l=this.messages.length,d=this.getSessionId();this.stream=this.connection.stream("SendMessage",e,t,d,n).subscribe({next:function(e){var t=i.messages[l];if(!t){e.sessionId&&!d&&i.initializeSession(e.sessionId),i.hideTypingIndicator(),l=i.messages.length;var n={id:e.messageId,role:"assistant",title:e.title,content:"",htmlContent:"",isStreaming:!0,userRating:null};i.messages.push(n),t=n}if(!e.title||t.title&&t.title===e.title||(t.title=e.title),e.references&&"object"===_typeof(e.references)&&Object.keys(e.references).length)for(var u=0,h=Object.entries(e.references);u".concat(A.index,"
"))}o+=p.replaceAll("",",")}t.content=o,t.htmlContent=r(o,t),i.messages[l]=t,i.$nextTick(function(){s(t),i.scrollToBottom()})},complete:function(){var e;i.processReferences(a,l),i.streamingFinished();var t=i.messages[l];t&&(t.isStreaming=!1),t&&t.content||i.hideTypingIndicator(),i.isConversationMode&&i.textToSpeechEnabled&&t&&t.content&&i.synthesizeSpeech(t.content),null===(e=i.stream)||void 0===e||e.dispose(),i.stream=null},error:function(e){var t;i.processReferences(a,l),i.streamingFinished();var n=i.messages[l];n&&(n.isStreaming=!1),i.hideTypingIndicator(),i.isNavigatingAway||i.addMessage(i.getServiceDownMessage()),null===(t=i.stream)||void 0===t||t.dispose(),i.stream=null,console.error("Stream error:",e)}})},getServiceDownMessage:function(){return{role:"assistant",content:"Our service is currently unavailable. Please try again later. We apologize for the inconvenience.",htmlContent:""}},processReferences:function(e,t){if(Object.keys(e).length){var n=this.messages[t],i=n.content||"",o=Object.entries(e).filter(function(e){var t=_slicedToArray(e,2),n=t[0],o=t[1];return i.includes(n)||i.includes("".concat(o.index,""))});if(!o.length)return;o.sort(function(e,t){var n=_slicedToArray(e,2)[1],i=_slicedToArray(t,2)[1];return n.index-i.index});var a,s=i.trim(),c=1,l=_createForOfIteratorHelper(o);try{for(l.s();!(a=l.n()).done;){var d=_slicedToArray(a.value,2),u=d[0],h=d[1],f="__CITE_".concat(h.index,"__");s=(s=s.replaceAll(u,f)).replaceAll("".concat(h.index,""),f),h._displayIndex=c++,h._placeholder=f}}catch(e){l.e(e)}finally{l.f()}var m,g=_createForOfIteratorHelper(o);try{for(g.s();!(m=g.n()).done;){var p=_slicedToArray(m.value,2)[1];s=s.replaceAll(p._placeholder,"".concat(p._displayIndex,""))}}catch(e){g.e(e)}finally{g.f()}s=s.replaceAll("",","),s+="

";var v,y=_createForOfIteratorHelper(o);try{for(y.s();!(v=y.n()).done;){var b=_slicedToArray(v.value,2),S=b[0],A=b[1],_=A.text||S;s+=A.link?"**".concat(A._displayIndex,"**. [").concat(_,"](").concat(A.link,")
"):"**".concat(A._displayIndex,"**. ").concat(_,"
")}}catch(e){y.e(e)}finally{y.f()}n.content=s,n.htmlContent=r(s,n),this.messages[t]=n,this.scrollToBottom()}},streamingStarted:function(){var e=this.buttonElement.getAttribute("data-stop-icon");e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.inputElement&&this.inputElement.setAttribute("disabled","disabled")},streamingFinished:function(){var e=this.buttonElement.getAttribute("data-start-icon");if(e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.inputElement&&(this.inputElement.removeAttribute("disabled"),this.inputElement.focus()),this.chatContainer)for(var t=this.chatContainer.querySelectorAll(".ai-streaming-icon"),n=0;n0){var e=this.audioPlayQueue.shift();this.playAudioBlob(e)}else this.isPlayingAudio=!1,this.conversationModeOnAudioEnded()},stopAudio:function(){this.currentAudioElement&&(this.currentAudioElement.pause(),this.currentAudioElement.currentTime=0,this.currentAudioElement=null),this.audioChunks=[],this.audioPlayQueue=[],this.isPlayingAudio=!1},toggleConversationMode:function(){this.isConversationMode?this.stopConversationMode():this.startConversationMode()},startConversationMode:function(){var e=this;this.conversationModeEnabled&&!this.isConversationMode&&this.connection&&(this.isConversationMode=!0,this.updateConversationButton(),this._conversationPartialTranscript="",this._conversationAssistantMessage=null,this._conversationPartialMessage=null,this.removeNotification("conversation-ended"),navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e._conversationSubject=new signalR.Subject,e._conversationStream=t;var i=window.AudioContext||window.webkitAudioContext;i&&(e._conversationAudioCtx=new i,e._conversationAnalyser=e._conversationAudioCtx.createAnalyser(),e._conversationAnalyser.fftSize=256,e._conversationAudioCtx.createMediaStreamSource(t).connect(e._conversationAnalyser));var o=Promise.resolve(),a=e._conversationAnalyser;e.mediaRecorder.addEventListener("dataavailable",function(t){if(t.data&&t.data.size>0){if(e.isPlayingAudio&&a){var n=new Uint8Array(a.frequencyBinCount);a.getByteFrequencyData(n);for(var i=0,s=0;s=30&&e.stopAudio()}o=o.then(_asyncToGenerator(_regenerator().m(function n(){var i,o,a,s;return _regenerator().w(function(n){for(;;)switch(n.n){case 0:return n.n=1,t.data.arrayBuffer();case 1:i=n.v,o=new Uint8Array(i),a=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),s=btoa(a);try{e._conversationSubject.next(s)}catch(e){}case 2:return n.a(2)}},n)})))}}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),o.then(function(){try{e._conversationSubject.complete()}catch(e){}})});var s=e.getProfileId(),r=e.getSessionId()||"",c=document.documentElement.lang||"en-US";e.connection.send("StartConversation",s,r,e._conversationSubject,n,c),e.mediaRecorder.start(1e3),e.isRecording=!0}).catch(function(t){console.error("Microphone access denied:",t),e.isConversationMode=!1,e.updateConversationButton()}))},stopConversationMode:function(){if(this.isConversationMode){if(this.isConversationMode=!1,this.updateConversationButton(),this.connection&&this.connection.invoke("StopConversation").catch(function(){}),this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1),this.stopAudio(),this._conversationPartialTranscript="",this._conversationPartialMessage=null,this._conversationAudioCtx&&(this._conversationAudioCtx.close().catch(function(){}),this._conversationAudioCtx=null,this._conversationAnalyser=null),this._conversationAssistantMessage){var e=this.messages[this._conversationAssistantMessage.index];e&&(e.isStreaming=!1),this._conversationAssistantMessage=null}for(var t=0;t=0?this.notifications.splice(n,1,e):this.notifications.push(e),this.$nextTick(function(){t.scrollToBottom()})}},updateNotification:function(e){if(e&&e.type){var t=this.notifications.findIndex(function(t){return t.type===e.type});t>=0&&this.notifications.splice(t,1,e)}},removeNotification:function(e){this.notifications=this.notifications.filter(function(t){return t.type!==e})},dismissNotification:function(e){this.removeNotification(e)},handleNotificationAction:function(e,t){if(this.connection){var n=this.getSessionId();this.connection.invoke("HandleNotificationAction",n,e,t).catch(function(e){console.error("Error handling notification action:",e)})}},scrollToBottom:function(){var e=this;this.autoScroll&&setTimeout(function(){e.chatContainer.scrollTop=e.chatContainer.scrollHeight-e.chatContainer.clientHeight},50)},handleUserInput:function(e){this.prompt=e.target.value},getProfileId:function(){return this.inputElement.getAttribute("data-profile-id")},setSessionId:function(e){this.inputElement.setAttribute("data-session-id",e||"")},resetSession:function(){this.stopRecording(),this.setSessionId(""),this.isSessionStarted=!1,this.sessionRating=null,this.widgetIsInitialized&&localStorage.removeItem(this.chatWidgetStateSession),this.messages=[],this.documents=[],n.autoCreateSession||this.showPlaceholder(),n.autoCreateSession&&this.startNewSession()},startNewSession:function(){var e=this.getProfileId();if(e&&this.connection){var t=this.selectedResponseHandler||null;this.connection.invoke("StartSession",e,t).catch(function(e){return console.error(e)})}},initializeApp:function(){var e=this;this.inputElement=document.querySelector(n.inputElementSelector),this.buttonElement=document.querySelector(n.sendButtonElementSelector),this.chatContainer=document.querySelector(n.chatContainerElementSelector),this.placeholder=document.querySelector(n.placeholderElementSelector),this.placeholder&&this.responseHandlers.length>0&&this.renderHandlerSelector();var t=this.getSessionId();if(!n.widget&&t?this.loadSession(t):!n.autoCreateSession||n.widget||t||this.startNewSession(),n.sessionDocumentsEnabled&&n.documentBarSelector&&(this.documentBar=document.querySelector(n.documentBarSelector),this.documentBar)){this.renderDocumentBar();var i=document.createElement("input");i.type="file",i.id="ai-chat-doc-input",i.className="d-none",i.multiple=!0,n.allowedExtensions&&(i.accept=n.allowedExtensions),i.addEventListener("change",function(t){return e.handleFileInputChange(t)}),this.documentBar.parentElement.appendChild(i);var o=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;o&&(o.addEventListener("dragover",function(t){return e.handleDragOver(t)}),o.addEventListener("dragleave",function(t){return e.handleDragLeave(t)}),o.addEventListener("drop",function(t){return e.handleDrop(t)}))}this.chatContainer.addEventListener("scroll",function(){if(e.stream){var t=e.chatContainer.scrollHeight-e.chatContainer.clientHeight-e.chatContainer.scrollTop<=30;e.autoScroll=t}}),this.inputElement.addEventListener("keydown",function(t){null==e.stream&&("Enter"!==t.key||t.shiftKey||(t.preventDefault(),e.buttonElement.click()))}),this.inputElement.addEventListener("input",function(t){e.handleUserInput(t),t.target.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)}),this.buttonElement.addEventListener("click",function(){if(null==e.stream)e.sendMessage();else if(e.stream.dispose(),e.stream=null,e.streamingFinished(),e.hideTypingIndicator(),e.messages.length>0){var t=e.messages[e.messages.length-1];"assistant"!==t.role||t.content?t.isStreaming&&(t.isStreaming=!1):e.messages.pop()}});for(var a=document.getElementsByClassName("profile-generated-prompt"),s=0;s '+a,setTimeout(function(){t.innerHTML=''},2e3)}}}}),this.speechToTextEnabled&&n.micButtonElementSelector&&(this.micButton=document.querySelector(n.micButtonElementSelector),this.micButton&&(this.micButton.style.display="",this.micButton.addEventListener("click",function(){e.toggleRecording()}))),this.conversationModeEnabled&&n.conversationButtonElementSelector&&(this.conversationButton=document.querySelector(n.conversationButtonElementSelector),this.conversationButton&&this.conversationButton.addEventListener("click",function(){e.toggleConversationMode()}))},loadSession:function(e){this.connection.invoke("LoadSession",e).catch(function(e){return console.error(e)})},reloadCurrentSession:function(){var e=this.getSessionId();e&&this.loadSession(e)},initializeSession:function(e,t){this.isSessionStarted&&!t||(this.fireEvent(new CustomEvent("initializingSessionOpenAIChat",{detail:{sessionId:e}})),this.setSessionId(e),this.isSessionStarted=!0,this.widgetIsInitialized&&localStorage.setItem(this.chatWidgetStateSession,e))},initializeWidget:function(){var e=this;if(n.widget.chatWidgetContainer)if(n.widget.chatWidgetStateName){if(document.querySelector(n.widget.chatWidgetContainer)){if(n.widget.chatHistorySection&&(this.chatHistorySection=document.querySelector(n.widget.chatHistorySection)),this.chatWidgetStateName=n.widget.chatWidgetStateName,this.chatWidgetStateSession=n.widget.chatWidgetStateName+"Session",this.widgetIsInitialized=!0,this.reloadCurrentSession(),n.autoCreateSession&&!this.getSessionId()&&this.startNewSession(),n.widget.showHistoryButton&&this.chatHistorySection){var t=document.querySelector(n.widget.showHistoryButton);if(t&&t.addEventListener("click",function(){e.chatHistorySection.classList.toggle("show")}),n.widget.closeHistoryButton){var i=document.querySelector(n.widget.closeHistoryButton);i&&i.addEventListener("click",function(){e.showChatScreen()})}}if(n.widget.newChatButton){var o=document.querySelector(n.widget.newChatButton);o&&o.addEventListener("click",function(){e.resetSession(),e.showChatScreen()})}}}else console.error("The widget chatWidgetStateName is required.");else console.error("The widget chatWidgetContainer is required.")},showChatScreen:function(){this.chatHistorySection&&this.chatHistorySection.classList.remove("show")},getSessionId:function(){var e=this.inputElement.getAttribute("data-session-id");return!e&&this.widgetIsInitialized&&(e=localStorage.getItem(this.chatWidgetStateSession)),e},copyResponse:function(e){navigator.clipboard.writeText(e)},updateFeedbackIcons:function(e,t){if(e){var n=e.querySelector(".rate-up-btn"),i=e.querySelector(".rate-down-btn");if(n){var o=!0===t?"fa-solid fa-thumbs-up":"fa-regular fa-thumbs-up",a=document.createElement("i");a.className=o,a.style.fontSize="0.9rem",n.textContent="",n.appendChild(a)}if(i){var s=!1===t?"fa-solid fa-thumbs-down":"fa-regular fa-thumbs-down",r=document.createElement("i");r.className=s,r.style.fontSize="0.9rem",i.textContent="",i.appendChild(r)}window.FontAwesome&&FontAwesome.dom&&FontAwesome.dom.i2svg&&FontAwesome.dom.i2svg({node:e})}},refreshAllFeedbackIcons:function(){for(var e=this.$el.querySelectorAll(".ai-chat-message-assistant-feedback"),t=0;t3?(o=m===i)&&(c=r[(s=r[4])?5:(s=3,3)],r[4]=r[5]=e):r[0]<=f&&((o=n<2&&fi||i>m)&&(r[4]=n,r[5]=i,h.n=m,s=0))}if(o||n>1)return a;throw u=!0,i}return function(o,d,m){if(l>1)throw TypeError("Generator is already running");for(u&&1===d&&f(d,m),s=d,c=m;(t=s<2?e:c)||!u;){r||(s?s<3?(s>1&&(h.n=-1),f(s,c)):h.n=c:h.v=c);try{if(l=2,r){if(s||(o="next"),t=r[o]){if(!(t=t.call(r,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,s<2&&(s=0)}else 1===s&&(t=r.return)&&t.call(r),s<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),s=1);r=e}else if((t=(u=h.n<0)?c:n.call(i,h))!==a)break}catch(t){r=e,s=1,c=t}finally{l=1}}return{value:t,done:u}}}(n,o,r),!0),l}var a={};function s(){}function c(){}function l(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(_regeneratorDefine2(t={},i,function(){return this}),t),u=l.prototype=s.prototype=Object.create(d);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,l):(e.__proto__=l,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(u),e}return c.prototype=l,_regeneratorDefine2(u,"constructor",l),_regeneratorDefine2(l,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(l,o,"GeneratorFunction"),_regeneratorDefine2(u),_regeneratorDefine2(u,o,"Generator"),_regeneratorDefine2(u,i,function(){return this}),_regeneratorDefine2(u,"toString",function(){return"[object Generator]"}),(_regenerator=function(){return{w:r,m:h}})()}function _regeneratorDefine2(e,t,n,i){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,t,n,i){function r(t,n){_regeneratorDefine2(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!i,configurable:!i,writable:!i}):e[t]=n:(r("next",0),r("throw",1),r("return",2))},_regeneratorDefine2(e,t,n,i)}function asyncGeneratorStep(e,t,n,i,o,r,a){try{var s=e[r](a),c=s.value}catch(e){return void n(e)}s.done?t(c):Promise.resolve(c).then(i,o)}function _asyncToGenerator(e){return function(){var t=this,n=arguments;return new Promise(function(i,o){var r=e.apply(t,n);function a(e){asyncGeneratorStep(r,i,o,a,s,"next",e)}function s(e){asyncGeneratorStep(r,i,o,a,s,"throw",e)}a(void 0)})}}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,a=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){s=!0,r=e},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw r}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=Array(t);n\n
\n
\n
{{ userLabel }}
\n
\n \n {{ assistantLabel }}\n
\n
\n

{{ message.title }}

\n
\n \n \n \n \n
\n
\n
\n
\n
\n \n {{ notification.content }}\n \n
\n
\n \n
\n
\n \n ',indicatorTemplate:'\n
\n \n Assistant\n
\n '};function t(e){if(!e)return"";var t=e.trim();return/^javascript:/i.test(t)||/^vbscript:/i.test(t)||/^data:text\/html/i.test(t)?"":e}function n(e){var t=document.createElement("span");return t.textContent=e,t.innerHTML}var i=new marked.Renderer;i.link=function(e){var n=t(e.href);return n?'').concat(e.text,""):e.text||""},i.code=function(e){var t=e.text||"",i=(e.lang||"").trim(),o=t;if("undefined"!=typeof hljs)if(i&&hljs.getLanguage(i))try{o=hljs.highlight(t,{language:i}).value}catch(e){}else try{o=hljs.highlightAuto(t).value}catch(e){}else o=n(t);var r=i?n(i):"code";return'
'.concat(r,'
').concat(o,"
")},i.image=function(n){var i=t(n.href);if(!i)return"";var o=n.text||e.generatedImageAltText,r=e.generatedImageMaxWidth;return'
\n ').concat(o,'\n
\n \n \n \n
\n
')};var o=0,r=[];function a(e){if(e&&e._pendingCharts&&e._pendingCharts.length){var t,n=_createForOfIteratorHelper(e._pendingCharts);try{for(n.s();!(t=n.n()).done;){var i=t.value,o=document.getElementById(i.chartId);if(o)if("undefined"!=typeof Chart)try{var r;o._chartInstance&&o._chartInstance.destroy();var a="string"==typeof i.config?JSON.parse(i.config):i.config;null!==(r=a.options)&&void 0!==r||(a.options={}),a.options.responsive=!0,a.options.maintainAspectRatio=!1,o._chartInstance=new Chart(o,a)}catch(e){console.error("Error creating chart:",e)}else console.error("Chart.js is not available on the page.")}}catch(e){n.e(e)}finally{n.f()}e._pendingCharts=[]}}function s(e,t){r=[];var n=marked.parse(e,{renderer:i});return t._pendingCharts=r.length>0?_toConsumableArray(r):[],DOMPurify.sanitize(n,{ADD_ATTR:["target"]})}function c(e){var t=new URL(e,window.location.href),n=window.location.href,i=function(){if(!window.parent||window.parent===window)return null;try{return window.parent.location.href||null}catch(e){return document.referrer||null}}();return n&&t.searchParams.set("browserPageUrl",n),i&&t.searchParams.set("browserParentPageUrl",i),t.toString()}function l(e){if(!e)return null;var t=new URL(e,window.location.href);return t.origin!==window.location.origin?null:t}function d(e){var t=l(e);t?window.setTimeout(function(){(function(e){if(!window.parent||window.parent===window)return!1;var t=l(e);if(!t)return!1;try{return window.parent.location.assign(t.toString()),!0}catch(e){return window.parent.postMessage({type:"crestapps-ai-chat:navigate",url:t.toString()},window.location.origin),!0}})(t.toString())||window.location.assign(t.toString())},150):console.warn("Ignored live navigation to a non same-origin URL.",e)}function u(){if(window.parent&&window.parent!==window)try{if(window.parent.location&&window.parent.document)return window.parent}catch(e){}return window}function h(e,t){if(!e)return"";var n=e.replace(/\s+/g," ").trim();return n.length>t?n.substring(0,t):n}function f(e,t){if(!e)return!1;if(e.closest(".ai-chat-app, .ai-admin-widget, .chat-interaction-app, .chat-interaction"))return!1;var n=t.getComputedStyle(e);if("none"===n.display||"hidden"===n.visibility)return!1;var i=e.getBoundingClientRect();return i.width>0&&i.height>0}function m(){var e=u(),t=u().document,n=[],i=[],o=[],r=e.location.href;t.querySelectorAll("h1, h2, h3").forEach(function(t){if(f(t,e)&&!(o.length>=12)){var n=h(t.innerText||t.textContent,120);n&&o.push(n)}}),t.querySelectorAll("a[href]").forEach(function(t){if(f(t,e)&&!(n.length>=40)){var i=h(t.innerText||t.textContent||t.getAttribute("aria-label"),120),o=t.href?new URL(t.href,r).toString():"",a=t.closest("tr, li, article, section, .card, .list-group-item, .content-item"),s=h((a||t).innerText||(a||t).textContent,160);(i||o)&&n.push({text:i,href:o,context:s})}}),t.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]').forEach(function(t){if(f(t,e)&&!(i.length>=20)){var n=h(t.innerText||t.textContent||t.value||t.getAttribute("aria-label"),120);n&&i.push({text:n})}});var a=h(t.body&&t.body.innerText||"",1500);return JSON.stringify({url:r,title:t.title||"",isParentContext:e!==window,headings:o,links:n,buttons:i,textPreview:a})}marked.use({extensions:[{name:"chart",level:"block",start:function(e){var t=e.indexOf("[chart:");return t>=0?t:void 0},tokenizer:function(e){var t=function(e){var t="[chart:",n=e.indexOf(t);if(n<0)return null;var i=n+t.length,o=i;for(;o=e.length||"{"!==e[o])return null;for(var r=0,a=!1,s=!1;o')+'')+'
'+'
";var n,i}}]});return{initialize:function(t){var n=Object.assign({},e,t);if(e=n,n.signalRHubUrl)if(n.appElementSelector)if(n.chatContainerElementSelector)if(n.inputElementSelector){if(n.sendButtonElementSelector)return Vue.createApp({data:function(){return{inputElement:null,buttonElement:null,chatContainer:null,placeholder:null,isSessionStarted:!1,isPlaceholderVisible:!0,chatWidgetStateName:null,chatWidgetStateSession:null,chatHistorySection:null,widgetIsInitialized:!1,isStreaming:!1,isNavigatingAway:!1,autoScroll:!0,stream:null,messages:[],notifications:[],prompt:"",documents:n.existingDocuments||[],isUploading:!1,uploadErrors:[],isDragOver:!1,documentBar:null,metricsEnabled:!!n.metricsEnabled,userLabel:n.userLabel,assistantLabel:n.assistantLabel,thumbsUpTitle:n.thumbsUpTitle,thumbsDownTitle:n.thumbsDownTitle,copyTitle:n.copyTitle,isRecording:!1,mediaRecorder:null,preRecordingPrompt:"",micButton:null,speechToTextEnabled:"AudioInput"===n.chatMode||"Conversation"===n.chatMode,textToSpeechEnabled:"Conversation"===n.chatMode,ttsVoiceName:n.ttsVoiceName||null,audioChunks:[],audioPlayQueue:[],isPlayingAudio:!1,currentAudioElement:null,ttsButton:null,conversationModeEnabled:"Conversation"===n.chatMode,conversationButton:null,isConversationMode:!1,selectedResponseHandler:"",responseHandlers:n.responseHandlers||[]}},computed:{lastAssistantIndex:function(){for(var e=this.messages.length-1;e>=0;e--)if("assistant"===this.messages[e].role)return e;return-1}},methods:{handleBeforeUnload:function(){this.isNavigatingAway=!0},handleDragOver:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!0;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.add("ai-chat-drag-over")}},handleDragLeave:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!1;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.remove("ai-chat-drag-over")}},handleDrop:function(e){if(n.sessionDocumentsEnabled){e.preventDefault(),e.stopPropagation(),this.isDragOver=!1;var t=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;t&&t.classList.remove("ai-chat-drag-over"),e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length>0&&this.uploadFiles(e.dataTransfer.files)}},triggerFileInput:function(){if(n.sessionDocumentsEnabled){var e=document.getElementById("ai-chat-doc-input");e&&e.click()}},handleFileInputChange:function(e){var t=e.target.files;t&&t.length>0&&this.uploadFiles(t),e.target.value=""},uploadFiles:function(e){var t=this;return _asyncToGenerator(_regenerator().m(function i(){var o,r,a,s,c,l,d,u,h;return _regenerator().w(function(i){for(;;)switch(i.p=i.n){case 0:if(n.uploadDocumentUrl){i.n=1;break}return i.a(2);case 1:if(o=t.getSessionId(),r=t.getProfileId(),o||r){i.n=2;break}return console.warn("Cannot upload documents without a session or profile."),i.a(2);case 2:for(t.isUploading=!0,t.uploadErrors=[],t.renderDocumentBar(),i.p=3,a=new FormData,o?a.append("sessionId",o):a.append("profileId",r),s=0;s0)for(u=0;u0&&(t.uploadErrors=d.failed),i.n=9;break;case 8:i.p=8,h=i.v,console.error("Upload error:",h),t.uploadErrors=[{fileName:"",error:"Upload failed. Please try again."}];case 9:return i.p=9,t.isUploading=!1,t.renderDocumentBar(),i.f(9);case 10:return i.a(2)}},i,null,[[3,8,9,10]])}))()},removeDocument:function(e){var t=this;return _asyncToGenerator(_regenerator().m(function i(){var o,r,a,s,c;return _regenerator().w(function(i){for(;;)switch(i.p=i.n){case 0:if(n.removeDocumentUrl){i.n=1;break}return i.a(2);case 1:return i.p=1,o=t.getSessionId(),i.n=2,fetch(n.removeDocumentUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({itemId:o,documentId:e.documentId})});case 2:if(!(r=i.v).ok){i.n=3;break}(a=t.documents.indexOf(e))>-1&&t.documents.splice(a,1),i.n=5;break;case 3:return i.n=4,r.text();case 4:s=i.v,console.error("Failed to remove document:",r.status,s);case 5:i.n=7;break;case 6:i.p=6,c=i.v,console.error("Remove document error:",c);case 7:return i.a(2)}},i,null,[[1,6]])}))()},formatFileSize:function(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":(e/1048576).toFixed(1)+" MB"},renderHandlerSelector:function(){if(this.placeholder&&0!==this.responseHandlers.length&&!this.placeholder.querySelector(".ai-chat-handler-selector")){var e=document.createElement("div");e.className="ai-chat-handler-selector mt-2",e.style.cssText="font-size: 0.85rem;";var t=document.createElement("label");t.className="form-label mb-1",t.style.fontSize="0.8rem",t.textContent="Response Handler";var n=document.createElement("select");n.className="form-select form-select-sm";var i=document.createElement("option");i.value="",i.textContent="Default (AI)",n.appendChild(i);for(var o=0;o20&&(o=o.substring(0,17)+"..."),e+='',e+=' ',e+=this.escapeHtml(o),e+=' ',e+=""}for(var r=0;r15&&(s=s.substring(0,12)+"..."),e+='',e+=' ',e+=this.escapeHtml(s),e+=' ',e+=""}this.isUploading&&(e+='',e+=' Uploading...',e+=""),e+='",e+="",this.documentBar.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0}));for(var l=this,d=this.documentBar.querySelectorAll("[data-doc-index]"),u=0;u/g,">")+"

";e._conversationPartialMessage?(e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent=o):(e.hidePlaceholder(),e._conversationPartialMessage={role:"user",content:n,htmlContent:o,isPartial:!0},e.messages.push(e._conversationPartialMessage)),e.scrollToBottom()}}else n&&!e._audioInputSent&&(e.prompt=e.preRecordingPrompt+n,e.inputElement&&(e.inputElement.value=e.prompt,e.inputElement.dispatchEvent(new Event("input"))))}),e.connection.on("ReceiveConversationUserMessage",function(t,n){if(n){if(e.stopAudio(),e._conversationAssistantMessage){var i=e.messages[e._conversationAssistantMessage.index];i&&(i.isStreaming=!1),e._conversationAssistantMessage=null}if(e._conversationPartialMessage){var o=n.replace(/&/g,"&").replace(//g,">");e._conversationPartialMessage.content=n,e._conversationPartialMessage.htmlContent="

"+o+"

",e._conversationPartialMessage.isPartial=!1,e._conversationPartialMessage=null}else e.addMessage({role:"user",content:n});e.scrollToBottom()}}),e.connection.on("ReceiveConversationAssistantToken",function(t,n,i,o){if(!e._conversationAssistantMessage){e.stopAudio(),e.hideTypingIndicator();for(var r=0;r".concat(g._displayIndex,"
"))}}catch(e){m.e(e)}finally{m.f()}n=n.replaceAll("",","),n+="

";var p,v=_createForOfIteratorHelper(i);try{for(v.s();!(p=v.n()).done;){var y=_slicedToArray(p.value,2),b=y[0],S=y[1],w=S.text||b;n+=S.link?"**".concat(S._displayIndex,"**. [").concat(w,"](").concat(S.link,")
"):"**".concat(S._displayIndex,"**. ").concat(w,"
")}}catch(e){v.e(e)}finally{v.f()}}}e.content=n,e.htmlContent=s(n,e)}this.addMessageInternal(e),this.hidePlaceholder(),this.$nextTick(function(){a(e),t.scrollToBottom()})},addMessages:function(e){for(var t=this,n=0;n0&&(a=a.then(_asyncToGenerator(_regenerator().m(function t(){var n,o,r,a;return _regenerator().w(function(t){for(;;)switch(t.n){case 0:return t.n=1,e.data.arrayBuffer();case 1:n=t.v,o=new Uint8Array(n),r=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),a=btoa(r),i.next(a);case 2:return t.a(2)}},t)}))))}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),a.then(function(){return i.complete()})});var s=document.documentElement.lang||"en-US";e.connection.send("SendAudioStream",o,r,i,n,s),e.mediaRecorder.start(1e3),e.isRecording=!0,e.updateMicButton()}).catch(function(e){console.error("Microphone access denied:",e)})},stopRecording:function(){this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1,this.updateMicButton())},toggleRecording:function(){this.isRecording?this.stopRecording():this.startRecording()},updateMicButton:function(){this.micButton&&(this.isRecording?(this.micButton.classList.add("stt-recording"),this.micButton.innerHTML=''):(this.micButton.classList.remove("stt-recording"),this.micButton.innerHTML=''))},streamMessage:function(e,t,n){var i=this;this.stream&&(this.stream.dispose(),this.stream=null),this.streamingStarted(),this.showTypingIndicator(),this.autoScroll=!0;var o="",r={},c=null,l=this.messages.length,d=this.getSessionId(),u=m();this.stream=this.connection.stream("SendMessage",e,t,d,n,u).subscribe({next:function(e){var t=i.messages[l];if(!t){e.sessionId&&!d&&i.initializeSession(e.sessionId),i.hideTypingIndicator(),l=i.messages.length;var n={id:e.messageId,role:"assistant",title:e.title,content:"",htmlContent:"",isStreaming:!0,userRating:null};i.messages.push(n),t=n}if(!e.title||t.title&&t.title===e.title||(t.title=e.title),e.references&&"object"===_typeof(e.references)&&Object.keys(e.references).length)for(var u=0,h=Object.entries(e.references);u".concat(w.index,"
"))}o+=p.replaceAll("",",")}t.content=o,t.htmlContent=s(o,t),i.messages[l]=t,i.$nextTick(function(){a(t),i.scrollToBottom()})},complete:function(){var e;i.processReferences(r,l),i.streamingFinished();var t=i.messages[l];t&&(t.isStreaming=!1),t&&t.content||i.hideTypingIndicator(),i.isConversationMode&&i.textToSpeechEnabled&&t&&t.content&&i.synthesizeSpeech(t.content),null===(e=i.stream)||void 0===e||e.dispose(),i.stream=null},error:function(e){var t;i.processReferences(r,l),i.streamingFinished();var n=i.messages[l];n&&(n.isStreaming=!1),i.hideTypingIndicator(),i.isNavigatingAway||i.addMessage(i.getServiceDownMessage()),null===(t=i.stream)||void 0===t||t.dispose(),i.stream=null,console.error("Stream error:",e)}})},getServiceDownMessage:function(){return{role:"assistant",content:"Our service is currently unavailable. Please try again later. We apologize for the inconvenience.",htmlContent:""}},processReferences:function(e,t){if(Object.keys(e).length){var n=this.messages[t],i=n.content||"",o=Object.entries(e).filter(function(e){var t=_slicedToArray(e,2),n=t[0],o=t[1];return i.includes(n)||i.includes("".concat(o.index,""))});if(!o.length)return;o.sort(function(e,t){var n=_slicedToArray(e,2)[1],i=_slicedToArray(t,2)[1];return n.index-i.index});var r,a=i.trim(),c=1,l=_createForOfIteratorHelper(o);try{for(l.s();!(r=l.n()).done;){var d=_slicedToArray(r.value,2),u=d[0],h=d[1],f="__CITE_".concat(h.index,"__");a=(a=a.replaceAll(u,f)).replaceAll("".concat(h.index,""),f),h._displayIndex=c++,h._placeholder=f}}catch(e){l.e(e)}finally{l.f()}var m,g=_createForOfIteratorHelper(o);try{for(g.s();!(m=g.n()).done;){var p=_slicedToArray(m.value,2)[1];a=a.replaceAll(p._placeholder,"".concat(p._displayIndex,""))}}catch(e){g.e(e)}finally{g.f()}a=a.replaceAll("",","),a+="

";var v,y=_createForOfIteratorHelper(o);try{for(y.s();!(v=y.n()).done;){var b=_slicedToArray(v.value,2),S=b[0],w=b[1],A=w.text||S;a+=w.link?"**".concat(w._displayIndex,"**. [").concat(A,"](").concat(w.link,")
"):"**".concat(w._displayIndex,"**. ").concat(A,"
")}}catch(e){y.e(e)}finally{y.f()}n.content=a,n.htmlContent=s(a,n),this.messages[t]=n,this.scrollToBottom()}},streamingStarted:function(){var e=this.buttonElement.getAttribute("data-stop-icon");e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.inputElement&&this.inputElement.setAttribute("disabled","disabled")},streamingFinished:function(){var e=this.buttonElement.getAttribute("data-start-icon");if(e&&this.buttonElement.replaceChildren(DOMPurify.sanitize(e,{RETURN_DOM_FRAGMENT:!0})),this.inputElement&&(this.inputElement.removeAttribute("disabled"),this.inputElement.focus()),this.chatContainer)for(var t=this.chatContainer.querySelectorAll(".ai-streaming-icon"),n=0;n0){var e=this.audioPlayQueue.shift();this.playAudioBlob(e)}else this.isPlayingAudio=!1,this.conversationModeOnAudioEnded()},stopAudio:function(){this.currentAudioElement&&(this.currentAudioElement.pause(),this.currentAudioElement.currentTime=0,this.currentAudioElement=null),this.audioChunks=[],this.audioPlayQueue=[],this.isPlayingAudio=!1},toggleConversationMode:function(){this.isConversationMode?this.stopConversationMode():this.startConversationMode()},startConversationMode:function(){var e=this;this.conversationModeEnabled&&!this.isConversationMode&&this.connection&&(this.isConversationMode=!0,this.updateConversationButton(),this._conversationPartialTranscript="",this._conversationAssistantMessage=null,this._conversationPartialMessage=null,this.removeNotification("conversation-ended"),navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}}).then(function(t){var n=MediaRecorder.isTypeSupported("audio/ogg;codecs=opus")?"audio/ogg;codecs=opus":MediaRecorder.isTypeSupported("audio/webm;codecs=opus")?"audio/webm;codecs=opus":"audio/webm";e.mediaRecorder=new MediaRecorder(t,{mimeType:n,audioBitsPerSecond:128e3}),e._conversationSubject=new signalR.Subject,e._conversationStream=t;var i=window.AudioContext||window.webkitAudioContext;i&&(e._conversationAudioCtx=new i,e._conversationAnalyser=e._conversationAudioCtx.createAnalyser(),e._conversationAnalyser.fftSize=256,e._conversationAudioCtx.createMediaStreamSource(t).connect(e._conversationAnalyser));var o=Promise.resolve(),r=e._conversationAnalyser;e.mediaRecorder.addEventListener("dataavailable",function(t){if(t.data&&t.data.size>0){if(e.isPlayingAudio&&r){var n=new Uint8Array(r.frequencyBinCount);r.getByteFrequencyData(n);for(var i=0,a=0;a=30&&e.stopAudio()}o=o.then(_asyncToGenerator(_regenerator().m(function n(){var i,o,r,a;return _regenerator().w(function(n){for(;;)switch(n.n){case 0:return n.n=1,t.data.arrayBuffer();case 1:i=n.v,o=new Uint8Array(i),r=o.reduce(function(e,t){return e+String.fromCharCode(t)},""),a=btoa(r);try{e._conversationSubject.next(a)}catch(e){}case 2:return n.a(2)}},n)})))}}),e.mediaRecorder.addEventListener("stop",function(){t.getTracks().forEach(function(e){return e.stop()}),o.then(function(){try{e._conversationSubject.complete()}catch(e){}})});var a=e.getProfileId(),s=e.getSessionId()||"",c=document.documentElement.lang||"en-US";e.connection.send("StartConversation",a,s,e._conversationSubject,n,c),e.mediaRecorder.start(1e3),e.isRecording=!0}).catch(function(t){console.error("Microphone access denied:",t),e.isConversationMode=!1,e.updateConversationButton()}))},stopConversationMode:function(){if(this.isConversationMode){if(this.isConversationMode=!1,this.updateConversationButton(),this.connection&&this.connection.invoke("StopConversation").catch(function(){}),this.isRecording&&this.mediaRecorder&&(this.mediaRecorder.stop(),this.isRecording=!1),this.stopAudio(),this._conversationPartialTranscript="",this._conversationPartialMessage=null,this._conversationAudioCtx&&(this._conversationAudioCtx.close().catch(function(){}),this._conversationAudioCtx=null,this._conversationAnalyser=null),this._conversationAssistantMessage){var e=this.messages[this._conversationAssistantMessage.index];e&&(e.isStreaming=!1),this._conversationAssistantMessage=null}for(var t=0;t=0?this.notifications.splice(n,1,e):this.notifications.push(e),this.$nextTick(function(){t.scrollToBottom()})}},updateNotification:function(e){if(e&&e.type){var t=this.notifications.findIndex(function(t){return t.type===e.type});t>=0&&this.notifications.splice(t,1,e)}},removeNotification:function(e){this.notifications=this.notifications.filter(function(t){return t.type!==e})},dismissNotification:function(e){this.removeNotification(e)},handleNotificationAction:function(e,t){if(this.connection){var n=this.getSessionId();this.connection.invoke("HandleNotificationAction",n,e,t).catch(function(e){console.error("Error handling notification action:",e)})}},scrollToBottom:function(){var e=this;this.autoScroll&&setTimeout(function(){e.chatContainer.scrollTop=e.chatContainer.scrollHeight-e.chatContainer.clientHeight},50)},handleUserInput:function(e){this.prompt=e.target.value},getProfileId:function(){return this.inputElement.getAttribute("data-profile-id")},setSessionId:function(e){this.inputElement.setAttribute("data-session-id",e||"")},resetSession:function(){this.stopRecording(),this.setSessionId(""),this.isSessionStarted=!1,this.sessionRating=null,this.widgetIsInitialized&&localStorage.removeItem(this.chatWidgetStateSession),this.messages=[],this.documents=[],n.autoCreateSession||this.showPlaceholder(),n.autoCreateSession&&this.startNewSession()},startNewSession:function(){var e=this.getProfileId();if(e&&this.connection){var t=this.selectedResponseHandler||null;this.connection.invoke("StartSession",e,t).catch(function(e){return console.error(e)})}},initializeApp:function(){var e=this;this.inputElement=document.querySelector(n.inputElementSelector),this.buttonElement=document.querySelector(n.sendButtonElementSelector),this.chatContainer=document.querySelector(n.chatContainerElementSelector),this.placeholder=document.querySelector(n.placeholderElementSelector),this.placeholder&&this.responseHandlers.length>0&&this.renderHandlerSelector();var t=this.getSessionId();if(!n.widget&&t?this.loadSession(t):!n.autoCreateSession||n.widget||t||this.startNewSession(),n.sessionDocumentsEnabled&&n.documentBarSelector&&(this.documentBar=document.querySelector(n.documentBarSelector),this.documentBar)){this.renderDocumentBar();var i=document.createElement("input");i.type="file",i.id="ai-chat-doc-input",i.className="d-none",i.multiple=!0,n.allowedExtensions&&(i.accept=n.allowedExtensions),i.addEventListener("change",function(t){return e.handleFileInputChange(t)}),this.documentBar.parentElement.appendChild(i);var o=this.inputElement?this.inputElement.closest(".ai-admin-widget-input, .text-bg-light"):null;o&&(o.addEventListener("dragover",function(t){return e.handleDragOver(t)}),o.addEventListener("dragleave",function(t){return e.handleDragLeave(t)}),o.addEventListener("drop",function(t){return e.handleDrop(t)}))}this.chatContainer.addEventListener("scroll",function(){if(e.stream){var t=e.chatContainer.scrollHeight-e.chatContainer.clientHeight-e.chatContainer.scrollTop<=30;e.autoScroll=t}}),this.inputElement.addEventListener("keydown",function(t){null==e.stream&&("Enter"!==t.key||t.shiftKey||(t.preventDefault(),e.buttonElement.click()))}),this.inputElement.addEventListener("input",function(t){e.handleUserInput(t),t.target.value.trim()?e.buttonElement.removeAttribute("disabled"):e.buttonElement.setAttribute("disabled",!0)}),this.buttonElement.addEventListener("click",function(){if(null==e.stream)e.sendMessage();else if(e.stream.dispose(),e.stream=null,e.streamingFinished(),e.hideTypingIndicator(),e.messages.length>0){var t=e.messages[e.messages.length-1];"assistant"!==t.role||t.content?t.isStreaming&&(t.isStreaming=!1):e.messages.pop()}});for(var r=document.getElementsByClassName("profile-generated-prompt"),a=0;a '+r,setTimeout(function(){t.innerHTML=''},2e3)}}}}),this.speechToTextEnabled&&n.micButtonElementSelector&&(this.micButton=document.querySelector(n.micButtonElementSelector),this.micButton&&(this.micButton.style.display="",this.micButton.addEventListener("click",function(){e.toggleRecording()}))),this.conversationModeEnabled&&n.conversationButtonElementSelector&&(this.conversationButton=document.querySelector(n.conversationButtonElementSelector),this.conversationButton&&this.conversationButton.addEventListener("click",function(){e.toggleConversationMode()}))},loadSession:function(e){this.connection.invoke("LoadSession",e).catch(function(e){return console.error(e)})},reloadCurrentSession:function(){var e=this.getSessionId();e&&this.loadSession(e)},initializeSession:function(e,t){this.isSessionStarted&&!t||(this.fireEvent(new CustomEvent("initializingSessionOpenAIChat",{detail:{sessionId:e}})),this.setSessionId(e),this.isSessionStarted=!0,this.widgetIsInitialized&&localStorage.setItem(this.chatWidgetStateSession,e))},initializeWidget:function(){var e=this;if(n.widget.chatWidgetContainer)if(n.widget.chatWidgetStateName){if(document.querySelector(n.widget.chatWidgetContainer)){if(n.widget.chatHistorySection&&(this.chatHistorySection=document.querySelector(n.widget.chatHistorySection)),this.chatWidgetStateName=n.widget.chatWidgetStateName,this.chatWidgetStateSession=n.widget.chatWidgetStateName+"Session",this.widgetIsInitialized=!0,this.reloadCurrentSession(),n.autoCreateSession&&!this.getSessionId()&&this.startNewSession(),n.widget.showHistoryButton&&this.chatHistorySection){var t=document.querySelector(n.widget.showHistoryButton);if(t&&t.addEventListener("click",function(){e.chatHistorySection.classList.toggle("show")}),n.widget.closeHistoryButton){var i=document.querySelector(n.widget.closeHistoryButton);i&&i.addEventListener("click",function(){e.showChatScreen()})}}if(n.widget.newChatButton){var o=document.querySelector(n.widget.newChatButton);o&&o.addEventListener("click",function(){e.resetSession(),e.showChatScreen()})}}}else console.error("The widget chatWidgetStateName is required.");else console.error("The widget chatWidgetContainer is required.")},showChatScreen:function(){this.chatHistorySection&&this.chatHistorySection.classList.remove("show")},getSessionId:function(){var e=this.inputElement.getAttribute("data-session-id");return!e&&this.widgetIsInitialized&&(e=localStorage.getItem(this.chatWidgetStateSession)),e},copyResponse:function(e){navigator.clipboard.writeText(e)},updateFeedbackIcons:function(e,t){if(e){var n=e.querySelector(".rate-up-btn"),i=e.querySelector(".rate-down-btn");if(n){var o=!0===t?"fa-solid fa-thumbs-up":"fa-regular fa-thumbs-up",r=document.createElement("i");r.className=o,r.style.fontSize="0.9rem",n.textContent="",n.appendChild(r)}if(i){var a=!1===t?"fa-solid fa-thumbs-down":"fa-regular fa-thumbs-down",s=document.createElement("i");s.className=a,s.style.fontSize="0.9rem",i.textContent="",i.appendChild(s)}window.FontAwesome&&FontAwesome.dom&&FontAwesome.dom.i2svg&&FontAwesome.dom.i2svg({node:e})}},refreshAllFeedbackIcons:function(){for(var e=this.$el.querySelectorAll(".ai-chat-message-assistant-feedback"),t=0;t(() => + BrowserAutomationService.ResolveRequestedSessionId(AgentConstants.DefaultSessionId, [])); + + Assert.Contains("startBrowserSession", exception.Message, StringComparison.Ordinal); + Assert.Contains("default", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveRequestedSessionId_WhenDefaultAlias_ShouldReturnMostRecentlyTouchedSession() + { + var oldest = CreateSession("oldest", new DateTime(2026, 03, 20, 12, 00, 00, DateTimeKind.Utc)); + var newest = CreateSession("newest", new DateTime(2026, 03, 20, 13, 00, 00, DateTimeKind.Utc)); + + var actual = BrowserAutomationService.ResolveRequestedSessionId(AgentConstants.DefaultSessionId, [oldest, newest]); + + Assert.Equal("newest", actual); + } + + [Fact] + public void ResolveRequestedSessionId_WhenExplicitSessionId_ShouldReturnItUnchanged() + { + var actual = BrowserAutomationService.ResolveRequestedSessionId("my-session", []); + + Assert.Equal("my-session", actual); + } + + [Fact] + public void ResolveBootstrapUrl_WhenParentPageUrlExists_ShouldPreferIt() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?browserPageUrl=https%3A%2F%2Fexample.com%2Fchat&browserParentPageUrl=https%3A%2F%2Fexample.com%2Fadmin%2Fsearch%2Findexes"); + + var actual = BrowserAutomationService.ResolveBootstrapUrl(httpContext); + + Assert.Equal("https://example.com/admin/search/indexes", actual); + } + + [Fact] + public void ResolveBootstrapUrl_WhenParentPageUrlMissing_ShouldUsePageUrl() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?browserPageUrl=https%3A%2F%2Fexample.com%2Fchat"); + + var actual = BrowserAutomationService.ResolveBootstrapUrl(httpContext); + + Assert.Equal("https://example.com/chat", actual); + } + + [Fact] + public void ResolveBootstrapUrl_WhenQueryMissing_ShouldFallBackToReferer() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Referer = "https://example.com/admin/content"; + + var actual = BrowserAutomationService.ResolveBootstrapUrl(httpContext); + + Assert.Equal("https://example.com/admin/content", actual); + } + + private static BrowserAutomationSession CreateSession(string sessionId, DateTime lastTouchedUtc) + { + var session = new BrowserAutomationSession( + sessionId, + "chromium", + true, + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DateTime(2026, 03, 20, 11, 00, 00, DateTimeKind.Utc)); + + session.Touch(lastTouchedUtc); + + return session; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs index 88b89ca2b..9e74a465d 100644 --- a/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserAutomationStartupTests.cs @@ -1,5 +1,6 @@ using CrestApps.OrchardCore.AI; -using CrestApps.OrchardCore.AI.Agent.BrowserAutomation; +using CrestApps.OrchardCore.AI.Agent; +using CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; @@ -15,20 +16,21 @@ public void ConfigureServices_RegistersExpectedSelectableBrowserTools() services.AddOptions(); services.AddLogging(); - var startup = new BrowserAutomationStartup(new PassthroughStringLocalizer()); + var startup = new CrestApps.OrchardCore.AI.Agent.BrowserAutomationFeatureStartup( + new PassthroughStringLocalizer()); startup.ConfigureServices(services); using var serviceProvider = services.BuildServiceProvider(); var definitions = serviceProvider.GetRequiredService>().Value; var selectableTools = definitions.Tools - .Where(x => !x.Value.IsSystemTool) + .Where(x => !x.Value.IsSystemTool && x.Value.Category.StartsWith("Browser ", StringComparison.Ordinal)) .ToDictionary(x => x.Key, x => x.Value); - Assert.Equal(37, selectableTools.Count); + Assert.Equal(38, selectableTools.Count); Assert.Equal(7, selectableTools.Count(x => x.Value.Category == "Browser Sessions")); - Assert.Equal(6, selectableTools.Count(x => x.Value.Category == "Browser Navigation")); + Assert.Equal(7, selectableTools.Count(x => x.Value.Category == "Browser Navigation")); Assert.Equal(7, selectableTools.Count(x => x.Value.Category == "Browser Inspection")); Assert.Equal(4, selectableTools.Count(x => x.Value.Category == "Browser Interaction")); Assert.Equal(6, selectableTools.Count(x => x.Value.Category == "Browser Forms")); @@ -37,10 +39,32 @@ public void ConfigureServices_RegistersExpectedSelectableBrowserTools() Assert.Contains(StartBrowserSessionTool.TheName, selectableTools.Keys); Assert.Contains(NavigateBrowserTool.TheName, selectableTools.Keys); + Assert.Contains(NavigateBrowserMenuTool.TheName, selectableTools.Keys); Assert.Contains(WaitForBrowserElementTool.TheName, selectableTools.Keys); Assert.Contains(DiagnoseBrowserPageTool.TheName, selectableTools.Keys); } + [Fact] + public void ConfigureServices_BaseStartup_DoesNotRegisterSelectableBrowserTools() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddLogging(); + + var startup = new CrestApps.OrchardCore.AI.Agent.Startup( + new PassthroughStringLocalizer()); + + startup.ConfigureServices(services); + + using var serviceProvider = services.BuildServiceProvider(); + var definitions = serviceProvider.GetRequiredService>().Value; + var selectableTools = definitions.Tools + .Where(x => !x.Value.IsSystemTool && x.Value.Category.StartsWith("Browser ", StringComparison.Ordinal)) + .ToDictionary(x => x.Key, x => x.Value); + + Assert.Empty(selectableTools); + } + private sealed class PassthroughStringLocalizer : IStringLocalizer { public LocalizedString this[string name] diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserNavigationPathParserTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserNavigationPathParserTests.cs new file mode 100644 index 000000000..0dbb819a6 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/BrowserNavigationPathParserTests.cs @@ -0,0 +1,28 @@ +using CrestApps.OrchardCore.AI.Agent.Tools.BrowserAutomation; + +namespace CrestApps.OrchardCore.Tests.Agent.BrowserAutomation; + +public sealed class BrowserNavigationPathParserTests +{ + [Theory] + [InlineData("Search >> Indexes", "Search|Indexes")] + [InlineData("Search > Indexes > Default", "Search|Indexes|Default")] + [InlineData("Search/Indexes", "Search|Indexes")] + [InlineData(@"Search\Indexes", "Search|Indexes")] + [InlineData("Search » Indexes", "Search|Indexes")] + [InlineData(" Content Definitions ", "Content Definitions")] + public void Split_ShouldReturnNormalizedSegments(string path, string expectedSegments) + { + var actual = BrowserNavigationPathParser.Split(path); + + Assert.Equal(expectedSegments.Split('|', StringSplitOptions.RemoveEmptyEntries), actual); + } + + [Fact] + public void Split_WhenPathIsBlank_ShouldReturnEmptyArray() + { + var actual = BrowserNavigationPathParser.Split(" "); + + Assert.Empty(actual); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/LivePageContextPromptBuilderTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/LivePageContextPromptBuilderTests.cs new file mode 100644 index 000000000..f3f6df66d --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Agent/BrowserAutomation/LivePageContextPromptBuilderTests.cs @@ -0,0 +1,50 @@ +using CrestApps.OrchardCore.AI; + +namespace CrestApps.OrchardCore.Tests.Agent.BrowserAutomation; + +public sealed class LivePageContextPromptBuilderTests +{ + [Fact] + public void Append_WhenContextExists_ShouldAppendVisiblePageSummary() + { + using var scope = AIInvocationScope.Begin(); + LivePageContextPromptBuilder.Store(scope.Context, """ + { + "url": "https://example.com/admin/indexes", + "title": "Indexes", + "isParentContext": true, + "headings": ["Indexes"], + "links": [ + { + "text": "Edit", + "href": "https://example.com/admin/indexes/chat-docs/edit", + "context": "chat-docs Edit Delete" + } + ], + "buttons": [ + { + "text": "Add Index" + } + ], + "textPreview": "chat-docs listed on the page" + } + """); + + var actual = LivePageContextPromptBuilder.Append("go to the edit page for chat-docs index", scope.Context); + + Assert.Contains("[Current visible page context]", actual, StringComparison.Ordinal); + Assert.Contains("https://example.com/admin/indexes/chat-docs/edit", actual, StringComparison.Ordinal); + Assert.Contains("chat-docs Edit Delete", actual, StringComparison.Ordinal); + } + + [Fact] + public void Store_WhenJsonIsInvalid_ShouldIgnoreIt() + { + using var scope = AIInvocationScope.Begin(); + LivePageContextPromptBuilder.Store(scope.Context, "{ invalid json"); + + var actual = LivePageContextPromptBuilder.Append("go somewhere", scope.Context); + + Assert.Equal("go somewhere", actual); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/Communications/SendEmailToolTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/Communications/SendEmailToolTests.cs index 7770a3dfc..7ab4b709e 100644 --- a/tests/CrestApps.OrchardCore.Tests/Agent/Communications/SendEmailToolTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Agent/Communications/SendEmailToolTests.cs @@ -1,4 +1,4 @@ -using CrestApps.OrchardCore.AI.Agent.Communications; +using CrestApps.OrchardCore.AI.Agent.Tools.Communications; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.AI; diff --git a/tests/CrestApps.OrchardCore.Tests/Agent/Contents/GetContentItemLinkToolTests.cs b/tests/CrestApps.OrchardCore.Tests/Agent/Contents/GetContentItemLinkToolTests.cs index d4c430033..c739a4ca2 100644 --- a/tests/CrestApps.OrchardCore.Tests/Agent/Contents/GetContentItemLinkToolTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Agent/Contents/GetContentItemLinkToolTests.cs @@ -1,4 +1,4 @@ -using CrestApps.OrchardCore.AI.Agent.Contents; +using CrestApps.OrchardCore.AI.Agent.Tools.Contents; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI;