llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
333 lines
10 KiB
Go
333 lines
10 KiB
Go
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 ""
|
||
}
|