magic-agent 2.1.5 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +103 -6
- package/client-ui/pc/app.css +3 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +21 -1
- package/client-ui/shared/conv/03-chat-message.js +43 -0
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +16 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +655 -87
- package/client-ui/shared/transport-http.js +16 -0
- package/client-ui-mobile/index.html +401 -553
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +507 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/cli/ask.go +6 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/service.go +11 -1
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +421 -0
- package/internal/suggest/suggest_test.go +218 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -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/package.json +5 -2
package/CLIENT-CONTRACT.md
CHANGED
|
@@ -240,23 +240,30 @@ magic-agent -e claude --stream --events --team \
|
|
|
240
240
|
**事件形状(客户端要认的只有一条)**:主会话里是**一张任务列表卡**,每个子任务是一条**普通会话**。
|
|
241
241
|
|
|
242
242
|
```
|
|
243
|
-
notice {text:"
|
|
243
|
+
notice {text:"团队模式:正在生成计划…"} ← 计划落笔(只发一次)
|
|
244
244
|
notice {text:"计划已生成(N 个子任务):<PLAN.md>"} ← 计划落定
|
|
245
245
|
tasks {text:"{\"tasks\":[ … ]}"} ← 任务列表**全量快照**
|
|
246
|
-
notice {text:"团队模式:t1「<标题>」仍在运行…(已 N 秒无新输出)"} ← 静默 >30s 时每 20s 一次
|
|
247
246
|
```
|
|
248
247
|
|
|
248
|
+
计划阶段不再每 5 秒发一条「已 N 秒」(2026-10-22):那一段「引擎还活着」由 core 侧续期
|
|
249
|
+
会话登记表的 `updated_at` 证明(`follow` 不会误判终态),画面上一行字都不多。
|
|
250
|
+
|
|
249
251
|
`tasks` 快照(`agent.KindTasks`)的 `text` 是 JSON:
|
|
250
252
|
|
|
251
253
|
```json
|
|
252
254
|
{"tasks":[{"id":"t1","title":"写正文","role":"writer","kind":"text",
|
|
253
255
|
"engine":"claude","model":"","status":"running","deps":["t0"],
|
|
256
|
+
"active":"需要授权:Read(等待用户选择)","runtime_ms":41200,
|
|
257
|
+
"arts":["/abs/path/OUT-t1.md"],
|
|
254
258
|
"note":"","session_id":"…","run_id":"…","output":"…"}]}
|
|
255
259
|
```
|
|
256
260
|
|
|
257
261
|
| 字段 | 说明 |
|
|
258
262
|
| --- | --- |
|
|
259
263
|
| `status` | `pending` / `running` / `done` / `failed` / `skipped`。渲染映射:`pending→pending`、`running→in_progress`、`done→completed`、`failed→failed`、`skipped→skipped` |
|
|
264
|
+
| `runtime_ms` | 这一行**已经跑了多久 / 最终跑了多久**(毫秒)。`pending` 行缺省;`running` 行随快照增长;落定后冻结(失败 / 跳过的也冻结)。是绝对值,客户端**不要**本地再计时 |
|
|
265
|
+
| `arts` | 这一行**实际交付的产物**(绝对路径,任务一落定就有)。主会话据此在任务列表卡下方画「子任务产物」区;不带 = 没有产物。存在性仍由宿主探针判(`pathProbe`),探针不可用时不画 |
|
|
266
|
+
| `active` | 仅 `running` 行有值,「此刻在干什么」的一句话:等授权 / 等用户作答,或静默超过 30s 时的「已 N 秒无新输出」。**行内次要信息**(排在「标题 … 状态 耗时」之后,字最小、可省略),**不要**拿它顶掉标题、也不要转成 `notice` |
|
|
260
267
|
| `deps` | 上游任务 id(有依赖的行口径是「等待上游 t1」)|
|
|
261
268
|
| `note` | 失败 / 跳过的一句话原因(成功时空)|
|
|
262
269
|
| `session_id` / `run_id` | 这个子任务**自己的**会话(点进去看过程)。`run_id` 一开跑就有,`session_id` 等引擎给出;都可能为空(没开登记 / 引擎不给 id)|
|
|
@@ -320,7 +327,8 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
320
327
|
`sessions` 是该类别下各子任务的引擎会话 id)。`--sessions` 的投影里还会多两个**父指针**字段:
|
|
321
328
|
`parent_run_id` / `parent_session_id`(空 = 顶层会话)—— 子会话这两项指向团队主会话,
|
|
322
329
|
客户端据此把它缩进挂在主会话下面(读侧优先 `parent_session_id`,缺失再按 `parent_run_id` 认亲;
|
|
323
|
-
|
|
330
|
+
父会话不在本次列表里就回落成顶层行,别丢)。移动端列表**默认折叠**这些子会话:父行只给
|
|
331
|
+
子会话计数徽标(有子任务在跑时亮运行色),点徽标才展开 / 收起。`mode` / `agents` / 父指针都由 `--sessions`
|
|
324
332
|
**投影出来**(不是只存在登记表文件里),列表页读的就是它们。
|
|
325
333
|
`missing[]` 是「任务书要求的交付物没落地」的清单(引擎不认 `deliverables` 时就是全缺),
|
|
326
334
|
**不是失败**:`state` 仍是 `done`,正文照样在 `output` 里。
|
|
@@ -346,6 +354,7 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
|
|
|
346
354
|
| 本体文件出流 | `GET /desk/media?path=<绝对路径>[&w=<px>]`(`?token=` 供 `<img>` / `<video>` 直接引用;**只发 image / video / audio**,`Range` 可拖动)。带 `w` 时按宽缩放并重编码(`64..4096`,失败/更大则回退原图,结果内存缓存);移动端传输层自动追加 `w=480`(缩略图;点开看大图时不带 `w`,取原文件) | 已实现 |
|
|
347
355
|
| 上传文件回读 | `GET /desk/uploads/{name}[?w=<px>]`(同上 `w` 语义) | 已实现 |
|
|
348
356
|
| 产物存在性探测 | `POST /desk/path-probe`:`{base, paths[]}` → `{ok, items:[{path, abs, exists, size}]}`;相对路径按 `base` 拼、`~/` 展开、`file://` 剥前缀、只认 regular 文件、最多 50 条。对话内核的「本轮产物」靠它把正文里的路径变成卡片(宿主经 `window.desk.pathProbe` 供进来) | 已实现 |
|
|
357
|
+
| 下一轮快捷回复 | `POST /desk/suggest`:`{user, reply, engine?, model?}` → `{suggestions:[string], engine, model, elapsedMs}`。服务端 exec core 的 `--suggest-followups`(**旁路调用,不续接会话**);`suggestions` 可以为空数组(= 这轮没有下一步,客户端不画按钮),失败如实 4xx/5xx。客户端**只在成功收尾后**调用,点击即发。并发上限 2(超出 429),超时 25s | 已实现 |
|
|
349
358
|
| 产物文本回读 | `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 / 文本 | 已实现 |
|
|
350
359
|
| 插件目录 | `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`),客户端只显示不推断 | 已实现 |
|
|
351
360
|
| 插件安装/升级 | `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 —— 这条接口不接受客户端传命令 | 已实现 |
|
|
@@ -627,6 +636,9 @@ const conv = MagicConversation.create({
|
|
|
627
636
|
onTeamChange, // 可选:(on) => void。用户在**组件自己的**团队面板里改了开关时回调
|
|
628
637
|
teamConfig, // 可选:() => {routes, rules, parallel, split, splitCode, adaptive},每轮下发时现读(宿主持有这份偏好)
|
|
629
638
|
features, // 可选:core 能力位数组,或 () => 数组(异步拉到之后才亮出团队入口)
|
|
639
|
+
onQueuePush, // 可选:(info) => void。运行中提交的追问进队(还没下发)时回调,见 3.2.3
|
|
640
|
+
onQueueTake, // 可选:(id) => void。轮到这条了,正要真正下发
|
|
641
|
+
onQueueDrop, // 可选:(id) => void。用户取消了(宿主该从磁盘删掉)
|
|
630
642
|
});
|
|
631
643
|
conv.send('…');
|
|
632
644
|
conv.interrupt(); conv.stop(); conv.answer('…');
|
|
@@ -635,6 +647,7 @@ conv.setEngine(n); conv.setModel(n); conv.setPermission(v); conv.setEngines(list
|
|
|
635
647
|
conv.setTeam(on); conv.teamOn(); conv.teamLabel(); conv.openTeamPicker();
|
|
636
648
|
conv.featureOn('feature.session.team');
|
|
637
649
|
conv.refreshEngines(force?); conv.loadHistory(sid, after?); conv.destroy();
|
|
650
|
+
conv.adoptQueue([{id, prompt, attachments, atts}]); // 把宿主落库的排队条目接回画布(见 3.2.3)
|
|
638
651
|
```
|
|
639
652
|
|
|
640
653
|
**团队模式在组件里是薄的**(这是刻意的,见 1.4):它只做三件事 —— 记住开关、按 `teamConfig()`
|
|
@@ -656,6 +669,11 @@ conv.refreshEngines(force?); conv.loadHistory(sid, after?); conv.destroy();
|
|
|
656
669
|
靠这一条才不会把整段历史重放一遍。宿主因此**可以**复用同一个组件实例(`client-ui-mobile`
|
|
657
670
|
的 `mountConversation` 就是这么做的);换会话、`newSession()`、读历史失败之后仍会正常重载。
|
|
658
671
|
|
|
672
|
+
`setSession(sid, s, {same:true})` 是给**别名 id** 用的:同一条会话可能先以 `run_id` 出现、
|
|
673
|
+
之后才拿到 `session_id`,组件自己不知道这层映射。宿主确认「是同一屏会话」后传 `same:true`,
|
|
674
|
+
组件就只同步外壳(并更新 `state.session`),**绝不**走换会话分支 —— 否则 `clearQueue()`
|
|
675
|
+
会把还没下发的排队追问整队删掉(移动端 2026-10-22「重新进来就看不到」的病根)。
|
|
676
|
+
|
|
659
677
|
### 3.2.2 beforeAsk:下发前的闸门(串行排队的挂载点)
|
|
660
678
|
|
|
661
679
|
```js
|
|
@@ -681,6 +699,28 @@ beforeAsk(info) // info: {isNew, session, workspace, engine, model, permission
|
|
|
681
699
|
- 移动端串行模式的口径是**同一 workspace 同时只有一个轮次在跑,不分新任务还是历史会话追问**
|
|
682
700
|
(追加追问与上一句写的是同一份会话日志,两个进程并发正是最该避免的场合)。
|
|
683
701
|
|
|
702
|
+
### 3.2.3 组件内排队:落库与「重进会话」恢复
|
|
703
|
+
|
|
704
|
+
同一会话上一轮还没收尾时再提交,组件把这一句排进 `state.queue`(**不打断**当前轮),
|
|
705
|
+
列在**输入框上方的排队区**(`.queuebar`:一行「排队中 · 本轮结束后自动发送」+ 每条一句、
|
|
706
|
+
带 ✕ 取消)。它**不进对话区** —— 还没发出去,画成用户气泡会让人以为已发送。
|
|
707
|
+
这条追问**还没下发给 core**,所以也不在会话日志里。
|
|
708
|
+
|
|
709
|
+
- `onQueuePush(info)` 进队时回调:`info = {id, prompt, attachments, atts, session, workspace,
|
|
710
|
+
engine, model, permission, team}`。宿主要落库(移动端:`magic.serial.queue.v1` + 列表「等待中」
|
|
711
|
+
角标 / 会话图标)。`atts` 是缩略图快照(`{name, remoteUrl}`),恢复排队行时要一起还原。
|
|
712
|
+
- `onQueueTake(id)` / `onQueueDrop(id)`:轮到下发 / 用户取消,宿主据此删掉那条落库记录。
|
|
713
|
+
- `setSession(sid, s)` 返回「历史真的画完」的 Promise。宿主应在它 resolve 之后调用
|
|
714
|
+
`conv.adoptQueue(items)`,把属于该会话、还没轮到的条目还回来 —— 组件把它们列回输入框
|
|
715
|
+
上方的排队区、接回队列,这一轮收尾时自动发出。`adoptQueue` 用宿主持久化 id;
|
|
716
|
+
重复喂同一个 id 是幂等的。
|
|
717
|
+
|
|
718
|
+
为什么必须这样:组件内存队列活不过 `destroy()`(返回列表后重进、切过别的会话、WebView 被
|
|
719
|
+
系统回收)。历史回放读的是已落盘日志,排队那句不在里面 —— 不回灌就会「重新进来就看不到」。
|
|
720
|
+
宿主把自己已经派发出去的条目(owner 已交给宿主、正在等上一轮收尾)留给宿主,**不要**在
|
|
721
|
+
`adoptQueue` 里重复接管,否则同一条会发两次;移动端用 `owner` + `dispatching` 两个标记
|
|
722
|
+
在「组件接管」与「宿主下发」之间做互斥。
|
|
723
|
+
|
|
684
724
|
### 3.2.1 transport 契约(宿主实现,四个方法)
|
|
685
725
|
|
|
686
726
|
```js
|
|
@@ -751,11 +791,12 @@ magic-agent 把会话的查询与修改都做成 CLI 命令,调用方 exec 即
|
|
|
751
791
|
| 向前翻更早 | 追加 `--before <skip> --limit <n>` → 另带 `count / before / nextBefore / firstSeq / more`(与 `--after` 互斥) |
|
|
752
792
|
| 读历史(精简) | 追加 `--session-log-lean`:输出省掉 `snapshot`(**只增参数**,不传则行为与旧版一致) |
|
|
753
793
|
| 停止会话 | `magic-agent --stop <session_id\|run_id>`(只停得掉**已存档**的会话) |
|
|
754
|
-
| **删除会话** | `magic-agent --delete-session <session_id\|run_id>` → `{id, records_removed, logs_removed, titles_removed, statuses_removed}`(`statuses_removed` 2026-10-21
|
|
794
|
+
| **删除会话** | `magic-agent --delete-session <session_id\|run_id>` → `{id, records_removed, logs_removed, titles_removed, statuses_removed, child_sessions_removed}`(`statuses_removed` 2026-10-21 只增;`child_sessions_removed` 2026-10-23 只增,删主会话时连带删掉的子会话条数) |
|
|
755
795
|
| 追加消息 | `magic-agent --append <session_id\|run_id> -p "…"` |
|
|
756
796
|
| 跑一轮(流式 + 控制) | `magic-agent --stream --events --control -e <engine> -p "…"`,控制命令从 stdin 进 |
|
|
757
797
|
| **存档这一轮** | 追加 `--save-registry`(登记表)与 `--save-history`(历史 jsonl),**两者默认都关** |
|
|
758
798
|
| **归纳会话标题** | `magic-agent --summarize-title <session_id\|run_id>` → `{id, title, cached?}` |
|
|
799
|
+
| **下一轮快捷回复** | `magic-agent --suggest-followups <file\|->` → `{suggestions:[string], engine, model, elapsedMs}`(请求是 `{user, reply, engine?, model?}`;`-` = stdin。**旁路调用**:新会话、不带工具、不续接当前对话 —— 见本节「下一轮建议」) |
|
|
759
800
|
| 读已存标题 | `magic-agent --session-title <id>` → `{id, title}`(不调 LLM) |
|
|
760
801
|
| **需求清单** | `magic-agent --requirements all\|<ws_id\|目录>` → `{ok, root, workspaces[]}`(只读,不调 LLM,见 §3.4.1) |
|
|
761
802
|
| **归纳需求** | `magic-agent --consolidate-requirements <ws_id\|目录\|.>` → 一轮归纳结果(**会调模型**) |
|
|
@@ -871,12 +912,21 @@ event: end data: {"session":"<id>","after":<末位置>,"state":"done|faile
|
|
|
871
912
|
|
|
872
913
|
⚠️ **删除是「全机生效」的(2026-10-19)**:`--delete-session` 与插件的
|
|
873
914
|
`DELETE /desk/session/{id}` 是同一份实现(`internal/session/delete.go`),删的是
|
|
874
|
-
**登记记录 + 事件日志(`<session_id>.jsonl` 与 `<run_id>.jsonl` 两个名字)+
|
|
915
|
+
**登记记录 + 事件日志(`<session_id>.jsonl` 与 `<run_id>.jsonl` 两个名字)+ 标题覆盖 + 状态**,
|
|
875
916
|
幂等(id 不认识也算成功)。为什么强调:会话列表的权威来源是登记表,各端都读它 ——
|
|
876
917
|
客户端**不要**把删除做成「只在本机藏起来」(本地记墓碑那种),
|
|
877
918
|
否则在 A 端删掉的会话在 B 端照旧列着(2026-10-19 用户报的
|
|
878
919
|
「从pc端删除的会话移动端还在显示」就是这么来的)。删除不可回放,界面提示要如实说。
|
|
879
920
|
|
|
921
|
+
⚠️ **删主会话会级联删子任务会话(2026-10-23,只增字段不升版本)**:团队模式拆出的
|
|
922
|
+
子任务各是一条普通会话,登记表里靠 `parent_run_id` / `parent_session_id` 挂在主会话下。
|
|
923
|
+
删除主会话时 core 会沿父指针把**整棵子树**(含子会话的登记记录 / 日志 / 标题 / 状态)一起
|
|
924
|
+
物理删除 —— 客户端只发一条 `DELETE /desk/session/<主会话 id>` 即可,不要再逐个删子会话;
|
|
925
|
+
本地列表也应把该主会话下的子行一起移除(可按同样两个父指针在本地算闭包,见移动端
|
|
926
|
+
`deleteClosureOf`)。只删子会话**不会**反向带走主会话。返回里新增
|
|
927
|
+
`child_sessions_removed`(插件的 JSON 是 `childSessionsRemoved`):本次连带删掉的**子会话
|
|
928
|
+
条数**(同一条子会话多轮 run 只算 1)。
|
|
929
|
+
|
|
880
930
|
⚠️ **`session` 字段回的是翻译后的 id(2026-10-15)**:日志文件按 `session_id` 命名,
|
|
881
931
|
但正在跑的会话在引擎吐出 id 之前只有 `run_id` —— core 一开跑就先按 `<run_id>.jsonl`
|
|
882
932
|
落盘、拿到 id 后改名为 `<session_id>.jsonl`,读侧(`--session-log` 与
|
|
@@ -913,6 +963,53 @@ core 侧只有一张空的 sid→title 覆盖表,于是:
|
|
|
913
963
|
⚠️ 老版本 core 没有 `--summarize-title` / `--sessions.title`(exit 1)—— 客户端应**如实回落**
|
|
914
964
|
(PC 用本地缓存的标题,移动端用 `prompt_head`),不要静默显示空标题。
|
|
915
965
|
|
|
966
|
+
#### 下一轮建议:生成口径收在 core,客户端只负责「什么时候问、怎么发」(2026-10-23)
|
|
967
|
+
|
|
968
|
+
用户定调:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」。
|
|
969
|
+
|
|
970
|
+
| 事实 | 说明 |
|
|
971
|
+
| --- | --- |
|
|
972
|
+
| 生成在 core | `magic-agent --suggest-followups <file\|->`(`internal/suggest`):提示词、截断、JSON 解析、去重与长度上限都只有一份;移动端 / PC 拿到的建议不会各说各话 |
|
|
973
|
+
| **不续接会话** | 独立进程、新会话、`ToolsOff`、不带 `SessionID` / `Continue` —— 「请生成建议」这类指令不会写进用户正在聊的上下文,也不进会话登记表 / 日志 |
|
|
974
|
+
| 引擎固定 `llm` | 与标题归纳同一档(纯文本、无工具副作用)。界面**不再传当前编码引擎**:建议是旁路,不能排进 codebuddy / claude 的队列 —— 主会话一轮没退干净时,`/desk/suggest` 会跟着 22s 超时(2026-10-23 实测);core 侧仍允许 `-e` 覆盖 |
|
|
975
|
+
| **允许空数组** | 模型判断「没有下一步」时回 `[]`,这是**合法结果**:客户端不画任何按钮,不拿模板凑 |
|
|
976
|
+
| 成功收尾后问 | 失败 / 被用户停止 / 还在跑的轮次都不问;本轮出现过提问卡(引擎在等作答)时也跳过,避免与作答卡抢注意力 |
|
|
977
|
+
| 历史最后一轮补一次 | 打开一条**已收尾、不在跑**的历史会话时,回放完对最后一轮补一次建议;更早的轮次不问(它的「下一步」早已被后续对话覆盖)。缓存按 `(sid, 最后一条 user 的 at/seq)` 放 `localStorage`,30 分钟内重开不重复调模型;空数组也缓存 |
|
|
978
|
+
| 历史建议只展示 | 历史补出来的建议**不排托管计时器、不自动发送**,只有用户亲手点才走正常发送路径;否则一打开旧会话,托管会立刻把几天前的一句建议发出去 |
|
|
979
|
+
| 输入是这一轮 | `user` = 用户上一句,`reply` = 本轮回答正文(core 侧再截断:user 1500 / reply 6000 runes,取回答**尾部**) |
|
|
980
|
+
| 点击 = 发送 | 界面把建议文本交给正常发送路径(用户气泡、排队、附件闸门、串行门全部一致),**不是**只填进输入框 |
|
|
981
|
+
| 建议行在输入框上方 | 与截图一致:一排圆角描边的短语、横向滚动、最多 3 枚;没有建议时**不占高度**。开新轮 / 换会话 / 销毁组件立刻清空并 abort 在途请求;历史补的建议没有「托管中」标记 |
|
|
982
|
+
| 失败静默 | 接口超时 / 非 2xx / 空结果都只是「这次没有建议」;界面不弹错、不重试、不画占位 |
|
|
983
|
+
| 老 core 降级 | core 没有这个 flag(exit 2/unknown flag)→ 插件 502 → 界面静默;组件侧 `transport.suggest` 不存在时直接不请求(诚实降级) |
|
|
984
|
+
|
|
985
|
+
⚠️ 建议是**给用户发的下一句**(用户口吻、≤16 汉字),不是给 agent 的指令;解析侧会丢掉
|
|
986
|
+
「AI / 助手 / …」开头的行与超过 28 runes 的长句,避免把模型的自述当成用户的话。
|
|
987
|
+
|
|
988
|
+
#### 会话托管:在「建议」之上自动发第一条(2026-10-23,纯客户端策略)
|
|
989
|
+
|
|
990
|
+
用户定调:「托管模式下自动按照最佳建议提交到会话中执行,是否开启在我的 tab 中可配置」。
|
|
991
|
+
|
|
992
|
+
这**不是**新的 wire 契约:没有新接口、没有新字段,core / 插件完全不需要知道托管是否开启。
|
|
993
|
+
它只是组件在「拿到建议」与「用户点建议」之间加的一条本地策略:
|
|
994
|
+
|
|
995
|
+
| 事实 | 说明 |
|
|
996
|
+
| --- | --- |
|
|
997
|
+
| 开关位置 | 移动端「我的」→「会话托管」;设备级(`localStorage`,随环境分桶),默认关。开关的权威值在宿主,组件只执行 |
|
|
998
|
+
| 自动发哪条 | 建议数组的**第一条**(模型排序即优先级);不拼接、不改写、不自己生成 |
|
|
999
|
+
| 发送路径 | 与用户点建议同一条:内核画用户气泡、串行门 `beforeAsk`、权限、排队、附件闸门全部照旧;不经过输入框 |
|
|
1000
|
+
| 先给一眼 | 建议画出来后等 1.2s 再发;这 1.2s 内输入框有字 / 有附件 / 已开新轮 / 已切会话 / 已关托管 → 放弃本次自动发送 |
|
|
1001
|
+
| 连续上限 | 12 轮。到顶 / 本轮失败 / 本轮被停止 → **自动暂停**并回调宿主把「我的」开关关掉;回放历史里的旧失败不算 |
|
|
1002
|
+
| 空建议 | 不自动发,也不拿模板凑;托管保持开启,等下一轮(用户手动发的那句)结束后再看 |
|
|
1003
|
+
| 历史建议 | 打开历史会话补出来的「最后一轮」建议**只展示**,不进托管;托管只对本次实时新收尾的轮次生效 |
|
|
1004
|
+
| 交付完成 | 小说 / 故事 / 文章 / 报告等**完整成品**交付完、AI 没有提问也没列下一步 → 建议必须为空;「再来一篇 / 换个题材 / 再写一个 / 继续写」由服务端确定性过滤(除非 AI 明确邀请),托管不得自动续写 |
|
|
1005
|
+
| 提问卡 | 托管开着时,卡出现 1.2s 后自动选**第一项**交卷(优先「继续 / 下一步 / 开始」措辞);用户碰卡、关闭卡、自己输入 → 取消这次自动答,不抢人的操作。只处理 `kind:"question"`,待授权(permission)绝不自动点,避免代用户授权 |
|
|
1006
|
+
| 成功判据 | 不只看收尾回调的 `ok`:`llm` 这类**不发 `turn_end`** 的引擎走兜底 `hardFinish(0,'已完成')`,也必须算成功。否则建议永远不请求、托管第一轮就误暂停(2026-10-23 实测修复) |
|
|
1007
|
+
| 停止 | 「我的」开关、建议行上的「托管中」标记、或正常的中断按钮(本轮被停 → 自动暂停) |
|
|
1008
|
+
| 可见性 | 待发时建议行前有「托管中」标记、第一条建议加描边;发送后建议行收起。不做「静默自己跑」 |
|
|
1009
|
+
|
|
1010
|
+
⚠️ 托管是**宿主级**能力(`client-ui/shared/conversation.js` 的 `hosting` / `setHosting`),
|
|
1011
|
+
不是内核渲染的一部分;magic-test 桌面端那份内联 `app.js` 不加载这个组件,因此不会出现托管开关。
|
|
1012
|
+
|
|
916
1013
|
#### 会话状态:与标题同一套存法,客户端只读(2026-10-21)
|
|
917
1014
|
|
|
918
1015
|
用户定调:「会话状态搞一个字段 跟会话标题一样存起来 更新这个字段来统一」。
|
|
@@ -931,7 +1028,7 @@ core 侧只有一张空的 sid→title 覆盖表,于是:
|
|
|
931
1028
|
| 表形状 | `{ "<session_id>": {"state","run_id","run_started","updated_at"} }`;键优先 `session_id`,引擎还没吐 id 时落在 `run_id` 上,拿到后迁移并删旧键(与标题同一坑:留双键就会按不同 id 读到两个状态) |
|
|
932
1029
|
| `run_started` 排序守卫 | 写入方是**各轮的进程**:旧一轮收尾(`Finish` / `Stop` / 进程已不在 → `gone`)可能晚于新一轮开跑。只在写入轮**不早于**表里那一轮时才覆盖,迟到的 `done` 不会盖掉新一轮的 `running` |
|
|
933
1030
|
| 写入时机 | 与登记表同一步(`Handle.write` + 所有 `writeRecord` 路径,含 `Stop` / 同会话旧轮被 supersede / 读侧归一 `gone`);不落登记表(`--save-registry` 未开)时状态也不留痕 |
|
|
934
|
-
| 删除会话一并清 | `--delete-session` / `DELETE /desk/session/{id}` 会删掉状态行(返回里的 `statuses_removed`),避免同 id
|
|
1031
|
+
| 删除会话一并清 | `--delete-session` / `DELETE /desk/session/{id}` 会删掉状态行(返回里的 `statuses_removed`),避免同 id 再建会话时继承一条过期状态;删主会话还会沿父指针级联删掉子任务会话(`child_sessions_removed`) |
|
|
935
1032
|
|
|
936
1033
|
状态词表与登记表完全一致:`running` / `waiting_approval` / `idle`(非终态),
|
|
937
1034
|
`done` / `failed` / `cancelled` / `interrupted` / `gone`(终态)。
|
package/client-ui/pc/app.css
CHANGED
|
@@ -375,6 +375,9 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
|
|
|
375
375
|
.tk-item .tk-pin{width:11px;height:11px;flex:none;display:grid;place-items:center;
|
|
376
376
|
color:var(--cinnabar-hi)}
|
|
377
377
|
.tk-item .tk-pin svg{width:11px;height:11px;display:block;fill:currentColor}
|
|
378
|
+
/* 运行中的**自动**置顶图钉(2026-10-22):同一个图形、更淡 —— 一眼能看出「它被临时钉上来了」,
|
|
379
|
+
又不会与用户手动置顶(实心朱砂)混为一谈;跑完 `qaTaskAutoPin` 变 false,图钉随之消失。 */
|
|
380
|
+
.tk-item .tk-pin--auto{opacity:.55}
|
|
378
381
|
/* 分组胶囊 tab(用户 2026-09-20:「对话列表中审查中的任务直接显示到了已归档里不对 增加1个审查中的
|
|
379
382
|
分组。同时3个状态的分组改成胶囊tab形式」+「已归档改成已完成」):
|
|
380
383
|
三档状态 = 处理中 / 审查中 / 已完成,渲染成**一枚分段控件**(与需求看板页头「列表 / 看板」、
|
package/client-ui/pc/index.html
CHANGED
|
@@ -69,6 +69,11 @@ body { overflow: hidden; background: var(--bg-page); }
|
|
|
69
69
|
.rail-dot.live{ background: #2E9E6B; box-shadow: 0 0 0 3px rgba(46,158,107,.16); }
|
|
70
70
|
.rail-dot.done{ background: #C9C1B6; }
|
|
71
71
|
.rail-dot.failed{ background: #C4553C; }
|
|
72
|
+
/* 运行中**自动临时置顶**的图钉(2026-10-22):与移动端、旧左栏同一图形、更淡 ——
|
|
73
|
+
跑完状态一变,行上的图钉与排在最前的顺序一起归位(见 93-rail.js 的 qaRailOrder)。 */
|
|
74
|
+
.rail-pin{ flex:none; width:11px; height:11px; display:grid; place-items:center;
|
|
75
|
+
color: var(--cinnabar-hi); opacity:.55; }
|
|
76
|
+
.rail-pin svg{ width:11px; height:11px; display:block; fill:currentColor; }
|
|
72
77
|
/* 「无历史」标记:这条会话有会话 id(能续接),但引擎当时非流式跑、没留下事件日志。
|
|
73
78
|
在行上先标出来,免得点开才发现只有一句说明(`93-rail.js` 的 noLog)。 */
|
|
74
79
|
.rail-nolog{
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
三份开合态一起删掉):用户要的是**固定的上限**,不是「想点就能看全」。
|
|
9
9
|
⚠️ 上限内**当前会话 / 正在处理的那条 / 置顶的那条优先占名额**(挤掉的是最老的那些普通行)——
|
|
10
10
|
正在跑的被挤出去等于进度效果白加,置顶被挤出去等于没置顶。
|
|
11
|
+
「正在处理」还包括**运行中自动临时置顶**(见 ⑤):它在排序里被提到手动置顶之后、普通会话之前,跑完自动归位。
|
|
11
12
|
⚠️ 超出 20 条的**不再有展开入口**:老对话的找回路径是**搜索**(见 ④)或
|
|
12
13
|
**在需求看板点卡片跳到它那条会话**(见 qaTaskReveal)——用户原话
|
|
13
14
|
「历史的能通过搜索或者点击需求跳转找到就行了」。列表底部留一行如实说明(`.tk-note`),
|
|
@@ -51,6 +52,9 @@
|
|
|
51
52
|
⚠️ 「点需求跳转」时**要清掉搜索词**(见 qaTaskReveal):否则跳过去的那条会被搜索条件过滤掉,
|
|
52
53
|
画布切了、左栏却看不到它(用户会认为没反应)。
|
|
53
54
|
⑤ **组内排序**:置顶在最前,其余按 `ts` 倒序(与行尾那个时间戳同源)。
|
|
55
|
+
**运行中的会话自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
|
|
56
|
+
它只影响本次排序 / 取行,**不写 `pinned`、不落库** —— 跑完自动归位,手动置顶仍是
|
|
57
|
+
用户自己的持久选择(手动置顶在前,运行中紧随其后,见 `qaTaskOrder`)。
|
|
54
58
|
⚠️ 归属哪些档由 ③ 的 `qaTaskGroupOf` 决定;本文件只负责「取哪一档的行、怎么排、列几条」。 */
|
|
55
59
|
/* 每档的**硬上限**(2026-09-22 用户:「任务对话区域永远最多显示20个」)——
|
|
56
60
|
不是「默认值」、也没有「查看更多」能突破它(那条路径 2026-09-22 整体下线)。
|
|
@@ -75,6 +79,11 @@ const QA_TK_DOTS = '<svg viewBox="0 0 24 24" aria-hidden="true">'
|
|
|
75
79
|
+ '<circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>';
|
|
76
80
|
const QA_TK_PIN = '<span class="tk-pin" role="img" aria-label="已置顶" title="已置顶">'
|
|
77
81
|
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 9V4h1a1 1 0 000-2H7a1 1 0 000 2h1v5a3 3 0 01-3 3v2h5.97v7l1 1 1-1v-7H19v-2a3 3 0 01-3-3z"/></svg></span>';
|
|
82
|
+
/* 运行中那枚**自动**图钉:与手动置顶同一个图形,但更淡(`--auto`),title 说清「跑完自动归位」——
|
|
83
|
+
不解释的话,用户会以为自己什么时候置顶过它。 */
|
|
84
|
+
const QA_TK_PIN_AUTO = '<span class="tk-pin tk-pin--auto" role="img" aria-label="运行中 · 自动置顶" '
|
|
85
|
+
+ 'title="运行中 · 自动置顶(跑完自动归位)">'
|
|
86
|
+
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 9V4h1a1 1 0 000-2H7a1 1 0 000 2h1v5a3 3 0 01-3 3v2h5.97v7l1 1 1-1v-7H19v-2a3 3 0 01-3-3z"/></svg></span>';
|
|
78
87
|
/** 行尾那枚状态标记(转圈 / 圆点)——**与需求状态 1 对 1**(用户 2026-09-20:「需求的状态为什么跟
|
|
79
88
|
* 任务状态不一致 应该1对1一致的啊」):
|
|
80
89
|
* 文案与状态一律取自 `qaTaskStatusOf`(需求派发的会话直接复用需求侧的 `reqStatusLabel`),
|
|
@@ -87,9 +96,24 @@ function qaTaskSt(t){
|
|
|
87
96
|
if(s.st === "running") return qaSpinHtml(s.label, title);
|
|
88
97
|
return `<span class="tk-st ${escHtml(s.st)}" title="${escHtml(title)}"></span>`;
|
|
89
98
|
}
|
|
90
|
-
/**
|
|
99
|
+
/** 这条会话是不是**正在跑**(运行中)。判据只走唯一状态口径 `qaTaskStatusOf`:
|
|
100
|
+
* `st === "running"` 才算 —— 排队 / 待处理(`pending`)不自动置顶,它们还没开始占资源;
|
|
101
|
+
* 需求派发的会话在跑审查轮时状态也是 `running`,同样按运行中处理(与转圈同源)。
|
|
102
|
+
* ⚠️ 这是**派生状态**,不写 `t.pinned`、不落库:跑完 `st` 一变,下一次渲染自动归位。 */
|
|
103
|
+
function qaTaskAutoPin(t){
|
|
104
|
+
return !!(t && typeof qaTaskStatusOf === "function" && qaTaskStatusOf(t).st === "running");
|
|
105
|
+
}
|
|
106
|
+
/** 组内排序:**手动置顶 → 运行中(自动临时置顶) → 其余按 ts 倒序**(ts 同时是行尾那个时间戳,
|
|
107
|
+
* 见 qaSaveSnapshotFor)。用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是
|
|
108
|
+
* 临时把在跑的活提上来,不能压过用户自己钉的会话;跑完自动归位。 */
|
|
91
109
|
function qaTaskOrder(a, b){
|
|
92
|
-
return ((b
|
|
110
|
+
return (qaTaskPinRank(b) - qaTaskPinRank(a)) || ((b.ts||0) - (a.ts||0));
|
|
111
|
+
}
|
|
112
|
+
/** 排序档位:2 = 用户手动置顶 · 1 = 运行中自动置顶 · 0 = 普通。 */
|
|
113
|
+
function qaTaskPinRank(t){
|
|
114
|
+
if(!t) return 0;
|
|
115
|
+
if(t.pinned) return 2;
|
|
116
|
+
return qaTaskAutoPin(t) ? 1 : 0;
|
|
93
117
|
}
|
|
94
118
|
/* ---------- 归组:三档互斥,**状态优先**(用户 2026-09-20 报的就是「审查中的跑到已完成里」)---------- */
|
|
95
119
|
/** 这条会话是不是「审查中」—— 即需求看板「待验证」那一列里的会话。两个判据(任一成立即算):
|
|
@@ -231,7 +255,7 @@ function qaTaskRowHtml(t){
|
|
|
231
255
|
return `<button class="tk-item${t.id===qaActiveId?" active":""}" data-id="${t.id}" type="button" title="${escHtml(t.title)}">
|
|
232
256
|
<span class="tico">${QA_TK_ICON}</span>
|
|
233
257
|
<span class="tt">${escHtml(t.title)}</span>
|
|
234
|
-
${t.pinned ? QA_TK_PIN : ""}
|
|
258
|
+
${t.pinned ? QA_TK_PIN : (qaTaskAutoPin(t) ? QA_TK_PIN_AUTO : "")}
|
|
235
259
|
${qaTaskSt(t)}
|
|
236
260
|
<span class="tk-eng">${escHtml(t.engine||"claude")}</span>
|
|
237
261
|
<span class="tm">${qaTaskTime(t.ts)}</span>
|
|
@@ -246,7 +270,7 @@ function qaTaskRowHtml(t){
|
|
|
246
270
|
* ⚠️ 返回的 list **仍按原顺序**(置顶在前 → ts 倒序),顶名额只换「谁留下」,不动排序。 */
|
|
247
271
|
function qaTaskFold(rows){
|
|
248
272
|
const keepRule = (t)=> t.id === qaActiveId || t.id === qaTaskRevealId
|
|
249
|
-
|| t
|
|
273
|
+
|| qaTaskAutoPin(t) || !!t.pinned;
|
|
250
274
|
if(rows.length <= QA_TK_VISIBLE) return { list: rows, hidden: 0 };
|
|
251
275
|
const take = new Set(rows.slice(0, QA_TK_VISIBLE).map(t=>t.id));
|
|
252
276
|
rows.filter(t=>keepRule(t) && !take.has(t.id)).forEach((t)=>{
|
|
@@ -126,6 +126,11 @@ async function qaStopActive(){
|
|
|
126
126
|
async function qaSend(v, opts){
|
|
127
127
|
const followup = !!(opts && opts.followup);
|
|
128
128
|
const from = (opts && opts.from) || "";
|
|
129
|
+
/* sid = 这条消息**归属哪条会话**(可选)。只有排队的下一条会传(qaMsgFlush):
|
|
130
|
+
它出队时 `qaActiveId` 可能已经被切走,而 qaSend 下面的判据与落笔用的都是 qaActiveId,
|
|
131
|
+
不显式指定就会把别人的消息发进当前显示的这条会话(用户 2026-10-23 报障:
|
|
132
|
+
「排队的和自动建议发送串会话了」)。传了就**只认它**,且落笔前核对画布确实还是它。 */
|
|
133
|
+
const sid = (opts && typeof opts.sid === "string") ? opts.sid : "";
|
|
129
134
|
/* mode = 这一轮是不是**某种模式**(目前只有 'rec' = 录屏模式,由 + 菜单的「录屏」胶囊决定,
|
|
130
135
|
见 25-record.js::recStart)。它一路透传到主进程 desk:ask —— 那边据此把用户原文换成
|
|
131
136
|
**模式指令**再交给引擎(用户气泡里显示的仍是他的原话,指令只进 prompt)。
|
|
@@ -147,8 +152,8 @@ async function qaSend(v, opts){
|
|
|
147
152
|
只贴图不发文字是允许的(贴一张截图直接发是很自然的动作),
|
|
148
153
|
此时 prompt 由 askAttPrompt 补一句中性引导。 */
|
|
149
154
|
const atts = askAttReady(from);
|
|
150
|
-
|
|
151
|
-
if(askAttFailed(from).length){ toast("有截图没保存成功,先重试或移除"); return; }
|
|
155
|
+
if(askAttBusy(from)){ toast("截图还在保存,稍等一下再发"); return false; }
|
|
156
|
+
if(askAttFailed(from).length){ toast("有截图没保存成功,先重试或移除"); return false; }
|
|
152
157
|
// 无桌面桥(纯浏览器打开):降级 = 创建流水线任务并跳转,两个输入框共用同一入口语义
|
|
153
158
|
if(!(window.desk && typeof window.desk.ask === "function")){
|
|
154
159
|
if(!v && !atts.length) return;
|
|
@@ -165,7 +170,10 @@ async function qaSend(v, opts){
|
|
|
165
170
|
}
|
|
166
171
|
/* 空输入直接 return(点停止钮时输入框是空的 → 那条路径已在 qaSendBtnSync 分流到
|
|
167
172
|
qaStopActive,走不到这里;这里只挡「回车但什么都没写」这种空提交)。 */
|
|
168
|
-
if(!v && !atts.length) return;
|
|
173
|
+
if(!v && !atts.length) return false;
|
|
174
|
+
/* 指定了会话(排队那条)但画布已经不是它了 → **一条都不发**,把调用方交给它的处理
|
|
175
|
+
(qaMsgFlush 会按返回值把条目退回队首,等切回来再发)。不静默吞掉,也不发错人。 */
|
|
176
|
+
if(sid && sid !== (qaActiveId || "")) return false;
|
|
169
177
|
/* 处理中提交的下一条 → **排队**,不打断在跑的那一轮(用户 2026-09-18:「会话处理中时
|
|
170
178
|
改成支持输入 但是新需求排列在会话框上面默认等待上一个结束后自动提交」)。
|
|
171
179
|
改造前这里是「停就是停」:撰写区锁死、提交钮就是停止钮,任何调进来的路径都只会停。
|
|
@@ -175,21 +183,24 @@ async function qaSend(v, opts){
|
|
|
175
183
|
⚠️ 只对 followup(任务对话实例)成立:home2 = 新建对话入口,它提交的永远是**新会话**,
|
|
176
184
|
不排进别人的队(qaBusyEx 也永不锁它,两边口径一致)。
|
|
177
185
|
⚠️ append(提问卡答案)不走这里:答案有自己的队列与时机(qaAskQueue),更不该排队。 */
|
|
178
|
-
if(!append && followup && qaTaskRunning(qaActiveId)){
|
|
186
|
+
if(!append && followup && qaTaskRunning(sid || qaActiveId)){
|
|
179
187
|
/* 模式轮(录屏 / 截屏 / 短视频)**不排队**:队列条目只带正文(qaMsgEnqueue({ text: v })),排进去就等于
|
|
180
188
|
「用户以为在录屏,结果变成了一句普通对话」—— 静默降级比直接拒绝更糟。
|
|
181
189
|
(recStart / shortsStart 发送前也拦了一道,这里是第二道:别指望调用方永远记得判。) */
|
|
182
190
|
if(mode){
|
|
183
191
|
/* 文案按模式给全称(2026-09-23 起模式轮不止「录屏」一种,别再硬编码)。 */
|
|
184
192
|
const mn = { rec: "录屏", shot: "截屏", shorts: "做短视频", sim: "发模拟测试" }[mode] || "发送";
|
|
185
|
-
toast("这条会话还在跑 —— 等它结束再" + mn); return;
|
|
193
|
+
toast("这条会话还在跑 —— 等它结束再" + mn); return false;
|
|
186
194
|
}
|
|
195
|
+
/* ⚠️ 排进的是**这条消息自己的会话**(sid || qaActiveId):显式 sid 的调用方(队列 flush)
|
|
196
|
+
已经声明了归属,不能因为此刻 qaActiveId 恰好是别人就排进别人的队 —— 那正是
|
|
197
|
+
2026-10-23 那个「排队串会话」的同一个错,只是晚一步发作。 */
|
|
187
198
|
if(from) askChipsClear(from); // 与正常发送同约定:发出即清掉该实例的胶囊
|
|
188
199
|
const srcInp = $("#home-input");
|
|
189
200
|
if(srcInp) srcInp.value = ""; // 正文已挪进队列条目 → 输入框腾空
|
|
190
|
-
qaMsgEnqueue(qaActiveId, { text: v });
|
|
201
|
+
qaMsgEnqueue(sid || qaActiveId, { text: v });
|
|
191
202
|
qaSendBtnSync(from || "home"); // 输入框空了 → 那枚钮切回「停止」
|
|
192
|
-
return;
|
|
203
|
+
return true;
|
|
193
204
|
}
|
|
194
205
|
if(from) askChipsClear(from);
|
|
195
206
|
const srcInput = followup ? $("#home-input") : $("#home2-input");
|
|
@@ -231,7 +242,7 @@ async function qaSend(v, opts){
|
|
|
231
242
|
const prompt = v || (attPaths.length ? "请看截图。" : "");
|
|
232
243
|
if(from) askAttClear(from); // 已进气泡 → 输入框上方的附件条功成身退
|
|
233
244
|
const h = qaRenderAnswer();
|
|
234
|
-
if(!h){ return; }
|
|
245
|
+
if(!h){ return false; }
|
|
235
246
|
/* ⚠️ 锁只落**这条会话**自己的撰写区:run 按 taskId=qaActiveId 登记 → qaTaskRunning 成立,
|
|
236
247
|
qaBusyEx 就只锁当前显示的这条;别的会话(含 home2 的新建入口)照常可发、互不设卡。
|
|
237
248
|
`userText` / `msgText`(2026-09-22)=这一轮的**消息台账**:持久层只存消息(不存样式),
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const QA_RAIL_MAX = 60; // 一屏最多画多少行(CLI 的登记表可能有几百条)
|
|
21
|
+
/* 运行中那枚**自动**置顶图钉(与移动端/旧左栏同一图形):行内说明「它凭什么在最前」,
|
|
22
|
+
跑完状态一变就消失 —— 别让用户以为自己置顶过它。 */
|
|
23
|
+
const QA_RAIL_PIN = '<span class="rail-pin" role="img" aria-label="运行中 · 自动置顶" '
|
|
24
|
+
+ 'title="运行中 · 自动置顶(跑完自动归位)">'
|
|
25
|
+
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 9V4h1a1 1 0 000-2H7a1 1 0 000 2h1v5a3 3 0 01-3 3v2h5.97v7l1 1 1-1v-7H19v-2a3 3 0 01-3-3z"/></svg></span>';
|
|
21
26
|
const QA_RAIL_POLL_MS = 30000; // 有会话在跑时的兜底轮询(其余时候靠「一轮结束」触发)
|
|
22
27
|
const QA_RAIL_TAIL_MS = 2500; // 打开一条 running 会话时的进度同步间隔(见 qaRailTailStart)
|
|
23
28
|
/* 一条会话「这一轮结束了」的事件名(与 core 的归一化词表同名)。
|
|
@@ -66,6 +71,18 @@ function qaRailGroupLabel(s){
|
|
|
66
71
|
if(st === "interrupted") return "已打断";
|
|
67
72
|
return "已完成";
|
|
68
73
|
}
|
|
74
|
+
/* 运行中的会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
|
|
75
|
+
判据与左栏「进行中」同一口径(`qaRailGroup === "live"`,等审批也算 —— 它同样需要被看见),
|
|
76
|
+
**不改任何数据、不落盘**:只在渲染取行时把它们排到最前,跑完下一次刷新自动归位。
|
|
77
|
+
为什么 PC 没有「置顶」按钮也要这条:左栏按 CLI 给的顺序平铺(最近活动倒序),
|
|
78
|
+
一个跑几十分钟的长任务会被后来新建的会话一路压到下面,用户得往下翻才能找到「正在跑的那条」。
|
|
79
|
+
⚠️ 稳定分区,不按时间重排 —— CLI 的顺序是既有契约,这里只做「提到最前」,不动同类先后。 */
|
|
80
|
+
function qaRailAutoPinned(s){ return qaRailGroup(s) === "live"; }
|
|
81
|
+
function qaRailOrder(rows){
|
|
82
|
+
const live = [], rest = [];
|
|
83
|
+
(rows || []).forEach((s)=>{ (qaRailAutoPinned(s) ? live : rest).push(s); });
|
|
84
|
+
return live.concat(rest);
|
|
85
|
+
}
|
|
69
86
|
/** 时间列:今天只给 时:分,其余给 月/日(与旧左栏同一口径)。 */
|
|
70
87
|
function qaRailTime(ts){
|
|
71
88
|
const n = Number(ts);
|
|
@@ -145,7 +162,9 @@ function qaRailRender(){
|
|
|
145
162
|
`<span class="n">${counts[k]}</span></button>`;
|
|
146
163
|
tabs.innerHTML = tab("all", "全部") + tab("live", "进行中") + tab("done", "已完成") + tab("failed", "失败");
|
|
147
164
|
}
|
|
148
|
-
|
|
165
|
+
/* 取行:**运行中先自动临时置顶**,再截 QA_RAIL_MAX —— 长任务不会被 60 条上限挤掉
|
|
166
|
+
(除非同时跑着 60 条以上,那种情况本来就只能显示最近这些)。 */
|
|
167
|
+
const rows = qaRailOrder(qaRailAll.filter(qaRailHit)).slice(0, QA_RAIL_MAX);
|
|
149
168
|
if(!qaRailAll.length){
|
|
150
169
|
box.innerHTML = `<div class="tk-empty" data-testid="rail-empty">${qaRailLoaded
|
|
151
170
|
? (qaRailErr ? escHtml(qaRailErr) : "还没有会话 —— 在右边直接提问就会开一条")
|
|
@@ -167,6 +186,7 @@ function qaRailRender(){
|
|
|
167
186
|
return `<button class="tk-item${s.id === qaActiveId ? " active" : ""}" data-sid="${escHtml(s.id)}" type="button" title="${escHtml(tip)}" data-testid="rail-item">
|
|
168
187
|
<span class="tico"><i class="rail-dot ${g}" aria-hidden="true"></i></span>
|
|
169
188
|
<span class="tt">${escHtml(title)}</span>
|
|
189
|
+
${qaRailAutoPinned(s) ? QA_RAIL_PIN : ""}
|
|
170
190
|
${noLog(s) ? `<span class="rail-nolog" title="没有可回放的历史">无历史</span>` : ""}
|
|
171
191
|
<span class="tm">${escHtml(qaRailTime(s.ts))}</span>
|
|
172
192
|
<span class="tk-dots" data-rail-menu="${escHtml(s.id)}" role="button" tabindex="-1" data-testid="rail-dots"
|
|
@@ -1443,10 +1443,53 @@ function qaRenderAnswer(host){
|
|
|
1443
1443
|
}else{
|
|
1444
1444
|
qaTodoFill(host, rows, { title: "任务列表" });
|
|
1445
1445
|
}
|
|
1446
|
+
/* 子任务产物的落点(2026-10-22 用户:「主会话产物要显示子任务的产物」)。
|
|
1447
|
+
以前它们只出现在整轮结束那份 result 信封里,主会话画布要等到最后才看得到;
|
|
1448
|
+
现在某个子任务一落定,core 就把它的产物挂到任务行上,这里立刻画出来。
|
|
1449
|
+
复用正文 / 工具输出同一条产物链(19-attachments::qaArtMount):存在性照样由主进程
|
|
1450
|
+
探针判,探针不可用(纯预览)时一张不画,绝不画出一枚必然裂掉的卡。 */
|
|
1451
|
+
mountTaskArts(host, rows);
|
|
1446
1452
|
qaScrollIntoView();
|
|
1447
1453
|
return host;
|
|
1448
1454
|
}
|
|
1449
1455
|
|
|
1456
|
+
/* mountTaskArts 把「这一轮所有子任务交出的产物」画在任务列表卡正下方。
|
|
1457
|
+
· 一次性汇总成一区(不按任务分子标题):用户要的是「这轮产出了什么」,
|
|
1458
|
+
重复的路径(交接单 / 多个任务引用同一份文件)在这里去重;
|
|
1459
|
+
· 幂等:同一份快照反复到达时把上一块摘掉重画 —— 内存里只记**当前**这份清单,
|
|
1460
|
+
走的是「整块替换」而不是「越叠越多」;
|
|
1461
|
+
· rows 里没带 arts(引擎的 TodoWrite 待办清单 / 老 core)时什么都不做。 */
|
|
1462
|
+
function mountTaskArts(panel, rows){
|
|
1463
|
+
const all = [];
|
|
1464
|
+
const seen = Object.create(null);
|
|
1465
|
+
(Array.isArray(rows) ? rows : []).forEach((r)=>{
|
|
1466
|
+
(r && Array.isArray(r.arts) ? r.arts : []).forEach((p)=>{
|
|
1467
|
+
/* 同一个文件可能被两个子任务共用(交接单 / 引用上游产物),写法还可能是
|
|
1468
|
+
绝对路径或相对路径 —— 按规范化路径判重(qaArtCanon,见 19-attachments)。
|
|
1469
|
+
它是运行时的全局,取不到就退回原串。 */
|
|
1470
|
+
const k = (typeof qaArtCanon === "function") ? qaArtCanon(p) : String(p || "");
|
|
1471
|
+
if(!p || !k || seen[k]) return;
|
|
1472
|
+
seen[k] = 1;
|
|
1473
|
+
all.push(p);
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
let box = panel.nextElementSibling;
|
|
1477
|
+
if(!(box && box.classList && box.classList.contains("qa-task-arts"))) box = null;
|
|
1478
|
+
if(!box){
|
|
1479
|
+
if(!all.length) return;
|
|
1480
|
+
box = document.createElement("div");
|
|
1481
|
+
box.className = "qa-task-arts";
|
|
1482
|
+
panel.parentNode.insertBefore(box, panel.nextSibling);
|
|
1483
|
+
}
|
|
1484
|
+
/* 整块替换:快照是**全量**的(每次状态变化都整份重发),产物清单也跟着替换;
|
|
1485
|
+
不 append 才不会越叠越多。上一轮已画好的卡先摘掉,免得探针回包前留着旧图。 */
|
|
1486
|
+
box.innerHTML = "";
|
|
1487
|
+
if(!all.length){ if(box.parentNode) box.parentNode.removeChild(box); return; }
|
|
1488
|
+
/* ⚠️ 取**运行时**的全局:qaArtMount 定义在 19-attachments 分片里,本文件装配在前、
|
|
1489
|
+
直接引用会在定义前求值(typeof 判定为 "undefined",产物区于是静默变空)。 */
|
|
1490
|
+
if(typeof qaArtMount === "function") qaArtMount(box, all, { label: "子任务产物" });
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1450
1493
|
/* ===== 「任务耗时」分组:一轮收尾时,把这一轮的**历史过程**并进一枚折叠组 =====
|
|
1451
1494
|
(2026-10-12 用户:「历史的思考和工具最终又合并在一个任务总耗时的分组里」,形制对齐 TraeWork)
|
|
1452
1495
|
· 收的是**思考块 + 工具组**(`.qa-think` / `.qa-tools`),正文留在外面 —— 那是结论,过程才是历史;
|
|
@@ -263,8 +263,13 @@ function qaAskFlush(taskId){
|
|
|
263
263
|
(主进程 `desk:ask` 是单会话槽)。剩下的等那一轮收尾再放 —— 队列自己就串起来了。
|
|
264
264
|
⚠️ 提问卡答案优先:qaAskQueue 里还有没发出去的答案时先不发队列消息(那是引擎正等着的
|
|
265
265
|
回答,抢在它前面发会把这一问的上下文冲掉)。
|
|
266
|
-
⚠️
|
|
267
|
-
|
|
266
|
+
⚠️ 只能在**用户正看着这条会话**时发(画布只有一份):在别的会话的画布里替它发就是
|
|
267
|
+
张冠李戴 —— 与 qaAskFlush 同一条判据。切回来即发。
|
|
268
|
+
⚠️ 2026-10-23 修(用户:「排队的和自动建议发送串会话了」):判据只看 qaActiveId **不够** ——
|
|
269
|
+
`qaTaskSwitch` 已经把 `qaActiveId` 换成新会话,但旧会话那条 `qaMsgFlush(id)` 的入参
|
|
270
|
+
还是旧的;`qaSend` 内部再读 `qaTaskRunning(qaActiveId)` / `qaActiveId` 落笔,就发进新会话了。
|
|
271
|
+
改法:`qaMsgFlush` 把**自己那条会话**(`owner`)显式交给 qaSend(`{ sid }`),由 qaSend
|
|
272
|
+
按 sid 落笔、发现画布已不是它就连一条都不发、把条目原样退回队列(见 qaMsgFlush)。
|
|
268
273
|
⚠️ 队列条目只存正文(`{ text }`):处理中贴的图留在**附件条**上(2026-10-19 起处理中
|
|
269
274
|
贴图不再被拦,见 19-attachments 的 askAttBind),收尾后 qaMsgFlush 走 qaSend
|
|
270
275
|
(from = 本实例)时才被取走 —— 贴图与排队的那条一起发出。 */
|
|
@@ -336,17 +341,35 @@ function qaMsgEnqueue(taskId, item){
|
|
|
336
341
|
toast("已排队 · 这一轮结束后自动发送<br><b>" + escHtml((item && item.text) || "") + "</b>", true);
|
|
337
342
|
}
|
|
338
343
|
/** 排队的下一条该不该现在发:这条会话没有一轮在跑 + 用户正看着它 + 没有待发的提问卡答案
|
|
339
|
-
* (见上面三条 ⚠️)。发一条即返回 —— 剩下的等这一轮收尾再来。
|
|
344
|
+
* (见上面三条 ⚠️)。发一条即返回 —— 剩下的等这一轮收尾再来。
|
|
345
|
+
* 返回 true = 已经交出去了(`qaSend` 认了这条),false = 还在队列里没动。
|
|
346
|
+
* ⚠️ 「交出去」是**异步**的(qaSend 是 async,且可能因为会话已不可用而把条目退回来):
|
|
347
|
+
* 这里按乐观口径先出队,退回由 qaMsgRequeue 放回队首 —— 顺序不会乱,也不会丢。 */
|
|
340
348
|
function qaMsgFlush(taskId){
|
|
341
349
|
const id = taskId || "";
|
|
342
350
|
const q = qaMsgQueue.get(id);
|
|
343
|
-
if(!q || !q.length || qaTaskRunning(id)) return;
|
|
344
|
-
if(id !== (qaActiveId || "")) return;
|
|
345
|
-
if((qaAskQueue.get(id) || []).length) return;
|
|
351
|
+
if(!q || !q.length || qaTaskRunning(id)) return false;
|
|
352
|
+
if(id !== (qaActiveId || "")) return false;
|
|
353
|
+
if((qaAskQueue.get(id) || []).length) return false;
|
|
346
354
|
const item = q.shift();
|
|
347
355
|
if(!q.length) qaMsgQueue.delete(id);
|
|
348
356
|
qaMsgQueueRender(id);
|
|
349
|
-
|
|
357
|
+
/* `sid` = 这条队列自己的会话,**不是** qaActiveId(上面已判过相等,但 qaSend 是 async 的:
|
|
358
|
+
真要并发切走,判据在 qaSend 里还会按 sid 再核一次 —— 只认这一条,不认调用瞬间的全局)。 */
|
|
359
|
+
const ret = qaSend(item.text, { followup: true, from: QA_QUEUE_INSTANCE, sid: id });
|
|
360
|
+
if(ret && typeof ret.then === "function"){
|
|
361
|
+
ret.then((ok)=>{ if(ok === false) qaMsgRequeue(id, item); }).catch(()=>{ qaMsgRequeue(id, item); });
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
/** 没发出去的那条退回**队首**(qaSend 发现画布已经不是这条会话 / 空正文时回 false)。
|
|
366
|
+
* 放队首而不是队尾:用户排的先后顺序不能因为一次「发不出去」被打乱。 */
|
|
367
|
+
function qaMsgRequeue(taskId, item){
|
|
368
|
+
const id = taskId || ""; if(!id || !item) return;
|
|
369
|
+
const q = qaMsgQueue.get(id) || [];
|
|
370
|
+
q.unshift(item);
|
|
371
|
+
qaMsgQueue.set(id, q);
|
|
372
|
+
qaMsgQueueRender(id);
|
|
350
373
|
}
|
|
351
374
|
/** 会话删了 → 它排的队也没意义了(不留悬挂条目)。 */
|
|
352
375
|
function qaMsgQueueDropFor(taskId){ qaMsgQueue.delete(taskId || ""); qaMsgQueueRender(); }
|
|
@@ -425,8 +425,24 @@ function qaStreamApplyOne(ev, ctx){
|
|
|
425
425
|
status: st === "running" ? "in_progress"
|
|
426
426
|
: st === "done" ? "completed"
|
|
427
427
|
: (st === "failed" || st === "skipped") ? st : "pending",
|
|
428
|
+
/* 行尾「状态 · 耗时」:2026-10-22 用户「应该显示子任务标题和状态耗时」。
|
|
429
|
+
文案由这里给(与状态同一处映射,免得两端各写一套词),耗时取 core 给的
|
|
430
|
+
绝对值 runtime_ms(running 行随快照增长、落定后冻结)。 */
|
|
431
|
+
st: st === "running" ? "运行中"
|
|
432
|
+
: st === "done" ? "已完成"
|
|
433
|
+
: st === "failed" ? "失败"
|
|
434
|
+
: st === "skipped" ? "已跳过" : "等待中",
|
|
435
|
+
ms: Number(t.runtime_ms) || 0,
|
|
436
|
+
/* 这一行已经交出来的产物(绝对路径)。画在任务列表卡**下面**那一块
|
|
437
|
+
「子任务产物」区里(见 03-chat-message 的 todoList),不塞进行里 ——
|
|
438
|
+
一行里放缩略图会把列表撑垮。 */
|
|
439
|
+
arts: Array.isArray(t.arts) ? t.arts.filter(x=>typeof x === "string" && x) : [],
|
|
428
440
|
/* 行尾标注「引擎 · 角色」:主会话的任务列表卡要能一眼看出这一行派给了谁 */
|
|
429
441
|
meta: [t.engine, t.role || t.kind].filter(Boolean).join(" · "),
|
|
442
|
+
/* 行内进度(团队心跳):core 只给「跑了很久没动静」的那一行挂它,
|
|
443
|
+
如「已 40 秒无新输出」。2026-10-22 用户:「子任务进度也不需要显示多行
|
|
444
|
+
在任务列表中显示即可」—— 以前它是主会话里一行行 notice。 */
|
|
445
|
+
active: st === "running" ? String(t.active || "").trim() : "",
|
|
430
446
|
};
|
|
431
447
|
}).filter(r=>r.text);
|
|
432
448
|
if(typeof h.todoList === "function") h.todoList(rows);
|