magic-agent 2.4.3 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +8 -2
package/internal/cli/team.go
CHANGED
|
@@ -48,6 +48,11 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
48
48
|
if err != nil {
|
|
49
49
|
return &usageError{err}
|
|
50
50
|
}
|
|
51
|
+
/* 主备里的「备」:按同一套键合并进 routes(见 team.ApplyFallbacks)。
|
|
52
|
+
主引擎那一对来自 --fallback-engine / --fallback-model(对所有跟随主引擎的类别生效)。 */
|
|
53
|
+
if routes, err = team.ApplyFallbacks(routes, opts.teamFallbacks); err != nil {
|
|
54
|
+
return &usageError{err}
|
|
55
|
+
}
|
|
51
56
|
if err := team.ValidateRoutes(routes); err != nil {
|
|
52
57
|
return &usageError{err}
|
|
53
58
|
}
|
|
@@ -59,14 +64,13 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
59
64
|
if err != nil {
|
|
60
65
|
return &usageError{err}
|
|
61
66
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
teamPerm = tier
|
|
67
|
+
/* 子任务权限:默认**继承主会话**(req.Permission 由 prepareAsk 解析好,含默认 full)。
|
|
68
|
+
显式给 --team-permission 时用它覆盖,用来把子任务压得比主会话更严。
|
|
69
|
+
早前的固定默认 accept-edits 会让每个要跑命令的子任务卡在「等待授权」上 ——
|
|
70
|
+
团队模式没有作答通道(子任务不共享上下文),只能干等到单任务超时。 */
|
|
71
|
+
teamPerm, err := teamPermission(req.Permission, opts.teamPerm)
|
|
72
|
+
if err != nil {
|
|
73
|
+
return &usageError{err}
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr()
|
|
@@ -123,7 +127,12 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
123
127
|
defer cancelAll()
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
h := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
130
|
+
h, berr := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
131
|
+
if berr != nil {
|
|
132
|
+
/* 登记失败不阻断团队路径(下面到处都在判 h != nil,跑得起来就跑),
|
|
133
|
+
但**必须把原因说清楚** —— 否则用户只看到「团队模式什么都没发生」。 */
|
|
134
|
+
fmt.Fprintf(stderr, "magic-agent: 会话登记失败(团队模式继续执行):%v\n", berr)
|
|
135
|
+
}
|
|
127
136
|
/* 团队模式**不挂** spawn hook:登记表里的 pid 必须一直是 magic-agent 自己。
|
|
128
137
|
团队路径会多次 spawn 主引擎(计划 / 汇总 / 补充规划),hook 会把 pid 换成
|
|
129
138
|
这些**短命**子进程的 pid;第一个(计划阶段)一退,normalizeDead 见 pid 不在
|
|
@@ -211,6 +220,8 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
211
220
|
res, err := team.Run(ctx, team.Options{
|
|
212
221
|
Engine: engine,
|
|
213
222
|
Model: req.Model,
|
|
223
|
+
FallbackEngine: req.FallbackEngine,
|
|
224
|
+
FallbackModel: req.FallbackModel,
|
|
214
225
|
Prompt: lastUserText(req.Messages),
|
|
215
226
|
SystemPrompt: req.SystemPrompt,
|
|
216
227
|
Reasoning: req.Reasoning,
|
|
@@ -316,6 +327,23 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
316
327
|
return nil
|
|
317
328
|
}
|
|
318
329
|
|
|
330
|
+
// teamPermission 决定子任务的权限档:默认继承主会话,显式 --team-permission 覆盖。
|
|
331
|
+
//
|
|
332
|
+
// 为什么默认继承(2026-10-24 用户定稿):团队模式没有作答通道,子任务一旦停在
|
|
333
|
+
// 「等待授权」就只能干等到单任务超时。主会话选了什么档,子任务就该用同一档 ——
|
|
334
|
+
// 用户在一次会话里只做一次权限决定。
|
|
335
|
+
func teamPermission(main agent.PermissionTier, raw string) (agent.PermissionTier, error) {
|
|
336
|
+
if strings.TrimSpace(raw) == "" {
|
|
337
|
+
return main, nil
|
|
338
|
+
}
|
|
339
|
+
tier, ok := agent.ParsePermissionTier(raw)
|
|
340
|
+
if !ok {
|
|
341
|
+
return "", fmt.Errorf("--team-permission 取值无效: %q(可选 %s)",
|
|
342
|
+
raw, strings.Join(agent.PermissionTiers(), " | "))
|
|
343
|
+
}
|
|
344
|
+
return tier, nil
|
|
345
|
+
}
|
|
346
|
+
|
|
319
347
|
// orphanSubs 收掉还没落定的子会话对应的子引擎进程组。
|
|
320
348
|
//
|
|
321
349
|
// 只在团队这一轮**异常退出**时调:正常收尾时每个子会话都在 runTask 的 end() 里
|
|
@@ -338,6 +338,45 @@ func TestTeamRouteParsingInvalid(t *testing.T) {
|
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
// TestTeamPermissionInheritsMain 子任务权限默认继承主会话(2026-10-24 报障回归)。
|
|
342
|
+
//
|
|
343
|
+
// 早前 --team-permission 的默认值是 accept-edits,且这个默认值**永远非空** →
|
|
344
|
+
// 子任务一律 accept-edits、跑命令就卡在「等待授权」,团队模式又没有作答通道,
|
|
345
|
+
// 只能干等到单任务 20 分钟超时。这里钉住「不给覆盖 = 用主会话那一档」。
|
|
346
|
+
func TestTeamPermissionInheritsMain(t *testing.T) {
|
|
347
|
+
for _, main := range []agent.PermissionTier{
|
|
348
|
+
agent.PermissionFull, agent.PermissionAuto, agent.PermissionAcceptEdits, agent.PermissionManual,
|
|
349
|
+
} {
|
|
350
|
+
got, err := teamPermission(main, "")
|
|
351
|
+
if err != nil {
|
|
352
|
+
t.Fatalf("继承 %s 不该报错:%v", main, err)
|
|
353
|
+
}
|
|
354
|
+
if got != main {
|
|
355
|
+
t.Errorf("空 --team-permission 应当继承主会话 %s,得到 %s", main, got)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 显式给档位 = 覆盖(用来把子任务压得更严)。
|
|
359
|
+
got, err := teamPermission(agent.PermissionFull, "accept-edits")
|
|
360
|
+
if err != nil || got != agent.PermissionAcceptEdits {
|
|
361
|
+
t.Errorf("显式覆盖失败:got=%s err=%v", got, err)
|
|
362
|
+
}
|
|
363
|
+
if _, err := teamPermission(agent.PermissionFull, "乱写"); err == nil {
|
|
364
|
+
t.Error("非法档位应当报错")
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// TestTeamPermissionFlagDefaultEmpty 默认值必须是空串 —— 非空会让「继承」永远走不到。
|
|
369
|
+
func TestTeamPermissionFlagDefaultEmpty(t *testing.T) {
|
|
370
|
+
cmd := NewRootCommand()
|
|
371
|
+
f := cmd.PersistentFlags().Lookup("team-permission")
|
|
372
|
+
if f == nil {
|
|
373
|
+
t.Fatal("缺少 --team-permission flag")
|
|
374
|
+
}
|
|
375
|
+
if f.DefValue != "" {
|
|
376
|
+
t.Errorf("--team-permission 默认值 = %q,应为空串(空 = 继承主会话)", f.DefValue)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
341
380
|
// nonEmptyLines 去掉空行。
|
|
342
381
|
func nonEmptyLines(s string) []string {
|
|
343
382
|
out := make([]string, 0, 64)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// ab.go - agent-browser 封装:内置浏览器 MCP 的「驱动/截图」底层。
|
|
4
|
+
//
|
|
5
|
+
// 用户定调(2026-10-25):内置浏览器 MCP 基于 agent-browser 插件实现。
|
|
6
|
+
// agent-browser(vercel-labs)是 Rust 原生 CLI,驱动真实 Chromium(CDP),
|
|
7
|
+
// 默认 headless,daemon 模型(首次 open 自动拉起,闲置约 1h 自动退出)。
|
|
8
|
+
//
|
|
9
|
+
// ── 为什么驱动面换 agent-browser,而中转显示不动 ──
|
|
10
|
+
// 旧的驱动路(/browse-input 派发给页面内 relay)只能派**合成事件**
|
|
11
|
+
// (isTrusted=false,反自动化站点忽略),截图靠 html2canvas(跨域污染、
|
|
12
|
+
// iframe 抓不到、慢页超时)。agent-browser 是真实 Chromium:真实事件、
|
|
13
|
+
// CDP 级截图、PDF、a11y 快照 —— 驱动能力全面胜出。
|
|
14
|
+
// 但 agent-browser 的浏览器在服务端,手机 iframe 看的页面走 /b/ 中转,
|
|
15
|
+
// 两者是**两个独立上下文**:手机端「看 + 触控」仍走中转 + relay 远程鼠标,
|
|
16
|
+
// 「驱动(MCP)+ 截图」走 agent-browser。
|
|
17
|
+
//
|
|
18
|
+
// ── spawn 形状 ──
|
|
19
|
+
// agent-browser 的 npm 包 postinstall 把 .cmd shim 改写成直调原生 exe
|
|
20
|
+
// (零 node 依赖),最稳的调法是**绝对路径 spawn 原生 exe**:
|
|
21
|
+
// <npm前缀>/node_modules/agent-browser/bin/agent-browser-<goos>-<goarch>[.exe]
|
|
22
|
+
// 统一加 --json:输出 {"success":true,"data":{…}};stdout 可能混非 JSON 行
|
|
23
|
+
// (✓ 提示;[agent-browser] 日志走 stderr),解析取首个 { 到末尾。
|
|
24
|
+
//
|
|
25
|
+
// ── 会话隔离 ──
|
|
26
|
+
// 固定 --session magic-client:与人类/其它工具的 default 会话隔离,
|
|
27
|
+
// cookie/localStorage 在会话内保留(登录态可跨动作复用)。
|
|
28
|
+
|
|
29
|
+
import (
|
|
30
|
+
"bytes"
|
|
31
|
+
"context"
|
|
32
|
+
"encoding/json"
|
|
33
|
+
"fmt"
|
|
34
|
+
"os"
|
|
35
|
+
"os/exec"
|
|
36
|
+
"path/filepath"
|
|
37
|
+
"runtime"
|
|
38
|
+
"strconv"
|
|
39
|
+
"strings"
|
|
40
|
+
"time"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// abSession agent-browser 会话名(--session)。固定一个,便于复用登录态。
|
|
44
|
+
const abSession = "magic-client"
|
|
45
|
+
|
|
46
|
+
// abTimeout 单次 CLI 调用超时。冷启动(拉 daemon + Chromium)实测 ~2s,
|
|
47
|
+
// 重页导航网络慢时更久,给 60s 兜底。
|
|
48
|
+
const abTimeout = 60 * time.Second
|
|
49
|
+
|
|
50
|
+
// EnvAgentBrowserBin 显式指定 agent-browser 原生 exe 的环境变量。
|
|
51
|
+
const EnvAgentBrowserBin = "MAGIC_AGENT_AGENT_BROWSER_BIN"
|
|
52
|
+
|
|
53
|
+
// abBin 探测 agent-browser 原生 exe:
|
|
54
|
+
// 环境变量 > npm 全局前缀(node_modules/agent-browser/bin/)> PATH。
|
|
55
|
+
//
|
|
56
|
+
// npm 全局前缀的定位只读环境变量(NPM_CONFIG_PREFIX / npm_config_prefix),
|
|
57
|
+
// 不起 npm 子进程 —— 探测在请求路径上,spawn 一次 npm 是秒级开销。
|
|
58
|
+
// 都没有时返回 "",调用方回 501 + 安装提示。
|
|
59
|
+
func abBin() string {
|
|
60
|
+
if v := strings.TrimSpace(os.Getenv(EnvAgentBrowserBin)); v != "" {
|
|
61
|
+
return v
|
|
62
|
+
}
|
|
63
|
+
// npm 全局前缀下的包目录(Windows 与 unix 同形状)。
|
|
64
|
+
for _, k := range []string{"NPM_CONFIG_PREFIX", "npm_config_prefix"} {
|
|
65
|
+
if p := strings.TrimSpace(os.Getenv(k)); p != "" {
|
|
66
|
+
if cand := abNativeExe(filepath.Join(p, "node_modules", "agent-browser", "bin")); cand != "" {
|
|
67
|
+
return cand
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 默认前缀兜底(Windows npm 默认 %APPDATA%\npm;unix 常见 /usr/local 等)。
|
|
72
|
+
for _, p := range abDefaultPrefixes() {
|
|
73
|
+
if cand := abNativeExe(filepath.Join(p, "node_modules", "agent-browser", "bin")); cand != "" {
|
|
74
|
+
return cand
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// PATH 命中(agent-browser / agent-browser.cmd / agent-browser.exe)。
|
|
78
|
+
if path, err := exec.LookPath("agent-browser"); err == nil {
|
|
79
|
+
return path
|
|
80
|
+
}
|
|
81
|
+
return ""
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// abNativeExe 在 bin 目录里找当前平台的原生 exe。
|
|
85
|
+
func abNativeExe(binDir string) string {
|
|
86
|
+
name := "agent-browser-" + runtime.GOOS + "-" + runtime.GOARCH
|
|
87
|
+
if runtime.GOOS == "windows" {
|
|
88
|
+
name += ".exe"
|
|
89
|
+
}
|
|
90
|
+
p := filepath.Join(binDir, name)
|
|
91
|
+
if _, err := os.Stat(p); err == nil {
|
|
92
|
+
return p
|
|
93
|
+
}
|
|
94
|
+
return ""
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// abDefaultPrefixes 各平台 npm 默认全局前缀(探测兜底,不跑 npm)。
|
|
98
|
+
func abDefaultPrefixes() []string {
|
|
99
|
+
out := []string{}
|
|
100
|
+
if appdata := os.Getenv("APPDATA"); appdata != "" {
|
|
101
|
+
out = append(out, filepath.Join(appdata, "npm"))
|
|
102
|
+
}
|
|
103
|
+
if home, err := os.UserHomeDir(); err == nil {
|
|
104
|
+
out = append(out, filepath.Join(home, ".npm-global"))
|
|
105
|
+
}
|
|
106
|
+
out = append(out, "/usr/local/lib", "/usr/local")
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// abResult agent-browser --json 的统一信封。
|
|
111
|
+
type abResult struct {
|
|
112
|
+
Success bool `json:"success"`
|
|
113
|
+
Data json.RawMessage `json:"data"`
|
|
114
|
+
Error *string `json:"error"`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// abRun 跑一条 agent-browser 命令,返回 data 字段。
|
|
118
|
+
//
|
|
119
|
+
// 统一 --json:成功时解析 {"success":true,"data":…} 取 data;
|
|
120
|
+
// 失败时 error 字段 + stderr 拼成可读信息。会话前置,超时 60s。
|
|
121
|
+
//
|
|
122
|
+
// 遇「Failed to read … os error 10060」自动重试一次:daemon 与浏览器之间的
|
|
123
|
+
// 连接在 close 后立刻复用、或长时间闲置后偶发断连,第一条命令会撞上
|
|
124
|
+
// TCP 半开连接(实测挂满超时才报错);再试一次就会触发 daemon 自愈重连。
|
|
125
|
+
func abRun(ctx context.Context, args ...string) (json.RawMessage, error) {
|
|
126
|
+
data, err := abRunOnce(ctx, args...)
|
|
127
|
+
if err == nil {
|
|
128
|
+
return data, nil
|
|
129
|
+
}
|
|
130
|
+
if strings.Contains(err.Error(), "os error 10060") || strings.Contains(err.Error(), "Failed to read") {
|
|
131
|
+
return abRunOnce(ctx, args...)
|
|
132
|
+
}
|
|
133
|
+
return nil, err
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func abRunOnce(ctx context.Context, args ...string) (json.RawMessage, error) {
|
|
137
|
+
bin := abBin()
|
|
138
|
+
if bin == "" {
|
|
139
|
+
return nil, fmt.Errorf("服务端未安装 agent-browser(npm install -g agent-browser && agent-browser install,或设 %s)", EnvAgentBrowserBin)
|
|
140
|
+
}
|
|
141
|
+
ctx, cancel := context.WithTimeout(ctx, abTimeout)
|
|
142
|
+
defer cancel()
|
|
143
|
+
full := append([]string{"--session", abSession, "--json"}, args...)
|
|
144
|
+
var cmd *exec.Cmd
|
|
145
|
+
if strings.EqualFold(filepath.Ext(bin), ".cmd") {
|
|
146
|
+
// npm shim:.cmd 必须经 cmd.exe 解释(Go 在 Windows 上不能直接 exec .cmd)。
|
|
147
|
+
cmd = exec.CommandContext(ctx, "cmd", append([]string{"/c", bin}, full...)...)
|
|
148
|
+
} else {
|
|
149
|
+
cmd = exec.CommandContext(ctx, bin, full...)
|
|
150
|
+
}
|
|
151
|
+
var stdout, stderr bytes.Buffer
|
|
152
|
+
cmd.Stdout = &stdout
|
|
153
|
+
cmd.Stderr = &stderr
|
|
154
|
+
runErr := cmd.Run()
|
|
155
|
+
out := stdout.String()
|
|
156
|
+
// stdout 可能混非 JSON 行(✓ 提示等):取首个 { 到末尾。
|
|
157
|
+
var res abResult
|
|
158
|
+
i := strings.IndexByte(out, '{')
|
|
159
|
+
if i >= 0 {
|
|
160
|
+
if err := json.Unmarshal([]byte(out[i:]), &res); err != nil {
|
|
161
|
+
msg := strings.TrimSpace(stderr.String())
|
|
162
|
+
if msg == "" {
|
|
163
|
+
msg = strings.TrimSpace(out)
|
|
164
|
+
}
|
|
165
|
+
return nil, fmt.Errorf("agent-browser 输出无法解析: %w(%s)", err, msg)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if res.Error != nil && *res.Error != "" {
|
|
169
|
+
return nil, fmt.Errorf("agent-browser: %s", *res.Error)
|
|
170
|
+
}
|
|
171
|
+
if !res.Success {
|
|
172
|
+
msg := strings.TrimSpace(stderr.String())
|
|
173
|
+
if msg == "" {
|
|
174
|
+
msg = strings.TrimSpace(out)
|
|
175
|
+
}
|
|
176
|
+
if runErr != nil && msg == "" {
|
|
177
|
+
msg = runErr.Error()
|
|
178
|
+
}
|
|
179
|
+
return nil, fmt.Errorf("agent-browser 命令失败: %s", msg)
|
|
180
|
+
}
|
|
181
|
+
return res.Data, nil
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// abOpen 打开一个页面(daemon 未起会自动拉起)。
|
|
185
|
+
func abOpen(ctx context.Context, url string) error {
|
|
186
|
+
_, err := abRun(ctx, "open", url)
|
|
187
|
+
return err
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// abShot 截图存到 outPath。fullPage 时截整页滚动高度(agent-browser 的 flag 是 --full)。
|
|
191
|
+
//
|
|
192
|
+
// ⚠️ flag 必须放在路径**前面**:agent-browser 把 screenshot 的最后一个位置参数
|
|
193
|
+
// 当路径,flag 落在后面会被当成「要截的选择器」(实测 success:true 但文件不写)。
|
|
194
|
+
func abShot(ctx context.Context, outPath string, fullPage bool) error {
|
|
195
|
+
args := []string{"screenshot"}
|
|
196
|
+
if fullPage {
|
|
197
|
+
args = append(args, "--full")
|
|
198
|
+
}
|
|
199
|
+
args = append(args, outPath)
|
|
200
|
+
_, err := abRun(ctx, args...)
|
|
201
|
+
return err
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// abGetURL 当前页面地址。
|
|
205
|
+
func abGetURL(ctx context.Context) (string, error) {
|
|
206
|
+
data, err := abRun(ctx, "get", "url")
|
|
207
|
+
if err != nil {
|
|
208
|
+
return "", err
|
|
209
|
+
}
|
|
210
|
+
return abDataString(data), nil
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// abGetTitle 当前页面标题。
|
|
214
|
+
func abGetTitle(ctx context.Context) (string, error) {
|
|
215
|
+
data, err := abRun(ctx, "get", "title")
|
|
216
|
+
if err != nil {
|
|
217
|
+
return "", err
|
|
218
|
+
}
|
|
219
|
+
return abDataString(data), nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// abEval 在页面里跑一段 JS,返回结果(原样 JSON)。
|
|
223
|
+
func abEval(ctx context.Context, js string) (json.RawMessage, error) {
|
|
224
|
+
return abRun(ctx, "eval", js)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// abMouse 派发鼠标动作(move/click/dblclick)。坐标是 CSS 像素。
|
|
228
|
+
func abMouse(ctx context.Context, action string, x, y float64) error {
|
|
229
|
+
switch action {
|
|
230
|
+
case "move":
|
|
231
|
+
_, err := abRun(ctx, "mouse", "move", fmtNum(x), fmtNum(y))
|
|
232
|
+
return err
|
|
233
|
+
case "click":
|
|
234
|
+
if _, err := abRun(ctx, "mouse", "move", fmtNum(x), fmtNum(y)); err != nil {
|
|
235
|
+
return err
|
|
236
|
+
}
|
|
237
|
+
if _, err := abRun(ctx, "mouse", "down"); err != nil {
|
|
238
|
+
return err
|
|
239
|
+
}
|
|
240
|
+
_, err := abRun(ctx, "mouse", "up")
|
|
241
|
+
return err
|
|
242
|
+
case "dblclick":
|
|
243
|
+
if err := abMouse(ctx, "click", x, y); err != nil {
|
|
244
|
+
return err
|
|
245
|
+
}
|
|
246
|
+
return abMouse(ctx, "click", x, y)
|
|
247
|
+
}
|
|
248
|
+
return fmt.Errorf("未知鼠标动作: %s", action)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// abScroll 滚动(dx 右 / dy 下,CSS 像素)。
|
|
252
|
+
func abScroll(ctx context.Context, dx, dy float64) error {
|
|
253
|
+
_, err := abRun(ctx, "mouse", "wheel", fmtNum(dy), fmtNum(dx))
|
|
254
|
+
return err
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// abType 向当前焦点元素输入文本(先点一下落点,再键盘输入)。
|
|
258
|
+
func abType(ctx context.Context, text string) error {
|
|
259
|
+
_, err := abRun(ctx, "keyboard", "type", text)
|
|
260
|
+
return err
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// abKey 按一个键(Enter / Escape / a …)。
|
|
264
|
+
func abKey(ctx context.Context, key string) error {
|
|
265
|
+
_, err := abRun(ctx, "press", key)
|
|
266
|
+
return err
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// abPDF 存成 PDF。
|
|
270
|
+
func abPDF(ctx context.Context, outPath string) error {
|
|
271
|
+
_, err := abRun(ctx, "pdf", outPath)
|
|
272
|
+
return err
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// abSnap a11y 快照(interactive 只出可交互元素)。返回快照文本。
|
|
276
|
+
func abSnap(ctx context.Context, interactive bool) (string, error) {
|
|
277
|
+
args := []string{"snapshot"}
|
|
278
|
+
if interactive {
|
|
279
|
+
args = append(args, "-i")
|
|
280
|
+
}
|
|
281
|
+
data, err := abRun(ctx, args...)
|
|
282
|
+
if err != nil {
|
|
283
|
+
return "", err
|
|
284
|
+
}
|
|
285
|
+
return abDataString(data), nil
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// fmtNum 浮点参数的最紧凑表示(整数不带小数点)。
|
|
289
|
+
func fmtNum(f float64) string {
|
|
290
|
+
if f == float64(int64(f)) {
|
|
291
|
+
return strconv.FormatInt(int64(f), 10)
|
|
292
|
+
}
|
|
293
|
+
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── 导出层(cmd/magic-client 与 HTTP 端点用)──
|
|
297
|
+
|
|
298
|
+
// ABTimeout 单次 agent-browser 调用超时(browsectl 兼容旧常量名)。
|
|
299
|
+
const ABTimeout = abTimeout
|
|
300
|
+
|
|
301
|
+
// ABOpen 打开页面。
|
|
302
|
+
func ABOpen(ctx context.Context, url string) error { return abOpen(ctx, url) }
|
|
303
|
+
|
|
304
|
+
// ABShot 截图落盘。
|
|
305
|
+
func ABShot(ctx context.Context, outPath string, fullPage bool) error {
|
|
306
|
+
return abShot(ctx, outPath, fullPage)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ABState 当前页面地址与标题。
|
|
310
|
+
func ABState(ctx context.Context) (url, title string, err error) {
|
|
311
|
+
if url, err = abGetURL(ctx); err != nil {
|
|
312
|
+
return "", "", err
|
|
313
|
+
}
|
|
314
|
+
title, err = abGetTitle(ctx)
|
|
315
|
+
if err != nil {
|
|
316
|
+
return "", "", err
|
|
317
|
+
}
|
|
318
|
+
return url, title, nil
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ABMouse 鼠标动作。
|
|
322
|
+
func ABMouse(ctx context.Context, action string, x, y float64) error {
|
|
323
|
+
return abMouse(ctx, action, x, y)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ABScroll 滚动。
|
|
327
|
+
func ABScroll(ctx context.Context, dx, dy float64) error { return abScroll(ctx, dx, dy) }
|
|
328
|
+
|
|
329
|
+
// ABType 输入文本。
|
|
330
|
+
func ABType(ctx context.Context, text string) error { return abType(ctx, text) }
|
|
331
|
+
|
|
332
|
+
// ABKey 按键。
|
|
333
|
+
func ABKey(ctx context.Context, key string) error { return abKey(ctx, key) }
|
|
334
|
+
|
|
335
|
+
// ABEval 跑 JS。
|
|
336
|
+
func ABEval(ctx context.Context, js string) (json.RawMessage, error) { return abEval(ctx, js) }
|
|
337
|
+
|
|
338
|
+
// ABPDF 存 PDF。
|
|
339
|
+
func ABPDF(ctx context.Context, outPath string) error { return abPDF(ctx, outPath) }
|
|
340
|
+
|
|
341
|
+
// ABSnap a11y 快照。
|
|
342
|
+
func ABSnap(ctx context.Context, interactive bool) (string, error) { return abSnap(ctx, interactive) }
|
|
343
|
+
|
|
344
|
+
// abDataString 从 --json 的 data 里取字符串。
|
|
345
|
+
// agent-browser 的标量结果有两种包法:data 本身是字符串,或 {result: …} / {text: …}。
|
|
346
|
+
func abDataString(data json.RawMessage) string {
|
|
347
|
+
s := strings.TrimSpace(string(data))
|
|
348
|
+
if s == "" {
|
|
349
|
+
return ""
|
|
350
|
+
}
|
|
351
|
+
// data 本身是 JSON 字符串。
|
|
352
|
+
var str string
|
|
353
|
+
if err := json.Unmarshal(data, &str); err == nil {
|
|
354
|
+
return str
|
|
355
|
+
}
|
|
356
|
+
// 包在对象字段里。
|
|
357
|
+
var obj struct {
|
|
358
|
+
Result string `json:"result"`
|
|
359
|
+
Text string `json:"text"`
|
|
360
|
+
URL string `json:"url"`
|
|
361
|
+
Title string `json:"title"`
|
|
362
|
+
Path string `json:"path"`
|
|
363
|
+
}
|
|
364
|
+
if err := json.Unmarshal(data, &obj); err == nil {
|
|
365
|
+
for _, v := range []string{obj.Result, obj.Text, obj.URL, obj.Title, obj.Path} {
|
|
366
|
+
if v != "" {
|
|
367
|
+
return v
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return s
|
|
372
|
+
}
|