feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
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。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
+301
@@ -0,0 +1,301 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Browser 管理一个 Chrome 实例(懒启动、进程内单例语义由 tool 层保证):
|
||||
// 首次使用时以 --remote-debugging-port 启动并轮询就绪;DebugBase 非空则
|
||||
// 直接连接已有调试端点(测试 / 外部浏览器),不启动进程。
|
||||
type Browser struct {
|
||||
DebugBase string // 直接使用的调试基址(http://127.0.0.1:9222)
|
||||
Bin string // 浏览器可执行文件覆盖;空 = 自动发现
|
||||
Headless bool // 默认 true;AUTOGO_BROWSER_HEADLESS=0 关闭
|
||||
ProfileDir string // 用户数据目录;空 = ~/.autogo/browser-profile(持久,登录态留得住)
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
base string
|
||||
current string // 当前 page target id(open/js/screenshot 的作用目标)
|
||||
actionMu sync.Mutex
|
||||
}
|
||||
|
||||
// Target 是调试端点上的一个 tab(page)。
|
||||
type Target struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerURL"`
|
||||
}
|
||||
|
||||
func (b *Browser) baseURL(ctx context.Context) (string, error) {
|
||||
if b.DebugBase != "" {
|
||||
return strings.TrimSuffix(b.DebugBase, "/"), nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.base != "" {
|
||||
return b.base, nil
|
||||
}
|
||||
bin, err := findBrowser(b.Bin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port, err := freePort()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := b.ProfileDir
|
||||
if dir == "" {
|
||||
// 默认用**持久**目录,不用临时目录:Chrome 的登录态与扩展都在 user-data-dir 里,
|
||||
// 临时目录等于每次启动都像一台从没登录过的新机器(登录一次也留不住),而且旧的
|
||||
// 临时目录从来没人清理,会在 /tmp 里越堆越多。
|
||||
dir, err = PersistentProfileDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// profile 体检结论不在这里直接打印(TUI 的 alt-screen 打不得),而是挂到启动
|
||||
// 失败的 error 上——那正是用户需要看到它们的时候(见下面 waitDebugReady 分支)。
|
||||
profileWarns := CheckProfile(dir)
|
||||
|
||||
args := []string{
|
||||
fmt.Sprintf("--remote-debugging-port=%d", port),
|
||||
"--user-data-dir=" + dir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--window-size=1366,900",
|
||||
"about:blank",
|
||||
}
|
||||
if b.Headless {
|
||||
args = append([]string{"--headless=new", "--hide-scrollbars"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("launch %s: %w", bin, err)
|
||||
}
|
||||
base := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
if err := waitDebugReady(ctx, base, 10*time.Second); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
// 起不来时把 profile 和体检结论一起给出:绝大多数"浏览器连不上"都是 profile
|
||||
// 相关(被运行中的 Chrome 占着 / 指向了默认目录 / 指向了 Default 子目录)。
|
||||
msg := fmt.Sprintf("浏览器调试端口没起来(%s,profile=%s):%v", bin, dir, err)
|
||||
for _, w := range profileWarns {
|
||||
msg += "\n - " + w
|
||||
}
|
||||
if len(profileWarns) == 0 {
|
||||
msg += "\n - profile 看起来正常;可以先手动跑一下该浏览器确认能启动,或用 browser.debug_base 连接一个已开的调试端口"
|
||||
}
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
b.cmd, b.base = cmd, base
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// waitDebugReady 轮询 /json/version 直到调试端点就绪。
|
||||
func waitDebugReady(ctx context.Context, base string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
for time.Now().Before(deadline) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
|
||||
if req != nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("debug endpoint %s not ready within %s", base, timeout)
|
||||
}
|
||||
|
||||
func freePort() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// findBrowser 按 GOOS 定位浏览器可执行文件(Chrome / Chromium / Edge 均支持 CDP)。
|
||||
func findBrowser(explicit string) (string, error) {
|
||||
if explicit != "" {
|
||||
if _, err := os.Stat(explicit); err == nil {
|
||||
return explicit, nil
|
||||
}
|
||||
if _, err := exec.LookPath(explicit); err == nil {
|
||||
return explicit, nil
|
||||
}
|
||||
return "", fmt.Errorf("AUTOGO_BROWSER_BIN %q not found", explicit)
|
||||
}
|
||||
var candidates []string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
candidates = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
case "windows":
|
||||
pf := os.Getenv("ProgramFiles")
|
||||
pf86 := os.Getenv("ProgramFiles(x86)")
|
||||
lap := os.Getenv("LOCALAPPDATA")
|
||||
// ARM64 Windows 可能安装在 Program Files (Arm) 或 x86 目录
|
||||
for _, root := range []string{pf, pf86, lap} {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join(root, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`),
|
||||
)
|
||||
}
|
||||
// Chrome SxS (Canary) 和 Edge Beta 也常见于 ARM64
|
||||
if lap != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(lap, `Google\Chrome SxS\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
default: // linux 等 unix
|
||||
// 标准 PATH 查找
|
||||
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// Flatpak 路径
|
||||
flatpakPaths := []string{
|
||||
"/var/lib/flatpak/exports/bin/com.google.Chrome",
|
||||
"/var/lib/flatpak/exports/bin/org.chromium.Chromium",
|
||||
"/home/*/.local/share/flatpak/exports/bin/com.google.Chrome",
|
||||
"/home/*/.local/share/flatpak/exports/bin/org.chromium.Chromium",
|
||||
}
|
||||
for _, p := range flatpakPaths {
|
||||
if strings.Contains(p, "*") {
|
||||
// 展开 home 目录通配符
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
p = strings.Replace(p, "/home/*", home, 1)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// Snap 路径
|
||||
snapPaths := []string{
|
||||
"/snap/chromium/current/usr/bin/chromium",
|
||||
"/snap/google-chrome/current/usr/bin/google-chrome",
|
||||
}
|
||||
for _, p := range snapPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"google-chrome", "chromium", "chrome"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no Chrome/Chromium/Edge found (set AUTOGO_BROWSER_BIN; candidates tried: %v)", candidates)
|
||||
}
|
||||
|
||||
// ListTargets 列出调试端点上的全部 target。
|
||||
func (b *Browser) ListTargets(ctx context.Context) ([]Target, error) {
|
||||
base, err := b.baseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var targets []Target
|
||||
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// NewTab 新开一个 page 并导航到 url。
|
||||
func (b *Browser) NewTab(ctx context.Context, url string) (*Target, error) {
|
||||
base, err := b.baseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
do := func(method string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, base+"/json/new?url="+neturl.QueryEscape(url), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
resp, err := do(http.MethodPut)
|
||||
if err == nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
resp.Body.Close()
|
||||
resp, err = do(http.MethodGet) // 老版本 Chrome 只支持 GET /json/new
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("/json/new: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var t Target
|
||||
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Close 终止托管的 Chrome 进程(DebugBase 注入模式为空操作)。
|
||||
func (b *Browser) Close() {
|
||||
b.mu.Lock()
|
||||
cmd := b.cmd
|
||||
b.cmd, b.base = nil, ""
|
||||
b.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user