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/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 d8652863c..7c263951a 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);
@@ -478,6 +478,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)
@@ -487,7 +489,7 @@ private async Task ProcessChatPromptAsync(ChannelWriter();
var completionService = services.GetRequiredService();
+ var effectivePrompt = LivePageContextPromptBuilder.Append(prompt, AIInvocationScope.Current);
var messageId = IdGenerator.GenerateId();
@@ -665,7 +668,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))
{
@@ -683,6 +686,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
{
@@ -1072,7 +1121,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 38e40c96a..628b81606 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))
{
@@ -418,6 +419,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
@@ -445,6 +447,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();
@@ -454,7 +458,7 @@ private async Task HandlePromptAsync(ChannelWriter wri
var handlerContext = new ChatResponseHandlerContext
{
- Prompt = prompt,
+ Prompt = effectivePrompt,
ConnectionId = Context.ConnectionId,
SessionId = interaction.ItemId,
ChatType = ChatContextType.ChatInteraction,
@@ -549,6 +553,8 @@ private async Task HandlePromptAsync(ChannelWriter wri
{
await interactionManager.UpdateAsync(interaction);
}
+
+ await NavigateCallerIfRequestedAsync(invocationScope.Context);
}
catch (Exception ex)
{
@@ -582,6 +588,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))
@@ -911,7 +963,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 db0c2aed0..ded0a5727 100644
--- a/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md
+++ b/src/CrestApps.OrchardCore.Documentations/docs/ai/agent.md
@@ -34,6 +34,64 @@ 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. |
+
+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** 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:
+
+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 548ed85df..1fe63485f 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`)
@@ -182,6 +189,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/AgentConstants.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/AgentConstants.cs
new file mode 100644
index 000000000..f47645b00
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/AgentConstants.cs
@@ -0,0 +1,25 @@
+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/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/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/Services/BrowserAutomationJson.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationJson.cs
new file mode 100644
index 000000000..c66b2dc19
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationJson.cs
@@ -0,0 +1,18 @@
+using System.Text.Json;
+
+namespace CrestApps.OrchardCore.AI.Agent.Services;
+
+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/Services/BrowserAutomationPage.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationPage.cs
new file mode 100644
index 000000000..cd57586d9
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationPage.cs
@@ -0,0 +1,32 @@
+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/Services/BrowserAutomationResultFactory.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationResultFactory.cs
new file mode 100644
index 000000000..01761f034
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationResultFactory.cs
@@ -0,0 +1,10 @@
+namespace CrestApps.OrchardCore.AI.Agent.Services;
+
+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/Services/BrowserAutomationService.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationService.cs
new file mode 100644
index 000000000..79b1db81d
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationService.cs
@@ -0,0 +1,668 @@
+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/Services/BrowserAutomationSession.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationSession.cs
new file mode 100644
index 000000000..8de8ce40e
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Services/BrowserAutomationSession.cs
@@ -0,0 +1,53 @@
+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/Tools/BrowserAutomation/BrowserAutomationToolBase.cs b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationToolBase.cs
new file mode 100644
index 000000000..0e2e419d2
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Agent/Tools/BrowserAutomation/BrowserAutomationToolBase.cs
@@ -0,0 +1,263 @@
+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 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 = AgentConstants.DefaultTimeoutMs)
+ {
+ var timeout = arguments.TryGetFirst("timeoutMs", out var parsedTimeout)
+ ? parsedTimeout
+ : fallbackValue;
+
+ return Math.Clamp(timeout, 1_000, AgentConstants.MaxTimeoutMs);
+ }
+
+ 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, AgentConstants.MaxCollectionItems);
+ }
+
+ protected static int GetMaxTextLength(AIFunctionArguments arguments, int fallbackValue = AgentConstants.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)
+ => GetOptionalString(arguments, "sessionId") ?? AgentConstants.DefaultSessionId;
+
+ 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 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