315 lines
8.5 KiB
Go
315 lines
8.5 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
"github.com/go-rod/stealth"
|
|
"trankilou.fr/lassistanoque/backend/internal/domain"
|
|
)
|
|
|
|
type BrowserConfig struct {
|
|
// Headless est un pointeur pour distinguer "non renseigné" (défaut : true) de "explicitement false".
|
|
Headless *bool `yaml:"headless"`
|
|
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
|
}
|
|
|
|
// BrowserTool maintains a persistent browser session across LLM tool calls.
|
|
type BrowserTool struct{}
|
|
|
|
var (
|
|
browserInstance *rod.Browser
|
|
currentPage *rod.Page
|
|
browserMu sync.Mutex
|
|
launchedHeadless bool
|
|
)
|
|
|
|
func (t *BrowserTool) Name() string {
|
|
return "browser"
|
|
}
|
|
|
|
func (t *BrowserTool) Description() string {
|
|
return "Control a web browser to navigate pages and interact with content. " +
|
|
"Maintains session state across calls (cookies, current page). " +
|
|
"Actions: 'navigate' (go to URL), 'click' (click element by CSS selector), " +
|
|
"'fill' (type into an input field), 'content' (get current page text), " +
|
|
"'links' (list all links on current page)."
|
|
}
|
|
|
|
func (t *BrowserTool) Params() []domain.ToolParam {
|
|
return []domain.ToolParam{
|
|
{JsonName: "headless", Type: "bool", Mandatory: true, Description: "Headless"},
|
|
{JsonName: "timeoutSeconds", Type: "int", Mandatory: true, Description: "Timeout en secondes"},
|
|
}
|
|
}
|
|
|
|
func (t *BrowserTool) Definition(ctx context.Context) *domain.ToolDefinition {
|
|
return &domain.ToolDefinition{
|
|
Type: "function",
|
|
Function: &domain.ToolFunction{
|
|
Name: t.Name(),
|
|
Description: t.Description(),
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"action": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"navigate", "click", "fill", "content", "links"},
|
|
"description": "The browser action to perform.",
|
|
},
|
|
"url": map[string]any{
|
|
"type": "string",
|
|
"description": "URL to navigate to. Required for action='navigate'.",
|
|
},
|
|
"selector": map[string]any{
|
|
"type": "string",
|
|
"description": "CSS selector of the element to interact with. Required for 'click' and 'fill'.",
|
|
},
|
|
"value": map[string]any{
|
|
"type": "string",
|
|
"description": "Text value to type into the field. Required for action='fill'.",
|
|
},
|
|
},
|
|
"required": []string{"action"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (t *BrowserTool) Execute(_ context.Context, input []byte, configuration string) ([]byte, error) {
|
|
|
|
slog.Debug("[browser]", "input", string(input))
|
|
|
|
var params struct {
|
|
Action string `json:"action"`
|
|
URL string `json:"url"`
|
|
Selector string `json:"selector"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(input, ¶ms); err != nil {
|
|
return nil, fmt.Errorf("invalid parameters: %w", err)
|
|
}
|
|
|
|
var toolConfig BrowserConfig
|
|
err := json.Unmarshal([]byte(configuration), &toolConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
timeout := time.Duration(toolConfig.TimeoutSeconds) * time.Second
|
|
|
|
// Headless est nil quand la configuration ne le précise pas : défaut à true.
|
|
headless := true
|
|
if toolConfig.Headless != nil {
|
|
headless = *toolConfig.Headless
|
|
}
|
|
|
|
if timeout <= 0 {
|
|
timeout = 60 * time.Second
|
|
}
|
|
|
|
browserMu.Lock()
|
|
defer browserMu.Unlock()
|
|
|
|
if err := ensureBrowser(headless); err != nil {
|
|
return nil, fmt.Errorf("browser init failed: %w", err)
|
|
}
|
|
|
|
switch params.Action {
|
|
case "navigate":
|
|
if params.URL == "" {
|
|
return nil, fmt.Errorf("'url' is required for action 'navigate'")
|
|
}
|
|
return t.navigate(params.URL, timeout)
|
|
|
|
case "click":
|
|
if params.Selector == "" {
|
|
return nil, fmt.Errorf("'selector' is required for action 'click'")
|
|
}
|
|
return t.click(params.Selector, timeout)
|
|
|
|
case "fill":
|
|
if params.Selector == "" || params.Value == "" {
|
|
return nil, fmt.Errorf("'selector' and 'value' are required for action 'fill'")
|
|
}
|
|
return t.fill(params.Selector, params.Value, timeout)
|
|
|
|
case "content":
|
|
return t.pageContent(timeout)
|
|
|
|
case "links":
|
|
return t.pageLinks(timeout)
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown action %q", params.Action)
|
|
}
|
|
}
|
|
|
|
func browserProfileDir() string {
|
|
if dir, err := os.UserCacheDir(); err == nil {
|
|
return filepath.Join(dir, "lassistanoque", "browser-profile")
|
|
}
|
|
return filepath.Join(os.TempDir(), "lassistanoque-browser-profile")
|
|
}
|
|
|
|
// ensureBrowser lance le navigateur au premier appel et le relance si le mode
|
|
// headless a changé dans la configuration.
|
|
func ensureBrowser(headless bool) error {
|
|
if browserInstance != nil {
|
|
if headless == launchedHeadless {
|
|
return nil
|
|
}
|
|
_ = browserInstance.Close()
|
|
browserInstance = nil
|
|
currentPage = nil
|
|
}
|
|
if err := initBrowser(headless); err != nil {
|
|
return err
|
|
}
|
|
launchedHeadless = headless
|
|
return nil
|
|
}
|
|
|
|
func initBrowser(headless bool) error {
|
|
path, _ := launcher.LookPath()
|
|
controlURL, err := launcher.New().
|
|
Bin(path).
|
|
Headless(headless).
|
|
UserDataDir(browserProfileDir()).
|
|
Delete("enable-automation").
|
|
Launch()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to launch browser: %w", err)
|
|
}
|
|
b := rod.New().ControlURL(controlURL)
|
|
if err := b.Connect(); err != nil {
|
|
return fmt.Errorf("failed to connect to browser: %w", err)
|
|
}
|
|
browserInstance = b
|
|
return nil
|
|
}
|
|
|
|
func ensurePage(timeout time.Duration) error {
|
|
if currentPage == nil {
|
|
page, err := stealth.Page(browserInstance.Timeout(timeout))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open page: %w", err)
|
|
}
|
|
currentPage = page
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *BrowserTool) navigate(rawURL string, timeout time.Duration) ([]byte, error) {
|
|
if err := ensurePage(timeout); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := currentPage.Timeout(timeout).Navigate(rawURL); err != nil {
|
|
return nil, fmt.Errorf("navigation failed: %w", err)
|
|
}
|
|
if err := currentPage.Timeout(timeout).WaitLoad(); err != nil {
|
|
return nil, fmt.Errorf("page load failed: %w", err)
|
|
}
|
|
return t.pageContent(timeout)
|
|
}
|
|
|
|
func (t *BrowserTool) click(selector string, timeout time.Duration) ([]byte, error) {
|
|
if err := ensurePage(timeout); err != nil {
|
|
return nil, err
|
|
}
|
|
el, err := currentPage.Timeout(timeout).Element(selector)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("element %q not found: %w", selector, err)
|
|
}
|
|
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
|
return nil, fmt.Errorf("click failed: %w", err)
|
|
}
|
|
if err := currentPage.WaitIdle(timeout); err != nil {
|
|
return nil, fmt.Errorf("wait after click failed: %w", err)
|
|
}
|
|
return t.pageContent(timeout)
|
|
}
|
|
|
|
func (t *BrowserTool) fill(selector, value string, timeout time.Duration) ([]byte, error) {
|
|
if err := ensurePage(timeout); err != nil {
|
|
return nil, err
|
|
}
|
|
el, err := currentPage.Timeout(timeout).Element(selector)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("element %q not found: %w", selector, err)
|
|
}
|
|
if err := el.SelectAllText(); err != nil {
|
|
return nil, fmt.Errorf("failed to select text: %w", err)
|
|
}
|
|
if err := el.Input(value); err != nil {
|
|
return nil, fmt.Errorf("fill failed: %w", err)
|
|
}
|
|
return t.pageContent(timeout)
|
|
}
|
|
|
|
func (t *BrowserTool) pageContent(timeout time.Duration) ([]byte, error) {
|
|
if currentPage == nil {
|
|
return []byte("No page loaded."), nil
|
|
}
|
|
|
|
info, err := currentPage.Info()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get page info: %w", err)
|
|
}
|
|
|
|
titleRes, err := currentPage.Eval("() => document.title")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get page title: %w", err)
|
|
}
|
|
title := titleRes.Value.String()
|
|
|
|
body, err := currentPage.Timeout(timeout).Element("body")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get page body: %w", err)
|
|
}
|
|
text, err := body.Text()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract page text: %w", err)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, "Page: %s\nURL: %s\n\n%s", title, info.URL, text)
|
|
return []byte(sb.String()), nil
|
|
}
|
|
|
|
func (t *BrowserTool) pageLinks(timeout time.Duration) ([]byte, error) {
|
|
if currentPage == nil {
|
|
return []byte("No page loaded."), nil
|
|
}
|
|
|
|
elements, err := currentPage.Timeout(timeout).Elements("a[href]")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get links: %w", err)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, "Links on current page (%d):\n\n", len(elements))
|
|
for i, el := range elements {
|
|
href, _ := el.Attribute("href")
|
|
text, _ := el.Text()
|
|
if href == nil {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(text)
|
|
if label == "" {
|
|
label = "(no text)"
|
|
}
|
|
fmt.Fprintf(&sb, "%d. %s — %s\n", i+1, label, *href)
|
|
}
|
|
return []byte(sb.String()), nil
|
|
}
|