magic-agent 2.4.3 → 2.6.3
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 +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -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 +69 -10
- package/cmd/magic-client/main.go +210 -32
- 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_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- 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/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- 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/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- 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 +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- 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 +168 -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/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- 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/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -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/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- 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/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- 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/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -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/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -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/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- 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 +6 -3
- 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/npm/install.js +11 -0
- package/package.json +14 -3
package/CLIENT-CONTRACT.md
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
| 面 | 命令 / 通道 | 状态 | 实现 |
|
|
13
13
|
| --- | --- | --- | --- |
|
|
14
14
|
| 引擎调用 | `-e <engine> -p "<prompt>"`(含超时、重试、退出码) | 稳定 | `internal/cli/ask.go` |
|
|
15
|
-
| 探测与能力 | `--engines [--no-models]`:`ok` / `bin` / `install` / `version` / `models` / `permission` / `reasoning` / `capabilities`
|
|
15
|
+
| 探测与能力 | `--engines [--no-models]`:`ok` / `bin` / `install` / `version` / `models` / `models_note` / `models_source` / `model_credits` / `credit_left` / `credit_suffix` / `model_quota` / `checkin_today` / `checkin_credit` / `permission` / `reasoning` / `capabilities` …(`credit_left` 为账号剩余额度,`credit_suffix` 为它的单位:codebuddy 系省略=积分,llm 的 MiniMax 套餐给 `%`;`model_quota` 为**按模型**的套餐额度快照 `{model: {interval_left, weekly_left}}`,仅 llm 的 MiniMax 模型给出,**按各自 apiKey 查**;`checkin_today`/`checkin_credit` 为每日签到状态,仅 codebuddy 系给出,查不到则省略;`logged_in` 为多账号槽位是否已登录,仅 codebuddy / codebuddy-ai 给出,客户端**只显示已登录的账号行**;`account` 为该槽位登录的账号昵称(票据里的 `account.nickname`,如 `Darren`,客户端画在引擎名下面);**`models_source` 为模型来源语义(2026-10 只增字段,不升 contractVersion):`dynamic` = models 可用 / `none` = 引擎本身不提供模型选择(arkclaw 按 claw_id 绑模型、codebuddy-gateway 用网关进程的模型),**不是故障,客户端必须保留该引擎行**并显示「引擎默认」/ `failed` = 有来源但本次探测失败(如 openclaw CLI 非 0 退出),可隐藏或置灰;老 core 无此字段时客户端按 `models_note` 的 `no dynamic model source` 前缀兜底**) | 稳定 | `internal/cli/root.go` + `internal/agent/credits.go` + `internal/agent/models.go` |
|
|
16
|
+
| 额度 / 签到现查 | `--quotas`:只查额度与签到,**不探测引擎、不启动任何 CLI**(`{engine, credit_left?, credit_suffix?, model_quota?, checkin_today?, checkin_credit?, logged_in?, account?}[]`,字段与 `--engines` 同名同义;秒级)。客户端用它把最新额度合并进缓存的引擎清单 —— 模型清单可缓存,额度不能 | 稳定 | `internal/cli/quotas.go` |
|
|
17
|
+
| 每日积分 | `--checkin <engine>`:领取该引擎账号的每日积分(codebuddy / codebuddy-ai),输出 `{engine, ok, credit, streak_days, checked_today, message?, credit_left?}`;业务失败(今天已领)也是 exit 0 + `ok:false` | 稳定 | `internal/cli/checkin.go` |
|
|
16
18
|
| 契约 envelope | `--contract`:`{contractVersion, version, features, engines[], teamRoles[]}`,`engines[]` 与 `--engines` **同构**;`version` 是 core 自身版本(2026-10-22 只增字段,与 `--version` 同一份,老 core 缺失时客户端显示「—」);`teamRoles[]` 为团队角色清单(2026-10-22 只增字段,字段缺失时客户端回落内置清单) | v2 | `internal/cli/root.go` + `internal/agent/capability.go` + `internal/team/role.go` |
|
|
17
19
|
| 事件流 | `--stream --events`:NDJSON,每行带 `v` / `seq`,首行 `ready`;文本/轮次事件带状态收敛四元组(`item_id` / `item_revision` / `status` / `snapshot`,见 1.1.1) | v1 | `internal/cli/events.go` |
|
|
18
20
|
| 控制回传 | `--stream --control`:stdin 收 NDJSON 命令(`ping` / `interrupt` / `stop` / `answer`) | v1 | `internal/cli/control.go` |
|
|
@@ -29,6 +31,7 @@
|
|
|
29
31
|
{"type":"tool_result","text":"<output>","id":"toolu_x"}
|
|
30
32
|
{"type":"turn_end","text":"<该轮正文>","session_id":"…"}
|
|
31
33
|
{"type":"turn_failed","session_id":"…","error":"<完整错误链>","reason":"<根因>","status":"failed"}
|
|
34
|
+
{"type":"turn_failed","session_id":"…","reason":"429 You have exceeded the 5-hour usage quota. It will reset at …","error":"<同上>","status":"failed","error_kind":"quota","error_code":"429","reset_at":"2026-10-25T15:07:27+08:00"}
|
|
32
35
|
{"type":"ask","text":"<一行摘要>","ask":{…}}
|
|
33
36
|
{"type":"result","engine":"…","model":"…","session_id":"…","text":"<全文>","thinking":"…","tools":[…]}
|
|
34
37
|
{"type":"error","engine":"…","reason":"<根因>","error":"<完整错误链>"}
|
|
@@ -41,15 +44,49 @@
|
|
|
41
44
|
|
|
42
45
|
开 `--events` 后**字段集不变**,每行多 `v` / `seq`(`seq` 从 1 单调 +1),
|
|
43
46
|
并额外产出四类 core 自己的事件:`ready`(首行,带 `engine` / `model`)、
|
|
44
|
-
`pong`、`interrupted`、`stopped`、`answered`、`control_error`。
|
|
47
|
+
`pong`、`interrupted`、`stopped`、`answered`、`ask_answered`、`control_error`。
|
|
48
|
+
|
|
49
|
+
**`ask` 有两种 kind(2026-10-25 补 permission 的落地口径)**:
|
|
50
|
+
|
|
51
|
+
| kind | 含义 | `ask` 里带什么 | 客户端动作 |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `question` | 模型提问(claude 的 AskUserQuestion) | `questions[]`(题面 + options) | 画提问卡;答案以 `【用户选择】…` 作为下一轮 user 消息(`--append` / `op:"answer"`)送回 |
|
|
54
|
+
| `permission` | **待授权**:计划审批 `ExitPlanMode`、manual 档的工具授权 | `tool_name` / `request_id` / `tool_input`,**没有 questions** | 画「允许 / 拒绝」裁决卡;点选后走 `op:"ask_answer"`(见下) |
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{"type":"ask","text":"需要授权:ExitPlanMode(等待用户选择)","ask":{
|
|
58
|
+
"engine":"codebuddy","kind":"permission","source":"control_request",
|
|
59
|
+
"tool_name":"ExitPlanMode","tool_use_id":"call_…","request_id":"perm_1792896027947_1",
|
|
60
|
+
"tool_input":{"allowedPrompts":[…]}}}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
⚠️ `permission` **不能**用 `op:"answer"` 作答:引擎这时正阻塞在子进程 stdin 的
|
|
64
|
+
`control_response` 上,user 消息要等这一轮结束才会被读 —— 发过去只会「答了也继续转圈」。
|
|
65
|
+
必须用 `POST /desk/ask/{id}/control`:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{"op":"ask_answer","request_id":"perm_1792896027947_1","allowed":true,"message":"","tool_name":"ExitPlanMode"}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
core 把它编码成引擎自己的控制应答(codebuddy:`{"allowed":true}` / `{"allowed":false,"reason":"…"}`,
|
|
72
|
+
实测口径见 `internal/agent/ask.go`)写进子进程 stdin;成功回 `ask_answered`,
|
|
73
|
+
写不回去(本轮已结束 / 引擎不支持)回 `control_error` —— 客户端要把失败如实告诉用户,
|
|
74
|
+
不要假装批准成功。旧版 core 没有这条 op(回 `control_error`「未知 op」),客户端据此回落文本追问。
|
|
45
75
|
|
|
46
76
|
**`model` 一律是完整 id,可能带 `custom-local:` 前缀**(codebuddy / codebuddy-ai 的
|
|
47
77
|
自定义端点模型,见 `docs/engines.md`)。这个值要原样用于 `POST /desk/ask` 的 `model`
|
|
48
78
|
与 `-m`;**展示时必须剥掉前缀**(`custom-local:` 只是 App / CLI 内部的端点区分约定,
|
|
49
|
-
对用户是噪点)。`--engines` 的 `models[]` 与 `
|
|
79
|
+
对用户是噪点)。`--engines` 的 `models[]`、`model_credits` 与 `model_vendors` 的键
|
|
80
|
+
**已经剥过**
|
|
50
81
|
(`agent.StripCustomLocalPrefix`),事件与 `/desk/sessions` 里的 `model` **没有** ——
|
|
51
82
|
客户端渲染前自己剥。
|
|
52
83
|
|
|
84
|
+
`--engines` 的 `model_vendors`(2026-10-24,只增字段,不升 `contractVersion`)是
|
|
85
|
+
`{模型 id → 供应商展示名}`,只给模型选择器的**第二行**用(与 dsh 的 `route/model`
|
|
86
|
+
路由行同一位置、同一视觉),不参与 `-m` / 落库。目前只有 codebuddy / codebuddy-ai
|
|
87
|
+
给出(取自它自己 `models.json` 的 `vendor`,空值 / `Custom` 已被 core 过滤);
|
|
88
|
+
字段缺失 = 不画第二行。
|
|
89
|
+
|
|
53
90
|
两条不变量(有测试钉住):**每轮一定以 `result` 或 `error` 收尾**;
|
|
54
91
|
`--events` 与 `--stream` 的字段集只差 `v` / `seq`。
|
|
55
92
|
|
|
@@ -66,6 +103,18 @@
|
|
|
66
103
|
| `snapshot` | 该 item 到此刻的**整段内容**(累积快照,不是增量)——⚠️ **只在实时流里**;落盘不写(见下方「落盘不存 snapshot」) |
|
|
67
104
|
| `parent` | 父委派 id(2026-10-21 只增字段,不升 `contractVersion`)。空 = 主会话自己的事件。语义与 claude 系原生的 `parent_tool_use_id` **完全一致**:带它的 `text` / `thinking` / `tool_use` / `tool_result` 属于父委派那次调用内部,渲染时挂进父卡的嵌套区而不是另起一笔 |
|
|
68
105
|
| `error` / `reason` | 仅 `turn_failed`:完整错误链 / 最内层根因 |
|
|
106
|
+
| `error_kind` | 仅 `turn_failed`(2026-10-25 只增字段):失败分类。`quota` = 额度用尽(余额 / 套餐用量上限,等恢复或换模型),`rate_limit` = 频率限制(隔一会儿再试)。**空 = 分不出来**,客户端退回普通失败卡 —— 别硬套标签 |
|
|
107
|
+
| `error_code` | 仅 `turn_failed`:通告里的状态码(`429` / `402` / `401` / `403` / `5xx`)。空 = 原文里没有 |
|
|
108
|
+
| `reset_at` | 仅 `turn_failed`:供应商给的**恢复时刻**,RFC3339(如 `2026-10-25T15:07:27+08:00`)。空 = 没说什么恢复 —— 客户端此时**不要**编「稍后」,把那一行整条省掉 |
|
|
109
|
+
|
|
110
|
+
> **为什么这三项在 core 而不在客户端**:分类与时刻解析的口径只有一份(`internal/agent/limitnotice.go`)。
|
|
111
|
+
> 客户端各自写正则必然各错一处,而且「把工具报错画成额度不足」比不画更糟。
|
|
112
|
+
> 客户端只做展示:`error_kind` 命中就画额度卡(标题 / 徽章 / 恢复时刻 / 建议 / 可展开原文),
|
|
113
|
+
> 其它一律退回普通「调用失败」卡。
|
|
114
|
+
>
|
|
115
|
+
> ⚠️ 额度那一类还有**正文侧**的约定:core 剥掉通告后的正文放在 `turn_failed.text`;
|
|
116
|
+
> 当这一轮**只有通告、没有真正文**时它是**空串**,而流式期间那段通告早就拼进气泡了 ——
|
|
117
|
+
> 客户端要按 `error_kind` 非空 + `text` 为空去**撤掉已画的正文**,否则卡片下面会一直铺着原文。
|
|
69
118
|
|
|
70
119
|
⚠️ **`parent` 不是团队模式专用**(这一点很关键,别把它设计成开关)。claude / codebuddy 原生
|
|
71
120
|
就会吐 `parent_tool_use_id`(子 agent 调用),core 只是把它**统一**成一个字段;
|
|
@@ -165,7 +214,7 @@ codex / dsh(+sdk) / llm / openclaw 暂不带 `item_id`,
|
|
|
165
214
|
|
|
166
215
|
| 集合 | 位置 | 回答的问题 |
|
|
167
216
|
| --- | --- | --- |
|
|
168
|
-
| `features[]` | `--contract` 顶层 | 这台 core 支不支持某条**通道**(`feature.session.events` / `feature.session.control` / `feature.session.hosted` / `feature.session.team` / `feature.requirements`) |
|
|
217
|
+
| `features[]` | `--contract` 顶层 | 这台 core 支不支持某条**通道**(`feature.session.events` / `feature.session.control` / `feature.session.hosted` / `feature.session.team` / `feature.requirements` / `feature.requirement.shots`) |
|
|
169
218
|
| `engines[].capabilities[]` | 每个引擎 | 这个**引擎**能做什么(`session.stream` / `attachment.native` / `engine.install` …) |
|
|
170
219
|
|
|
171
220
|
静态与运行态严格分开:`capabilities` / `features` 是静态声明,`ok` / `streaming` / `models` /
|
|
@@ -189,6 +238,12 @@ codex / dsh(+sdk) / llm / openclaw 暂不带 `item_id`,
|
|
|
189
238
|
分支给出「当前 core 不支持需求聚合」的明确文案,比一个消失的入口更容易理解。
|
|
190
239
|
其他客户端可自行取舍,但**一旦显示就必须能说清为什么不工作**(不要把 501 吞成空列表)。
|
|
191
240
|
|
|
241
|
+
`feature.requirement.shots`(2026-10-25 只增量,**不**进 `requiredFeatures`):这台 core 认
|
|
242
|
+
`--requirement-shot`(见 §3.4.1.1)。它与 `feature.requirements` **分开** —— core 可能支持
|
|
243
|
+
需求聚合但还不支持截图。插件对 `POST /desk/requirements/shots` 先查这个位,缺了回
|
|
244
|
+
**501 + 中文原因**。界面侧:功能点行的**相机徽标**只在这个位存在时才出现
|
|
245
|
+
(不给「点了必然 501」的死入口),但「需求」页签本身**照常显示**(理由同上)。
|
|
246
|
+
|
|
192
247
|
**移动端底栏(2026-10-22 定稿)**:`任务 · 需求 ·「+」· 发现 · 我的`。
|
|
193
248
|
|
|
194
249
|
- 「定时任务」页签**已删除**。它的功能没删:会话菜单里的「定时任务」仍然能唤起(现在给一句
|
|
@@ -224,7 +279,7 @@ codex / dsh(+sdk) / llm / openclaw 暂不带 `item_id`,
|
|
|
224
279
|
```bash
|
|
225
280
|
magic-agent -e claude --stream --events --team \
|
|
226
281
|
--team-route image=llm/hunyuan-image-3.0 --team-route video=arkclaw \
|
|
227
|
-
--team-parallel 4 --team-task-timeout 5m
|
|
282
|
+
--team-parallel 4 --team-task-timeout 5m \
|
|
228
283
|
-p '做一套水世界的小游戏,附一张主视觉图'
|
|
229
284
|
```
|
|
230
285
|
|
|
@@ -232,6 +287,8 @@ magic-agent -e claude --stream --events --team \
|
|
|
232
287
|
| --- | --- |
|
|
233
288
|
| `--team` | 开启。**必须配合 `--stream`**(过程靠事件流呈现,一次性 envelope 装不下)|
|
|
234
289
|
| `--team-route <key>=<engine>[/<model>]` | 给计划阶段、某类子任务或某个角色指定引擎/模型,可重复。`key ∈ plan\|text\|code\|image\|video\|game`(`plan` = 计划阶段,不是可执行子任务的类别),或 `role:<name>`(裸角色名也认:writer / researcher / reviewer / coder / designer / director / builder,与五类子任务一一覆盖;`video` / `code` 仍是类别键,不会被当成角色);**不给就是跟随主引擎**(六类一致)。优先级:任务写死 > 角色 > 类别 > 主引擎 |
|
|
290
|
+
| `--team-fallback <key>=<engine>[/<model>]` | 给计划阶段 / 某类子任务 / 某个角色指定**备用**引擎(2026-10-24)。键与语法同 `--team-route`,可重复;只配它不配 `--team-route` = 主引擎跟随主引擎 + 这一路的备。主引擎失败且**零产出**时降级一次,计划阶段也不例外(`plan` 行)|
|
|
291
|
+
| `--fallback-engine` / `--fallback-model` | 主引擎(`-e` / `-m`)的备用。影响主会话与所有「跟随主引擎」的类别;某一类/角色另有 `--team-fallback` 时以那一路为准 |
|
|
235
292
|
| `--team-parallel` | 同时最多跑几个子任务(默认 4)|
|
|
236
293
|
| `--team-task-timeout` | 单个子任务超时(默认 = 总超时/2,夹在 1m..20m)|
|
|
237
294
|
| `--team-plan-only` | 只出计划与任务书,不执行(先看方案)|
|
|
@@ -239,7 +296,7 @@ magic-agent -e claude --stream --events --team \
|
|
|
239
296
|
| `--team-split` | 拆解粒度:`coarse`(最多 3 个)\| `standard`(6,默认)\| `fine`(10)。只改「最多拆几个」,其余拆解规则不变 |
|
|
240
297
|
| `--team-split-code` | 简单代码也拆成独立子任务(**默认关**:单文件脚本 / 几行改动由主引擎在最终回复里直接给出)|
|
|
241
298
|
| `--team-rule role:<name>=<规则>` | 改写某个角色「这类任务适合什么」,替掉角色表里的内置职责(进计划提示词;不改执行者的系统提示词)。可重复;规则折叠成一行、上限 500 字;未知角色 / 空规则 → 退出码 2 |
|
|
242
|
-
| `--team-permission` |
|
|
299
|
+
| `--team-permission` | 子任务的权限上限。**不给 = 继承主会话 `--permission`**(会话设成完全访问,子任务也是完全访问);显式给档位可把子任务压得更严(如 `accept-edits`)。⚠️ 团队模式没有作答通道:`manual` / `accept-edits` 下子任务一旦要跑命令就会卡在「等待授权」直到单任务超时,**不要**用它给命令类任务设这两档 |
|
|
243
300
|
| `--team-run-dir` | 覆盖运行目录的父目录 |
|
|
244
301
|
|
|
245
302
|
**事件形状(客户端要认的只有一条)**:主会话里是**一张任务列表卡**,每个子任务是一条**普通会话**。
|
|
@@ -342,6 +399,34 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
342
399
|
用户要看的是「真要跑起来,每个子任务会收到什么」;`tasks[]` 在 plan-only 下为空数组
|
|
343
400
|
(没有执行),`plan_path` / `run_dir` 照常给。
|
|
344
401
|
|
|
402
|
+
### 1.2 主备降级的口径(2026-10-26,只增 kind,不升 `contractVersion`)
|
|
403
|
+
|
|
404
|
+
用户在一次设置里配「主 + 备」(`fallbackEngine` / `fallbackModel`)。主的那条不行时 core
|
|
405
|
+
自动换备用再跑一次,口径写死在 `internal/agent/fallback.go`:
|
|
406
|
+
|
|
407
|
+
| 情形 | 行为 |
|
|
408
|
+
|---|---|
|
|
409
|
+
| **同引擎换模型**(`fallbackEngine == engine`,`fallbackModel` 不同) | **降级**,按备用模型重跑(手机最常见的配法:dsh 主 modelverse、备 volcengine) |
|
|
410
|
+
| **换引擎** | 降级;备用模型**不往下传**(各引擎的模型清单互不相通),备用用它自己的默认模型 |
|
|
411
|
+
| 判定「主引擎失败」 | 返回错误,**或**正常结束但只有过程(思考 / 工具 / 计划)没有正文 / 图片 / 工具结果 |
|
|
412
|
+
| 已经出了正文 / 图片 / 工具结果 | **不降级**(否则用户会看到两段互相矛盾的答案),保留原始错误 |
|
|
413
|
+
| 备用就是主引擎同模型 / 备用引擎不存在 / 备用不支持流式 | 不降级,失败时补一条 `notice`:「本轮没有降级:<原因>」 |
|
|
414
|
+
| `ctx` 取消 / 超时、用户打断 | 不降级 |
|
|
415
|
+
|
|
416
|
+
降级发生时,事件流里**先出这一条**,再跟备用引擎的事件:
|
|
417
|
+
|
|
418
|
+
```json
|
|
419
|
+
{"type":"fallback","text":"「dsh」执行失败(…),已切到备用模型「dsh」的 volcengine/x 重试","fallback":{
|
|
420
|
+
"from_engine":"dsh","from_model":"modelverse/x","to_engine":"dsh","to_model":"volcengine/x",
|
|
421
|
+
"switched_engine":false,"reason":"…"}}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
- `switched_engine:false` = 同引擎换模型;`true` = 换了引擎。
|
|
425
|
+
- 主引擎残留的 `turn_end` / `turn_failed` **不会**再出现在流里(备用紧接着还要跑)。
|
|
426
|
+
- 团队模式:计划阶段同样降级(降级帧 text 前缀「团队模式:」),子任务结果里的
|
|
427
|
+
`engine` / `model` 是**实际作答**的那一路。
|
|
428
|
+
- 没降级的原因只在**失败**时给(`notice`),成功轮不打扰。
|
|
429
|
+
|
|
345
430
|
## 二、插件承诺的面(`magic-client`)
|
|
346
431
|
|
|
347
432
|
| 面 | 路径 | 状态 |
|
|
@@ -351,23 +436,45 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
351
436
|
| 模块清单 | `GET /desk/modules`(从 profile 派生:id / 入口 / 声明的表) | 已实现 |
|
|
352
437
|
| 会话登记表 | `GET /desk/sessions`(core 的 `--sessions`,跨进程持久;**只回本客户端发起的会话**) | 已实现 |
|
|
353
438
|
| 项目业务接口 | `ANY /api/*` → 原样转发到 profile 的 `backend.url` | 已实现 |
|
|
354
|
-
| 发起一轮 | `POST /desk/ask`(SSE 中继 core 的 NDJSON,前置 `event: open`,收尾 `event: end`)。**服务端会先入队**:同 workspace 串行 + `request_id` 幂等;真在等时先推 `event: queued`(`{job, state, position}`,客户端可忽略),准入后照旧 `open` | 已实现 |
|
|
355
|
-
| 作业队列(多客户端) | `POST /desk/jobs`:提交(不挂长连接,2xx 即可);`GET /desk/jobs[?workspace=][&limit=]`:队列快照(含他人任务与 `position`);`GET /desk/jobs/{id}`;`GET /desk/jobs/{id}/events?after=<seq>`:按 seq 回放本轮事件(形状同 `ask/{id}/events` + `job` 字段);`POST /desk/jobs/{id}/cancel`;`POST /desk/jobs/cancel {request_id}`(还没拿到 job id 时取消);`GET /desk/jobs/events?[workspace=]`:队列变更 SSE(每 2s 一帧 `event: jobs`)。持久化在 `<profile data dir>/jobs/`,服务重启后 queued 保留、running
|
|
356
|
-
| 控制与回审批 | `POST /desk/ask/{id}/control`(与 core 的 `--control` 同协议,插件不解释 op) | 已实现 |
|
|
439
|
+
| 发起一轮 | `POST /desk/ask`(SSE 中继 core 的 NDJSON,前置 `event: open`,收尾 `event: end`)。**服务端会先入队**:同 workspace 串行 + `request_id` 幂等;真在等时先推 `event: queued`(`{job, state, position}`,2026-10-26 起另带 `serial` 与 `auto`,客户端可忽略),准入后照旧 `open` | 已实现 |
|
|
440
|
+
| 作业队列(多客户端) | `POST /desk/jobs`:提交(不挂长连接,2xx 即可);`GET /desk/jobs[?workspace=][&limit=]`:队列快照(含他人任务与 `position`);`GET /desk/jobs/{id}`;`GET /desk/jobs/{id}/events?after=<seq>`:按 seq 回放本轮事件(形状同 `ask/{id}/events` + `job` 字段);`POST /desk/jobs/{id}/cancel`;`POST /desk/jobs/cancel {request_id}`(还没拿到 job id 时取消);`POST /desk/jobs/{id}/update {serial}` 与 `POST /desk/jobs/update {request_id, serial}`(2026-10-24,只增:改**排队中**作业的串行/并行 —— 客户端切任务模式时同步旧排队条目,串行↔并行马上生效;已开跑的作业回 `updated:false` 不打断);`GET /desk/jobs/events?[workspace=]`:队列变更 SSE(每 2s 一帧 `event: jobs`)。持久化在 `<profile data dir>/jobs/`,服务重启后 queued 保留、running 残留标失败。**自动模式(2026-10-26,只增)**:作业带 `mode:"auto"` 与 `auto` 结论对象 `{conflict, level, reason, overlaps, by, at}`(`by` = `rule` 规则 / `model` 建议引擎 / `undecided` 没判出来按并行 / `pending` 判定中);状态多一个 **`judging`** —— 已受理、正在判定,**不是调度候选**、不会被抢跑,判定完成(异步,客户端提交侧立即返回,不等)后回 `queued` 或直接开跑。客户端遇到 `judging` 应保留「等待中」行原地不动(别当终态删掉);服务重启时残留的 `judging` 直接按**并行**转 `queued`(**不重判**)。判为串行的自动作业会等**同空间所有在跑的作业**(含并行作业)收尾,普通 `serial` 作业的老语义不变。作业记录里 `fallback_engine` / `fallback_model` 是**配置**;`fallback_used` / `fallback_engine_used` / `fallback_model_used`(2026-10-26 只增)是**本轮真的由备用接上了**(服务端扫 `type:"fallback"` 帧回填),没降级就不出现 —— 排查「主备生效没有」看这三个 | 已实现 |
|
|
441
|
+
| 控制与回审批 | `POST /desk/ask/{id}/control`(与 core 的 `--control` 同协议,插件不解释 op;`ping` / `interrupt` / `stop` / `answer` / `ask_answer`,后两者见 1.1) | 已实现 |
|
|
357
442
|
| 断线续传 | `GET /desk/ask/{id}/events?after=<seq>`(按**本轮流 seq** 增量补拉;带 `done` / `code` / `stderr` / `snapshotRequired`;`after=0` 时首条是本轮**合成的用户提问**,见 2.4) | 已实现 |
|
|
358
443
|
| 运行中列表 | `GET /desk/asks`(**本进程**正在跑的轮次,每项 `{id, engine, started, session?, run?}`;与 sessions 的区别见下) | 已实现 |
|
|
359
444
|
| 运行中会话尾随 | `GET /desk/session/{id}/follow?[after=<seq>][&lean=1]`(SSE:`open` / 增量事件 / `snapshot` / `end`;读磁盘日志,**跨进程**,客户端接续运行中会话的**首选**通道;见 3.4) | 已实现 |
|
|
360
445
|
| 本体文件出流 | `GET /desk/media?path=<绝对路径>[&w=<px>]`(`?token=` 供 `<img>` / `<video>` 直接引用;**只发 image / video / audio**,`Range` 可拖动)。带 `w` 时按宽缩放并重编码(`64..4096`,失败/更大则回退原图,结果内存缓存);移动端传输层自动追加 `w=480`(缩略图;点开看大图时不带 `w`,取原文件) | 已实现 |
|
|
361
446
|
| 上传文件回读 | `GET /desk/uploads/{name}[?w=<px>]`(同上 `w` 语义) | 已实现 |
|
|
362
447
|
| 产物存在性探测 | `POST /desk/path-probe`:`{base, paths[]}` → `{ok, items:[{path, abs, exists, size}]}`;相对路径按 `base` 拼、`~/` 展开、`file://` 剥前缀、只认 regular 文件、最多 50 条。对话内核的「本轮产物」靠它把正文里的路径变成卡片(宿主经 `window.desk.pathProbe` 供进来) | 已实现 |
|
|
363
|
-
| 下一轮快捷回复 | `POST /desk/suggest`:`{user, reply, engine?, model?}` → `{suggestions:[string], engine, model, elapsedMs}`。服务端 exec core 的 `--suggest-followups`(**旁路调用,不续接会话**);`suggestions` 可以为空数组(= 这轮没有下一步,客户端不画按钮),失败如实 4xx/5xx。客户端**只在成功收尾后**调用,点击即发。并发上限 2(超出 429),超时 25s | 已实现 |
|
|
448
|
+
| 下一轮快捷回复 | `POST /desk/suggest`:`{user, reply, engine?, model?, fallbackEngine?, fallbackModel?}` → `{suggestions:[string], engine, model, elapsedMs}`。服务端 exec core 的 `--suggest-followups`(**旁路调用,不续接会话**);`suggestions` 可以为空数组(= 这轮没有下一步,客户端不画按钮),失败如实 4xx/5xx。客户端**只在成功收尾后**调用,点击即发。并发上限 2(超出 429),超时 25s。`fallbackEngine`(2026-10-24 只增字段)= 主建议引擎失败后先试它,再落到 core 内置的 llm 兜底 | 已实现 |
|
|
364
449
|
| 产物文本回读 | `POST /desk/path-read`:`{path, base}` → `{ok:true, text, size, path}` / `{ok:true, tooBig:true, size}` / `{ok:true, binary:true, size}` / `{ok:false, error}`,统一 200。上限 512 KB;前 8000 字节含 NUL 判二进制;`~/`、`file://`、相对 `base` 同 `path-probe` 口径。移动端产物卡「查看」经 `window.desk.pathRead` 用它打开 Markdown / 文本 | 已实现 |
|
|
365
450
|
| 插件目录 | `GET /desk/plugins` → `{plugins:[{id, name, cli, tagline, description, requirements?, homepage?, installed, version?, bin?, sourcePath?, sourceExists, install, upgrade, examples[], usePrompt, note?}]}`。**这不是引擎**(core 的 `--engines` 里没有它们):它是「发现 → 插件」里那两个工程 CLI(`magic-shorts` / `magic-video`)的介绍与安装状态。`installed` / `version` 由服务端探测(`exec.LookPath` + 可执行文件所属 npm 包的 `package.json`,不跑 `--version`),客户端只显示不推断 | 已实现 |
|
|
366
451
|
| 插件安装/升级 | `POST /desk/plugins/{id}/{install\|upgrade}` → SSE:`event: open`(`{id, action, commands[]}`)→ 每条命令 `event: step` → 输出逐行 `event: line` → `event: end`(`{ok, code, error?, plugin}`)。**命令只来自服务端那张固定表**(argv,不过 shell),客户端只传 id 与动作,未知 id/动作一律 404 —— 这条接口不接受客户端传命令 | 已实现 |
|
|
367
|
-
|
|
|
452
|
+
| 知识库目录 | `GET /desk/knowledge` → `{knowledge:[{id, name, cli, tagline, description, requirements?, homepage?, installed, version?, bin?, binSource?, latestVersion?, upgradeAvailable, pythonPath?, pythonVersion?, install, upgrade, examples[], usePrompt, note?}]}`。**这不是引擎**(core 的 `--engines` 里没有它):它是「发现 → 知识库」里的 OpenViking(面向 Agent 的上下文数据库)。`installed` / `version` / `latestVersion` / `upgradeAvailable` **全部由服务端探测**,客户端只显示不推断。探测顺序:环境变量 `MAGIC_CLIENT_OPENVIKING_BIN` → 自建 venv(家目录 `.openviking-venv`)→ PATH(命令名 `ov` 与 `openviking` 都认);**venv 排在 PATH 前** —— 一键安装装进 venv 后命令不在 PATH 上,只查 PATH 会把「刚装好」误报成「未安装」。`version` 分四层兜底(`--version` → dist-info 目录名 → `importlib.metadata` → `pip show`),读不到就留空 + `note` 说明,**绝不编**。`latestVersion` 走 PyPI JSON 实时查(**代码里不写死版本号**),查不到就留空 + `note`,HTTP 仍 200 | 已实现 |
|
|
453
|
+
| 知识库安装/升级 | `POST /desk/knowledge/{id}/{install\|upgrade}` → SSE:`event: open`(`{id, action, commands[]}`)→ 每条命令 `event: step` → 输出逐行 `event: line` → `event: end`(`{ok, code, error?, knowledge}`),形状与插件那条一致。命令由服务端按本机 python(需 ≥3.10)**动态构造**(建 venv → `pip install [--upgrade] openviking` → `pip show`),argv 数组不过 shell,客户端只传 id 与动作,未知 id/动作一律 404 —— 这条接口不接受客户端传命令。没有可用 python 时回 400(装不了,不给一条跑不通的命令);已有安装在进行时回 409 | 已实现 |
|
|
454
|
+
| 脚本清单 | `GET /desk/scripts` → `{scripts:[{id, name, path, args[], cwd?, interpreter?, env[]?, note?, state:{state, pid?, exitCode, startedAt?, endedAt?, logSeq, error?}}]}`。`state` ∈ `stopped`/`running`/`done`/`failed`(**服务端给的,客户端不推断**)。脚本是**用户自己登记**的(路径 + 参数),不是服务端固定表、也不是目录扫描 | 已实现 |
|
|
455
|
+
| 脚本增删改 | `POST /desk/scripts`(`{name?, path, args[], cwd?, interpreter?, env[]?, note?}`)→ 带 id 的条目;`PUT /desk/scripts/{id}`(改定义,**不动运行态**);`DELETE /desk/scripts/{id}`(运行中 → 409,先停再删;**配置与落盘日志一起删**)。**路径必须是绝对路径、必须是存在的文件、扩展名在白名单内**(`.sh .bash .cmd .bat .ps1 .py .js .mjs`,**不含 `.exe`**)—— 白名单比黑名单稳;命令**不过 shell**(`.cmd/.bat/.ps1` 由服务端包成 PowerShell 单引号字面量调用)。**这条接口不接受客户端传任意命令行** | 已实现 |
|
|
456
|
+
| 脚本解释器与环境变量 | `interpreter`(可空)= 解释器绝对路径,填了就跑 `<解释器> <脚本路径> [参数…]`,不填就直接跑脚本文件本身。**解释器不受上面的扩展名白名单约束**(白名单里没有 `.exe`,而 Python/Node 脚本在 Windows 上没法直接 exec),但**必须是绝对路径、必须是存在的文件、不能是目录**。`env`(可空)= 追加的环境变量数组,逐条 `"KEY=VALUE"`(KEY 不能含空白,条数 ≤ 32),会覆盖同名的父进程环境变量。典型用途:Python 脚本要 `PYTHONUNBUFFERED=1`(否则 stdout 块缓冲,实时日志变成「跑完一大半才刷出来」)+ `PYTHONIOENCODING=utf-8`(否则中文日志乱码) | 已实现 |
|
|
457
|
+
| 脚本启停 | `POST /desk/scripts/{id}/start` → 200(条目 + 运行态,**不挂长连接**);`POST /desk/scripts/{id}/stop` → 200。启动 = spawn 一个**独立进程组**的后台进程(停止时整组杀,不留孤儿 worker);状态机 `stopped → running → (done \| failed \| stopped)`。运行中重复 start → 409。**用户主动停的记 `stopped`**(不是 `failed`) | 已实现 |
|
|
458
|
+
| 脚本日志 | `GET /desk/scripts/{id}/log?after=<seq>` → SSE:`event: line`(`{seq, text}`)逐行 → `event: state`(运行态)收尾。`after=<seq>` 只补之后的行(断线重连用;**序号跨重启单调递增**,否则重连会一行都收不到)。服务端内存留最近 500 行(环),完整副本落 `~/.magic-agent/scripts/logs/<id>.log`(**追加**)。脚本在**这台桌面**上跑,不是手机 | 已实现 |
|
|
459
|
+
| 移动端 APK 分发 | `GET /desk/app` → `{version, size, url, upgrade}`(`version` 取自随包的 `npm/apk/version.json`,与 npm 包同一个版本号;没带 APK 时 `version` 为空串、`url` 为空);`upgrade`(2026-10-26 只增字段,不升 `contractVersion`)= **这台桌面能不能让手机顺手升级 npm 那份 core**(npm 在 PATH 上且找得到目标二进制),老插件没有该字段 → 按 `false` 处理;`GET /desk/app.apk[?token=]` → 安装包本体(`application/vnd.android.package-archive`)。移动端「我的」→「版本」点一下把 `/desk/app` 与 `/desk/core` 一起读,两条链有更新就一起摆出来 | 已实现 |
|
|
460
|
+
| 桌面端 core 版本 / npm 升级(2026-10-26) | `GET /desk/core` → `{version, installed, latest, upgradable, restartOnly, canUpgrade, upgrade, bin, prefix, daemon, note}`:`version` = **此刻正在跑的那份** core 版本(盘上二进制若在本进程启动后被换掉,则以契约缓存为准 —— 读新文件会答出没在跑的版本);`installed` = 盘上那份的 `--version`;`latest` = `npm view magic-agent version`(离线为空串);`upgradable` = 有更高版本可装;`restartOnly` = 已经装好、只差重启(`installed > version`,或二进制是本进程启动后才换上去的);`canUpgrade` = 本机能执行 npm 升级;`upgrade` = 将要执行的完整命令(只读展示用);`bin`/`prefix` = 目标二进制与 npm 前缀。**`/desk/core` 永不触发安装**。
|
|
461
|
+
`POST /desk/core/upgrade` → SSE:`event: open`(`{command, bin, from, to}`)→ `event: step`(`{index, command}`)→ 输出逐行 `event: line`(`{index, line}`)→ `event: end`(`{ok, code, restarting, note?, error?, core}`,`core` 同 `GET /desk/core` 的形状,前端据此刷新版本格)。执行 `npm install magic-agent@<latest> --no-fund --no-audit -g [--prefix <prefix>]`,**绝不降级**(`latest` 不高于已装 → 409)、**盘上已是本进程启动后换上的新版 → 409「只差重启」,不再装一遍**;装完以「盘上那份的版本真的变了」为成功判据(npm 退出 0 但没装成 → `ok:false` + 原因),成功后若本实例是后台 `magic-client start` 拉起的,走与 `POST /desk/restart` 同一条原地重启链(`restarting:true`),否则提示手工重启 | 已实现 |
|
|
368
462
|
| 移动端环境配置 | `GET /desk/prefs` → 这台桌面的移动端配置 JSON(没存过是 `{}`);`PUT /desk/prefs` 整份覆盖(≤256KB,原子写)。字段由移动端定义、桌面端原样存(`~/.magic-agent/mobile-prefs.json`),当前是「默认引擎/模型 + 团队配置 + 空间选择 + 置顶」。**多客户端连同一台桌面共用这一份** —— 不再是各存各的浏览器 localStorage | 已实现 |
|
|
463
|
+
| 飞书推送 · 群机器人(2026-10-26) | `POST /desk/feishu/webhook {webhook, webhook_secret?, url?}` —— **推荐通道**:地址必须形如 `https://open.feishu.cn/open-apis/bot/v2/hook/<token>`(`open.larksuite.com` 也认),**落盘前当场发一条测试卡片**,发不出去就不保存。`webhook_secret` 非空时按飞书自定义机器人规范签名(以 `timestamp + "\n" + secret` 为密钥对空串做 HMAC-SHA256 再 base64)。配了它就优先走它,应用凭据与收件人都不再需要 | 已实现 |
|
|
464
|
+
| 飞书推送 · 绑定会话(2026-10-26 定稿) | `POST /desk/feishu/bind {code, to}`:**移动端只让用户填一个 `oc_` 开头的会话 ID**,绑定码由页面透明地先调 `POST /desk/feishu/code` 取到再一起提交(用户看不见)。服务端据此写 `receive_id/receive_type=chat_id`,推送走 `receive_id_type=chat_id`。绑定成功顺手发一条确认卡片;状态里 `bound_to` 只回末 6 位(`…8ce2a4`) | 已实现 |
|
|
465
|
+
| 飞书推送(2026-10-26) | `GET /desk/feishu` → `{mode, configured, source, app_id, bound, enabled, bound_at?, bound_type?, bound_to?, webhook_tail?, open_id_tail?, recent[]}`(`mode` = `webhook` / `app`;`bound_to` 是**打过码**的收件人)(**永不回显 app_secret**,open_id 只给末 6 位);`POST /desk/feishu/code` → `{code, expires_in}`(6 位数字,10 分钟有效);`POST /desk/feishu/bind {code, to}` 绑定并自动开启(码一次性、错码/过期拒绝)。`to` 可以是**飞书邮箱**(`receive_id_type=email`,推荐)、`ou_` 开头的 open_id(须 ≥20 字符,`ou_me` 这类占位符直接 400)、或 `oc_` 开头的群 id;老的 `{open_id}` 字段仍接受;`POST /desk/feishu/unbind`;`POST /desk/feishu/enable {enabled}`;`POST /desk/feishu/config {app_id, app_secret}`(先验令牌再落盘,`~/.magic-agent/feishu-notify.json` 0600);`POST /desk/feishu/test` 发测试卡片;`POST /desk/feishu/push {event: result\|confirm, ask_id, session_id?, title?, body?}` 幂等推送(键 = event+ask_id,重复回 `{ok,dupe}`;没开/没配回 `200 {disabled,reason}`;发送失败退避重试 3 次后 502 并释放幂等键)。凭据来源优先级:本机文件 > `MAGIC_AGENT_FEISHU_APP_ID/SECRET` > config.json 的 `feishu` 节。手机端在 `pollOnce` 的 running→done / waiting_approval 跳变时调用 push(`status_ask_id` 为幂等键) | 已实现 |
|
|
466
|
+
| 重启后台服务 | `POST /desk/restart` → `{ok:true, restarting:true, pid}`。桌面端**先回响应**,再由定时器(默认 1s)拉一个脱离终端的 `magic-client restart --state <本实例的状态文件>` 助手停旧实例、按那份状态里的参数拉起新实例(端口 / 令牌 / 界面目录 / profile / `--lan` / `--allow-any-origin` / `hostPid` 沿用;公网域名本就由壳托管、不随客户端重启变化,见 `/desk/health.publicUrl` 的落盘说明)。⚠️ 会话是**这个实例**起的子进程 → 它起的会话会被中断(状态落 `interrupted`/`gone`);**同机其它客户端实例的会话不受影响**(`gone` 的判据是那条会话自己的 pid 还在不在,见 `internal/session/session.go`)。**两条启动路径都能自重启**(2026-10-26):`magic-client start` 起的认 `client.json`,前台 `serve` 认 `<数据目录>/serve.json`(serve 监听成功后自登记,`--state` 可覆盖)。一条状态记录都没有(老版本 serve / 显式跳过登记)时回 **409**,`error` 里直接给出可照做的命令。重复点击幂等(`already_pending:true`),不会再起第二个助手 | 已实现 |
|
|
467
|
+
| 内置浏览器中转 | `GET/POST /b/<scheme>/<host><path>[?magicToken=]`:服务端代取页面并就地改写(链接/资源指回中转、剥 `X-Frame-Options`/CSP、注入 `<base>` 与 relay 脚本),iframe 的流量走**桌面端网络**。鉴权自管(不套 guard):**改写出的每条 URL 都带 `?magicToken=`**(转发上游前由服务端剥掉,不泄漏给目标站);另在首帧下发 cookie `magic_browse` 作辅助凭据 —— 跨站 iframe(APK 壳 origin 与桌面端 LAN 不同源)下 SameSite=Lax cookie 不随子资源请求发送,不能只靠它。`GET /b/relay.js` 是注入脚本本体(见下一行)。⚠️ 移动端 UI **默认直连**(2026-10-25 起):不开浏览器里的「桌面端中转」开关时 iframe 直接加载目标站、不走本中转;开关打开后才走 `/b/`(桌面独有站点 / 拒绝内嵌的站点 / 需要 relay 注入时用) | 已实现 |
|
|
468
|
+
| 浏览器输入中继 | `WS /browse-input?role=viewer\|driver[&token=]`(鉴权同 `/b/`;`role` 缺省按 driver)。页面内 relay 脚本连 `viewer`(同一时刻至多一个,后连顶前连),手机 UI 连 `driver`(可多个)。JSON 文本帧:viewer→server 发 `hello`/`state`(`{title,url,w,h}`,页面 CSS 像素)/`ack`(`{seq,ok,reason?}`);server→viewer 发 `input`(`{seq,action,x?,y?,dx?,dy?,text?,key?}`,`action` ∈ `move\|down\|up\|click\|dblclick\|scroll\|type\|key`)与 `ping`;driver→server 发 `input`(server 重编 seq 后转发,无 viewer 时回 `ack{ok:false}`)与 `ping`(转发给 viewer 请求一帧 `state`,无 viewer 回 `ack{ok:false}`);server→driver 发 `state`/`ack`/`viewer`(`{online}` 上下线通知)。坐标 = 被代理页面自身 CSS 像素。**2026-10-25 起 relay 只承担「远程触控」**:`shot` 动作已退役(页面内 html2canvas 有跨域污染 / iframe 抓不到 / 慢页超时三个硬伤),截图与程序化驱动走 agent-browser(见下一行)。已知边界:移动端 UI 默认直连(不走 `/b/`),只有经中转加载的页面才注入 relay 脚本、中继才可用;合成事件 `isTrusted=false`,个别反自动化站点会忽略 | 已实现 |
|
|
469
|
+
| 浏览器 MCP(agent-browser,2026-10-25) | 驱动/截图面基于 **agent-browser 插件**(vercel-labs,Rust 原生 CLI,真实 Chromium/CDP,默认 headless,daemon 模型:首次 `open` 自动拉起、闲置约 1h 自动退出)。CLI:`magic-client browse open <url> \| state \| click <x> <y> \| move <x> <y> \| dblclick <x> <y> \| scroll <dx> <dy> \| type <文本> \| key <键名> \| shot [--out 文件] [--full-page] \| eval <js> \| pdf <文件> \| snap [-i]`。会话固定 `--session magic-client`(与人类/其它工具的 default 会话隔离,登录态可跨动作复用)。探测链:环境变量 `MAGIC_AGENT_AGENT_BROWSER_BIN` > npm 全局前缀(`<prefix>/node_modules/agent-browser/bin/agent-browser-<goos>-<goarch>[.exe]`,**绝对路径 spawn 原生 exe,零 node 依赖**)> PATH;都没有时 CLI 退出码 1 + 安装提示、HTTP 端点回 **501 + 中文原因**(`npm install -g agent-browser && agent-browser install`)。单次调用统一 `--json`(`{"success":true,"data":…}`),超时 60s。⚠️ 与 `/b/` 中转是**两个独立浏览器上下文**:手机 iframe 看的页面走中转 + relay 远程触控;agent-browser 截的是它在服务端自己打开的页面(独立 profile,不共享中转 cookie jar,需登录的页面截出来可能是登录页) | 已实现 |
|
|
470
|
+
| 需求截图服务端截取 | `POST /desk/requirements/shot-remote`(body = `{"url":"http(s)://…","full_page":true}`):服务端 agent-browser 打开 url 截**全页图**,落 `/desk/uploads/<纳秒>-reqshot.png`(`os.TempDir()/magic-client-uploads`,与 `/desk/upload` 同一个家),回 `{ok:true, data:"/desk/uploads/…png", size}` —— `data` 直接喂标注浮层的 `<img>`(带 `?token=` 回读)。能力位 `feature.requirement.shots` 缺失回 501;服务端没装 agent-browser 回 501 + 安装提示;url 非 http/https 回 400;agent-browser 打开/截图失败回 502 + 原因 | 已实现 |
|
|
471
|
+
| 需求截图写入 | `POST /desk/requirements/shots?ws=&module=&name=&caption=`(body = **PNG 原始字节**,不是 multipart)。能力位 `feature.requirement.shots` 缺失回 501;`name` 必填;空 body 回 400;超 8MB 回 **413**。core 落盘并把一行追加进该功能点文档的「页面完成情况」段(见 §3.4.1.1) | 已实现 |
|
|
472
|
+
| 需求关系图谱 | `GET /desk/requirements/graph?ws=<ws_id\|目录>`(留空 = 全部)。能力位同 `feature.requirements`(缺了回 501)。core `--requirements-graph` 纯读转发 → `{ok, root, workspaces:[{id, name, dir?, nodes, edges}]}`(形状见 §3.4.1「会话↔需求关系」) | 已实现 |
|
|
369
473
|
| 静态资源 | `GET /`(`--ui-dir` + profile 的 `ui.entry`) | 已实现 |
|
|
370
|
-
|
|
|
474
|
+
| 应用(工作台)目录 | `GET /desk/apps` → `{apps:[{id, name, tagline, description?, homepage?, cli, command, port, installed, sourcePath?, note?, openUrl?, state:{state, running, managed, pid?, port?, url, localUrl?, logTail?, startedAt?, error?}}], notes:[…]}`。**这不是引擎,也不是插件清单**:它是「发现 → 应用」里 magic-shorts / magic-video 的**本地工作台**(`magic-shorts serve` → :8899、`magic-video workbench` → :7789)。服务端负责在桌面端起停与反代,客户端只显示与触发。`state.state` = `stopped` / `starting` / `running`;`running` 以**端口探活**为准(别人在终端 / 菜单栏起的也算 running,此时 `managed:false`,手机只能打开、不能停);`url` 是手机可用的相对入口 `/workbench/{id}/`(= `openUrl`),客户端拿它丢给内置浏览器 | 已实现 |
|
|
475
|
+
| 应用启停 | `POST /desk/apps/{id}/{start\|stop}` → SSE:`event: open {id, action}` … `event: end {ok, code, error?, app}`。`start` 起进程后**等端口应答**(最多 20 s)再收尾,起不来把输出尾巴放在 `error` 里(`ok:false`);`stop` 只停自己起的(整组带走),外部实例回 409 + 人话;未知 id / 动作一律 404。命令只来自服务端固定表,客户端不传命令行 | 已实现 |
|
|
476
|
+
| 应用反代 | `GET /workbench/{id}/…` → `127.0.0.1:<port>/…`。**入口** `/workbench/{id}/` 先探活:没在跑回 503 + 人话,在跑则 302 到 `/?via={id}`;页面从此**在根路径下**跑,页面里 `/api/…` 这类根相对请求由 `handleStatic` 按 `Referer` 的 `/workbench/{id}/` 前缀(或 `?via=`)转给工作台 —— 所以移动端内置浏览器的 iframe **不能**是 `referrerpolicy="no-referrer"` | 已实现 |
|
|
477
|
+
| 鉴权 | `X-Magic-Token` 头或 `?token=`;默认绑 `0.0.0.0:9100` 且令牌强制 ≥24 字符;`--local` 时只绑 `127.0.0.1`(见 2.4) | 已实现 |
|
|
371
478
|
|
|
372
479
|
### 2.1 扩展接口(业务项目对着写)
|
|
373
480
|
|
|
@@ -421,17 +528,16 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
421
528
|
| 未配置后端 | `501` + 一条点名 `backend.url` 的说明,不静默 404 |
|
|
422
529
|
| 地址非法 | **启动即失败**(比运行时 502 好查) |
|
|
423
530
|
|
|
424
|
-
### 2.5
|
|
531
|
+
### 2.5 局域网接入(默认开启,`--local` 关闭)
|
|
425
532
|
|
|
426
|
-
|
|
427
|
-
手机 / 平板要连就得绑局域网,而这一开,**令牌就是唯一的门锁** —— 所以两者是配套的:
|
|
533
|
+
默认绑 `0.0.0.0:9100`(手机 / 平板开箱可连);这一开,**令牌就是唯一的门锁** —— 所以两者是配套的:
|
|
428
534
|
|
|
429
535
|
| 行为 | 说明 |
|
|
430
536
|
| --- | --- |
|
|
431
|
-
| `--lan
|
|
537
|
+
| 默认(或 `--lan`) | 绑 `0.0.0.0:9100`,启动时把本机**所有**可用局域网地址连令牌一起打印(一台机器常有多个网卡) |
|
|
432
538
|
| 令牌下限 | 24 字符;弱令牌**拒绝启动**(自动生成的默认令牌是 32 位十六进制,够用) |
|
|
433
539
|
| 无令牌访问 | `401` —— 不因为是内网就放行 |
|
|
434
|
-
|
|
|
540
|
+
| `--local` | 只绑 `127.0.0.1`,局域网地址**连不上** |
|
|
435
541
|
| 地址的挑选与顺序 | 虚拟网卡(容器 / 虚拟交换机 / 隧道)的地址**不报**;公网段地址在还有私有地址时也不报;剩下的按「出站主地址(默认路由那张网卡)→ 其余」排。**第一个 = 手机最可能连上的那个** —— 移动端只认 `[0]` |
|
|
436
542
|
|
|
437
543
|
⚠️ **地址顺序是契约的一部分(2026-10-23 修正)**:以前直接按网卡枚举顺序返回,
|
|
@@ -449,21 +555,32 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
449
555
|
| `engine` | 必填;profile 的 `engines` 白名单之外的会被 403 |
|
|
450
556
|
| `prompt` | 必填 |
|
|
451
557
|
| `model` / `workspace` | 可选,透传给 core 的 `-m` / `-w` |
|
|
558
|
+
| `fallbackEngine` / `fallbackModel` | 可选(2026-10-24,只增字段),主引擎的**备用**。口径见 1.2:主引擎失败或**只有过程没有正文**时降级一次(**同引擎换模型也算**),发一条 `fallback` 帧;已经出了正文 / 图片 / 工具结果则不降级。`fallbackEngine` 同样要过 profile 白名单(否则 403)。空 = 不降级(老客户端行为不变)|
|
|
452
559
|
| `permission` | 可选,四档授权模型(`manual` \| `accept-edits` \| `auto` \| `full`),透传给 core 的 `--permission`。**空 = 不传**,由 core 用它自己的默认档(`agent.DefaultPermissionTier`)—— 刻意没有默认预设,见下 |
|
|
453
560
|
| `reasoning` | 可选,思考档位(`off` \| `minimal` \| `low` \| `medium` \| `high` \| `xhigh` \| `max`),透传给 core 的 `--reasoning`。**空 = 不传**,core 自己的默认档是 **`max`**(开着)—— 所以「关闭思考」必须**显式传 `off`**,省略等于开着 + 最高档 |
|
|
454
561
|
| `session` | 可选,续接一个已有会话(传 `/desk/sessions` 里的 id)→ core 的 `--session` |
|
|
455
562
|
| `idle` | 可选,常驻会话空闲收工时长;默认 `0` = 本轮结束就收工 |
|
|
456
563
|
| `team` | 可选,团队模式(见下)。**nil / 缺省 = 关闭**(默认关)|
|
|
457
564
|
| `request_id` | 可选(2026-10-23,只增字段),客户端生成的**幂等键**。同一个 id 重复提交(断线重连 / App 重启 / 两个标签页)只执行一次:服务端返回原作业并接回去。空 = 旧客户端,仍参与 workspace 串行,但没有幂等保护 |
|
|
458
|
-
| `serial` | 可选(2026-10-23,只增字段),默认 `true`:参与「同一 workspace 单槽」串行。客户端「并行模式」显式传 `false`(服务端仍套全局并发上限,`MAGIC_CLIENT_MAX_RUNNING`,默认 4
|
|
565
|
+
| `serial` | 可选(2026-10-23,只增字段),默认 `true`:参与「同一 workspace 单槽」串行。客户端「并行模式」显式传 `false`(服务端仍套全局并发上限,`MAGIC_CLIENT_MAX_RUNNING`,默认 4)。**并行作业不注入 core 工作空间锁**(2026-10-24 起,锁只对串行注入)—— 否则同空间「并行」会在 core Begin 处被锁重新排成队;排队中的作业可用 `POST /desk/jobs/update` 改这个标志,模式切换即时生效 |
|
|
566
|
+
| `mode` | 可选(2026-10-26,只增字段),三档:`"auto"` = **自动任务模式**:服务端按「本轮提示词 + 同空间运行中任务的会话摘要/已改文件」判断两边是否冲突,冲突则排进同空间单槽、不冲突照常并行;`"parallel"` = 一律并行、**不判定**;`"serial"` = 同空间单槽串行、**不判定**。**两字段同时给时以 `mode` 为准**,`serial` 只当缺省回退(只发 `mode:"parallel"`、不发 `serial` 也照样并行)。空 / 不认识的值 = 沿用 `serial` 的老语义,老客户端行为不变。判定链路见下 |
|
|
567
|
+
| `judgeEngine` / `judgeModel` | 可选(2026-10-26,只增),`mode:"auto"` 时用哪个引擎做语义判定(客户端传用户配的「建议引擎」)。空 = core 默认 `llm`。判定是旁路调用(新进程、ToolsOff、不写会话历史),判定是异步旁路,**任何失败/超时/未配置都按并行放行** |
|
|
568
|
+
|
|
569
|
+
**自动任务模式(`mode:"auto"`)的判定口径与边界**(2026-10-26):
|
|
570
|
+
|
|
571
|
+
- 判定对象只有**同一 workspace** 的 `running` / `waiting_approval` 会话,外加同空间**还在排队**的作业(提示词即意图)。`idle` 不算 —— 常驻会话本轮已结束,不占文件。
|
|
572
|
+
- 先规则、后模型:① 同一条会话还有一轮没结束 → 直接串行;② 同空间没有任何候选、锁也没被活进程持有 → 直接并行(不调模型,绝大多数提交走这条);③ 提示词里显式写出的路径命中候选**已改文件** → 直接串行;④ 其余交给建议引擎(`judgeEngine`,2.5s 预算)判「两件事是否在动同一个东西」。
|
|
573
|
+
- 失败方向:引擎没配 / 超时 / 输出解析不了 / 并发闸满 → **一律并行**并标 `by:"undecided"`。判定是保护不是闸门,绝不能把任务卡死。
|
|
574
|
+
- 不重判:判为串行的作业在 `queued` 里等候,由调度器在同空间空闲时自然放行;**不会**过一会儿再问一次模型。客户端切回「并行 / 串行」时对已排队作业仍走 `/desk/jobs/update`(即时生效),切到「自动」**不回溯**。
|
|
575
|
+
- **看不见的冲突(如实说明)**:用户在终端里跑的构建 / dev server / 编辑器、以及不经过本插件的进程,没有会话记录也没有工作空间锁,自动模式无从判断 —— 这类并行只能靠用户切「串行」兜底。
|
|
459
576
|
|
|
460
577
|
`team`(2026-10-21 只增字段,不升 `contractVersion`):
|
|
461
578
|
|
|
462
579
|
```json
|
|
463
580
|
"team": {
|
|
464
|
-
"routes": { "role:coder": {"engine":"claude"},
|
|
465
|
-
"image": {"engine":"llm","model":"hunyuan-image-3.0"},
|
|
466
|
-
"video": {"engine":"arkclaw"} },
|
|
581
|
+
"routes": { "role:coder": {"engine":"claude","fallbackEngine":"codebuddy"},
|
|
582
|
+
"image": {"engine":"llm","model":"hunyuan-image-3.0","fallbackEngine":"arkclaw"},
|
|
583
|
+
"video": {"engine":"arkclaw","fallbackEngine":"codebuddy"} },
|
|
467
584
|
"rules": { "role:coder": "只拆多文件、要构建或测试的改动" },
|
|
468
585
|
"parallel": 4,
|
|
469
586
|
"planOnly": false,
|
|
@@ -476,7 +593,7 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
476
593
|
|
|
477
594
|
| 子字段 | 说明 |
|
|
478
595
|
| --- | --- |
|
|
479
|
-
| `routes` | `kind → {engine, model?}`,`kind ∈ plan\|text\|code\|image\|video\|game`,或角色键 `role:<name>`(2026-10-22 只增字段,不升 `contractVersion`)。**类别名 / 角色名非法、只给 model 不给 engine
|
|
596
|
+
| `routes` | `kind → {engine, model?, fallbackEngine?, fallbackModel?}`,`kind ∈ plan\|text\|code\|image\|video\|game`,或角色键 `role:<name>`(2026-10-22 只增字段,不升 `contractVersion`)。**类别名 / 角色名非法、只给 model 不给 engine、引擎(含备用引擎)不在 profile 白名单 → 400**(翻译由插件做,裁决由 core 做)。整条 `{engine:""}` 视为「该项不单独指定」:类别跟随主引擎,角色跟随其所属类别,插件不下发;**只配 `fallbackEngine`** 是合法的(主引擎跟随主引擎 + 这一路的备)。角色名清单从 `/desk/health.teamRoles`(core `--contract.teamRoles` 转发)取;老 core 没有该字段时插件只做格式校验,名字交给 core 裁决 |
|
|
480
597
|
| `rules` | 可选,`role:<name> → 规则文本`(2026-10-22 只增字段,不升 `contractVersion`)→ `--team-rule`。改写该角色「这类任务适合什么」(计划提示词里的角色表),空文本不下发、留空 = core 内置 `duty`;键必须是已知角色(类别键 / 未知角色 → 400)|
|
|
481
598
|
| `parallel` | 0 / 缺省 = core 默认 4 |
|
|
482
599
|
| `planOnly` | 只出计划,不执行 |
|
|
@@ -508,6 +625,57 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
508
625
|
**服务端作业队列**(`true` / 缺失)。有它时客户端应把排队交给服务端(`/desk/jobs` 或带
|
|
509
626
|
`request_id` 的 `/desk/ask`),本地不再自行判忙;缺失时客户端退回本地串行门(老插件)。
|
|
510
627
|
|
|
628
|
+
`/desk/health.restarting`(2026-10-25 只增字段,不升 `contractVersion`):本实例是否
|
|
629
|
+
已受理「重启服务」并在等定时器拉起助手(`true` / 缺失 = 否)。`POST /desk/restart` 的
|
|
630
|
+
链路(见 `internal/client/restart.go`):桌面端**先立标志、先答 200**(重复点击响应带
|
|
631
|
+
`already_pending: true`,不再起第二个助手),默认 1 秒后由**进程内定时器**拉起一个
|
|
632
|
+
脱离终端的 `magic-client restart` 助手停旧实例、按状态文件参数起新实例。探活语义:
|
|
633
|
+
带 `restarting: true` 的 200 = 旧实例认账、即将下线(可当作「看到下线」);再探到
|
|
634
|
+
`restarting` 为假(缺失)的 200 = 新一代已起来。老插件没有该字段 → 客户端照旧
|
|
635
|
+
「先看到连不上、再看到 200」的判据,两种字段都要认。
|
|
636
|
+
|
|
637
|
+
`/desk/health.restartHandoff`(2026-10-25 只增字段,不升 `contractVersion`):旧实例受理重启、
|
|
638
|
+
且在定时器到点前算出了「新实例会是什么样」时给出
|
|
639
|
+
`{urls: ["https://xxx.trycloudflare.com/", "http://10.0.0.5:9100/"], token: "…"}`;
|
|
640
|
+
不该交代时是 `null`(不是缺失 —— `/desk/health` 的键集恒定,客户端两种都认)。
|
|
641
|
+
|
|
642
|
+
为什么需要(用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):手机存的是
|
|
643
|
+
「地址 + 令牌」。令牌跨重启稳定(`token` 文件),地址却可能变 —— 换 Wi-Fi 后网卡 IP 变了、
|
|
644
|
+
端口撞了换了端口、状态文件丢了令牌重发。旧实例恰好能在死前算出新实例的参数,所以把结论
|
|
645
|
+
挂在**自己**的 health 上,手机探活时顺手取走:`urls` 里**公网域名排第一**(隧道由壳托管、
|
|
646
|
+
客户端重启不碰它,是重启后最不容易失效的入口),随后才是局域网地址(回环地址不放)。
|
|
647
|
+
`token` 是本机局域网门锁,只回给已持令牌的 `/desk/health`(guard 之后),不裸奔。
|
|
648
|
+
交接有效期 2 分钟(`restartHandoffTTL`):旧实例没被停掉也不会一直发一份可能过时的地址。
|
|
649
|
+
|
|
650
|
+
`/desk/health.pid` 与 `/desk/health.startedAt`(2026-10-27 只增字段,不升 `contractVersion`):
|
|
651
|
+
`pid` = 本进程 pid(整数),`startedAt` = 本进程启动时刻(RFC3339,UTC)。
|
|
652
|
+
|
|
653
|
+
为什么需要(用户:「点了手机端重启 好像没效果」):重启换的是**进程**,而会话、任务、
|
|
654
|
+
设置全在磁盘上 —— 界面重启前后长得**一模一样**。客户端探到 200 就弹「桌面端已重启」,
|
|
655
|
+
用户看着没变的列表只会得出「没效果」。`instanceId` 帮不上忙:它是**同一台桌面**的稳定
|
|
656
|
+
身份(多入口归一依赖它),跨重启必须不变,所以不能用来判断新旧代。
|
|
657
|
+
→ 客户端应在 `POST /desk/restart` **之前**读一次 health 记下 `pid` / `startedAt`,
|
|
658
|
+
重启后对比:pid 或 startedAt 变了才是「换了一代」的硬证据,可据此说「已重启(pid A → B)」;
|
|
659
|
+
两个字段都缺失(老插件)时**不许编造**已换代的结论,如实说「请求已完成、无法核对」。
|
|
660
|
+
|
|
661
|
+
⚠️ 客户端实现要点(2026-10-27 真机踩过):指纹必须在 POST **之前**读 —— 重启只需 3~7 秒,
|
|
662
|
+
POST 之后再读旧实例可能已经没了,拿不到基线就永远判不出换代。
|
|
663
|
+
另外重启太快时可能错过「连不上」的空窗,所以不能只靠 `sawDown`;
|
|
664
|
+
**只要探到的 pid 与基线不同,同样算成功**(新进程在应答就是新进程)。
|
|
665
|
+
|
|
666
|
+
客户端口径:`waitRestartBack` 每次探活都记下 `restartHandoff`,等不到新一代时用它改
|
|
667
|
+
**原环境**的 url / token(eid、名称不动 —— 排队任务与配置槽跟着 eid 走),改完整页重载;
|
|
668
|
+
改之前一律先验证新入口能连通(`/desk/health` 200)。老插件没有该字段 → 客户端退回
|
|
669
|
+
「先看到连不上、再看到 200」的旧判据,行为不变。
|
|
670
|
+
|
|
671
|
+
`/desk/health.publicUrl`(2026-10-22 只增字段,不升 `contractVersion`)**现在会落盘**
|
|
672
|
+
(`<profile 数据目录>/public-url`,2026-10-25):公网域名只随**隧道**重起而变,
|
|
673
|
+
magic-client 重启并不碰 cloudflared,可探测结果以前只在内存里 —— 客户端一重启
|
|
674
|
+
`publicUrl` 就空,手机于是退回局域网地址,表现成「重启后地址变了」。现在启动即读回
|
|
675
|
+
上次的域名顶上、后台探测(`publicTTL` 2 分钟)再纠正:探到新域名就覆盖,连续两次探空
|
|
676
|
+
且过了 90 秒宽限期(`publicGrace`,cloudflared 可能起得比客户端慢)才认定隧道真没了并
|
|
677
|
+
删掉落盘值。客户端因此**不该**因为「重启后 publicUrl 暂时没变 / 还是老值」就判它失效。
|
|
678
|
+
|
|
511
679
|
⚠️ **`/desk/health.version` 是插件启动时缓存的 core 版本**:只升级 CLI、没重启插件时它会
|
|
512
680
|
一直报旧值(2026-10-22 实测:CLI 已 2.1.3,手机仍显示 1.0.111)。要显示「此刻真正的
|
|
513
681
|
core 版本」必须读 `/desk/contract.version`(每次都重新问 core);`health.version` 只适合
|
|
@@ -520,6 +688,17 @@ core 版本」必须读 `/desk/contract.version`(每次都重新问 core);
|
|
|
520
688
|
桌面端版本放进 `title`。点「版本」读 `/desk/app`,发现更高版本后触发 `/desk/app.apk`
|
|
521
689
|
下载(系统浏览器/安装器接手,不静默安装)。
|
|
522
690
|
|
|
691
|
+
桌面端 CLI 也走同一次「检测更新」(2026-10-26,用户:「版本检测需要同时考虑 apk 和 npm」):
|
|
692
|
+
`/desk/app` 管 APK,`/desk/core` 管 npm 那份,两条链互不替代 ——
|
|
693
|
+
手机 App 可能是新的而桌面还是旧的,反之亦然。
|
|
694
|
+
|
|
695
|
+
- 界面把两条链的结果一起摆出来(`App vA → vB` / `桌面 vX → vY`),确认后**先升桌面、再下 APK**:
|
|
696
|
+
打开系统安装器会切走 App,之后就跑不了 SSE 了。
|
|
697
|
+
- `restartOnly` 为真时界面只说「重启 magic-client 后生效」,**不再喊一次升级** —— 再点一次
|
|
698
|
+
就是 `npm i -g` 降级安装(原地重启那几秒,旧进程读盘读到的是新文件,很容易误判)。
|
|
699
|
+
- 老插件没有 `/desk/core`(404)→ 只走 APK 那条;`/desk/app.upgrade === false` → 本机装不了
|
|
700
|
+
npm 那份,界面把「桌面」一行标成「请手工升级」,不给一个点了会失败的按钮。
|
|
701
|
+
|
|
523
702
|
关于 `permission` 的两条语义,客户端必须照做:
|
|
524
703
|
|
|
525
704
|
1. **没选就原样不传,不要替 core 补默认值。** core 只在**显式传过** `--permission` 时
|
|
@@ -573,18 +752,23 @@ core 的 stdout 事件流里没有 user 事件(它只写进会话日志),
|
|
|
573
752
|
⚠️ 登记表目录(`~/.magic-agent/sessions`)是**全机共享**的:用户手工跑的
|
|
574
753
|
`magic-agent -p …`、CI、别的 magic-* 应用都在往里写。所以 `/desk/sessions` 只回
|
|
575
754
|
**本客户端 + 同族 `magic-*` 来源**的行(`internal/client/service.go` 的
|
|
576
|
-
`
|
|
755
|
+
`sessionClientVisible`),其余滤掉 —— 原口径见用户 2026-10-12「不是 magic-agent 客户端
|
|
577
756
|
调用的任务不显示」。
|
|
578
757
|
|
|
579
758
|
- 本插件自己注入的 `appId`:手机 / 桌面从本客户端发起的会话;
|
|
580
|
-
- `magic-
|
|
581
|
-
`MAGIC_AGENT_CLIENT=magic-test`,见 magic-test 的
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
759
|
+
- **同族 `magic-*` 前缀**(2026-10-26 起不再认死名字):掌天瓶桌面壳(它不经本插件,
|
|
760
|
+
直接 exec core 并注入 `MAGIC_AGENT_CLIENT=magic-test`,见 magic-test 的
|
|
761
|
+
`desktop/agent-cli.cjs`)、core 自身 / 直连 CLI 的显式标识 `magic-agent`、
|
|
762
|
+
以及将来新增的任何 `magic-xxx` 客户端 —— 用户 2026-10-15:「通过 magic-test 新建的
|
|
763
|
+
会话要在移动端 h5 看到,同时 h5 上要能同时看到 magic-test 与 magic-agent 两种来源」;
|
|
764
|
+
2026-10-26:「多个客户端要能拉到同样的会话历史」。
|
|
765
|
+
|
|
766
|
+
⚠️ 此前放行名单是**钉死的两个字符串** `{magic-test, magic-agent}`,每接一个新客户端
|
|
767
|
+
都要回来改常量;忘改的表现是**那个客户端静默少一堆会话**,且只有它看不见,极难归因。
|
|
768
|
+
现在按前缀判定,新增客户端不必再动代码。
|
|
769
|
+
|
|
770
|
+
代价:名单之外(匿名 `client` = 手工跑 / CI / 旧记录、以及不相干应用)的记录
|
|
771
|
+
**不出现在客户端列表里**(磁盘上仍然留着,登记表**不再按时间清理**,见 3.4 的存档开关)——
|
|
588
772
|
想看全机视图用 core 的 `magic-agent --sessions`,那条命令**不过滤**。
|
|
589
773
|
|
|
590
774
|
### 2.2 扩展接口(业务项目对着写)
|
|
@@ -622,11 +806,107 @@ core 的 stdout 事件流里没有 user 事件(它只写进会话日志),
|
|
|
622
806
|
| `client-ui/shared/conversation.css` | 组件样式。结构尺寸共用;颜色走 3.3 的变量契约,由宿主供值 |
|
|
623
807
|
| `client-ui/shared/transport-http.js` | 传输层的**可选参考实现**(HTTP 版,打 `/desk/*`)。组件不依赖它 |
|
|
624
808
|
| `client-ui/index.html` | PC 宿主:oklch 令牌 + 一条顶栏 + 挂组件 |
|
|
625
|
-
| `client-ui-mobile/index.html` | 移动端宿主:AA 安卓端那套十六进制令牌 + 移动形态顶栏 + 挂组件;「我的」里可设默认引擎/模型(`localStorage` 的 `magic.engine` / `magic.model`,新建任务作为 `engine` / `model` 初值,会话内落定后由 `onPick` 回写);「发现 → 插件」是 `GET /desk/plugins` 的目录页(介绍 / 安装状态 / 安装升级 /
|
|
809
|
+
| `client-ui-mobile/index.html` | 移动端宿主:AA 安卓端那套十六进制令牌 + 移动形态顶栏 + 挂组件;「我的」里可设默认引擎/模型(`localStorage` 的 `magic.engine` / `magic.model`,新建任务作为 `engine` / `model` 初值,会话内落定后由 `onPick` 回写);「发现 → 插件」是 `GET /desk/plugins` 的目录页(介绍 / 安装状态 / 安装升级 / 让它干活);「发现 → 知识库」是 `GET /desk/knowledge` 的目录页(OpenViking 的本机安装状态 / 当前版本 / PyPI 最新版本 / 一键安装与升级 / 让它干活),三态(未安装 / 已安装 vX / 可升级 vY)都由服务端探测后宣布,页面不自己推断;「发现 → 脚本」是 `GET /desk/scripts` 的清单页(登记脚本 / 启停 / 看实时日志),内容与逻辑在 `client-ui-mobile/scripts.js` + `scripts.css`(独立子页面模块,与 `reqs.js` 同一纪律) |
|
|
626
810
|
|
|
627
811
|
移动端打包时(magic-test 的 `tools/build-mobile-www.cjs`)会把 `client-ui/shared/`
|
|
628
812
|
一并复制进 `www/shared/` —— 页面按同级 `shared/` 引用它,缺了这个复制对话区会是空白。
|
|
629
813
|
|
|
814
|
+
#### 移动端「消息通知」通道(2026-10-24 补记,2026-10-25 修正)
|
|
815
|
+
|
|
816
|
+
「我的 → 消息通知」有三个开关(任务结果 / 任务待确认 / 定时任务),存在
|
|
817
|
+
`localStorage` 的 `magic.notify`(**纯设备偏好,不进 `/desk/prefs` 远端同步**)。
|
|
818
|
+
但**通知能不能真的弹出来,取决于设备上有没有一条通着的原生通道**,不是看开关。
|
|
819
|
+
|
|
820
|
+
通道只有两条,按环境自动选:
|
|
821
|
+
|
|
822
|
+
| 环境 | 通道 | 说明 |
|
|
823
|
+
| --- | --- | --- |
|
|
824
|
+
| APK 壳(Capacitor) | `window.Capacitor.Plugins.LocalNotifications` | **系统通知**(通知栏里那条)。插件在 APK 的 dex 里,`POST_NOTIFICATIONS` 权限也已声明 |
|
|
825
|
+
| 真浏览器打开 `/m/` | `window.Notification` | 系统通知。**Android WebView 不支持 Notifications API** —— 所以这条在 App 内根本不存在,不许拿它当 App 内的兜底 |
|
|
826
|
+
| 两条都不通 | 应用内 toast | 一条灰条。**不是**系统通知 |
|
|
827
|
+
|
|
828
|
+
⚠️ **远程页里 `window.Capacitor` 根本不存在**(根因 2026-10-25 从 APK smali 逐行确认):
|
|
829
|
+
APK 壳配对后加载的是桌面的 `/m/` 远程页。Capacitor 的 JS 注入有两条路,对远程页**全都不通**:
|
|
830
|
+
① `DOCUMENT_START_SCRIPT`(现代 WebView 都支持)—— 脚本只注册给壳自己的 origin
|
|
831
|
+
(`http://localhost`),远程页不在集合里;② 这条分支生效时本地服务器的 `jsInjector`
|
|
832
|
+
被置成 **null**(`Bridge.loadWebView` 里 `v4 = null`),`handleProxyRequest` 的注入条件
|
|
833
|
+
① `jsInjector != null` 直接失败 —— 远程页被原样代理,**一个字节的 JS 都没注入**。
|
|
834
|
+
但**传输层**在:`useLegacyBridge = true` 走 `addJavascriptInterface("androidBridge")`,
|
|
835
|
+
注入**所有 frame**(App 内 APK 下载用的 `window.MagicUpdate` 就是这么工作的)。
|
|
836
|
+
即使运行时在(如 App 加载本地页 `ui.html`),**传输层 `androidBridge` 也是按 origin
|
|
837
|
+
白名单注入的** —— 远程桌面地址(`http://<局域网IP>:9100`)不在白名单里时,
|
|
838
|
+
传输层不注入,插件调用**既不报错也不回话**(promise 永不 settle)。
|
|
839
|
+
|
|
840
|
+
**兜底桥(2026-10-25 补,纯前端、无需重打 APK)**:`notifyNativeShim()` 在
|
|
841
|
+
`window.Capacitor` 不在、`window.androidBridge` 在时自建最小 Capacitor 响应面 ——
|
|
842
|
+
`androidBridge.postMessage(JSON.stringify({callbackId, pluginId, methodName, options}))`
|
|
843
|
+
发原生消息,`window.Capacitor.fromNative({callbackId, success, data|error})` 收响应,
|
|
844
|
+
协议与 MessageHandler 的 legacy 桥逐字段对齐。**只挂 `fromNative`/`nativePromise`,
|
|
845
|
+
不伪造 `window.Capacitor.Plugins.*`** —— `notifyPlugin()` 永远只认真运行时,
|
|
846
|
+
自建桥带 `__shim` 标记,真伪可区分。调用通道一律走 `notifyNativePlugin()`
|
|
847
|
+
(真运行时优先,自建桥兜底)。
|
|
848
|
+
|
|
849
|
+
所以移动端这一段的实现必须遵守:
|
|
850
|
+
|
|
851
|
+
- **一律超时即失败**(`notifyWithTimeout`),不靠「插件对象在不在」猜通不通;
|
|
852
|
+
超时按失败处理、落到 toast,**不能因为「调了没抛错」就当成功**。
|
|
853
|
+
- **状态位必须问原生**:`checkPermissions()` / `requestPermissions()` 返回
|
|
854
|
+
`{display: 'granted' | 'denied' | 'prompt'}`,页面显示的「已开启 / 已拒绝 / 去授权」
|
|
855
|
+
全部由它推导 —— 不许拿「插件对象在」当「已接入」。
|
|
856
|
+
- **`schedule()` 不传 `schedule` 字段 = 立即显示**(插件 `isScheduled()` 为 false 时直接
|
|
857
|
+
`notificationManager.notify()`);传了 `schedule.at` 反而是「排到那个时刻」,那是定时
|
|
858
|
+
任务的事、不是「现在弹一条」。
|
|
859
|
+
- 通知 `id` 必须是 Java int 范围内的数字(超了整个 call 被 reject),且不重复
|
|
860
|
+
(同 id 会覆盖前一条)。
|
|
861
|
+
- **通道自检与诊断入口(2026-10-25 撤)**:「发一条测试通知」与「通道诊断」曾作为
|
|
862
|
+
设置页行露出(静默失败过一次后的教训);通道打通后用户定调正式 UI 不要调试工具,
|
|
863
|
+
两个入口撤掉。底层 `notifyTest()` / `notifyDiagnose()` **保留**(行为由契约检查
|
|
864
|
+
第 ⑦~⑩ 组钉住)—— 通道再出问题时挂回任意入口就能当场自证,诊断分档
|
|
865
|
+
(no-runtime / shim / no-plugin / transport-dead)与 origin/插件清单报告不变。
|
|
866
|
+
|
|
867
|
+
APK 侧让传输层通到远程页的办法:`assets/capacitor.config.json` 里
|
|
868
|
+
`android.useLegacyBridge = true`(走 `addJavascriptInterface`,注入所有 frame;
|
|
869
|
+
现代的 `addWebMessageListener` 按 origin 白名单匹配,表达不了「任意局域网 IP」)。
|
|
870
|
+
⚠️ **这只解决传输层,不解决注入层** —— 注入(`window.Capacitor` 存不存在)由
|
|
871
|
+
Capacitor 自己的本地服务器决定,远程页可能压根没被注入。两者要分开看。
|
|
872
|
+
2026-10-25 起注入层断的那一档由上面的兜底桥在页面侧补齐。
|
|
873
|
+
|
|
874
|
+
**系统通知的边界**:轮询(`pollOnce`,5s)只在**页面活着**时跑 —— 锁屏、切后台、
|
|
875
|
+
离开同一网络就什么都收不到。需要「人不在 App 里也收到」走**飞书推送**(见下面一节)。
|
|
876
|
+
校验:`npm run check:notify`(纯逻辑)+ 真机验收(移动端「我的 → 通知与连接 → 系统通知」)。
|
|
877
|
+
|
|
878
|
+
#### 移动端「飞书推送」(2026-10-26)
|
|
879
|
+
|
|
880
|
+
「我的 → 账户 → 飞书推送」独立子页面:绑定 / 解绑 / 总开关 / 应用凭据 / 测试消息 /
|
|
881
|
+
最近 5 次推送结果。接口形状见第 2 节「飞书推送」那一行,实现在
|
|
882
|
+
`internal/client/feishu.go`(单测 `feishu_test.go`,假飞书,不碰外网)。
|
|
883
|
+
|
|
884
|
+
- **推送源是桌面端**,不是手机:task 完成 / 待确认时,手机在 `pollOnce` 的同一次跳变里
|
|
885
|
+
顺带 `POST /desk/feishu/push`,桌面端用飞书机器人推到用户本人(`open_id`)。
|
|
886
|
+
- **幂等键在服务端**(`event|ask_id`):多台手机、多个 tab 各自轮询时同一轮只推一次;
|
|
887
|
+
发送失败会释放键并退避重试 3 次 —— 「失败一次就永远不再推」是不允许的。
|
|
888
|
+
- **凭据**:`app_id` / `app_secret` 属于部署方,**不下发到移动端**;移动端只写
|
|
889
|
+
`~/.magic-agent/feishu-notify.json`(0600),`GET /desk/feishu` 只回「配没配 / 来源」。
|
|
890
|
+
- **绑定**:6 位绑定码(10 分钟有效、一次性、错码不退让)+ `open_id`。远程页里没有
|
|
891
|
+
飞书登录态,拿不到用户身份;绑定码只发给申请者本人,够用来定位「推给谁」。
|
|
892
|
+
- **两条通道,优先群机器人**(2026-10-26 用户:「为什么需要邮箱啊」):`POST /desk/feishu/webhook`
|
|
893
|
+
只要一个地址,收件人就是那个群 —— 不用建应用、不用等审批、不需要 open_id / 邮箱。
|
|
894
|
+
「应用方式」(App ID/Secret + 收件人)作为进阶保留:只有当你想一条条发给个人时才需要。
|
|
895
|
+
- **卡片带「打开 Magic Agent」按钮**:链接必须**先回打 `/desk/health` 验通**才写进卡片
|
|
896
|
+
(候选顺序:手机在 `push`/`test` 里报的 `url` → 缓存里的公网域名 → 本轮 cloudflared
|
|
897
|
+
的实际域名,全部并发验、1.5s 上限)。**一个都不通就不放链接** —— 宁可没有按钮,也不给人
|
|
898
|
+
一个打不开的门。2026-10-26 用户实测「地址不对 无法访问」的根因就是:实例里缓存的公网域名
|
|
899
|
+
属于上一轮隧道(cloudflared 重启即换域名),启动瞬间 `publicURL()` 会先返回旧值。
|
|
900
|
+
验通的是公网域名时顺手 `applyProbeResult` 纠正缓存。
|
|
901
|
+
⚠️ 链接里含访问令牌 —— 等同把控制权交给能看见这个会话的人,正文里明写「别转发」。
|
|
902
|
+
- **收件人优先填邮箱**(2026-10-26 实测教训):`open_id` 是**应用维度**的 —— 同一个人
|
|
903
|
+
在不同飞书应用下 open_id 不同,且飞书客户端任何界面都不显示它。让用户手抄等于让他猜:
|
|
904
|
+
实测有人填了 `ou_me`,旧代码照收 → 绑成功、推送全失败、界面还显示「已绑定」。
|
|
905
|
+
现在 `bind` 接受邮箱(`receive_id_type=email`)或 `oc_` 群 id,并对 `ou_` 做长度校验。
|
|
906
|
+
状态里存 `receive_id`/`receive_type`;老状态文件只有 `open_id` 时按 open_id 回落。
|
|
907
|
+
- 开关关着 / 没绑 / 没配时 `push` 回 `200 {disabled, reason}` —— 这是**正常状态**,
|
|
908
|
+
不是错误,客户端不该把它当失败重试。
|
|
909
|
+
|
|
630
910
|
> **组件宿主页必须先引**:`shared/vendor/marked.min.js` + `shared/vendor/purify.min.js`
|
|
631
911
|
> (并设 `window.QA_HLJS_URL` 指向 highlight.js),再引内核分片 `shared/conv/01-dom.js`、
|
|
632
912
|
> `02-media-kinds.js`、`08-ask-event-map.js`、`10-markdown-render.js`、`29-msg-variants.js`
|
|
@@ -837,6 +1117,7 @@ magic-agent 把会话的查询与修改都做成 CLI 命令,调用方 exec 即
|
|
|
837
1117
|
| 读已存标题 | `magic-agent --session-title <id>` → `{id, title}`(不调 LLM) |
|
|
838
1118
|
| **需求清单** | `magic-agent --requirements all\|<ws_id\|目录>` → `{ok, root, workspaces[]}`(只读,不调 LLM,见 §3.4.1) |
|
|
839
1119
|
| **归纳需求** | `magic-agent --consolidate-requirements <ws_id\|目录\|.>` → 一轮归纳结果(**会调模型**) |
|
|
1120
|
+
| **追加需求截图** | `magic-agent --requirement-shot <ws_id\|目录> --requirement-name <功能点名> [--requirement-module <模块>] [--requirement-caption <说明>] < shot.png` → `{ok, path, …}`(PNG 从 **stdin** 读,见 §3.4.1.1) |
|
|
840
1121
|
|
|
841
1122
|
`--session-log` 的输出形状与插件同名的 HTTP 接口**逐字段对齐**,两条路可互换。
|
|
842
1123
|
其中 `kind:"user"` 事件新增可选 `hosted:true`(2026-10-23 只增字段,`omitempty`):
|
|
@@ -922,7 +1203,8 @@ event: end data: {"session":"<id>","after":<末位置>,"state":"done|faile
|
|
|
922
1203
|
- 游标 = 最后一条已收到事件的 `seq`,重连请求带 `?after=<游标>`(断点续传,不重放已画过的帧);
|
|
923
1204
|
- 指数退避:0.8s 起、翻倍、单次 15s 封顶;连续 12 次或 6 分钟仍连不上才按失败收尾;
|
|
924
1205
|
- 重连期间那一轮**不封口**(不调 `finish` / `qaRunEnd`),底部继续显示运行态;
|
|
925
|
-
|
|
1206
|
+
界面插一行「连接中断,正在自动重连…」,恢复后就地改成「已重新连接,继续同步」,
|
|
1207
|
+
短暂提示后自动收走(不是这一轮的历史,别长期挂在画布上);
|
|
926
1208
|
- 4xx(404 老网关 / 401 令牌失效等)是确定性失败,**不重试**,立即 fallback / 如实报错。
|
|
927
1209
|
参考实现:`client-ui/shared/transport-http.js` 的 `followSession`(`onRetry` / `onResume`
|
|
928
1210
|
可选回调,老宿主不实现也不影响续流);闸门 `npm run check:follow`。
|
|
@@ -1021,11 +1303,11 @@ core 侧只有一张空的 sid→title 覆盖表,于是:
|
|
|
1021
1303
|
| 生成在 core | `magic-agent --suggest-followups <file\|->`(`internal/suggest`):提示词、截断、JSON 解析、去重与长度上限都只有一份;移动端 / PC 拿到的建议不会各说各话 |
|
|
1022
1304
|
| **默认关闭** | 建议引擎**不配置 = 不开启建议功能**(2026-10-23 用户定调)。组件 `suggestEngine` 为空时不发 `/desk/suggest`、不画建议、不补历史建议、托管也不自动执行;老用户升级后默认没有建议,需去「我的」里显式选引擎 |
|
|
1023
1305
|
| 配置入口 | 移动端「我的 → 建议引擎 / 建议模型」两行,点开的是与「默认引擎 / 模型」相同的**二列抽屉**:左列首项「不开启」(默认),其余是 `/desk/engines` 探测到的可用引擎;点左列只切右列,点右列才落定。引擎存 `magic.suggest.engine`、模型存 `magic.suggest.model`,都属于环境配置(`ENV_SCOPE_KEYS` + `/desk/prefs` 同步);改完立刻 `conv.setSuggestEngine(engine, model)` 生效,下一轮收尾开始生成 |
|
|
1024
|
-
| 选引擎 / 模型 | `llm`
|
|
1025
|
-
| **思考固定关闭** | 建议调用一律带 `NoThinking`,各引擎走官方开关:`claude` → `--thinking disabled`;`codex` → `model_reasoning_effort="none"`;`llm` → `reasoning_effort="none"`(直连请求体;CLI 委托路径用 `-o reasoning_effort none
|
|
1306
|
+
| 选引擎 / 模型 | `llm` 显式选中的模型**先**按它自己跑(只传 `reasoning_effort=none`,不预先换模型);若该模型拒绝该参数(`MiniMax-M3.1-Flash` 要求 adaptive thinking,HTTP 400),**失败后降级到 `models.json` 里第一条 `thinking.disabled` 条目重试一次**(本机 `minimax-nothink`)—— 建议链路要求「一律不思考」,显式选的模型撑不住时必须换到能关思考的那条,否则整条建议链路静默失败(2026-10-24 修)。**建议模型留空**时默认就用那条 `thinking.disabled` 条目。`claude` / `codex` 可用各自的关闭档 |
|
|
1307
|
+
| **思考固定关闭** | 建议调用一律带 `NoThinking`,各引擎走官方开关:`claude` → `--thinking disabled`;`codex` → `model_reasoning_effort="none"`;`llm` → `reasoning_effort="none"`(直连请求体;CLI 委托路径用 `-o reasoning_effort none`);空建议模型用 `thinking.disabled` 变体作默认值,显式模型撑不住时降级到该变体;`codebuddy` → settings `alwaysThinkingEnabled=false`;`dsh` → SDK `InitializeParams.reasoningEffort="off"`。`trae`/`openclaw`/`arkclaw` 没有已知开关,仍保留 `NoThinking` 标记,失败后再降级 `llm` |
|
|
1026
1308
|
| **不续接会话** | 独立进程、新会话、`ToolsOff`、不带 `SessionID` / `Continue` —— 「请生成建议」这类指令不会写进用户正在聊的上下文,也不进会话登记表 / 日志 |
|
|
1027
|
-
| 引擎走「无工具 + JSON」通道 | core 的 `suggest.Generate` 固定 **`ToolsOff` + `JSONSchema` + `NoThinking`**,引擎以 `--print --output-format json` 返回一小段结果,不走流式、不带工具。默认选 `llm`;建议模型留空时用 `models.json` 第一条 `thinking.disabled` 变体(本机 `minimax-nothink
|
|
1028
|
-
| 慢引擎不拖界面 | `claude` / `codex` / `codebuddy` / `dsh` 等非 `llm` 引擎给 10s 主引擎预算;失败/超时才降级 `llm`,并清掉当前引擎的 model
|
|
1309
|
+
| 引擎走「无工具 + JSON」通道 | core 的 `suggest.Generate` 固定 **`ToolsOff` + `JSONSchema` + `NoThinking`**,引擎以 `--print --output-format json` 返回一小段结果,不走流式、不带工具。默认选 `llm`;建议模型留空时用 `models.json` 第一条 `thinking.disabled` 变体(本机 `minimax-nothink`),显式模型则先按它自己跑、撑不住(拒绝 `reasoning_effort=none`)时降级到该变体 |
|
|
1310
|
+
| 慢引擎不拖界面 | `claude` / `codex` / `codebuddy` / `dsh` 等非 `llm` 引擎给 10s 主引擎预算;失败/超时才降级 `llm`,并清掉当前引擎的 model。**降级那一档最多 8s**,主引擎的预算要先给它留出来,否则主引擎把整个 timeout 吃光、降级永远轮不到。界面默认不传 engine,直接走快路径,20s 超时不会触发 |
|
|
1029
1311
|
| **允许空数组** | 模型判断「没有下一步」时回 `[]`,这是**合法结果**:客户端不画任何按钮,不拿模板凑 |
|
|
1030
1312
|
| 成功收尾后问 | 失败 / 被用户停止 / 还在跑的轮次都不问;本轮出现过提问卡(引擎在等作答)时也跳过,避免与作答卡抢注意力 |
|
|
1031
1313
|
| 历史最后一轮补一次 | 打开一条**已收尾、不在跑**的历史会话时,回放完对最后一轮补一次建议;更早的轮次不问(它的「下一步」早已被后续对话覆盖)。缓存按 `(sid, 最后一条 user 的 at/seq, 建议引擎, 建议模型)` 放 `localStorage`,30 分钟内重开不重复调模型;空数组也缓存 |
|
|
@@ -1084,13 +1366,14 @@ magic-test 桌面端那份内联 `app.js` 不加载这个组件,因此不会
|
|
|
1084
1366
|
| --- | --- |
|
|
1085
1367
|
| `--sessions` 给 `status` | **会话级唯一口径**,客户端状态判定一律先读它;`state` 降级为「这一轮的状态」,只在老版本 core 没有 `status` 时回落 |
|
|
1086
1368
|
| `alive` 与 `status` 同源 | `alive = (status == running)`。同一行不会出现 `status=idle` 却 `alive=true` |
|
|
1369
|
+
| `status=queued`(2026-10-24 只增取值) | 服务端作业队列**受理即登记**的排队占位:作业还没开跑(`pid=0`、`alive=false`、`has_log=false`),`run_id` = 客户端 `request_id`(缺省用 job id),`title` 回落 `prompt_head` 且 `title_summarized=true`(没有日志,别去归纳)。真开跑后这一行由 core 自己的 running 记录接手(占位被撤)。客户端把它画成「等待中」,**不要**当成「没发生过」或「已结束」 |
|
|
1087
1370
|
| 覆盖表路径 | `~/.magic-agent/session-status.json`(与 `session-titles.json` 同目录),进程内缓存;`MAGIC_AGENT_SESSIONS` 指向别处时取它的父目录 |
|
|
1088
1371
|
| 表形状 | `{ "<session_id>": {"state","run_id","run_started","updated_at"} }`;键优先 `session_id`,引擎还没吐 id 时落在 `run_id` 上,拿到后迁移并删旧键(与标题同一坑:留双键就会按不同 id 读到两个状态) |
|
|
1089
1372
|
| `run_started` 排序守卫 | 写入方是**各轮的进程**:旧一轮收尾(`Finish` / `Stop` / 进程已不在 → `gone`)可能晚于新一轮开跑。只在写入轮**不早于**表里那一轮时才覆盖,迟到的 `done` 不会盖掉新一轮的 `running` |
|
|
1090
1373
|
| 写入时机 | 与登记表同一步(`Handle.write` + 所有 `writeRecord` 路径,含 `Stop` / 同会话旧轮被 supersede / 读侧归一 `gone`);不落登记表(`--save-registry` 未开)时状态也不留痕 |
|
|
1091
1374
|
| 删除会话一并清 | `--delete-session` / `DELETE /desk/session/{id}` 会删掉状态行(返回里的 `statuses_removed`),避免同 id 再建会话时继承一条过期状态;删主会话还会沿父指针级联删掉子任务会话(`child_sessions_removed`) |
|
|
1092
1375
|
|
|
1093
|
-
状态词表与登记表完全一致:`running` / `waiting_approval` / `idle`(非终态),
|
|
1376
|
+
状态词表与登记表完全一致:`running` / `waiting_approval` / `idle` / `queued`(非终态),
|
|
1094
1377
|
`done` / `failed` / `cancelled` / `interrupted` / `gone`(终态)。
|
|
1095
1378
|
|
|
1096
1379
|
⚠️ 老版本 core 的 `--sessions` 没有 `status` —— 客户端**如实回落 `state`**(行为与旧版一致,
|
|
@@ -1111,6 +1394,55 @@ CLI 默认**什么都不落**,因为绝大多数调用(手工跑一轮 / CI
|
|
|
1111
1394
|
(2026-10-15:它的会话要能在移动端 H5 上被列出并点开)。`--append` 在没有登记记录时会按约定找 `<dir>/<run_id>.sock`
|
|
1112
1395
|
(`session.AppendSocketPath`),所以「默认开着常驻、却追加不了」不会发生。
|
|
1113
1396
|
|
|
1397
|
+
### 3.4.0 多客户端仲裁(2026-10-26,默认生效)
|
|
1398
|
+
|
|
1399
|
+
**用户定调:「是 magic-agent 本身要支持多客户端」。**
|
|
1400
|
+
|
|
1401
|
+
在 core 里仲裁,而不是让每个宿主自己排一遍。理由是能起轮次的地方本来就有很多:
|
|
1402
|
+
桌面壳直接 exec core、HTTP 插件排自己的队列、手机连同一个插件、还有人手工
|
|
1403
|
+
`magic-agent -p`、CI、别的 `magic-*` 应用。**任何进程内的队列都只管得住自己那一份** ——
|
|
1404
|
+
少一个宿主漏实现,同一批文件就已经被两个引擎改了。宿主可以自己再排一层(为了显示
|
|
1405
|
+
排队位次、给别的客户端让路),但那是**视图与礼貌**,不是正确性的来源。
|
|
1406
|
+
|
|
1407
|
+
| 事实 | 说明 |
|
|
1408
|
+
| --- | --- |
|
|
1409
|
+
| 工作空间串行**默认开** | `MAGIC_AGENT_WORKSPACE_LOCK` 不设置 = 启用;`0` / `false` / `off` / `no` = 关闭。⚠️ 2026-10-26 前是「必须显式设为 `1` 才启用」,只有 magic-client 注入 —— 于是桌面壳那条路完全绕过串行(实测:手机在跑时 PC 能在同目录起第二个引擎) |
|
|
1410
|
+
| 关掉的合法场景 | ① 跑批 / 测试;② 团队模式子任务(与主任务同空间,再取锁就是自己锁自己 —— core 内部按 `ParentRunID` 自动跳过);③ **插件的「并行」模式**:`jobs.go` 让同空间多条作业并行时,必须给这些子进程显式传 `=0`,否则 core 在 `Begin` 处重新把它们串起来,界面上就是「切了并行、任务还是一个一个跑」。判据集中在 `service.go::workspaceLockEnv`(串行不传、并行传 `=0`),并有用例钉住 |
|
|
1411
|
+
| 锁的粒度 | **工作空间**(`-w/--workspace`;未指定则用进程 cwd)。同一空间同时只跑一条轮次,不同空间并行 |
|
|
1412
|
+
| 等待上限 | `MAGIC_AGENT_WORKSPACE_LOCK_WAIT`(Go duration,默认 `10m`)。超时报错里**带占用者身份**(pid / 客户端 / 设备 / 已占用时长),而不是只说「超时了」 |
|
|
1413
|
+
| 锁文件 | `<sessions dir>/locks/<sha1(绝对路径)>.lock`,JSON 内容:`{pid, client, source, run_id, session_id, engine, since, workspace}`。⚠️ **旧格式(`pid\n纳秒时间`)照旧认** —— 升级途中有上一代进程留下的锁文件,只认 JSON 会读不出 pid → 判不出死进程 → 那个锁要等 6 小时 stale 才回收 |
|
|
1414
|
+
| 陈旧回收 | 持有进程已不在 → 取锁方回收(`O_CREATE|O_EXCL` 原子创建 + 按 pid 判死)。`LockHolderFor()` 是**只读**的,绝不替别人判死删文件 |
|
|
1415
|
+
| `--sessions` 新增两字段 | `workspace_locked`(这个会话所在目录是否被**别人**占着,排除自己)+ `lock_holder`(占用者身份)。只增字段,不升 `contractVersion`,老客户端忽略即可 |
|
|
1416
|
+
| 为什么要这两个字段 | 等待发生在 `Begin` 里,客户端**发请求那一刻看不到**「我要等」—— 用户看到的只是「点了没反应、转很久」。把判断上移到列表,客户端就能在按下发送之前说「这个目录正被手机端占着,已排队」 |
|
|
1417
|
+
|
|
1418
|
+
#### 跨客户端的重复接管:不静默杀
|
|
1419
|
+
|
|
1420
|
+
同一 `session_id` 被两端同时续接时,两边的 `sessionWriter` 各算各的 `seq`,
|
|
1421
|
+
同一份 `<session_id>.jsonl` 里出现成对重号 —— 一端「已完成」、另一端永远等不到终态
|
|
1422
|
+
(2026-10-21 报障的根因)。原先的处理是**无条件** `SIGTERM` 掉旧的那一轮。
|
|
1423
|
+
|
|
1424
|
+
多客户端之后这变成一把暗刀:用户在手机上跑着,在 PC 上点开同一条接着问(本意是「继续」),
|
|
1425
|
+
手机端那条就被**静默终止**,界面显示「突然已停止」。
|
|
1426
|
+
|
|
1427
|
+
现在的口径:
|
|
1428
|
+
|
|
1429
|
+
| 情形 | 行为 |
|
|
1430
|
+
| --- | --- |
|
|
1431
|
+
| 同一个 `client`(含两边都为空 = 都是直接跑 CLI) | **照旧接管**(先 SIGTERM,宽限期不退再 SIGKILL,写 `cancelled`) |
|
|
1432
|
+
| 跨 `client` | **不杀**,只在 stderr 说明「另一端仍在运行,如需继续请先 `--stop` 那一端」。并发由工作空间锁在 `Begin` 阶段挡住 |
|
|
1433
|
+
|
|
1434
|
+
留前一条的理由:用户开两个终端跑同一会话同样是灾难,`client` 都是空 = 同一来源。
|
|
1435
|
+
后一条只说「不同客户端不该互相杀」,没说「并发就对了」。
|
|
1436
|
+
|
|
1437
|
+
#### 客户端该怎么做(三条)
|
|
1438
|
+
|
|
1439
|
+
1. **不要自己实现串行作为正确性保证**。可以是体验(排队提示、位次),但底层必须有 core 的锁。
|
|
1440
|
+
2. **`--sessions` 的 `workspace_locked` / `lock_holder` 拿来提示**,别自己读 `locks/` 目录 ——
|
|
1441
|
+
那是内部布局。读侧用 `LockHolderFor()` 同源的字段。
|
|
1442
|
+
3. **跨端续接要先提示**:看到同会话另一端 `status` 是活的(`IsLiveState`),
|
|
1443
|
+
应当问用户「那边还在跑,要接管吗」,而不是直接发出去(core 已经不会再替你杀对面,
|
|
1444
|
+
但两轮并发仍然不是好事)。
|
|
1445
|
+
|
|
1114
1446
|
### 3.4.1 需求聚合(`--requirements` / `--consolidate-requirements`,2026-10-22)
|
|
1115
1447
|
|
|
1116
1448
|
**能力定位**:把「会话里聊过的需求」自动归纳成功能点文档。这是从业务项目(掌天瓶 /
|
|
@@ -1172,8 +1504,8 @@ CLI)才按 `claude → codebuddy → codebuddy-ai → codex → trae → dsh`
|
|
|
1172
1504
|
```
|
|
1173
1505
|
|
|
1174
1506
|
`--consolidate-requirements` 的输出:`{ok, workspace, dir, module?, noop?, bootstrap?, bootstrap_engine?,
|
|
1175
|
-
scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pending, error?}`
|
|
1176
|
-
(`module` 只在模块级整理时出现;此时 `consumed` 恒为 0)。
|
|
1507
|
+
scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pending, merged?, merged_modules?, error?}`
|
|
1508
|
+
(`module` 只在模块级整理时出现;此时 `consumed` 恒为 0、`merged` 恒为 0)。
|
|
1177
1509
|
`noop=true` 是「没有未归纳记忆、也没 `--requirement-force`」的正常结果,不是错;
|
|
1178
1510
|
`bootstrap=true` 表示这一轮做了首次目录初始化(`bootstrap_engine` 是实际读目录的引擎,
|
|
1179
1511
|
`scanned` 是扫描到的条目数,排障用)。
|
|
@@ -1185,9 +1517,24 @@ scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pen
|
|
|
1185
1517
|
- **归纳是增量的、只增不减**:模型只给 `desc / points / conclusions / sources` 编号,
|
|
1186
1518
|
骨架(最终版 / 需求描述 / 需求点 / 已定结论 / 来源对话 / 更新记录)与日期由代码写;
|
|
1187
1519
|
已有文档的模块**不搬家**(模型每轮的 module 会漂,文档一旦归类就稳定)。
|
|
1520
|
+
- **模块去重(2026-10-25)**:空间级整理收尾时会把「表达同一件事」的已有模块合并
|
|
1521
|
+
(模型语义判定 + 确定性候选闸门;并入功能点更多的模块,同名文档内容合并不丢,
|
|
1522
|
+
各补一行「模块合并」更新记录)。`merged` 是合并掉的模块个数、`merged_modules` 是
|
|
1523
|
+
「被合并 → 并入」清单;带 `module` 的模块级整理**不做**模块合并(只动一个模块的纪律)。
|
|
1524
|
+
手动「整理」即使没有新记忆也会查一次(`noop` 与 `merged` 互斥)。
|
|
1188
1525
|
- **存储**在 `~/.magic-agent/requirements/`(`MAGIC_AGENT_REQUIREMENTS` 可覆盖),
|
|
1189
1526
|
与业务项目的 `~/.magic-test/requirements/` **互不相干、不自动迁移**:
|
|
1190
1527
|
`<ws>/features/<模块>/<名称>.md` 是给人直接打开的 markdown。
|
|
1528
|
+
- **会话↔需求关系(2026-10-25)**:归纳落账关系时**同步**写 `<ws>/relations.jsonl`
|
|
1529
|
+
(追加型,每行 `{ts, session, stitle?, module, feature, n?}`;去重键 = session+module+feature+n,
|
|
1530
|
+
每轮归纳收尾压缩一次;模块合并时 `module` 字段同步改写)。`stitle` 是写入当时的会话标题快照
|
|
1531
|
+
—— 登记表会被清理,图谱**不能**依赖事后回查。文档的「来源对话」段(30 条截断)只是展示,
|
|
1532
|
+
关系以此文件为准。读取口:CLI `--requirements-graph [all|<ws|目录>]` 与 HTTP
|
|
1533
|
+
`GET /desk/requirements/graph?ws=`(能力位同 `feature.requirements`,缺了回 501),
|
|
1534
|
+
输出 `{ok, root, workspaces:[{id, name, dir?, nodes, edges}]}`,node 形状
|
|
1535
|
+
`{id, type:"session"|"feature", title, module?}`(feature id = `f:<模块>/<名>`、
|
|
1536
|
+
session id = `s:<session>`),edge `{source, target}`(同对去重)。relations 里指向
|
|
1537
|
+
已不存在文档的功能点连同边**静默丢弃**(文档目录现算清单为准)。
|
|
1191
1538
|
|
|
1192
1539
|
**客户端要做的事**:列表页只调 `--requirements`(每刷一次都调,绝不顺手触发归纳);
|
|
1193
1540
|
「整理」按钮调 `--consolidate-requirements`,并**传它要整理的那个空间 + 当前配置的默认引擎**
|
|
@@ -1200,11 +1547,90 @@ scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pen
|
|
|
1200
1547
|
按钮在首次初始化时要允许几分钟等待(文案「初始化中…」),完成后再 `--requirements` 刷新。
|
|
1201
1548
|
打开一篇文档读 `file` 绝对路径的正文即可(普通 markdown,走既有的阅读器)。插件在每轮 `/desk/ask` 收尾后**防抖 12s 自动归纳**
|
|
1202
1549
|
该轮所在工作区(`MAGIC_CLIENT_NO_REQ_AUTO=1` 关闭),所以客户端不必自己轮询触发。
|
|
1203
|
-
移动端把「需求」做成常驻页签(见 §1.3
|
|
1550
|
+
移动端把「需求」做成常驻页签(见 §1.3 的说明),列表页读失败要显示原因而不是空列表。
|
|
1204
1551
|
**没有功能点的工作区不渲染**(用户 2026-10-22):「全空 + 一条灰字说明」比「一列空工作区」
|
|
1205
1552
|
更像出了错;`feature_count == 0` 且模块里也没有行的那个 `workspaces[]` 项直接跳过,
|
|
1206
1553
|
整页都为空时再合成一句提示(有 `pending` 就引导点「整理」,否则说「聊几句就会有」)。
|
|
1207
1554
|
|
|
1555
|
+
#### 3.4.1.1 页面完成情况截图(2026-10-25)
|
|
1556
|
+
|
|
1557
|
+
**需求**:需求文档整理时,**涉及页面改造**的功能点要调用内置浏览器 MCP 对完成情况
|
|
1558
|
+
**截图标注并放进文档里**(用户原话)。
|
|
1559
|
+
|
|
1560
|
+
**能力位**:`feature.requirement.shots`(与 `feature.requirements` **分开** —— core 可能
|
|
1561
|
+
支持需求聚合但还不支持截图;界面要能分别判断,别把整个「需求」页签藏掉)。
|
|
1562
|
+
插件对 `POST /desk/requirements/shots` 先查这个位,缺了回 **501 + 中文原因**(点名缺哪个位)。
|
|
1563
|
+
|
|
1564
|
+
**文档形状**:功能点文档多出**第七段「页面完成情况」**(排在「已定结论」之后、
|
|
1565
|
+
「来源对话」之前):
|
|
1566
|
+
|
|
1567
|
+
```markdown
|
|
1568
|
+
## 页面完成情况
|
|
1569
|
+
|
|
1570
|
+
页面地址:https://example.com/app
|
|
1571
|
+
-  <!-- 2026-10-25T10:00:00Z -->
|
|
1572
|
+
```
|
|
1573
|
+
|
|
1574
|
+
- 页面地址行前缀固定为 `页面地址:`;**地址只认 http/https**(占位符 / 本机路径 / `javascript:` 一律不收)。
|
|
1575
|
+
- 截图行是 ``;路径含空格或括号时用 `<>` 包(markdown 的标准转义)。
|
|
1576
|
+
- 行末的 `<!-- RFC3339 -->` 是**截图时刻**(渲染不可见、解析能取回)—— 清单上的「最后截图时间」靠它。
|
|
1577
|
+
- **文档格式由 core 拥有**:客户端不写文档、不碰 `~/.magic-agent/requirements/`。
|
|
1578
|
+
|
|
1579
|
+
**清单里的新字段**(`features[]` 里,只增不删):
|
|
1580
|
+
|
|
1581
|
+
| 字段 | 类型 | 含义 |
|
|
1582
|
+
| --- | --- | --- |
|
|
1583
|
+
| `page_url` | string | 该功能点对应的页面地址(归纳时从对话里提取;可空) |
|
|
1584
|
+
| `shots` | int | 已有的「页面完成情况」截图数(0 = 还没截) |
|
|
1585
|
+
| `shot_at` | int | 最后一张截图的时刻(unix 毫秒;0 = 还没截) |
|
|
1586
|
+
|
|
1587
|
+
**归纳侧的口径**:模型**只给增量**,其中 `page_url` 是**可选**字段(不在 `required` 里);
|
|
1588
|
+
提示词明确「只有当记忆里明确出现了这个页面(http/https 开头)才填,并原样照抄;没有就填空串,
|
|
1589
|
+
**绝不猜、绝不编**」。新值**合法时**才覆盖旧值(空 / 非法一律保留旧值)。
|
|
1590
|
+
归纳的**全量重写**必须**保住**已有的截图段与页面地址(否则每轮整理都把截图冲掉)。
|
|
1591
|
+
|
|
1592
|
+
**追加截图(CLI)**:
|
|
1593
|
+
|
|
1594
|
+
```bash
|
|
1595
|
+
magic-agent --requirement-shot <ws_id|目录> --requirement-name <功能点名> \
|
|
1596
|
+
[--requirement-module <模块>] [--requirement-caption <说明>] < shot.png
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
PNG 走 **stdin**(不进 argv:几百 KB 会撞命令行长度上限)。落盘到
|
|
1600
|
+
`<wsid>/shots/<模块或 unfiled>/<功能点slug>/<unix毫秒>[-n].png`(0600;校验 PNG magic;
|
|
1601
|
+
上限 8MB)。**找不到对应功能点时明确报错**(不新建同名空文档)。输出:
|
|
1602
|
+
`{ok, workspace, dir, module?, name?, file?, path?, shots, error?}`。
|
|
1603
|
+
|
|
1604
|
+
**追加截图(HTTP)**:`POST /desk/requirements/shots?ws=&module=&name=&caption=`
|
|
1605
|
+
(body = **PNG 原始字节**,不是 multipart)。插件 → core 走 `--requirement-shot`,PNG 经 stdin。
|
|
1606
|
+
`name` 必填;空 body 回 400;超 8MB 回 **413**(不是 400 —— 「图片太大」与「请求体坏了」
|
|
1607
|
+
要分得开,用户才看得懂);能力位缺了回 501。
|
|
1608
|
+
|
|
1609
|
+
**客户端要做的事**(移动端「需求」tab;2026-10-25 起截图改基 agent-browser):
|
|
1610
|
+
|
|
1611
|
+
- 功能点行上出**相机徽标**的条件 = `page_url` 非空 **且** core 有 `feature.requirement.shots`。
|
|
1612
|
+
文案分两档:还没截过 = 「待截图」,截过了 = 「N 图」。**没有 `page_url` 就不出徽标**
|
|
1613
|
+
(不给「点了必然失败」的死入口)。
|
|
1614
|
+
- 点徽标 → 抽屉(**页面地址可改** —— 归纳提错或漏提时人来定更准;**标注说明可写**)→
|
|
1615
|
+
「打开页面并截图」一步完成两件事:
|
|
1616
|
+
1. 手机端**强制走 `/b/` 中转**打开页面(给用户看、可触控 —— relay 只做远程鼠标);
|
|
1617
|
+
2. 同时 `POST /desk/requirements/shot-remote` 让**服务端 agent-browser** 对页面地址
|
|
1618
|
+
截一张全页图(真实 Chromium,不受跨域污染 / iframe 限制),回图片地址 → 标注浮层。
|
|
1619
|
+
- **服务端没装 agent-browser** → shot-remote 回 501 + 安装提示,界面如实 toast
|
|
1620
|
+
(`npm install -g agent-browser && agent-browser install`),不假装能截。
|
|
1621
|
+
- 标注浮层:矩形拖拽 / 文字 / 三色 / 撤销 / 清除 / 取消 / 完成。**文字不用 `window.prompt`**
|
|
1622
|
+
(Android WebView 与 Electron 小窗都没实现它,调用只会静默返回 null —— 表现是「点了没反应」)。
|
|
1623
|
+
- 「完成」→ canvas 导 PNG → `POST /desk/requirements/shots` → 刷新清单(徽标变「N 图」)。
|
|
1624
|
+
- **读文档里的截图**:`core` 写的是**本机绝对路径**(`C:/…` 或 `/…`),
|
|
1625
|
+
而 DOMPurify 的协议白名单会**摘掉**这种 `src`(默认只放行 http/https/mailto/tel/cid/xmpp)。
|
|
1626
|
+
所以渲染前要改写成 `<img data-reqshot="绝对路径">`(`data-*` 消毒默认放行),
|
|
1627
|
+
渲染后再把 `src` 补成回读地址(`/desk/media?path=…`,移动端带 `w=480`)。
|
|
1628
|
+
协议相对地址(`//host/a.png`)是**网页上的图**,不许当本机路径处理。
|
|
1629
|
+
|
|
1630
|
+
**已知边界**:标注只有矩形与文字(无箭头 / 马赛克 / 裁剪);服务端截的是 agent-browser
|
|
1631
|
+
独立 profile 里 page_url 加载完的初始状态(不共享中转 cookie jar,需登录的页面截出来
|
|
1632
|
+
可能是登录页;也不是手机 iframe 里滚到的位置)—— 与「手机上看到的」可能有差,如实记录。
|
|
1633
|
+
|
|
1208
1634
|
|
|
1209
1635
|
### 3.5 两条不变量
|
|
1210
1636
|
|