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 }