mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 17:46:32 -04:00
447 lines
17 KiB
Zig
447 lines
17 KiB
Zig
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const zenai = @import("zenai");
|
|
|
|
const App = @import("../App.zig");
|
|
const HttpClient = @import("../browser/HttpClient.zig");
|
|
const CDPNode = @import("../cdp/Node.zig");
|
|
const mcp_tools = @import("../mcp/tools.zig");
|
|
const protocol = @import("../mcp/protocol.zig");
|
|
|
|
const Self = @This();
|
|
|
|
allocator: std.mem.Allocator,
|
|
app: *App,
|
|
http_client: *HttpClient,
|
|
notification: *lp.Notification,
|
|
browser: lp.Browser,
|
|
session: *lp.Session,
|
|
node_registry: CDPNode.Registry,
|
|
tool_schema_arena: std.heap.ArenaAllocator,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, app: *App) !*Self {
|
|
const http_client = try HttpClient.init(allocator, &app.network);
|
|
errdefer http_client.deinit();
|
|
|
|
const notification = try lp.Notification.init(allocator);
|
|
errdefer notification.deinit();
|
|
|
|
const self = try allocator.create(Self);
|
|
errdefer allocator.destroy(self);
|
|
|
|
var browser = try lp.Browser.init(app, .{ .http_client = http_client });
|
|
errdefer browser.deinit();
|
|
|
|
self.* = .{
|
|
.allocator = allocator,
|
|
.app = app,
|
|
.http_client = http_client,
|
|
.notification = notification,
|
|
.browser = browser,
|
|
.session = undefined,
|
|
.node_registry = CDPNode.Registry.init(allocator),
|
|
.tool_schema_arena = std.heap.ArenaAllocator.init(allocator),
|
|
};
|
|
|
|
self.session = try self.browser.newSession(self.notification);
|
|
return self;
|
|
}
|
|
|
|
pub fn deinit(self: *Self) void {
|
|
self.tool_schema_arena.deinit();
|
|
self.node_registry.deinit();
|
|
self.browser.deinit();
|
|
self.notification.deinit();
|
|
self.http_client.deinit();
|
|
self.allocator.destroy(self);
|
|
}
|
|
|
|
/// Returns the list of tools in zenai provider.Tool format.
|
|
pub fn getTools(self: *Self) ![]const zenai.provider.Tool {
|
|
const arena = self.tool_schema_arena.allocator();
|
|
const tools = try arena.alloc(zenai.provider.Tool, mcp_tools.tool_list.len);
|
|
for (mcp_tools.tool_list, 0..) |t, i| {
|
|
const parsed = try std.json.parseFromSliceLeaky(
|
|
std.json.Value,
|
|
arena,
|
|
t.inputSchema,
|
|
.{},
|
|
);
|
|
tools[i] = .{
|
|
.name = t.name,
|
|
.description = t.description orelse "",
|
|
.parameters = parsed,
|
|
};
|
|
}
|
|
return tools;
|
|
}
|
|
|
|
/// Execute a tool by name with JSON arguments, returning the result as a string.
|
|
pub fn call(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) ![]const u8 {
|
|
const arguments = if (arguments_json.len > 0)
|
|
(std.json.parseFromSlice(std.json.Value, arena, arguments_json, .{}) catch
|
|
return "Error: invalid JSON arguments").value
|
|
else
|
|
null;
|
|
|
|
const Action = enum {
|
|
goto,
|
|
navigate,
|
|
markdown,
|
|
links,
|
|
nodeDetails,
|
|
interactiveElements,
|
|
structuredData,
|
|
detectForms,
|
|
evaluate,
|
|
eval,
|
|
semantic_tree,
|
|
click,
|
|
fill,
|
|
scroll,
|
|
waitForSelector,
|
|
};
|
|
|
|
const action_map = std.StaticStringMap(Action).initComptime(.{
|
|
.{ "goto", .goto },
|
|
.{ "navigate", .navigate },
|
|
.{ "markdown", .markdown },
|
|
.{ "links", .links },
|
|
.{ "nodeDetails", .nodeDetails },
|
|
.{ "interactiveElements", .interactiveElements },
|
|
.{ "structuredData", .structuredData },
|
|
.{ "detectForms", .detectForms },
|
|
.{ "evaluate", .evaluate },
|
|
.{ "eval", .eval },
|
|
.{ "semantic_tree", .semantic_tree },
|
|
.{ "click", .click },
|
|
.{ "fill", .fill },
|
|
.{ "scroll", .scroll },
|
|
.{ "waitForSelector", .waitForSelector },
|
|
});
|
|
|
|
const action = action_map.get(tool_name) orelse return "Error: unknown tool";
|
|
|
|
return switch (action) {
|
|
.goto, .navigate => self.execGoto(arena, arguments),
|
|
.markdown => self.execMarkdown(arena, arguments),
|
|
.links => self.execLinks(arena, arguments),
|
|
.nodeDetails => self.execNodeDetails(arena, arguments),
|
|
.interactiveElements => self.execInteractiveElements(arena, arguments),
|
|
.structuredData => self.execStructuredData(arena, arguments),
|
|
.detectForms => self.execDetectForms(arena, arguments),
|
|
.evaluate, .eval => self.execEvaluate(arena, arguments),
|
|
.semantic_tree => self.execSemanticTree(arena, arguments),
|
|
.click => self.execClick(arena, arguments),
|
|
.fill => self.execFill(arena, arguments),
|
|
.scroll => self.execScroll(arena, arguments),
|
|
.waitForSelector => self.execWaitForSelector(arena, arguments),
|
|
};
|
|
}
|
|
|
|
fn execGoto(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const GotoParams = struct {
|
|
url: [:0]const u8,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return "Error: missing or invalid 'url' argument";
|
|
self.performGoto(args.url, args.timeout, args.waitUntil) catch return "Error: navigation failed";
|
|
return "Navigated successfully.";
|
|
}
|
|
|
|
fn execMarkdown(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const UrlParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(UrlParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
lp.markdown.dump(page.window._document.asNode(), .{}, &aw.writer, page) catch return "Error: failed to generate markdown";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execLinks(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const UrlParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(UrlParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
const links_list = lp.links.collectLinks(arena, page.window._document.asNode(), page) catch
|
|
return "Error: failed to collect links";
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
for (links_list, 0..) |href, i| {
|
|
if (i > 0) aw.writer.writeByte('\n') catch {};
|
|
aw.writer.writeAll(href) catch {};
|
|
}
|
|
return aw.written();
|
|
}
|
|
|
|
fn execNodeDetails(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct { backendNodeId: CDPNode.Id };
|
|
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId";
|
|
|
|
_ = self.session.currentPage() orelse return "Error: page not loaded";
|
|
|
|
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse
|
|
return "Error: node not found";
|
|
|
|
const page = self.session.currentPage().?;
|
|
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &self.node_registry, page) catch
|
|
return "Error: failed to get node details";
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(&details, .{}, &aw.writer) catch return "Error: serialization failed";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execInteractiveElements(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const UrlParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(UrlParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
const elements = lp.interactive.collectInteractiveElements(page.window._document.asNode(), arena, page) catch
|
|
return "Error: failed to collect interactive elements";
|
|
lp.interactive.registerNodes(elements, &self.node_registry) catch
|
|
return "Error: failed to register nodes";
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(elements, .{}, &aw.writer) catch return "Error: serialization failed";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execStructuredData(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const UrlParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(UrlParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
const data = lp.structured_data.collectStructuredData(page.window._document.asNode(), arena, page) catch
|
|
return "Error: failed to collect structured data";
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(data, .{}, &aw.writer) catch return "Error: serialization failed";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execDetectForms(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const UrlParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(UrlParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
const forms_data = lp.forms.collectForms(arena, page.window._document.asNode(), page) catch
|
|
return "Error: failed to collect forms";
|
|
lp.forms.registerNodes(forms_data, &self.node_registry) catch
|
|
return "Error: failed to register form nodes";
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return "Error: serialization failed";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execEvaluate(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct {
|
|
script: [:0]const u8,
|
|
url: ?[:0]const u8 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'script' argument";
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
var ls: lp.js.Local.Scope = undefined;
|
|
page.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
|
|
var try_catch: lp.js.TryCatch = undefined;
|
|
try_catch.init(&ls.local);
|
|
defer try_catch.deinit();
|
|
|
|
const js_result = ls.local.compileAndRun(args.script, null) catch |err| {
|
|
const caught = try_catch.caughtOrError(arena, err);
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
caught.format(&aw.writer) catch {};
|
|
return aw.written();
|
|
};
|
|
|
|
return js_result.toStringSliceWithAlloc(arena) catch "undefined";
|
|
}
|
|
|
|
fn execSemanticTree(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const TreeParams = struct {
|
|
url: ?[:0]const u8 = null,
|
|
backendNodeId: ?u32 = null,
|
|
maxDepth: ?u32 = null,
|
|
timeout: ?u32 = null,
|
|
waitUntil: ?lp.Config.WaitUntil = null,
|
|
};
|
|
const args = parseArgsOrDefault(TreeParams, arena, arguments);
|
|
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
|
|
|
|
var root_node = page.window._document.asNode();
|
|
if (args.backendNodeId) |node_id| {
|
|
if (self.node_registry.lookup_by_id.get(node_id)) |n| {
|
|
root_node = n.dom;
|
|
}
|
|
}
|
|
|
|
const st = lp.SemanticTree{
|
|
.dom_node = root_node,
|
|
.registry = &self.node_registry,
|
|
.page = page,
|
|
.arena = arena,
|
|
.prune = true,
|
|
.max_depth = args.maxDepth orelse std.math.maxInt(u32) - 1,
|
|
};
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
st.textStringify(&aw.writer) catch return "Error: failed to generate semantic tree";
|
|
return aw.written();
|
|
}
|
|
|
|
fn execClick(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct { backendNodeId: CDPNode.Id };
|
|
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId";
|
|
|
|
const page = self.session.currentPage() orelse return "Error: page not loaded";
|
|
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found";
|
|
|
|
lp.actions.click(node.dom, page) catch |err| {
|
|
if (err == error.InvalidNodeType) return "Error: node is not an HTML element";
|
|
return "Error: failed to click element";
|
|
};
|
|
|
|
const page_title = page.getTitle() catch null;
|
|
return std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
|
|
args.backendNodeId,
|
|
page.url,
|
|
page_title orelse "(none)",
|
|
}) catch "Clicked element.";
|
|
}
|
|
|
|
fn execFill(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct {
|
|
backendNodeId: CDPNode.Id,
|
|
text: []const u8,
|
|
};
|
|
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId or text";
|
|
|
|
const page = self.session.currentPage() orelse return "Error: page not loaded";
|
|
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found";
|
|
|
|
lp.actions.fill(node.dom, args.text, page) catch |err| {
|
|
if (err == error.InvalidNodeType) return "Error: node is not an input, textarea or select";
|
|
return "Error: failed to fill element";
|
|
};
|
|
|
|
const page_title = page.getTitle() catch null;
|
|
return std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
|
|
args.backendNodeId,
|
|
args.text,
|
|
page.url,
|
|
page_title orelse "(none)",
|
|
}) catch "Filled element.";
|
|
}
|
|
|
|
fn execScroll(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct {
|
|
backendNodeId: ?CDPNode.Id = null,
|
|
x: ?i32 = null,
|
|
y: ?i32 = null,
|
|
};
|
|
const args = parseArgsOrDefault(Params, arena, arguments);
|
|
const page = self.session.currentPage() orelse return "Error: page not loaded";
|
|
|
|
var target_node: ?*@import("../browser/webapi/Node.zig") = null;
|
|
if (args.backendNodeId) |node_id| {
|
|
const node = self.node_registry.lookup_by_id.get(node_id) orelse return "Error: node not found";
|
|
target_node = node.dom;
|
|
}
|
|
|
|
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
|
|
if (err == error.InvalidNodeType) return "Error: node is not an element";
|
|
return "Error: failed to scroll";
|
|
};
|
|
|
|
const page_title = page.getTitle() catch null;
|
|
return std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{
|
|
args.x orelse 0,
|
|
args.y orelse 0,
|
|
page.url,
|
|
page_title orelse "(none)",
|
|
}) catch "Scrolled.";
|
|
}
|
|
|
|
fn execWaitForSelector(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
|
|
const Params = struct {
|
|
selector: [:0]const u8,
|
|
timeout: ?u32 = null,
|
|
};
|
|
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'selector' argument";
|
|
|
|
_ = self.session.currentPage() orelse return "Error: page not loaded";
|
|
|
|
const timeout_ms = args.timeout orelse 5000;
|
|
|
|
const node = lp.actions.waitForSelector(args.selector, timeout_ms, self.session) catch |err| {
|
|
if (err == error.InvalidSelector) return "Error: invalid selector";
|
|
if (err == error.Timeout) return "Error: timeout waiting for selector";
|
|
return "Error: failed waiting for selector";
|
|
};
|
|
|
|
const registered = self.node_registry.register(node) catch return "Element found.";
|
|
return std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found.";
|
|
}
|
|
|
|
fn ensurePage(self: *Self, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
|
|
if (url) |u| {
|
|
try self.performGoto(u, timeout, waitUntil);
|
|
}
|
|
return self.session.currentPage() orelse error.PageNotLoaded;
|
|
}
|
|
|
|
fn performGoto(self: *Self, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void {
|
|
const session = self.session;
|
|
if (session.page != null) {
|
|
session.removePage();
|
|
}
|
|
const page = try session.createPage();
|
|
_ = try page.navigate(url, .{
|
|
.reason = .address_bar,
|
|
.kind = .{ .push = null },
|
|
});
|
|
|
|
var runner = try session.runner(.{});
|
|
try runner.wait(.{
|
|
.ms = timeout orelse 10000,
|
|
.until = waitUntil orelse .done,
|
|
});
|
|
}
|
|
|
|
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
|
|
const args_raw = arguments orelse return .{};
|
|
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
|
|
}
|
|
|
|
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
|
|
const args_raw = arguments orelse return null;
|
|
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
|
|
}
|