magic-agent 1.0.49 → 1.0.109
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 +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2443 -178
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- 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 +1 -1
package/README.md
CHANGED
|
@@ -128,13 +128,13 @@ magic-agent --engines --no-models | jq -r '.[] | select(.install) | "\(.engine)\
|
|
|
128
128
|
### 客户端契约(`--contract`)
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
magic-agent --contract # {"contractVersion":
|
|
131
|
+
magic-agent --contract # {"contractVersion":2,"engines":[...]}
|
|
132
132
|
magic-agent --contract | jq -r '.contractVersion' # 启动时先校验这个
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
| 字段 | 含义 |
|
|
136
136
|
|------|------|
|
|
137
|
-
| `contractVersion` | 契约面版本(当前 `
|
|
137
|
+
| `contractVersion` | 契约面版本(当前 `2`)。客户端超出自己支持的范围就明确报错并提示升级,不要带着不匹配的能力继续跑 |
|
|
138
138
|
| `engines` | 与 `--engines` 的行完全同构,每行另含 `capabilities` |
|
|
139
139
|
|
|
140
140
|
版本规则:**只增字段不升版本**;字段改名 / 语义变化 / 删除时必须升 `contractVersion`。`--contract` 默认**不探测模型**(模型探测要起真 CLI,30 秒级,客户端启动路径等不起),要连模型一起拿就写 `--no-models=false`。
|
|
@@ -245,6 +245,29 @@ magic-agent -e claude --stream --keep-alive=false -p "..." # 要「一轮
|
|
|
245
245
|
|
|
246
246
|
> ⚠️ **默认常驻带来的行为变化**:进程会在最后一轮结束后继续存活 `--idle` 等待追加。调用方若要「一轮完成」的信号,请读事件流里的 **`turn_end`** 事件(而不是等进程退出),或启动时传 `--keep-alive=false`。
|
|
247
247
|
|
|
248
|
+
## 需求聚合(`--requirements` / `--consolidate-requirements`)
|
|
249
|
+
|
|
250
|
+
把会话里聊过的需求自动归纳成「模块 → 功能点」两级 markdown 文档,存储在
|
|
251
|
+
`~/.magic-agent/requirements/`(`MAGIC_AGENT_REQUIREMENTS` 可覆盖)。
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
magic-agent --requirements all # 列全部工作区(只读,不调模型)
|
|
255
|
+
magic-agent --requirements ~/work/proj # 只列这个目录(也认 ws id)
|
|
256
|
+
magic-agent --consolidate-requirements ~/work/proj # 手动整理:首次=按目录初始化,之后只处理新会话
|
|
257
|
+
magic-agent --consolidate-requirements . --requirement-all # 全量重放(回填/校正)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- **第一次整理 = 初始化**:工作区还没初始化过时,先有界扫描空间目录,用**客户端配置的默认
|
|
261
|
+
引擎**(`--requirement-engine`;空 = 自动挑 claude / codebuddy / codex / dsh 等能读代码的)
|
|
262
|
+
反推一版用户视角的需求基线;此前旧会话记为已消费,之后只消费上次整理后的新会话。
|
|
263
|
+
默认引擎不能读目录(如 `llm`)会明确报错并提示换引擎。目录为空不算失败,会回落到会话增量。
|
|
264
|
+
- **自动归纳只增量**:插件在每轮对话收尾后防抖 12s 自动归纳(`MAGIC_CLIENT_NO_REQ_AUTO=1`
|
|
265
|
+
关闭),它带 `--requirement-auto`,**绝不触发首次目录初始化**。
|
|
266
|
+
- 需求页每个空间组头有独立「整理」,只整理那个空间;顶栏「全部整理」按空间串行跑一遍,
|
|
267
|
+
一键触发全部(某个空间失败不影响后面的)。没有新会话时返回 `noop`,不会空跑一次模型。
|
|
268
|
+
|
|
269
|
+
详细契约见 [CLIENT-CONTRACT.md](CLIENT-CONTRACT.md) §3.4.1。
|
|
270
|
+
|
|
248
271
|
## 停止指定会话
|
|
249
272
|
|
|
250
273
|
```bash
|
|
@@ -454,6 +477,12 @@ magic-client serve --ui-dir magic-h5
|
|
|
454
477
|
数据从 IPC→CLI 或自有后端来都行(参考 `client-ui/shared/transport-http.js` 与
|
|
455
478
|
`CLIENT-CONTRACT.md` 的事件流形状)。
|
|
456
479
|
|
|
480
|
+
> 输入框支持**粘贴 / 拖入图片**:组件与 + 菜单共用同一条管线,走可选的
|
|
481
|
+
> `transport.upload(file)` → magic-client 的 `POST /desk/upload` 落盘(返回 core 那台机器的
|
|
482
|
+
> 绝对路径),路径随后随 `payload.attachments` 下发给引擎。自定义 transport 想支持贴图,
|
|
483
|
+
> 补上 `upload(file)`(返回 `{path, name, size, url}`)即可;没有这个方法时组件会如实提示
|
|
484
|
+
> 「当前传输层不支持上传附件」,不会假装贴上了。
|
|
485
|
+
|
|
457
486
|
## 更多文档
|
|
458
487
|
|
|
459
488
|
| 文档 | 内容 |
|
package/client-ui/pc/app.css
CHANGED
|
@@ -5089,43 +5089,9 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
|
|
|
5089
5089
|
.qa-fc-p{flex:1;min-width:0;font-family:var(--mono);font-size:10px;color:var(--paper-dim);
|
|
5090
5090
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5091
5091
|
|
|
5092
|
-
/* ----
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
.qa-todo-hd{display:flex;align-items:center;height:26px;padding:0 8px;
|
|
5096
|
-
border-bottom:1px solid var(--line);background:rgba(28,25,23,.028)}
|
|
5097
|
-
.qa-todo-sum{font-family:var(--mono);font-size:10px;color:var(--paper-mute);
|
|
5098
|
-
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5099
|
-
.qa-todo-list{padding:5px 8px 7px}
|
|
5100
|
-
.qa-todo-row{display:flex;align-items:baseline;gap:7px;padding:2.5px 0;
|
|
5101
|
-
font-size:11.5px;line-height:1.5;color:var(--paper-dim)}
|
|
5102
|
-
.qa-todo-ico{width:12px;height:12px;flex:none;align-self:center;border-radius:50%;
|
|
5103
|
-
border:1.3px solid var(--line);display:grid;place-items:center;color:#FFF}
|
|
5104
|
-
.qa-todo-ico svg{width:8px;height:8px;display:block;stroke:currentColor;fill:none;
|
|
5105
|
-
stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
|
|
5106
|
-
.qa-todo-row.is-in_progress{color:var(--paper);font-weight:600}
|
|
5107
|
-
.qa-todo-row.is-in_progress .qa-todo-ico{border-color:var(--amber);color:var(--amber)}
|
|
5108
|
-
.qa-todo-row.is-completed{color:var(--paper-mute)}
|
|
5109
|
-
.qa-todo-row.is-completed .qa-todo-ico{border-color:var(--jade);background:var(--jade)}
|
|
5110
|
-
.qa-todo-row.is-completed .qa-todo-t{text-decoration:line-through}
|
|
5111
|
-
|
|
5112
|
-
/* ---- 「任务列表」面板形态(.qa-todo-panel;一条应答只有一块,见 03-chat-message 的 todoList)----
|
|
5113
|
-
与工具卡明细里那一段(无标题)共用上面那套帧语言,只多三样:左侧标题、右端计数与开合箭头、
|
|
5114
|
-
收起时藏列表(标题栏留着 —— 收起的是一份清单,不是一个入口)。
|
|
5115
|
-
开合态只落 `open` 类:快照恢复走 `innerHTML = snapshot`,类跟着还原,不会「恢复后又弹开」。 */
|
|
5116
|
-
.qa-todo-panel{min-width:0;margin:0 0 8px;border:1px solid var(--line);
|
|
5117
|
-
border-radius:var(--r-md);overflow:hidden;background:#FFF}
|
|
5118
|
-
.qa-todo-panel .qa-todo-hd{gap:7px}
|
|
5119
|
-
.qa-todo-tt{flex:none;font-size:11.5px;font-weight:600;color:var(--paper)}
|
|
5120
|
-
/* 计数推到右端(只对面板生效:工具卡里那一段是唯一子节点,不该被推走任何东西) */
|
|
5121
|
-
.qa-todo-panel .qa-todo-sum{margin-left:auto}
|
|
5122
|
-
.qa-todo-toggle{flex:none;display:grid;place-items:center;width:16px;height:16px;
|
|
5123
|
-
border-radius:4px;color:var(--paper-mute);cursor:pointer}
|
|
5124
|
-
.qa-todo-toggle:hover{background:rgba(28,25,23,.05)}
|
|
5125
|
-
.qa-todo-toggle svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:2.4;
|
|
5126
|
-
stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
|
|
5127
|
-
.qa-todo-panel.open .qa-todo-toggle svg{transform:rotate(180deg)}
|
|
5128
|
-
.qa-todo-panel:not(.open) .qa-todo-list{display:none}
|
|
5092
|
+
/* ---- 待办清单 / 任务列表(.qa-todo*):皮肤已收进共用副本 ----
|
|
5093
|
+
见 client-ui/shared/conv/conv.css(三端同一份,含面板形态与失败/跳过两档)。
|
|
5094
|
+
本文件**不再**重复定义这一族,否则两边各改一半会各自漂移。 */
|
|
5129
5095
|
|
|
5130
5096
|
/* ---- 助手图片块(KindImage / P0-1)----
|
|
5131
5097
|
正文区(.qa-t)与委派卡嵌套区(.qa-sub)共用同一张图卡:细边 + 圆角、宽度自适应
|
|
@@ -5171,10 +5137,6 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
|
|
|
5171
5137
|
[data-theme="dark"] .qa-cf-bd pre,[data-theme="dark"] .qa-tool-sec pre{
|
|
5172
5138
|
color:#D6D3D1;background:transparent}
|
|
5173
5139
|
[data-theme="dark"] .qa-tool-bd > * + *,[data-theme="dark"] .qa-fc-hd{border-color:#3B3835}
|
|
5174
|
-
[data-theme="dark"] .qa-todo-hd{background:rgba(245,244,243,.045);border-color:#3B3835}
|
|
5175
|
-
[data-theme="dark"] .qa-todo-ico{border-color:#57534E}
|
|
5176
|
-
[data-theme="dark"] .qa-todo-row.is-in_progress{color:#F5F4F3}
|
|
5177
|
-
[data-theme="dark"] .qa-todo-row.is-completed{color:#A8A29E}
|
|
5178
5140
|
[data-theme="dark"] .qa-img{border-color:#3B3835}
|
|
5179
5141
|
[data-theme="dark"] .qa-cf-copy:hover{background:rgba(245,244,243,.09);color:#F5F4F3}
|
|
5180
5142
|
[data-theme="dark"] .qa-mk-row[data-tog="1"]:hover{background:rgba(245,244,243,.05)}
|
|
@@ -121,9 +121,13 @@ function qaPermOf(id){ return QA_PERMS.find(x=>x.id === id) || QA_PERMS.find(x=>
|
|
|
121
121
|
/* 模型 id → 可读模型名:**只用于显示**(模型按钮 / 对话「生成中」行 / 需求看板卡片 / 提示行 / toast)。
|
|
122
122
|
查法 = 按 id 在该引擎的 modelLabels 里找(llm 配置文件里的 name,或 llm 配置的 model_name);
|
|
123
123
|
当前引擎查不到时扫全部引擎的标签(看板里的需求可能属于别的引擎),再查不到就原样返回 id。
|
|
124
|
-
⚠️ 透传给 CLI 的 `-m`、任务 / 需求落库、派发参数一律仍用 id —— 名字只活在展示层。
|
|
124
|
+
⚠️ 透传给 CLI 的 `-m`、任务 / 需求落库、派发参数一律仍用 id —— 名字只活在展示层。
|
|
125
|
+
⚠️ 入参可能是带 `custom-local:` 前缀的完整 id(会话登记表 / start 事件都是这种),
|
|
126
|
+
而 `--engines` 的清单与标签表**早已剥过前缀**(见 agent.StripCustomLocalPrefix)——
|
|
127
|
+
不剥就会查不到标签、把整串 id 连前缀一起显示出来。剥完再查(2026-10-21 用户:
|
|
128
|
+
「codebuddy的custom-local的模型前缀不用显示」),查不到时的兜底也用剥过的形状。 */
|
|
125
129
|
function qaModelName(id, engine){
|
|
126
|
-
const key = String(id || "");
|
|
130
|
+
const key = String(id || "").replace(/^custom-local:/, "");
|
|
127
131
|
if(!key) return "";
|
|
128
132
|
const hit = (e)=> (e && e.modelLabels && e.modelLabels[key]) || "";
|
|
129
133
|
return hit(qaEngines.find(x=>x.engine === (engine || qaEngine)))
|
|
@@ -146,10 +146,29 @@ function normalizeEngineRow(r){
|
|
|
146
146
|
.catch(() => ({ ok: false, engines: [] }));
|
|
147
147
|
},
|
|
148
148
|
|
|
149
|
-
/*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
/* 附件落盘:Electron 壳给的是 IPC 直写(`desk.pasteImage`,把图写进磁盘并回路径);
|
|
150
|
+
浏览器宿主没有本地壳,走 magic-client 自己的 `POST /desk/upload` —— 文件落到 core 那台
|
|
151
|
+
机器的临时目录、回绝对路径,与移动端「先上传再下发」是同一条通道(不是假装成功)。
|
|
152
|
+
返回形状与 IPC 那份对齐:{ok, item:{name, path, url, bytes}},内核按它校验(缺 path 即失败)。 */
|
|
153
|
+
pasteImage(p) {
|
|
154
|
+
const buf = (p && p.buf) || null;
|
|
155
|
+
if (!buf) return Promise.resolve({ ok: false, error: "没有收到图片数据" });
|
|
156
|
+
const type = (p && p.type) || "image/png";
|
|
157
|
+
const ext = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
|
|
158
|
+
"image/gif": "gif", "image/bmp": "bmp" }[type] || "png";
|
|
159
|
+
const d = new Date();
|
|
160
|
+
const pad = (n)=> String(n).padStart(2, "0");
|
|
161
|
+
/* 名字给人看、也给下游按扩展名认图;真正落盘的文件名由服务端加时间戳前缀去重。 */
|
|
162
|
+
const name = "paste-" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate())
|
|
163
|
+
+ "-" + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + "." + ext;
|
|
164
|
+
let file;
|
|
165
|
+
try { file = new File([buf], name, { type }); }
|
|
166
|
+
catch (e) { file = new Blob([buf], { type }); } // 老内核没有 File 构造器
|
|
167
|
+
return transport.upload(file).then((r)=>({
|
|
168
|
+
ok: true,
|
|
169
|
+
item: { name: (r && r.name) || name, path: (r && r.path) || "",
|
|
170
|
+
url: (r && r.url) || "", bytes: (r && r.size) || 0 },
|
|
171
|
+
}), (e)=>({ ok: false, error: String((e && e.message) || e) }));
|
|
153
172
|
},
|
|
154
173
|
/* 任务本地库(SQLite):magic-agent 没有本地库 —— 空清单 + 写入如实回「跳过」。
|
|
155
174
|
04-task-list.js 的 qaDb* 会把这些结果当「桥不可用」,于是只走 localStorage。 */
|
|
@@ -110,13 +110,15 @@ function qaEngMenuBuild(){
|
|
|
110
110
|
qaAskEach("-engine-menu", m=>{ m.innerHTML = html; });
|
|
111
111
|
qaMenuReplace();
|
|
112
112
|
}
|
|
113
|
-
/** 模型菜单:首项「自动」(按配置链的链首),其后是该引擎探测到的模型。原样搬自上游。
|
|
113
|
+
/** 模型菜单:首项「自动」(按配置链的链首),其后是该引擎探测到的模型。原样搬自上游。
|
|
114
|
+
* ⚠️ 展示名一律经 qaModelName(已剥 `custom-local:` 前缀,见 04-task-list.js):
|
|
115
|
+
* 这里的 `id` 可能是任务 / 会话带回来的完整 id,而 `labels` / `credits` 是按
|
|
116
|
+
* `--engines` 那份已剥前缀的清单建的 —— 直接用 id 当名字就会把前缀露出来。 */
|
|
114
117
|
function qaMdlMenuBuild(){
|
|
115
118
|
const auto = qaModelAuto();
|
|
116
119
|
const e = qaEngines.find(x=>x.engine === qaEngine) || null;
|
|
117
|
-
const labels = (e && e.modelLabels) || {};
|
|
118
120
|
const credits = (e && e.modelCredits) || {};
|
|
119
|
-
const crOf = (id)=>{ const v = String(credits[id] || "").trim(); return v ? "x" + v : ""; };
|
|
121
|
+
const crOf = (id)=>{ const v = String(credits[qaModelText(id)] || "").trim(); return v ? "x" + v : ""; };
|
|
120
122
|
const ids = (e && Array.isArray(e.models) ? e.models : []).map(m=>String(m||"")).filter(Boolean);
|
|
121
123
|
const autoName = auto ? qaModelName(auto, qaEngine) : "引擎默认";
|
|
122
124
|
const autoCr = auto ? crOf(auto) : "";
|
|
@@ -126,7 +128,7 @@ function qaMdlMenuBuild(){
|
|
|
126
128
|
if(auto === id) bits.push("链首");
|
|
127
129
|
const cr = crOf(id);
|
|
128
130
|
if(cr) bits.push(cr);
|
|
129
|
-
return { id, name:
|
|
131
|
+
return { id, name: qaModelName(id, qaEngine), note: bits.join(" · ") };
|
|
130
132
|
}));
|
|
131
133
|
const html = '<span class="ag-menu-cap">模型</span>' + opts.map(m=>
|
|
132
134
|
`<button class="ag-model-opt" data-model="${escHtml(m.id||"")}" type="button" role="option" aria-selected="false" title="${escHtml(m.id ? ("指定模型 · 下发给 -m " + m.id) : ("自动 · 按设置里「" + qaEngine + "」的模型链(链首 " + autoName + ")"))}">
|
|
@@ -401,6 +403,11 @@ function qaComposerInit(){
|
|
|
401
403
|
漏了它的表现是「卡片画出来了但点不动」,而且不报错(绑定函数自己只是加监听)。
|
|
402
404
|
`bindNavTasks()` 是左栏那一套(本版没有左栏),刻意不调。 */
|
|
403
405
|
if(typeof bindQaStreamDelegation === "function") bindQaStreamDelegation();
|
|
406
|
+
/* 内核的「用户离开底部」闸(2026-10-21):`qaUserAway` 由它维护,而 `qaScrollIntoView`
|
|
407
|
+
靠它决定要不要把画布贴到底。那段监听原来只写在圆点轨的 qaDotsBind 里(本版没调),
|
|
408
|
+
闸恒开 → 用户上滚看历史时被每一笔流式增量拽回结尾(移动端报障的同一根因)。
|
|
409
|
+
幂等:重复调用不会再绑一遍。 */
|
|
410
|
+
if(typeof qaScrollGateBind === "function") qaScrollGateBind();
|
|
404
411
|
/* 引擎清单异步取回后再重刷一次(拿不到就保持兜底清单,界面不开天窗) */
|
|
405
412
|
qaEnginesFetch(false);
|
|
406
413
|
}
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
|
|
20
20
|
const QA_RAIL_MAX = 60; // 一屏最多画多少行(CLI 的登记表可能有几百条)
|
|
21
21
|
const QA_RAIL_POLL_MS = 30000; // 有会话在跑时的兜底轮询(其余时候靠「一轮结束」触发)
|
|
22
|
+
const QA_RAIL_TAIL_MS = 2500; // 打开一条 running 会话时的进度同步间隔(见 qaRailTailStart)
|
|
23
|
+
/* 一条会话「这一轮结束了」的事件名(与 core 的归一化词表同名)。
|
|
24
|
+
* ⚠️ 只在**增量**里判:打开时回放到的老终态事件不算数(那条会话可能又在新一轮里跑着)。 */
|
|
25
|
+
const QA_RAIL_TERM_KINDS = ["turn_end", "turn_failed", "result", "error", "interrupted", "stopped", "end"];
|
|
22
26
|
|
|
23
27
|
let qaRailAll = []; // 最近一次拉到的会话(宿主归一后的行)
|
|
24
28
|
let qaRailSkipped = 0; // 被略掉的一次性调用条数(如实写在列表下方)
|
|
@@ -29,22 +33,32 @@ let qaRailErr = "";
|
|
|
29
33
|
let qaRailLoaded = false;
|
|
30
34
|
let qaRailTimer = null;
|
|
31
35
|
let qaRailMenuEl = null;
|
|
36
|
+
let qaRailTail = null; // 正在同步的那条 running 会话(见 qaRailTailStart)
|
|
32
37
|
|
|
33
|
-
/**
|
|
38
|
+
/** 这条会话的**统一状态**(CLI 的 `--sessions` 行里的 `status`,会话级、与标题同源保存)。
|
|
39
|
+
* ⚠️ 优先读 `status`,没有再回落 `state`:`state` 是**这一轮**的状态(同一条会话每轮一个
|
|
40
|
+
* 记录,各说各的),两端挑中不同轮就会得出不同结论(2026-10-21 报障「PC 已完成、移动端
|
|
41
|
+
* 还在排队」)。`status` 是会话级唯一口径;老版本 CLI 不给这个字段时回落,行为不变。 */
|
|
42
|
+
function qaRailState(s){
|
|
43
|
+
return String((s && (s.status || s.state)) || "");
|
|
44
|
+
}
|
|
45
|
+
/** 会话落到哪一档(左栏分组)—— 判据全部来自 CLI 给的 status / state / alive。
|
|
34
46
|
* ⚠️ 状态词与 magic-agent 的记录、agents-anywhere 的 TimelineStatus 对齐(2026-09-29):
|
|
35
47
|
* running / waiting_approval / done / failed / cancelled / interrupted / gone。
|
|
36
48
|
* `waiting_approval` 必须算**进行中** —— 它没在跑,但也没结束;归到「已完成」的话,
|
|
37
|
-
* 用户会看到「我还在等它回话,它却已完成」。
|
|
49
|
+
* 用户会看到「我还在等它回话,它却已完成」。
|
|
50
|
+
* `idle`(常驻会话本轮已结束、进程还在等追加)**不算进行中**,落到「已完成」—— 与
|
|
51
|
+
* PC 按事件流显示的口径一致(2026-10-21)。 */
|
|
38
52
|
function qaRailGroup(s){
|
|
39
53
|
if(!s) return "done";
|
|
40
|
-
const st =
|
|
54
|
+
const st = qaRailState(s);
|
|
41
55
|
if(s.alive || st === "running") return "live";
|
|
42
56
|
if(st === "waiting_approval") return "live";
|
|
43
57
|
if(st === "failed" || st === "gone") return "failed";
|
|
44
58
|
return "done";
|
|
45
59
|
}
|
|
46
60
|
function qaRailGroupLabel(s){
|
|
47
|
-
const st =
|
|
61
|
+
const st = qaRailState(s);
|
|
48
62
|
const g = qaRailGroup(s);
|
|
49
63
|
if(g === "live") return st === "waiting_approval" ? "等审批" : "进行中";
|
|
50
64
|
if(g === "failed") return "失败";
|
|
@@ -146,7 +160,7 @@ function qaRailRender(){
|
|
|
146
160
|
box.innerHTML = rows.map((s) => {
|
|
147
161
|
const g = qaRailGroup(s);
|
|
148
162
|
const title = s.title || "(无标题会话)";
|
|
149
|
-
const sub = [s.engine, s.model].filter(Boolean).join(" · ");
|
|
163
|
+
const sub = [s.engine, qaModelName(s.model, s.engine)].filter(Boolean).join(" · ");
|
|
150
164
|
const tip = [title, sub, s.workspace, s.sessionId || s.runId, s.updatedAt,
|
|
151
165
|
noLog(s) ? "这条会话没有留下事件日志(引擎当时是非流式跑的),点开看不到历史" : ""]
|
|
152
166
|
.filter(Boolean).join("\n");
|
|
@@ -194,36 +208,162 @@ function qaRailEnsureTask(s){
|
|
|
194
208
|
t.engine = s.engine || t.engine || qaEngine;
|
|
195
209
|
t.model = s.model || "";
|
|
196
210
|
t.sessionId = s.sessionId || "";
|
|
197
|
-
|
|
211
|
+
const st = qaRailState(s);
|
|
212
|
+
t.status = qaRailGroup(s) === "live" ? "running" : (st === "failed" ? "error" : "done");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 历史回放的渲染上下文:打开一条会话时建一份,**增量同步继续用同一份** ——
|
|
216
|
+
* `h` 是当前这条应答块,`tools` / `agents` 是工具卡与委派卡的登记表。
|
|
217
|
+
* 分开建的话,tail 收到的 tool_result 会找不到自己那张卡(凭空多出一张「结果」卡)。 */
|
|
218
|
+
function qaRailCtx(s){
|
|
219
|
+
return {
|
|
220
|
+
h: null, tools: new Map(), agents: new Map(), rid: 0, taskId: qaActiveId,
|
|
221
|
+
s: s, seq: 0, count: 0,
|
|
222
|
+
};
|
|
198
223
|
}
|
|
199
224
|
|
|
200
|
-
/**
|
|
201
|
-
*
|
|
202
|
-
|
|
225
|
+
/** 逐条喂进 qaStreamApply(与实时同一条渲染管线)。
|
|
226
|
+
* 回放 / 增量都走这里;`user` 那条不是渲染层事件(core 补记的用户提问),
|
|
227
|
+
* 走 qaRenderMine 画成用户气泡,并把这一轮的应答块**收口**(下一轮另起一块)。
|
|
228
|
+
* ⚠️ 只读:台账 / 会话绑定 / 收尾动作一律不落笔(历史不该改写现在)。
|
|
229
|
+
* 返回实际消费的事件条数(增量游标按它对齐)。 */
|
|
230
|
+
function qaRailApply(ctx, events){
|
|
203
231
|
const list = Array.isArray(events) ? events : [];
|
|
204
|
-
let
|
|
232
|
+
let n = 0;
|
|
205
233
|
for(const ev of list){
|
|
206
234
|
const kind = String((ev && ev.kind) || "");
|
|
207
235
|
if(!kind) continue;
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
n += 1;
|
|
237
|
+
const sq = Number(ev && ev.seq);
|
|
238
|
+
if(Number.isFinite(sq) && sq > ctx.seq) ctx.seq = sq;
|
|
239
|
+
if(kind === "user"){
|
|
240
|
+
ctx.h = null;
|
|
241
|
+
qaRenderMine(String(ev.text || ""), []);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if(!ctx.h){ ctx.h = qaRenderAnswer(); if(!ctx.h) break; }
|
|
210
245
|
try{
|
|
211
246
|
qaStreamApply(Object.assign({}, ev, { type: kind }), {
|
|
212
|
-
h, tools:
|
|
213
|
-
/* 回放是**只读**的:台账 / 会话绑定 / 收尾动作一律不落笔(历史不该改写现在) */
|
|
247
|
+
h: ctx.h, tools: ctx.tools, agents: ctx.agents, rid: 0, taskId: ctx.taskId,
|
|
214
248
|
setMs(){}, onText(){}, onStep(){}, onSession(){}, onError(){}, onDone(){},
|
|
215
249
|
});
|
|
216
250
|
}catch(e){ console.warn("[rail] 回放事件失败 " + kind + ":", (e && e.message) || e); }
|
|
217
251
|
}
|
|
218
|
-
|
|
252
|
+
ctx.count += n;
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 全量回放。`live=true` 时不写「历史回放」收尾行 —— 这条会话还在跑,
|
|
257
|
+
* 收尾行由 tail 在终态那一刻补(见 qaRailTailStart)。返回上下文供 tail 续用。 */
|
|
258
|
+
function qaRailReplay(events, s, live){
|
|
259
|
+
const list = Array.isArray(events) ? events : [];
|
|
260
|
+
const ctx = qaRailCtx(s);
|
|
261
|
+
qaRailApply(ctx, list);
|
|
262
|
+
if(!ctx.h && !list.some((e) => String((e && e.kind) || "") === "user")){
|
|
219
263
|
qaRenderDone(true, "这条会话没有留下事件", $("#qa-stream"));
|
|
220
|
-
return;
|
|
264
|
+
return ctx;
|
|
221
265
|
}
|
|
222
|
-
if(h){
|
|
266
|
+
if(ctx.h && !live){
|
|
223
267
|
/* 收尾行按会话自己的状态给一句(历史是只读的,不显示「生成中」那种活状态) */
|
|
224
268
|
const g = qaRailGroup(s);
|
|
225
|
-
qaRenderDone(g !== "failed", g === "failed" ? "上次调用失败" :
|
|
269
|
+
qaRenderDone(g !== "failed", g === "failed" ? "上次调用失败" : "历史回放", $("#qa-stream"));
|
|
226
270
|
}
|
|
271
|
+
return ctx;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ---------- running 会话的进度同步(跨进程) ----------
|
|
275
|
+
* 用户 2026-10-21:「running 状态的就同步一下进度」—— 移动端 / CLI 起的会话,
|
|
276
|
+
* 在桌面这边点开时不能只看到点开那一刻的快照。
|
|
277
|
+
*
|
|
278
|
+
* 数据源仍是宿主的 `desk.agentSessionLog`(背后是 `magic-agent --session-log`,
|
|
279
|
+
* 读的是磁盘上那条 `<id>.jsonl`,**与跑它的进程无关**),所以本进程之外起的轮次
|
|
280
|
+
* 一样能追。宿主只给一次性全量读取,于是这里定时重读、按游标只喂新增的那几条:
|
|
281
|
+
* · 宿主若把 `after` 透传给了 core(`--session-log --after <seq>`),回来的就是纯增量;
|
|
282
|
+
* · 没透传/老 core → 回来是全量,按**条数**对齐(seq 在旧版 writer 里每轮会回落,不能当游标)。
|
|
283
|
+
* 停表的三种情形:收到终态事件(再补一拍,收尾行可能后到)/会话状态不再 live/
|
|
284
|
+
* 本机在这条会话上发了一轮(那一轮由本进程的实时管线自己画,别重复)。 */
|
|
285
|
+
|
|
286
|
+
/** 全量回放之后再画一行「正在同步」,让「它还在跑」这件事在界面上有据可依。 */
|
|
287
|
+
function qaRailLiveRow(text){
|
|
288
|
+
const box = $("#qa-stream"); if(!box) return null;
|
|
289
|
+
const d = document.createElement("div");
|
|
290
|
+
d.className = "qa-done qa-pin";
|
|
291
|
+
d.setAttribute("data-rail-live", "1");
|
|
292
|
+
d.innerHTML = `<span class="d"></span><span></span>`;
|
|
293
|
+
d.querySelector("span:last-child").textContent = text;
|
|
294
|
+
box.appendChild(d);
|
|
295
|
+
return d;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function qaRailTailStop(finishLabel){
|
|
299
|
+
const t = qaRailTail;
|
|
300
|
+
qaRailTail = null;
|
|
301
|
+
if(!t) return;
|
|
302
|
+
if(t.timer) clearInterval(t.timer);
|
|
303
|
+
if(t.marker && t.marker.parentNode) t.marker.remove();
|
|
304
|
+
if(t.notice && t.notice.parentNode) t.notice.remove();
|
|
305
|
+
if(finishLabel && qaActiveId === t.sid) qaRenderDone(true, finishLabel, $("#qa-stream"));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 从一次返回里挑出「还没画过」的事件。 */
|
|
309
|
+
function qaRailTailFresh(ctx, list){
|
|
310
|
+
if(!list.length) return [];
|
|
311
|
+
const fseq = Number(list[0] && list[0].seq);
|
|
312
|
+
/* 纯增量:首条 seq 比已看到的还大(宿主把 after 透传下去了)。 */
|
|
313
|
+
if(ctx.seq > 0 && Number.isFinite(fseq) && fseq > ctx.seq) return list;
|
|
314
|
+
/* 全量:按条数对齐(只认物理顺序,与 seq 怎么编号无关)。 */
|
|
315
|
+
return list.length > ctx.count ? list.slice(ctx.count) : [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function qaRailTailStart(s, ctx, notice){
|
|
319
|
+
qaRailTailStop();
|
|
320
|
+
const sid = String((s && s.id) || "");
|
|
321
|
+
if(!sid) return;
|
|
322
|
+
const tail = {
|
|
323
|
+
sid: sid, ctx: ctx, timer: null, busy: false, endAt: 0,
|
|
324
|
+
marker: qaRailLiveRow("这条会话正在运行 · 实时同步中"),
|
|
325
|
+
notice: notice || null,
|
|
326
|
+
};
|
|
327
|
+
qaRailTail = tail;
|
|
328
|
+
|
|
329
|
+
/* 读一次增量并落画。返回「这批里有没有终态事件」。读失败不掐连接(下一拍再试)。 */
|
|
330
|
+
const poll = async () => {
|
|
331
|
+
const d = window.desk;
|
|
332
|
+
if(!d || typeof d.agentSessionLog !== "function") return false;
|
|
333
|
+
let r = null;
|
|
334
|
+
try{ r = await d.agentSessionLog(sid, { after: ctx.seq }); }
|
|
335
|
+
catch(e){ return false; }
|
|
336
|
+
if(qaRailTail !== tail || qaActiveId !== sid) return false;
|
|
337
|
+
if(!r || !r.ok) return false;
|
|
338
|
+
const fresh = qaRailTailFresh(ctx, Array.isArray(r.events) ? r.events : []);
|
|
339
|
+
if(!fresh.length) return false;
|
|
340
|
+
if(tail.notice && tail.notice.parentNode){ tail.notice.remove(); tail.notice = null; }
|
|
341
|
+
qaRailApply(ctx, fresh);
|
|
342
|
+
/* 同步行永远压在队尾:新事件是 append 到 #qa-stream 的,不挪它会被压在下面。 */
|
|
343
|
+
if(tail.marker && tail.marker.parentNode) $("#qa-stream").appendChild(tail.marker);
|
|
344
|
+
qaScrollIntoView();
|
|
345
|
+
return fresh.some((ev) => QA_RAIL_TERM_KINDS.includes(String((ev && ev.kind) || "")));
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const tick = async () => {
|
|
349
|
+
if(qaRailTail !== tail || tail.busy) return;
|
|
350
|
+
if(qaActiveId !== sid){ qaRailTailStop(); return; }
|
|
351
|
+
/* 本机正在这条会话上跑一轮:那一轮由实时管线自己画,tail 再画一遍就是双份。 */
|
|
352
|
+
if(typeof qaTaskRunning === "function" && qaTaskRunning(qaActiveId)){ qaRailTailStop(); return; }
|
|
353
|
+
tail.busy = true;
|
|
354
|
+
const final = !!tail.endAt && Date.now() >= tail.endAt;
|
|
355
|
+
let term = false;
|
|
356
|
+
try{ term = await poll(); } finally { tail.busy = false; }
|
|
357
|
+
if(qaRailTail !== tail) return;
|
|
358
|
+
if(final){ qaRailTailStop("已同步到最新"); return; }
|
|
359
|
+
if(term && !tail.endAt) tail.endAt = Date.now() + QA_RAIL_TAIL_MS; // 下一拍补收尾行后停
|
|
360
|
+
if(!tail.endAt){
|
|
361
|
+
/* 登记表兜底:这一轮没发终态事件(被打断 / 进程被杀)时靠 state 收尾。 */
|
|
362
|
+
const row = qaRailAll.find((x) => x.id === sid) || null;
|
|
363
|
+
if(row && qaRailGroup(row) !== "live") tail.endAt = Date.now() + QA_RAIL_TAIL_MS;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
tail.timer = setInterval(tick, QA_RAIL_TAIL_MS);
|
|
227
367
|
}
|
|
228
368
|
|
|
229
369
|
async function qaRailOpen(id){
|
|
@@ -233,7 +373,9 @@ async function qaRailOpen(id){
|
|
|
233
373
|
const d = window.desk;
|
|
234
374
|
if(!d || typeof d.agentSessionLog !== "function"){ toast("这一版没有会话历史通道"); return; }
|
|
235
375
|
const box = $("#qa-stream"); if(!box) return;
|
|
376
|
+
const live = qaRailGroup(s) === "live";
|
|
236
377
|
|
|
378
|
+
qaRailTailStop(); // 换会话 → 上一条的同步停掉
|
|
237
379
|
qaRailStowRun();
|
|
238
380
|
qaActiveId = sid;
|
|
239
381
|
qaRailEnsureTask(s);
|
|
@@ -248,7 +390,17 @@ async function qaRailOpen(id){
|
|
|
248
390
|
catch(e){ r = { ok: false, error: String((e && e.message) || e) }; }
|
|
249
391
|
if(qaActiveId !== sid) return; // 用户已经切走 → 丢弃这次结果
|
|
250
392
|
box.innerHTML = "";
|
|
393
|
+
|
|
394
|
+
/* ⚠️ 这条会话还在跑时,「读不到 / 还没事件」都**不是**死路:
|
|
395
|
+
移动端刚起的那一轮,磁盘上可能连日志都还没建(core 先写登记表再开日志)。
|
|
396
|
+
如实说一句「在等」,然后把 tail 挂上 —— 第一批事件一到画面就长出来。 */
|
|
251
397
|
if(!r || !r.ok){
|
|
398
|
+
if(live){
|
|
399
|
+
box.innerHTML = qaConvNoticeHtml("这条会话还在跑,暂时读不到事件(" +
|
|
400
|
+
((r && r.error) || "未知原因") + ")—— 进度会自动接上。", "wait");
|
|
401
|
+
qaRailTailStart(s, qaRailCtx(s), box.firstElementChild);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
252
404
|
box.innerHTML = qaConvNoticeHtml("读会话历史失败:" + ((r && r.error) || "未知原因") +
|
|
253
405
|
"(老版 magic-agent 不认识 `--session-log`,设置里升级一下就好)", "lost");
|
|
254
406
|
return;
|
|
@@ -257,18 +409,25 @@ async function qaRailOpen(id){
|
|
|
257
409
|
引擎当时是**非流式**跑的,magic-agent 的事件日志只在流式调用时写。行上那个
|
|
258
410
|
「无历史」标记说的就是这件事,这里把原因说得更细一点(会话本身还能继续追问)。 */
|
|
259
411
|
if(!Array.isArray(r.events) || !r.events.length){
|
|
412
|
+
if(live){
|
|
413
|
+
box.innerHTML = qaConvNoticeHtml("这条会话正在运行 —— 等它产出第一批事件,这里会自动出现。", "wait");
|
|
414
|
+
qaRailTailStart(s, qaRailCtx(s), box.firstElementChild);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
260
417
|
box.innerHTML = qaConvNoticeHtml(
|
|
261
418
|
"这条会话没有留下事件日志(引擎当时是非流式跑的),所以看不到历史。" +
|
|
262
419
|
"会话本身还在 —— 直接在这里追问,引擎会接着原来的上下文继续。", "lost");
|
|
263
420
|
return;
|
|
264
421
|
}
|
|
265
|
-
qaRailReplay(r.events, s);
|
|
422
|
+
const ctx = qaRailReplay(r.events, s, live);
|
|
423
|
+
if(live) qaRailTailStart(s, ctx, null);
|
|
266
424
|
qaScrollIntoView();
|
|
267
425
|
}
|
|
268
426
|
|
|
269
427
|
/** 新建对话:把当前上下文清掉,下一句话就是**全新会话**(不带给引擎任何 --session)。 */
|
|
270
428
|
function qaRailNew(){
|
|
271
429
|
const box = $("#qa-stream");
|
|
430
|
+
qaRailTailStop(); // 上一条会话的同步别往清空后的画面里写
|
|
272
431
|
qaRailStowRun();
|
|
273
432
|
qaActiveId = null;
|
|
274
433
|
if(box){ box.innerHTML = ""; box.hidden = true; }
|