Add IWebElementLocator

This commit is contained in:
hchen 2025-09-30 16:39:58 -05:00
parent f4b669d229
commit 00e25026fb
15 changed files with 315 additions and 32 deletions

View file

@ -5,6 +5,9 @@ namespace BotSharp.Abstraction.Browsing.Models;
[DebuggerStepThrough] [DebuggerStepThrough]
public class ElementLocatingArgs public class ElementLocatingArgs
{ {
[JsonPropertyName("element_locator_desc")]
public string ElementLocatorDescription { get; set; } = string.Empty;
[JsonPropertyName("match_rule")] [JsonPropertyName("match_rule")]
public string MatchRule { get; set; } = string.Empty; public string MatchRule { get; set; } = string.Empty;
@ -26,6 +29,8 @@ public class ElementLocatingArgs
[JsonPropertyName("selector")] [JsonPropertyName("selector")]
public string? Selector { get; set; } public string? Selector { get; set; }
public ElementPosition? Position { get; set; }
public bool Parent { get; set; } public bool Parent { get; set; }
public bool FailIfMultiple { get; set; } public bool FailIfMultiple { get; set; }

View file

@ -0,0 +1,8 @@
using BotSharp.Abstraction.Browsing.Models;
namespace BotSharp.Abstraction.Browsing.Settings;
public interface IWebElementLocator
{
Task<ElementPosition> DetectElementCoordinates(IWebBrowser browser, string contextId, string elementDescription);
}

View file

@ -16,8 +16,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-web-action_on_element.fn.liquid" />
<None Remove="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-web-go_to_page.fn.liquid" />
<None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\instructions\instruction.liquid" /> <None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\instructions\instruction.liquid" />
<None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\templates\extract_data.liquid" /> <None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\templates\extract_data.liquid" />
<None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\templates\html_parser.liquid" /> <None Remove="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\templates\html_parser.liquid" />
@ -28,6 +26,9 @@
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-action_on_element.json"> <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-action_on_element.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-take_screenshot.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-close_browser.json"> <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-close_browser.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
@ -37,12 +38,6 @@
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-locate_element.json"> <Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\functions\util-web-locate_element.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-web-action_on_element.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\6745151e-6d46-4a02-8de4-1c4f21c7da95\templates\util-web-go_to_page.fn.liquid">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\agent.json"> <Content Include="data\agents\f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b\agent.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>

View file

@ -8,9 +8,12 @@ public partial class PlaywrightWebDriver
var result = await LocateElement(message, location); var result = await LocateElement(message, location);
if (result.IsSuccess) if (result.IsSuccess)
{ {
action.Position = location.Position;
await DoAction(message, action, result); await DoAction(message, action, result);
result.UrlAfterAction = _instance.GetPage(message.ContextId)?.Url;
} }
result.UrlAfterAction = _instance.GetPage(message.ContextId)?.Url;
return result; return result;
} }
} }

View file

@ -1,5 +1,6 @@
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Xml.Linq;
namespace BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver; namespace BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
@ -8,33 +9,66 @@ public partial class PlaywrightWebDriver
public async Task DoAction(MessageInfo message, ElementActionArgs action, BrowserActionResult result) public async Task DoAction(MessageInfo message, ElementActionArgs action, BrowserActionResult result)
{ {
var page = _instance.GetPage(message.ContextId); var page = _instance.GetPage(message.ContextId);
if (string.IsNullOrEmpty(result.Selector)) if (string.IsNullOrEmpty(result.Selector) && action.Position == null)
{ {
Serilog.Log.Error($"Selector is not set."); Serilog.Log.Error($"Selector is not set.");
return; return;
} }
ILocator locator = page.Locator(result.Selector); ILocator? locator;
var count = await locator.CountAsync();
if (result.Selector != null)
{
locator = page.Locator(result.Selector);
if (count == 0) var count = await locator.CountAsync();
{
Serilog.Log.Error($"Element not found: {result.Selector}"); if (count == 0)
return;
}
else if (count > 1)
{
if (!action.FirstIfMultipleFound)
{ {
Serilog.Log.Error($"Multiple eElements were found: {result.Selector}"); Serilog.Log.Error($"Element not found: {result.Selector}");
return; return;
} }
else else if (count > 1)
{ {
locator = page.Locator(result.Selector).First;// 匹配到多个时取第一个否则当await locator.ClickAsync();匹配到多个就会抛异常。 if (!action.FirstIfMultipleFound)
{
Serilog.Log.Error($"Multiple eElements were found: {result.Selector}");
return;
}
else
{
locator = page.Locator(result.Selector).First;// 匹配到多个时取第一个否则当await locator.ClickAsync();匹配到多个就会抛异常。
}
}
await ExecuteAction(message, page, locator, action);
}
else if (action.Position != null && action.Position.X != 0 && action.Position.Y != 0)
{
if (action.Position != null && action.Position.X != 0 && action.Position.Y != 0)
{
var elementHandle = await page.EvaluateHandleAsync(
@"(coords) => document.elementFromPoint(coords.x, coords.y)",
new { x = (int)action.Position.X, y = (int)action.Position.Y }
);
await ExecuteAction(message, page, elementHandle.AsElement(), action);
} }
} }
else
{
Serilog.Log.Error($"Selector or position is not set.");
return;
}
if (action.WaitTime > 0)
{
await Task.Delay(1000 * action.WaitTime);
}
}
private async Task ExecuteAction(MessageInfo message, IPage page, ILocator locator, ElementActionArgs action)
{
if (action.Action == BroswerActionEnum.Click) if (action.Action == BroswerActionEnum.Click)
{ {
if (action.Position == null) if (action.Position == null)
@ -201,12 +235,174 @@ public partial class PlaywrightWebDriver
} }
} }
} }
}
if (action.WaitTime > 0) private async Task ExecuteAction(MessageInfo message, IPage page, IElementHandle elementHandle, ElementActionArgs action)
{
var body = page.Locator("body");
if (action.Action == BroswerActionEnum.Click)
{ {
await body.ClickAsync(new LocatorClickOptions
{
Position = new Position
{
X = action.Position.X,
Y = action.Position.Y
}
});
}
else if (action.Action == BroswerActionEnum.DropDown)
{
var tagName = await body.EvaluateAsync<string>("el => el.tagName.toLowerCase()");
if (tagName == "select")
{
await HandleSelectDropDownAsync(page, body, action);
}
else
{
await body.ClickAsync();
if (!string.IsNullOrWhiteSpace(action.PressKey))
{
await page.Keyboard.PressAsync(action.PressKey);
await page.Keyboard.PressAsync("Enter");
}
else
{
var optionLocator = page.Locator($"//div[text()='{action.Content}']");
var optionCount = await optionLocator.CountAsync();
if (optionCount == 0)
{
Serilog.Log.Error($"Dropdown option not found: {action.Content}");
return;
}
await optionLocator.First.ClickAsync();
}
}
}
else if (action.Action == BroswerActionEnum.InputText)
{
await elementHandle.FillAsync(action.Content);
if (action.PressKey != null)
{
if (action.DelayBeforePressingKey > 0)
{
await Task.Delay(action.DelayBeforePressingKey);
}
await body.PressAsync(action.PressKey);
}
}
else if (action.Action == BroswerActionEnum.FileUpload)
{
var _states = _services.GetRequiredService<IConversationStateService>();
var files = new List<string>();
if (action.FileUrl != null && action.FileUrl.Length > 0)
{
files.AddRange(action.FileUrl);
}
var hooks = _services.GetServices<IWebDriverHook>();
foreach (var hook in hooks)
{
files.AddRange(await hook.GetUploadFiles(message));
}
if (files.Count == 0)
{
Serilog.Log.Warning($"No files found to upload: {action.Content}");
return;
}
var fileChooser = await page.RunAndWaitForFileChooserAsync(async () =>
{
await body.ClickAsync();
});
var guid = Guid.NewGuid().ToString();
var directory = Path.Combine(Path.GetTempPath(), guid);
DeleteDirectory(directory);
Directory.CreateDirectory(directory);
var localPaths = new List<string>();
var http = _services.GetRequiredService<IHttpClientFactory>();
using var httpClient = http.CreateClient();
foreach (var fileUrl in files)
{
try
{
using var fileData = await httpClient.GetAsync(fileUrl);
var fileName = new Uri(fileUrl).AbsolutePath;
var localPath = Path.Combine(directory, Path.GetFileName(fileName));
await using var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None);
await fileData.Content.CopyToAsync(fs);
localPaths.Add(localPath);
}
catch (Exception ex)
{
Serilog.Log.Error($"FileUpload failed for {fileUrl}. Message: {ex.Message}");
}
}
await fileChooser.SetFilesAsync(localPaths);
await Task.Delay(1000 * action.WaitTime); await Task.Delay(1000 * action.WaitTime);
} }
else if (action.Action == BroswerActionEnum.Typing)
{
await body.PressSequentiallyAsync(action.Content);
if (action.PressKey != null)
{
if (action.DelayBeforePressingKey > 0)
{
await Task.Delay(action.DelayBeforePressingKey);
}
await body.PressAsync(action.PressKey);
}
}
else if (action.Action == BroswerActionEnum.Hover)
{
await body.HoverAsync();
}
else if (action.Action == BroswerActionEnum.DragAndDrop)
{
// Locate the element to drag
var box = await body.BoundingBoxAsync();
if (box != null)
{
// Calculate start position
float startX = box.X + box.Width / 2; // Start at the center of the element
float startY = box.Y + box.Height / 2;
// Drag offsets
float offsetX = action.Position.X;
// Move horizontally
if (action.Position.Y == 0)
{
// Perform drag-and-move
// Move mouse to the start position
var mouse = page.Mouse;
await mouse.MoveAsync(startX, startY);
await mouse.DownAsync();
// Move mouse smoothly in increments
var tracks = GetVelocityTrack(offsetX);
foreach (var track in tracks)
{
startX += track;
await page.Mouse.MoveAsync(startX, 0, new MouseMoveOptions
{
Steps = 3
});
}
// Release mouse button
await Task.Delay(1000);
await mouse.UpAsync();
}
else
{
throw new NotImplementedException();
}
}
}
} }
private void DeleteDirectory(string directory) private void DeleteDirectory(string directory)
{ {
if (Directory.Exists(directory)) if (Directory.Exists(directory))

View file

@ -21,6 +21,23 @@ public partial class PlaywrightWebDriver
IsSuccess = false IsSuccess = false
}; };
} }
// Use IWebElementLocator to detect element position by element description
var locators = _services.GetServices<IWebElementLocator>();
foreach (var el in locators)
{
location.Position = await el.DetectElementCoordinates(this, message.ContextId, location.ElementLocatorDescription);
if (location.Position != null && location.Position.X > 0 && location.Position.Y > 0)
{
result.Message = $"Position based locating is found at {location.Position}";
return new BrowserActionResult
{
IsSuccess = true
};
}
}
ILocator locator = page.Locator("body"); ILocator locator = page.Locator("body");
int count = 0; int count = 0;
var keyword = string.Empty; var keyword = string.Empty;

View file

@ -7,6 +7,7 @@ public class WebUtilityHook : IAgentUtilityHook
private const string GO_TO_PAGE_FN = $"{PREFIX}go_to_page"; private const string GO_TO_PAGE_FN = $"{PREFIX}go_to_page";
private const string LOCATE_ELEMENT_FN = $"{PREFIX}locate_element"; private const string LOCATE_ELEMENT_FN = $"{PREFIX}locate_element";
private const string ACTION_ON_ELEMENT_FN = $"{PREFIX}action_on_element"; private const string ACTION_ON_ELEMENT_FN = $"{PREFIX}action_on_element";
private const string TAKE_SCREENSHOT_FN = $"{PREFIX}take_screenshot";
public void AddUtilities(List<AgentUtility> utilities) public void AddUtilities(List<AgentUtility> utilities)
{ {
@ -20,12 +21,10 @@ public class WebUtilityHook : IAgentUtilityHook
new UtilityItem new UtilityItem
{ {
FunctionName = GO_TO_PAGE_FN, FunctionName = GO_TO_PAGE_FN,
TemplateName = $"{GO_TO_PAGE_FN}.fn"
}, },
new UtilityItem new UtilityItem
{ {
FunctionName = ACTION_ON_ELEMENT_FN, FunctionName = ACTION_ON_ELEMENT_FN,
TemplateName = $"{ACTION_ON_ELEMENT_FN}.fn"
}, },
new UtilityItem new UtilityItem
{ {
@ -34,6 +33,10 @@ public class WebUtilityHook : IAgentUtilityHook
new UtilityItem new UtilityItem
{ {
FunctionName = CLOSE_BROWSER_FN FunctionName = CLOSE_BROWSER_FN
},
new UtilityItem
{
FunctionName = TAKE_SCREENSHOT_FN
} }
] ]
} }

View file

@ -21,6 +21,6 @@ global using BotSharp.Abstraction.Utilities;
global using BotSharp.Plugin.WebDriver.Models; global using BotSharp.Plugin.WebDriver.Models;
global using BotSharp.Plugin.WebDriver.Services; global using BotSharp.Plugin.WebDriver.Services;
global using BotSharp.Plugin.WebDriver.LlmContexts; global using BotSharp.Plugin.WebDriver.LlmContexts;
global using BotSharp.Plugin.WebDriver.Drivers;
global using BotSharp.Abstraction.Browsing.Models; global using BotSharp.Abstraction.Browsing.Models;
global using BotSharp.Abstraction.Browsing; global using BotSharp.Abstraction.Browsing;
global using BotSharp.Abstraction.Browsing.Settings;

View file

@ -45,6 +45,7 @@ public class UtilWebActionOnElementFn : IFunctionCallback
FunctionArgs = message.FunctionArgs FunctionArgs = message.FunctionArgs
}; };
browser.SetServiceProvider(_services); browser.SetServiceProvider(_services);
var result = await browser.ActionOnElement(msg, locatorArgs, actionArgs); var result = await browser.ActionOnElement(msg, locatorArgs, actionArgs);
message.Content = $"{actionArgs.Action} executed {(result.IsSuccess ? "success" : "failed")}."; message.Content = $"{actionArgs.Action} executed {(result.IsSuccess ? "success" : "failed")}.";

View file

@ -0,0 +1,39 @@
namespace BotSharp.Plugin.WebDriver.UtilFunctions;
public class UtilWebTakeScreenshotFn : IFunctionCallback
{
public string Name => "util-web-take_screenshot";
public string Indication => "Taking screenshot for current viewport.";
private readonly IServiceProvider _services;
private readonly ILogger _logger;
public UtilWebTakeScreenshotFn(
IServiceProvider services,
ILogger<UtilWebCloseBrowserFn> logger)
{
_services = services;
_logger = logger;
}
public async Task<bool> Execute(RoleDialogModel message)
{
var conv = _services.GetRequiredService<IConversationService>();
var webDriverService = _services.GetRequiredService<WebDriverService>();
var services = _services.CreateScope().ServiceProvider;
var browser = services.GetRequiredService<IWebBrowser>();
var msg = new MessageInfo
{
AgentId = message.CurrentAgentId,
MessageId = message.MessageId,
ContextId = webDriverService.GetMessageContext(message)
};
var path = webDriverService.GetScreenshotFilePath(message.MessageId);
message.Content = "Took screenshot completed. You can take another screenshot if needed.";
var screenshot = await browser.ScreenshotAsync(msg, path);
message.Data = screenshot.Body;
return true;
}
}

View file

@ -4,6 +4,10 @@
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"element_locator_desc": {
"type": "string",
"description": "The element locator description."
},
"selector": { "selector": {
"type": "string", "type": "string",
"description": "element selector in XPath, use syntax of Playwright in .NET" "description": "element selector in XPath, use syntax of Playwright in .NET"
@ -43,6 +47,6 @@
"description": "meta data information if user provided" "description": "meta data information if user provided"
} }
}, },
"required": [ "selector", "action" ] "required": [ "element_locator_desc", "action" ]
} }
} }

View file

@ -4,6 +4,10 @@
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"element_locator_desc": {
"type": "string",
"description": "The element locator description."
},
"selector": { "selector": {
"type": "string", "type": "string",
"description": "element selector in XPath, use syntax of Playwright in .NET" "description": "element selector in XPath, use syntax of Playwright in .NET"
@ -17,6 +21,6 @@
"description": "ignore error logging if element not exists" "description": "ignore error logging if element not exists"
} }
}, },
"required": [ "selector" ] "required": [ "element_locator_desc" ]
} }
} }

View file

@ -0,0 +1,10 @@
{
"name": "util-web-take_screenshot",
"description": "Take screenshot for current viewport.",
"parameters": {
"type": "object",
"properties": {
},
"required": []
}
}

View file

@ -1 +0,0 @@
When user asks to do specific actions (click, fill content, scroll) on a web page, use tool of util-web-action_on_element.

View file

@ -1 +0,0 @@
When user asks to open a web page, use tool of util-web-go_to_page.