package cdp import ( "context" "encoding/base64" "encoding/json" "fmt" "os" "strings" "sync" "time" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/textx" "git.gopher.ink/mirrors2/AutoZero/tools" ) // browser 工具:经 Chrome DevTools Protocol 跨平台驱动浏览器 // (替代旧 macOS-only 的 AppleScript 方案)。截图走 Page.captureScreenshot, // 不依赖屏幕录制权限;js 执行走 Runtime.evaluate,无需任何浏览器开关。 const ( navigateTimeout = 15 * time.Second evaluateTimeout = 30 * time.Second maxJSOutput = 16000 ) var ( browserMu sync.Mutex browserShared *Browser defaultsMu sync.Mutex defaultsSet bool defaultsVal *Browser ) // SetBrowserDefaults 由 main 在配置装载后调用(env 已并入 config); // 未调用时 sharedBrowser 回退为直接读环境变量(库独立使用场景)。 func SetBrowserDefaults(bin string, headless bool, profile, debugBase string) { defaultsMu.Lock() defer defaultsMu.Unlock() defaultsSet = true defaultsVal = &Browser{Bin: bin, Headless: headless, ProfileDir: profile, DebugBase: debugBase} } func sharedBrowser() *Browser { browserMu.Lock() defer browserMu.Unlock() if browserShared == nil { defaultsMu.Lock() if defaultsSet { browserShared = defaultsVal } else { browserShared = &Browser{ Bin: os.Getenv("AUTOGO_BROWSER_BIN"), Headless: os.Getenv("AUTOGO_BROWSER_HEADLESS") != "0", ProfileDir: os.Getenv("AUTOGO_BROWSER_PROFILE"), DebugBase: os.Getenv("AUTOGO_BROWSER_DEBUG_BASE"), } } defaultsMu.Unlock() } return browserShared } // CloseBrowser 终止托管的浏览器进程(main 退出前调用)。 func CloseBrowser() { browserMu.Lock() defer browserMu.Unlock() if browserShared != nil { browserShared.Close() } } // BrowserTool 返回跨平台 browser 工具。 func BrowserTool() *tools.Tool { return browserToolWith(sharedBrowser()) } func browserToolWith(br *Browser) *tools.Tool { return &tools.Tool{ Name: "browser", Description: "Control a browser via Chrome DevTools Protocol (cross-platform; spawns a headless Chrome by default, set AUTOGO_BROWSER_HEADLESS=0 to show a window). " + "Actions: open (navigate the current tab to url), tabs (list open tabs), js (execute JavaScript in the current tab and return the result; async/await supported), screenshot (capture the current tab, returned as an image you can see). " + "Prefer `js` to read and verify page state (fast, returns values like document.title or video.paused); use `open`/`js` to navigate and act. Use `screenshot` only to deliver a visual to the user, not for routine verification. " + "AUTOGO_BROWSER_BIN overrides the browser binary (Chrome/Chromium/Edge).", SideEffect: true, Kind: tools.KindNetwork, // 访问外部网络 Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{"type": "string", "enum": []string{"open", "tabs", "js", "screenshot"}, "description": "open: navigate to url; tabs: list tabs; js: run javascript in current tab; screenshot: capture current tab"}, "url": map[string]any{"type": "string", "description": "URL to open (action=open)"}, "javascript": map[string]any{"type": "string", "description": "JavaScript to execute, should end with an expression or return value, e.g. document.title or JSON.stringify(document.body.innerText.slice(0,5000)) (action=js)"}, }, "required": []string{"action"}, }, Run: func(ctx context.Context, input string) (tools.Result, error) { m, err := arg(input) if err != nil { return tools.Result{}, err } switch action := str(m, "action"); action { case "open": u := str(m, "url") if strings.TrimSpace(u) == "" { return tools.Result{}, fmt.Errorf("action open requires url") } return br.open(ctx, u) case "tabs": return br.tabs(ctx) case "js": js := str(m, "javascript") if strings.TrimSpace(js) == "" { return tools.Result{}, fmt.Errorf("action js requires javascript") } return br.js(ctx, js) case "screenshot": return br.screenshot(ctx) default: return tools.Result{}, fmt.Errorf("unknown action %q", action) } }, } } // resolveTarget 选定当前作用 tab:优先记住的 current(已关闭则回退), // 其次第一个 page target;都没有则新开 about:blank。 func (b *Browser) resolveTarget(ctx context.Context) (*Target, error) { targets, err := b.ListTargets(ctx) if err != nil { return nil, err } var pages []Target for _, t := range targets { if t.Type == "page" { pages = append(pages, t) } } for i := range pages { if pages[i].ID == b.current { return &pages[i], nil } } if len(pages) > 0 { b.current = pages[0].ID return &pages[0], nil } t, err := b.NewTab(ctx, "about:blank") if err != nil { return nil, err } b.current = t.ID return t, nil } // withTargetConn 解析目标 tab 并建立临时 CDP 连接(动作结束即关闭)。 func (b *Browser) withTargetConn(ctx context.Context, fn func(c *Conn) (tools.Result, error)) (tools.Result, error) { b.actionMu.Lock() defer b.actionMu.Unlock() t, err := b.resolveTarget(ctx) if err != nil { return tools.Result{}, err } conn, err := Dial(ctx, t.WebSocketDebuggerURL) if err != nil { return tools.Result{}, err } defer conn.Close() return fn(conn) } func (b *Browser) open(ctx context.Context, u string) (tools.Result, error) { return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) { if _, err := c.Call(ctx, "Page.enable", nil); err != nil { return tools.Result{}, err } loaded := c.Subscribe("Page.loadEventFired") if _, err := c.Call(ctx, "Page.navigate", map[string]any{"url": u}); err != nil { return tools.Result{}, err } note := "" if _, err := loaded.Wait(ctx, navigateTimeout); err != nil { note = " (load event timeout, page may still be loading)" } title, _ := evaluateString(ctx, c, "document.title") return tools.TextResult("opened " + u + " — title: " + title + note), nil }) } func (b *Browser) tabs(ctx context.Context) (tools.Result, error) { targets, err := b.ListTargets(ctx) if err != nil { return tools.Result{}, err } var lines []string for _, t := range targets { if t.Type != "page" { continue } lines = append(lines, fmt.Sprintf("- %s | %s | %s", t.ID, t.Title, t.URL)) } if len(lines) == 0 { return tools.TextResult("no open tabs"), nil } return tools.TextResult(strings.Join(lines, "\n")), nil } func (b *Browser) js(ctx context.Context, code string) (tools.Result, error) { return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) { out, err := evaluateString(ctx, c, jsWrap(code)) if err != nil { return tools.Result{}, err } if len(out) > maxJSOutput { // 页面文本几乎必然含中文:按字节切会在结果尾部留下半个字, // 那既污染 transcript(模型看到乱码),也会让会话 JSONL 里出现非法序列。 out = textx.ClipBytes(out, maxJSOutput) + "...[truncated]" } return tools.TextResult(out), nil }) } func (b *Browser) screenshot(ctx context.Context) (tools.Result, error) { return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) { raw, err := c.Call(ctx, "Page.captureScreenshot", map[string]any{"format": "png"}) if err != nil { return tools.Result{}, err } var res struct { Data string `json:"data"` } if err := json.Unmarshal(raw, &res); err != nil || res.Data == "" { return tools.Result{}, fmt.Errorf("captureScreenshot: bad result") } png, err := base64.StdEncoding.DecodeString(res.Data) if err != nil { return tools.Result{}, err } path := fmt.Sprintf("%s/autogo-page-%d.png", os.TempDir(), time.Now().UnixNano()) if err := os.WriteFile(path, png, 0o644); err != nil { return tools.Result{}, err } if p, serr := tools.ShrinkForVision(path); serr == nil { path = p } title, _ := evaluateString(ctx, c, "document.location.href") return tools.Result{Text: "page screenshot: " + title, Images: []llm.Image{{Path: path}}}, nil }) } // evaluateString 执行 JS 并把结果转为字符串(错误详情带回)。 func evaluateString(ctx context.Context, c *Conn, expression string) (string, error) { raw, err := c.Call(ctx, "Runtime.evaluate", map[string]any{ "expression": expression, "returnByValue": true, "awaitPromise": true, "userGesture": true, }) if err != nil { return "", err } var res struct { Result struct { Type string `json:"type"` Value any `json:"value"` } `json:"result"` ExceptionDetails *struct { Text string `json:"text"` Exception struct { Description string `json:"description"` } `json:"exception"` } `json:"exceptionDetails"` } if err := json.Unmarshal(raw, &res); err != nil { return "", err } if res.ExceptionDetails != nil { detail := res.ExceptionDetails.Exception.Description if detail == "" { detail = res.ExceptionDetails.Text } return "", fmt.Errorf("js error: %s", detail) } switch v := res.Result.Value.(type) { case nil: return "", nil case string: return v, nil default: b, err := json.Marshal(v) if err != nil { return fmt.Sprint(v), nil } return string(b), nil } } // jsWrap 包一层 async + try/catch:支持 await,保证返回字符串。 func jsWrap(js string) string { return fmt.Sprintf( `(async()=>{try{const r=await eval(%q);return typeof r==="string"?r:JSON.stringify(r)}catch(e){return "JS_ERROR: "+e.message}})()`, js, ) } // arg 解析工具输入 JSON(与 tools 包内同名逻辑一致)。 // 兼容模型可能生成的 {"arguments": {...}} 包装格式。 func arg(input string) (map[string]any, error) { m := map[string]any{} if input == "" { return m, nil } if err := json.Unmarshal([]byte(input), &m); err != nil { return nil, fmt.Errorf("invalid tool arguments: %w", err) } // 兼容 {"arguments": {...}} 包装:提取内层对象 if args, ok := m["arguments"]; ok { if argsMap, ok := args.(map[string]any); ok { return argsMap, nil } } return m, nil } func str(m map[string]any, key string) string { if v, ok := m[key].(string); ok { return v } return "" }