magic-agent 1.0.1 → 1.0.2

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.
Files changed (40) hide show
  1. package/CLIENT-CONTRACT.md +298 -0
  2. package/README.md +35 -0
  3. package/client-ui/assets/OFL-Caveat.txt +93 -0
  4. package/client-ui/assets/caveat.woff2 +0 -0
  5. package/client-ui/index.html +246 -0
  6. package/client-ui/pc/app.css +5110 -0
  7. package/client-ui/pc/assets/damiao-mascot.png +0 -0
  8. package/client-ui/pc/assets/icon.png +0 -0
  9. package/client-ui/pc/index.html +154 -0
  10. package/client-ui/pc/js/00-base.js +52 -0
  11. package/client-ui/pc/js/03-chat-message.js +880 -0
  12. package/client-ui/pc/js/04-task-list.js +430 -0
  13. package/client-ui/pc/js/05-task-list-render.js +812 -0
  14. package/client-ui/pc/js/06-ask-answer.js +204 -0
  15. package/client-ui/pc/js/07-chat-stream-render.js +493 -0
  16. package/client-ui/pc/js/08-ask-event-map.js +171 -0
  17. package/client-ui/pc/js/09-stream-apply.js +367 -0
  18. package/client-ui/pc/js/10-markdown.js +430 -0
  19. package/client-ui/pc/js/18-askbox.js +262 -0
  20. package/client-ui/pc/js/19-attachments.js +679 -0
  21. package/client-ui/pc/js/29-msg-variants.js +462 -0
  22. package/client-ui/pc/js/90-glue.js +212 -0
  23. package/client-ui/pc/js/91-workbench-stubs.js +134 -0
  24. package/client-ui/pc/js/92-composer.js +406 -0
  25. package/client-ui/pc/js/93-rail.js +362 -0
  26. package/client-ui/pc/vendor/highlight.min.js +1262 -0
  27. package/client-ui/pc/vendor/marked.min.js +6 -0
  28. package/client-ui/pc/vendor/purify.min.js +3 -0
  29. package/client-ui/shared/conversation.css +256 -0
  30. package/client-ui/shared/conversation.js +1225 -0
  31. package/client-ui/shared/transport-http.js +123 -0
  32. package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
  33. package/client-ui-mobile/assets/caveat.woff2 +0 -0
  34. package/client-ui-mobile/index.html +173 -0
  35. package/npm/dist/darwin-arm64/magic-agent +0 -0
  36. package/npm/dist/darwin-x64/magic-agent +0 -0
  37. package/npm/dist/linux-arm64/magic-agent +0 -0
  38. package/npm/dist/linux-x64/magic-agent +0 -0
  39. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  40. package/package.json +4 -1
@@ -0,0 +1,812 @@
1
+ /* ---------- 左栏「任务 · 对话」渲染 ----------
2
+ 四条用户定稿的显示规则:
3
+ ① **处理中的进度效果**(2026-09-17):status=running 的行渲染一枚 12px 旋转进度环(QA_TK_SPIN),
4
+ 不再是那枚安静的脉冲圆点;done/error 仍走圆点(.tk-st)。
5
+ 派发需求产生的会话同样带 running 状态(REQ_TASK_STATUS),所以看板派发也能看到转圈。
6
+ ② **每档永远最多显示 20 条**(2026-09-22 用户:「任务对话区域永远最多显示20个」)——
7
+ 原先是「默认 5 条 + 查看更多 (N) 展开全部」(2026-09-17 那条已下线,`.tk-more` 连同
8
+ 三份开合态一起删掉):用户要的是**固定的上限**,不是「想点就能看全」。
9
+ ⚠️ 上限内**当前会话 / 正在处理的那条 / 置顶的那条优先占名额**(挤掉的是最老的那些普通行)——
10
+ 正在跑的被挤出去等于进度效果白加,置顶被挤出去等于没置顶。
11
+ ⚠️ 超出 20 条的**不再有展开入口**:老对话的找回路径是**搜索**(见 ④)或
12
+ **在需求看板点卡片跳到它那条会话**(见 qaTaskReveal)——用户原话
13
+ 「历史的能通过搜索或者点击需求跳转找到就行了」。列表底部留一行如实说明(`.tk-note`),
14
+ 不让人以为「只有 20 条」。
15
+ ③ **三档状态 = 胶囊 tab**(2026-09-20 用户原话:「对话列表中审查中的任务直接显示到了已归档里不对
16
+ 增加1个审查中的分组。同时3个状态的分组改成胶囊tab形式」+「已归档改成已完成」):
17
+ · 三档 = 「处理中 / 审查中 / 已完成」,渲染成一枚**分段控件**(`.tk-tabs` / `.tk-tab`,与需求看板
18
+ 页头的「列表 / 看板」、用户菜单里的「外观」同一套语汇):**一次只显示一档的内容**,
19
+ 点哪个 tab 就看哪一组,每档带该组计数 `(N)`。tab 即分组 —— 旧的「可折叠帽子堆在一起」
20
+ 那种形态已下线(`.tk-group` / `.tk-group-head` 连同样式一起删了,别只删 JS 留死样式)。
21
+ · **会话状态 = 需求状态,1 对 1**(2026-09-20 用户追加:「需求的状态为什么跟任务状态不一致
22
+ 应该1对1一致的啊」):状态值、文案、分档**全部**出自唯一一份口径 `qaTaskStatusOf`
23
+ (12-req-board.js)—— 需求派发的会话,status 就是需求 `status` 逐字
24
+ (pending / running / verify / done / error / stopped),文案直接复用需求侧的
25
+ `reqStatusLabel`(待处理 / 排队中 / 处理中 / 审查中 / 待验证 / 验证未通过 / 审查未定论 /
26
+ 已完成 / 失败 / 已停止)。以前会话侧自己抄了一张映射表(`stopped→error`、`pending→queued`),
27
+ 于是「卡片写已停止、这一行写上次调用失败」「卡片写待处理、这一行写排队中」——两边各说各话。
28
+ · **三档都是「状态」,且互斥**(见 qaTaskGroupOf):**状态优先,归档只对「不再跑」的行生效**:
29
+ - 审查中 = 需求正走审查这条线(= 需求看板「待验证」那一列的会话,见 qaTaskReviewing);
30
+ - 处理中 = **还没结束**的(排队 / 在跑)—— 哪怕它被归档过也留在这里(状态说了算);
31
+ - 已完成 = 跑完的(`status='done'`:干活轮过审 / 人工确认完成)**或**用户归档了它
32
+ (归档现在唯一的用途:把「失败 / 已停止」这类不再跑的会话收进已完成)。
33
+ · **「审查中」优先于「已完成」**,而「已归档」这一档**整个改名成「已完成」并接住 status=done**
34
+ —— 用户 2026-09-20 报的正是「审查中的任务显示到了(当时叫已归档的)那一档里」。
35
+ 行尾 ⋯ 的「归档 / 移出归档」只在**真会改变归属**时才出(见 qaTkMenuItems),不留空动作。
36
+ · **三档状态只装「需求派发的会话」**(`qaTaskIsReqConv`),计数与需求看板的列一一对应:
37
+ 处理中档 = 需求「待处理」列 + 「处理中」列;审查中档 = 「待验证」列;已完成档 = 「已完成」列。
38
+ **没绑需求(或绑的需求已被删)的普通对话单独进第 4 档「对话」**(空则不渲染那枚 tab)——
39
+ 用户 2026-09-20:「处理中的任务就应该跟需求数一致啊」:不分开的话左栏永远比需求多
40
+ (实测 已完成 56 vs 需求 50,多出来的就是首页直接发起的普通对话)。
41
+ · 归属只由**状态 + 用户动作**决定,且**不改 ts**(行尾时间与「最新 5 条」的基准原样保留)。
42
+ · 默认停在「处理中」档;「审查中 / 已完成」不切过去就看不见(= 旧契约里「已归档默认收起」)。
43
+ · 置顶行排在**本组最前**(置顶 = 钉在顶部,不另开第四个分组),行内带一枚朱砂图钉。
44
+ ④ **搜索 = 找历史会话的唯一入口**(2026-09-22 用户:「支持一下搜索功能」):
45
+ 搜索框在 `#nav-tasks` **之上**(`index.html` 里的静态节点,不随列表重绘消失,见 `.tk-search`)。
46
+ 关键词匹配 **会话标题 + 关联需求的标题 / 正文**(都来自内存里各自的清单,不额外发 IPC)。
47
+ ① **跨档搜索**:命中结果不再按 tab 分组(tab 计数本来就不含搜索条件,摆在搜索结果上方只会误导),
48
+ 命中的行按同一套排序(置顶优先 → ts 倒序)平铺,**上限同为 20 条**(与 ② 同一条口径);
49
+ ② 清空关键词 = 退回 tab 视图(搜索是**临时态**,不做持久化,切工作目录一并复位);
50
+ ③ **一次命中不到就如实说**(`.tk-empty`):换关键词,或去需求看板点卡片跳过去。
51
+ ⚠️ 「点需求跳转」时**要清掉搜索词**(见 qaTaskReveal):否则跳过去的那条会被搜索条件过滤掉,
52
+ 画布切了、左栏却看不到它(用户会认为没反应)。
53
+ ⑤ **组内排序**:置顶在最前,其余按 `ts` 倒序(与行尾那个时间戳同源)。
54
+ ⚠️ 归属哪些档由 ③ 的 `qaTaskGroupOf` 决定;本文件只负责「取哪一档的行、怎么排、列几条」。 */
55
+ /* 每档的**硬上限**(2026-09-22 用户:「任务对话区域永远最多显示20个」)——
56
+ 不是「默认值」、也没有「查看更多」能突破它(那条路径 2026-09-22 整体下线)。
57
+ 超出部分靠搜索 / 点需求卡片跳转找回,见文件头 ② ④。 */
58
+ const QA_TK_VISIBLE = 20;
59
+ let qaTaskTab = "active"; // 当前选中的胶囊 tab(active / review / done / chat)
60
+ /* 搜索关键词(**临时态**:不落 localStorage;清空 = 回 tab 视图;切工作目录一并复位)。
61
+ 非空时列表切进「搜索结果」形态:跨档平铺命中行、上限同样 20 条(见文件头 ④)。 */
62
+ let qaTkQuery = "";
63
+ /* 「这一次一定要露出来的那条」(点需求卡片 / 点列表跳转时由 qaTaskReveal 置位)——
64
+ 20 条上限生效时它优先占名额(见 qaTaskFold);qaTaskSwitch 接管成 qaActiveId 后即清空。 */
65
+ let qaTaskRevealId = "";
66
+ const QA_TK_GROUPS = [["active", "处理中"], ["review", "审查中"], ["done", "已完成"], ["chat", "对话"]];
67
+ /** 处理中/审查中的转圈进度环(2026-09-17 用户:「处理中的进度效果」)——
68
+ * 文案随**需求状态**走(跑审查轮时 aria-label 是「审查中」,不是「处理中」),见 qaTaskStatusOf。 */
69
+ function qaSpinHtml(label, title){
70
+ return `<span class="tk-spin" role="status" aria-label="${escHtml(label)}" title="${escHtml(title)}">`
71
+ + '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" pathLength="100" stroke-dasharray="57 100"/></svg></span>';
72
+ }
73
+ /* 行尾 ⋯(下拉操作入口)与置顶图钉(material push_pin,实心,走 fill:currentColor) */
74
+ const QA_TK_DOTS = '<svg viewBox="0 0 24 24" aria-hidden="true">'
75
+ + '<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
+ const QA_TK_PIN = '<span class="tk-pin" role="img" aria-label="已置顶" title="已置顶">'
77
+ + '<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
+ /** 行尾那枚状态标记(转圈 / 圆点)——**与需求状态 1 对 1**(用户 2026-09-20:「需求的状态为什么跟
79
+ * 任务状态不一致 应该1对1一致的啊」):
80
+ * 文案与状态一律取自 `qaTaskStatusOf`(需求派发的会话直接复用需求侧的 `reqStatusLabel`),
81
+ * 所以「需求卡片写已停止、这一行写上次调用失败」那种错位不会再出现;
82
+ * 颜色只有一档映射(转圈=在跑 · 琥珀=审查这条线 · 靛蓝=排队/待处理 · 朱红=失败/已停止 · 灰=已完成)。
83
+ * ⚠️ `status` 逐字进 class(`tk-st <st>`),样式见 app.css —— 新增状态记得同时加一条颜色。 */
84
+ function qaTaskSt(t){
85
+ const s = qaTaskStatusOf(t);
86
+ const title = s.detail ? `${s.label} · ${s.detail}` : s.label;
87
+ if(s.st === "running") return qaSpinHtml(s.label, title);
88
+ return `<span class="tk-st ${escHtml(s.st)}" title="${escHtml(title)}"></span>`;
89
+ }
90
+ /** 组内排序:**置顶在前**,其余按 ts 倒序(ts 同时是行尾那个时间戳,见 qaSaveSnapshotFor)。 */
91
+ function qaTaskOrder(a, b){
92
+ return ((b.pinned?1:0) - (a.pinned?1:0)) || ((b.ts||0) - (a.ts||0));
93
+ }
94
+ /* ---------- 归组:三档互斥,**状态优先**(用户 2026-09-20 报的就是「审查中的跑到已完成里」)---------- */
95
+ /** 这条会话是不是「审查中」—— 即需求看板「待验证」那一列里的会话。两个判据(任一成立即算):
96
+ * ① 需求 `status='verify'`:干活轮跑完了、等审查结论(含审查未通过 / 审查未给结论);
97
+ * ② 需求 `status='running'` 且 `phase='verify'`:审查轮**正在跑**(看板卡片标「审查中」)。
98
+ * ⚠️ 两个判据都走 `qaTaskStatusOf`(唯一口径):② 的 phase 要按 **reqId 反查需求**,
99
+ * 查不到(没这条需求 / 看板还没加载)就只按 ① 判,绝不猜 —— 猜错的代价是把一条普通会话
100
+ * 搬进「审查中」,比漏判更糟。 */
101
+ function qaTaskReviewing(t){
102
+ const s = qaTaskStatusOf(t);
103
+ return s.st === "verify" || (s.st === "running" && s.phase === "verify");
104
+ }
105
+ /** 这条会话算不算「**需求派发的会话**」—— 三档状态只装它们(计数才能与需求看板的列对上)。
106
+ * 判据:① 绑了需求(`t.reqId`,库里 `tasks.req_id`;老数据退回 id 前缀 `qa-req-<需求id>`);
107
+ * ② 那条需求**还在**(看板已加载时按 reqId 查得到)。
108
+ * ⚠️ 「绑了但查不到」有两种情形,处理方式不同:
109
+ * · **看板还没加载**(启动那一瞬 / 切目录的中间态)→ **先信绑定关系**,别把整列会话瞬移进「对话」档
110
+ * (否则每次刷新都会看到计数先跳一下);
111
+ * · **看板已加载、需求确实不在**(需求被删了)→ 这条只剩对话历史 → 归「对话」档
112
+ * (用户要的是「数字对得上」,留着它会让左栏比需求多一条)。
113
+ * ⚠️ 没绑需求的(首页直接发起的普通对话)→ 一律「对话」档。 */
114
+ function qaTaskIsReqConv(t){
115
+ const reqId = (t && (t.reqId || qaReqOfId(t.id))) || "";
116
+ if(!reqId) return false;
117
+ if(typeof qaReqItemOf === "function" && qaReqItemOf(reqId)) return true;
118
+ const loaded = (typeof qaReqBoardLoaded === "function") && qaReqBoardLoaded();
119
+ return !loaded;
120
+ }
121
+ /** 会话 → 归属的 tab(**互斥**,判据全部来自 `qaTaskStatusOf`):
122
+ * · 对话 = 不是需求派发的会话(没绑需求 / 绑的需求已删,见 qaTaskIsReqConv)—— 单独一档;
123
+ * · 审查中 = 审查这条线(见 qaTaskReviewing);
124
+ * · 处理中 = **还没结束**的(排队 / 在跑)—— 状态说了算,**归档不许把在跑的行挪走**;
125
+ * · 已完成 = 跑完的(`status='done'`)或**用户归档了的**(只有「不再跑」的行才吃这一档:
126
+ * 失败 / 已停止收进已完成,正是「归档」这枚动作现在唯一的用途)。
127
+ * ⚠️ 「在跑 + 已归档」这种组合(历史上真出现过:先归档、之后又被重新派发)必须落在「处理中」——
128
+ * 需求卡写着「处理中」而左栏那行躺在「已完成」里,就是用户报的「状态不一致」。 */
129
+ function qaTaskGroupOf(t){
130
+ if(!qaTaskIsReqConv(t)) return "chat";
131
+ if(qaTaskReviewing(t)) return "review";
132
+ const s = qaTaskStatusOf(t);
133
+ if(s.live) return "active";
134
+ if(s.st === "done" || t.archived) return "done";
135
+ return "active";
136
+ }
137
+ /* ---------- 搜索:关键词匹配 +「哪一条算命中」(见文件头 ④)----------
138
+ 匹配面 = 会话标题 + **关联需求的标题 / 正文**(需求派发的会话标题就是 LLM 归纳的要点,
139
+ 而正文里往往才是用户真正记得的那句话;两个清单都在内存里,不额外发 IPC)。
140
+ ⚠️ 大小写不敏感、前后空白不算内容(用户手输的空格不该让结果为空)。 */
141
+ function qaTkQueryTrim(){ return String(qaTkQuery || "").trim().toLowerCase(); }
142
+ function qaTkHit(t){
143
+ const q = qaTkQueryTrim(); if(!q) return false;
144
+ if(String(t.title || "").toLowerCase().includes(q)) return true;
145
+ const reqId = (t && (t.reqId || qaReqOfId(t.id))) || "";
146
+ if(!reqId) return false;
147
+ const it = (typeof qaReqItemOf === "function") ? qaReqItemOf(reqId) : null;
148
+ if(!it) return false;
149
+ return String(it.title || "").toLowerCase().includes(q)
150
+ || String(it.body || "").toLowerCase().includes(q);
151
+ }
152
+ /** 写关键词(**唯一写入口**):值没变、且输入框内容也一致时什么都不做(避免每敲一个字都重画)。 */
153
+ function qaTkQuerySet(v){
154
+ const next = String(v == null ? "" : v);
155
+ const box = $("#tk-search-input");
156
+ if(next === qaTkQuery){
157
+ if(box && box.value !== next) box.value = next; // 外部复位(切目录 / 跳转)时同步输入框
158
+ return false;
159
+ }
160
+ qaTkQuery = next;
161
+ if(box && box.value !== next) box.value = next;
162
+ const clear = $("#tk-search-clear");
163
+ if(clear) clear.hidden = !next;
164
+ qaTaskRender();
165
+ return true;
166
+ }
167
+ /** 清空搜索(跳转前 / 切工作目录时用)——顺带收起那枚「清空」按钮 */
168
+ function qaTkQueryReset(){ return qaTkQuerySet(""); }
169
+ /** 分组胶囊 tab(处理中 / 审查中 / 已完成 / 对话)——**一枚分段控件**,当前档是白底胶囊。
170
+ * 用户 2026-09-20:「3个状态的分组改成胶囊tab形式」+「已归档改成已完成」+「处理中的任务就应该跟需求数一致啊」。
171
+ * **三档状态 = 需求派发的会话,计数与需求看板的列一一对应**(处理中档 = 待处理列 + 处理中列;
172
+ * 审查中档 = 待验证列;已完成档 = 已完成列)——「数字对得上」是用户反复提的那条。
173
+ * 为此多出第 4 档「对话」:**没绑需求(或绑的需求已被删)的普通对话**单独放,不混进那三档,
174
+ * 否则左栏数永远比需求多(实测:已完成 56 vs 需求 50,多出来的 6 条就是首页直接发起的普通对话)。
175
+ * ⚠️ 三档状态**恒定渲染**(哪怕 0 条)——它是「状态筛选器」,计数 (0) 本身就是信息;
176
+ * 「对话」档**只在非空时出现**(用户要的是三档状态,没普通对话时不该多一枚 tab)。
177
+ * ⚠️ `data-group` 必须留着:它不只是给样式用的选择器,也是**组件契约** ——
178
+ * CT-53 用它断言分组、并在 `#nav-tasks .tk-tab[data-group="done"]` 里定位「已完成」那一档。
179
+ * ⚠️ `role=tablist / role=tab / aria-selected` 与需求看板页头那枚 `.req-seg` 同一套语义。 */
180
+ function qaTaskTabsHtml(counts){
181
+ const tabs = QA_TK_GROUPS
182
+ .filter(([key])=> key !== "chat" || (counts.chat || 0) > 0) // 「对话」空则不出现
183
+ .map(([key, label])=>{
184
+ const on = key === qaTaskTab;
185
+ const n = counts[key] || 0;
186
+ const tip = key === "chat" ? "没绑需求的普通对话(首页直接发起的)" : `${label}(需求侧同名状态的会话)`;
187
+ return `<button class="tk-tab${on ? " on" : ""}" type="button" role="tab" data-group="${key}"
188
+ data-n="${n}" data-testid="tk-tab-${key}" aria-selected="${on}"
189
+ title="${on ? "当前显示「" : "切到「"}${label}」(${n} 条)· ${tip}">${label}<span class="gn">(${n})</span></button>`;
190
+ }).join("");
191
+ return `<div class="tk-tabs" role="tablist" aria-label="对话分组">${tabs}</div>`;
192
+ }
193
+ /** 某档空时的提示(tab 是筛选器,切过去没有内容要**说清楚为什么**,不能只剩一片空白) */
194
+ function qaTaskEmptyHtml(key){
195
+ const txt = key === "review" ? "没有审查中的对话 —— 需求跑完干活轮、进入审查后会先落到这里"
196
+ : key === "done" ? "还没有已完成的对话 —— 跑完或被归档的会话会落到这里"
197
+ : key === "chat" ? "还没有普通对话 —— 首页直接发起的对话会落到这里"
198
+ : (qaTasks.length ? "该工作目录下没有进行中的对话 —— 跑完的都在「已完成」里"
199
+ : "还没有对话 —— 在首页发送第一条任务");
200
+ return `<div class="tk-empty">${txt}</div>`;
201
+ }
202
+ /** 切 tab(点当前档 = 无操作):只改左栏显示归属,不动会话 / 画布。
203
+ * ⚠️ 不改 ts、不 touch —— 切 tab 是纯浏览动作(同「切会话只存快照」那条契约)。 */
204
+ function qaTaskTabSet(key){
205
+ if(key === qaTaskTab) return false;
206
+ if(!QA_TK_GROUPS.some(g=>g[0] === key)) return false;
207
+ qaTaskTab = key;
208
+ qaTaskRender();
209
+ return true;
210
+ }
211
+ /** 超出 20 条时列表底部那行**说明**(2026-09-22 起取代「查看更多 / 收起」按钮)。
212
+ * 为什么是「说明」而不是「按钮」:用户要的是**固定上限**(「永远最多显示20个」),
213
+ * 并明确给了老对话的找回路径(「历史的能通过搜索或者点击需求跳转找到就行了」)——
214
+ * 所以这里只如实说清「还有多少条、去哪找」,不再提供一枚能突破上限的入口。
215
+ * ⚠️ 只改可见条数,**一条都不删**:历史全在 tasks.db / requirements 表里,搜索与需求跳转都能到。 */
216
+ function qaTaskCapNote(total, hidden){
217
+ if(total <= QA_TK_VISIBLE || hidden <= 0) return "";
218
+ return `<div class="tk-note" data-testid="tk-cap-note">仅显示最近 ${QA_TK_VISIBLE} 条 · `
219
+ + `还有 ${hidden} 条更早的(在上方搜索里找,或在需求看板点卡片跳过去)</div>`;
220
+ }
221
+ /** 搜索结果那头一行(跨档平铺时用,取代胶囊 tab —— tab 的计数不含搜索条件,摆在上方只会误导)。 */
222
+ function qaTaskHitNote(hits, shown){
223
+ const q = String(qaTkQuery || "").trim();
224
+ return `<div class="tk-note" data-testid="tk-search-note">搜索「${escHtml(q)}」· 命中 ${hits} 条`
225
+ + (shown < hits ? ` · 只显示最近 ${QA_TK_VISIBLE} 条` : "") + `</div>`;
226
+ }
227
+ /** 一行任务:标题 / 图钉(置顶) / 状态 / 引擎 / 时间 / 行尾下拉 ⋯。
228
+ * ⚠️ 行尾 ⋯ 是**行按钮内部的 span**(与它取代的 × 同款):嵌套 <button> 是非法 HTML,
229
+ * 点击仍走 #nav-tasks 上的事件委托,先于 .tk-item 分支命中(见 bindNavTasks)。 */
230
+ function qaTaskRowHtml(t){
231
+ return `<button class="tk-item${t.id===qaActiveId?" active":""}" data-id="${t.id}" type="button" title="${escHtml(t.title)}">
232
+ <span class="tico">${QA_TK_ICON}</span>
233
+ <span class="tt">${escHtml(t.title)}</span>
234
+ ${t.pinned ? QA_TK_PIN : ""}
235
+ ${qaTaskSt(t)}
236
+ <span class="tk-eng">${escHtml(t.engine||"claude")}</span>
237
+ <span class="tm">${qaTaskTime(t.ts)}</span>
238
+ <span class="tk-dots" data-menu="${escHtml(t.id)}" data-testid="tk-dots"
239
+ title="更多操作 · ${qaTkMenuItems(t).map(x=>x[1]).join(" / ")}">${QA_TK_DOTS}</span>
240
+ </button>`;
241
+ }
242
+ /** 「每档永远最多 20 条」这条规则的应用(见文件头 ②):返回本档本次要渲染的行 + 超出的条数。
243
+ * ① 先取排序后的前 `QA_TK_VISIBLE` 条;
244
+ * ② **保留规则**(当前会话 / 刚被跳转定位的那条 / 正在处理的 / 置顶的)优先占名额 ——
245
+ * 被挤出 20 名的保留项**顶掉最老的普通行**(不是把上限撑破:上限是硬的,见 ②)。
246
+ * ⚠️ 返回的 list **仍按原顺序**(置顶在前 → ts 倒序),顶名额只换「谁留下」,不动排序。 */
247
+ function qaTaskFold(rows){
248
+ const keepRule = (t)=> t.id === qaActiveId || t.id === qaTaskRevealId
249
+ || t.status === "running" || !!t.pinned;
250
+ if(rows.length <= QA_TK_VISIBLE) return { list: rows, hidden: 0 };
251
+ const take = new Set(rows.slice(0, QA_TK_VISIBLE).map(t=>t.id));
252
+ rows.filter(t=>keepRule(t) && !take.has(t.id)).forEach((t)=>{
253
+ for(let i = rows.length - 1; i >= 0; i--){ // 从最老的一头找可顶掉的行
254
+ const c = rows[i];
255
+ if(take.has(c.id) && !keepRule(c)){ take.delete(c.id); take.add(t.id); break; }
256
+ }
257
+ });
258
+ const list = rows.filter(t=>take.has(t.id));
259
+ return { list, hidden: rows.length - list.length };
260
+ }
261
+ function qaTaskRender(){
262
+ const box = $("#nav-tasks"); if(!box) return;
263
+ // workspace 过滤:左栏「任务 · 对话」只列当前工作目录下的会话
264
+ //(过滤只影响显示 —— qaTasks 仍持全量,避免切目录时丢掉其他目录的 DOM 快照)
265
+ // 排序按 ts 倒序(置顶优先):「最新的 20 个」以时间为准,不依赖数组插入顺序
266
+ const all = qaTasks.filter(t=>wsMatch(t.workspace)).slice().sort(qaTaskOrder);
267
+ if(!all.length){
268
+ htmlWrite(box, `<div class="tk-empty">${qaTasks.length
269
+ ? "该工作目录下还没有对话 —— 在首页发送第一条任务"
270
+ : "还没有对话 —— 在首页发送第一条任务"}</div>`);
271
+ return;
272
+ }
273
+ /* 搜索态(关键词非空):**跨档平铺命中行**(见文件头 ④)——
274
+ 此刻「当前是哪一档」不参与显示(tab 栏整体让位给那行命中计数:
275
+ tab 上的计数不含搜索条件,摆在上方会让人以为「搜索也按档筛选」)。 */
276
+ const searchKey = String(qaTkQuery || "").trim();
277
+ let html;
278
+ if(searchKey){
279
+ const hits = all.filter(qaTkHit);
280
+ const fold = qaTaskFold(hits);
281
+ html = qaTaskHitNote(hits.length, fold.list.length);
282
+ html += hits.length
283
+ ? fold.list.map(qaTaskRowHtml).join("") + qaTaskCapNote(hits.length, fold.hidden)
284
+ : `<div class="tk-empty" data-testid="tk-search-empty">没有匹配「${escHtml(searchKey)}」的对话 —— 换个关键词,或去需求看板点卡片跳到那条对话</div>`;
285
+ } else {
286
+ /* 一次分组、各档一份:**归组互斥**(qaTaskGroupOf),所以「审查中」的那几条
287
+ 不会同时出现在「已完成」或「处理中」里 —— 这正是用户 2026-09-20 报的那件事。
288
+ 计数与各档实际条数同源(同一个 groups 对象),不会出现「tab 上写 3 条、点进去只有 2 行」。
289
+ 三档状态只装**需求派发的会话**(计数与需求看板的列对得上),普通对话进「对话」档。 */
290
+ const groups = { active: [], review: [], done: [], chat: [] };
291
+ all.forEach(t=>{ groups[qaTaskGroupOf(t)].push(t); });
292
+ const counts = { active: groups.active.length, review: groups.review.length,
293
+ done: groups.done.length, chat: groups.chat.length };
294
+ /* 「对话」档空时不渲染那枚 tab —— 用户正停在这一档(最后一条普通对话被删 / 被归档走了)时
295
+ 要退回「处理中」,否则会停在一档已经不存在的视图上(tab 栏里没有它、列表也不该显示它)。 */
296
+ if(!counts.chat && qaTaskTab === "chat") qaTaskTab = "active";
297
+ const rows = groups[qaTaskTab] || [];
298
+ const fold = qaTaskFold(rows);
299
+ html = qaTaskTabsHtml(counts);
300
+ html += rows.length
301
+ ? fold.list.map(qaTaskRowHtml).join("") + qaTaskCapNote(rows.length, fold.hidden)
302
+ : qaTaskEmptyHtml(qaTaskTab);
303
+ }
304
+ /* 幂等写(见 01-core.js 的 htmlWrite):**渲染结果没变就不碰 DOM**。
305
+ 运行中每 700ms 一次的看板同步(qaReqConvSync → qaTaskRender)都会调到这里,
306
+ 而正在跑的那条的**行内容**其实没变(变的是会话快照,快照不上行)——
307
+ 整份重建纯属白做,还会打散 hover 态与行尾菜单锚点、让条目在「最近 20 条」的边界线上
308
+ 反复进出(用户 2026-09-19:「处理中的任务显示一直在变 一会消失一会又显示在第一条」)。
309
+ 写了才做下面那步锚点补偿:没重建就不存在「⋯ 是新节点」的问题。 */
310
+ if(!htmlWrite(box, html)) return;
311
+ /* 列表被整份重建后,行尾菜单的锚点(那枚 ⋯)是**新节点** —— 不补这一步会出现
312
+ 「菜单还浮在原位、但行尾已经没有 ⋯ 是热的」这种脱钩态。锚点还在就补回打开态,
313
+ 锚点随这次重绘消失(归档/切目录/被折叠)就顺手收掉菜单。 */
314
+ if(qaTkMenuFor !== null){
315
+ const dots = box.querySelector(`.tk-dots[data-menu="${qaTkMenuFor}"]`);
316
+ if(dots) dots.dataset.open = "1"; else qaTkMenuClose();
317
+ }
318
+ }
319
+ /** 新对话开始:登记任务(标题 = prompt 首行压缩截断;引擎/模型/会话id/状态随任务落库。
320
+ * sessionId 此时为空 —— 首问由引擎新建会话,result 事件回传 id 后经 qaSessionBind 写回)
321
+ * @param {string} model 这条任务用哪个模型(调用方按 `qaModelFor(from, ...)` 算好传来,
322
+ * 见 04-task-list.js)。**1 任务 = 1 模型**:它从此就是这条任务的模型,任务对话里只显示不改。
323
+ * 缺省回落 qaModel(老调用方 / 极端兜底),不编值。 */
324
+ function qaTaskBegin(title, model){
325
+ const clean = String(title || "").replace(/\s+/g, " ").trim().slice(0, 30);
326
+ const m = String(model == null ? qaModel : model || "");
327
+ const id = "qa-" + Date.now();
328
+ const t = { id, title: clean || "新对话", snapshot: "", ts: Date.now(),
329
+ engine: qaEngine, model: m, sessionId: "", status: "running",
330
+ workspace: wsDirOf() }; // 归属当前工作目录(切换 workspace 时按它过滤)
331
+ qaTasks.unshift(t);
332
+ qaTasksTrim(); // 上限 = 淘汰**最老的**(见 04-task-list.js 的 qaTasksTrim)
333
+ qaActiveId = id;
334
+ /* 左栏跟着切到「对话」档(2026-09-23 用户:「跳转对话框的时候 队列列表的tab也要自动切换到正确状态的tab」)。
335
+ 新会话不是需求派发的(无 reqId → qaTaskGroupOf 恒为 "chat"),若此刻 tab 停在
336
+ 「处理中 / 审查中 / 已完成」档,刚开的这条在左栏里**看不见**,观感是「发出去就没了」。
337
+ 统一走 qaTaskReveal(切档 + 露行 + 顺带清搜索),与看板跳转/派发自动进入同一套机制;
338
+ 前面刚 unshift 的这条 ts 最新、必在名额内,reveal 只是保个险。 */
339
+ try{ qaTaskReveal(id); }catch(e){ qaTaskTab = "chat"; qaTaskRender(); }
340
+ /* 显示值跟上这条任务(生成中胶囊 / +菜单提示 / 只读标签都读 qaModel)——
341
+ 任务对话那枚只读标签显示的正是「这条任务用的模型」。 */
342
+ if(typeof qaModelSync === "function") qaModelSync();
343
+ qaTaskRender(); qaTaskPersist(); qaDbCreate(t);
344
+ }
345
+ /* opts.touch === false:**只存快照,不刷新「最近活动时间」**。
346
+ 为什么必须区分(2026-09-18 用户:「点任务对话的任务时顺序会变化,不应该变化」):
347
+ 左栏顺序 = ts 倒序(qaTaskRender),而 ts 同时是行尾那个时间戳。切会话若顺手刷新 ts,
348
+ 刚被切走的那条会瞬间跳到列表最上面 —— 顺序随每次点击乱跳,落库还会连 updated_at 一起污染。
349
+ 所以「纯浏览(切会话)」不 touch,「真的有新内容(发送 / 流式收尾 / 失败)」才 touch。 */
350
+ function qaTaskSaveSnapshot(status, opts){ qaSaveSnapshotFor(qaActiveId, status, opts); }
351
+ /** 按**指定会话**存快照(流式 end / 失败 / 切换前 / 后台那条跑完时都用它)。
352
+ * ⚠️ 两个坑:
353
+ * ① 画布可能被摘到后台 run 的 holder 里(用户切走了)→ 必须从 holder 取 HTML,
354
+ * 否则存下来的是**当前显示的那个会话**的画面(张冠李戴);
355
+ * ② 这条会话**还在跑**时不许把状态从 running 改写成 done(切走时就是这么调的)——
356
+ * 判据是「它有没有在飞的 run」,而不是调用方传没传 status。 */
357
+ function qaSaveSnapshotFor(taskId, status, opts){
358
+ if(!taskId) return;
359
+ const t = qaTasks.find(x=>x.id===taskId); if(!t) return;
360
+ const run = qaRunOfTask(taskId);
361
+ // 落笔处:优先用调用方给的 holder(**收尾**时 run 可能刚被摘出注册表,但画布还留在 holder 里,
362
+ // 不显式传就会去读「当前显示的那条会话」的画布 —— 张冠李戴)。
363
+ const holder = (opts && opts.holder) || (run && run.holder) || null;
364
+ const off = !!holder;
365
+ const s = off ? holder : $("#qa-stream");
366
+ t.snapshot = (s && (off || !s.hidden)) ? s.innerHTML : "";
367
+ if(status) t.status = status;
368
+ /* 「这一轮跑完但状态还是 running」→ 落 done:**只对普通对话**成立。
369
+ 需求派发出来的会话(`t.reqId` 非空)的状态**由需求说了算**(1 对 1,见 qaTaskStatusOf)——
370
+ 需求还在跑(`status='running'`)时,这里把会话改成 done 就等于「需求卡写着处理中、左栏那行
371
+ 写着已完成」,正是用户 2026-09-20 报的那种不一致;而且这条会话此刻**可能压根没有 qaRun**
372
+ (壳重载过 / 事件对不上号 / 是另一个 workspace 的派发),`!run` 这个判据会误判成「跑完了」。
373
+ 需求侧的状态变化由看板同步刷(`qaReqConvSync`),这里绝不越权改写。 */
374
+ else if(!t.reqId && t.status === "running" && !run) t.status = "done";
375
+ const touch = !(opts && opts.touch === false);
376
+ if(touch) t.ts = Date.now();
377
+ qaTaskPersist(); qaDbUpdate(t, touch ? null : { touch:false });
378
+ /* 消息台账(2026-09-22):**持久层存的是消息,不是上面那份 HTML 快照**(见 27-conv-log.js)。
379
+ 收尾的五个点(end / error / 停止 / 调用失败 / 切走)全都汇到这里,所以在这里收口 ——
380
+ 只写一处,漏不掉。push 自带幂等(同人同文不重复记、正文变长改写末尾那条),
381
+ 所以「中途切走只拿到半截正文」与「收尾被调用多次」两种情形都不会写脏。 */
382
+ if(typeof qaConvTurnSync === "function"){
383
+ const r = run || qaRunOfTask(taskId);
384
+ /* 过程步骤(2026-09-23):随消息一起落库。steps 用 run.stepsSaved 记「已保存到第几条」——
385
+ 收尾点可能被调多次(end 之后又 stop、中途切走后跑完),只传没落过库的增量,
386
+ 重复收尾不会把同一段思考/工具写两遍。 */
387
+ let steps = null;
388
+ if(r && Array.isArray(r.logSteps)){
389
+ steps = r.logSteps.slice(r.stepsSaved || 0);
390
+ r.stepsSaved = r.logSteps.length;
391
+ }
392
+ qaConvTurnSync(taskId, r ? r.userText : "", r ? r.msgText : "", steps);
393
+ }
394
+ }
395
+ function qaSessionBind(sessionId){ qaSessionBindFor(qaActiveId, sessionId); }
396
+ /** 把 magic-agent 返回的会话 id 写回**指定会话**(生成中切走、那条后台跑完时也要写对,
397
+ * 绝不能写「当前显示的那条」—— 否则会话锚点会挂到别的会话上)。 */
398
+ function qaSessionBindFor(taskId, sessionId){
399
+ // 把 magic-agent 返回的会话id 写回当前任务:localStorage 快照 + SQLite 同步落库,
400
+ // 追问时经 ask({ sessionId }) 续接同一会话(1 任务 = 1 会话的核心链路)。
401
+ if(!sessionId || !taskId) return;
402
+ const t = qaTasks.find(x=>x.id===taskId); if(!t || t.sessionId === sessionId) return;
403
+ t.sessionId = sessionId;
404
+ if(t.sidLost) delete t.sidLost; // 拿到 id → 不再需要 `-c` 兜底
405
+ qaTaskPersist(); qaDbUpdate(t, { sessionId });
406
+ }
407
+ /* 本轮结束(成功/失败/被停)后仍没有会话 id → 记一笔 sidLost。
408
+ 为什么要记:`session_id` 只在 CLI 的 result 行里给,中途被停/超时的那轮永远拿不到它;
409
+ 没有这个标记,下一轮追问就只能从零开一个新会话(引擎失忆 = 用户看到的「没了 session」)。
410
+ 有这个标记 → 追问时下发 `-c`,续接该目录最近一次会话(引擎自己那条),至少接得回去。 */
411
+ function qaTaskMarkSidLost(){ qaTaskMarkSidLostFor(qaActiveId); }
412
+ function qaTaskMarkSidLostFor(taskId){
413
+ const t = qaTasks.find(x=>x.id===taskId); if(!t) return;
414
+ if(t.sessionId){ if(t.sidLost) delete t.sidLost; } else t.sidLost = 1;
415
+ qaTaskPersist();
416
+ }
417
+ /* 没有会话 id 时要不要带 `-c`(续接「该目录最近一次会话」):
418
+ ① 必须**真的跑过一轮且没拿到 id**(sidLost)—— 新建会话的第一轮绝不带,
419
+ 否则会把同目录里别的会话接过来(串台);
420
+ ② 引擎门槛:magic-agent 的 `-c` 在本机 CLI 系引擎(claude / codebuddy / trae / codex / llm)
421
+ 成立;arkclaw 明确不支持(CLI 帮助原文:「A2A 无『查询最近上下文』接口,请显式传 --session」),
422
+ openclaw 的会话与 agent 绑定、连 cwd 都被忽略 → 这两个不下发,维持原行为;
423
+ codebuddy-gateway 同样不支持(网关没有「查询最近会话」的接口,CLI 会显式报错)→ 也不下发。
424
+ ⚠️ 若上游 CLI 以后在 `--engines` 里给出这类能力字段,这张表应换成读字段(别长期硬编码)。 */
425
+ const QA_CONTINUE_ENGINES = ["claude", "codebuddy", "trae", "codex", "llm"];
426
+ function qaContinueRecent(t){
427
+ if(!t || t.sessionId || !t.sidLost) return false;
428
+ return QA_CONTINUE_ENGINES.indexOf(String(qaEngine || "")) >= 0;
429
+ }
430
+ /* 切入任务对话界面时,把该任务自己的 **engine / model** 还原回当前上下文,
431
+ 并同步两处下拉(home2=工作台首页 / home=任务对话)。
432
+ 为什么模型也要还原(2026-09-21 用户定稿:「还是都可以选择模型 但是自动切换模型的时候
433
+ 对话框下拉的选中模型显示也要变」):**1 任务 = 1 会话 = 1 模型** —— 模型在新建任务那一刻
434
+ 定下(工作台首页那枚下拉 / 或「自动」),进了这条任务的对话可以在那枚下拉里改(改的是
435
+ **这条任务**),自动切换时也会被主进程改掉。
436
+ 不还原的话:点了 A 任务、下拉却显示着 B 任务(或首页)的模型 —— 界面与行为对不上。 */
437
+ function qaTaskApplyModel(t){
438
+ if(!t) return;
439
+ const eng = t.engine || "claude";
440
+ if(eng !== qaEngine){
441
+ qaEngine = eng;
442
+ try{ localStorage.setItem("qaEngine", qaEngine); }catch(err){}
443
+ /* 引擎变了 → 该引擎的模型链必须重取(「自动」档用链首,且链是跟着引擎走的)。
444
+ ⚠️ 这里**不 await**:本函数在「点侧栏那条会话」的链上是同步调用的,阻塞一次 IPC
445
+ 会让切会话卡一下;链取回后显示值落定,随后的发送自然用新值。 */
446
+ if(typeof qaModelChainLoad === "function") qaModelChainLoad(qaEngine, true);
447
+ }
448
+ /* 显示值 = 这条任务自己的模型(`qaModelSync()` 走 qaModelFor("home", qaActiveId)) */
449
+ if(typeof qaModelSync === "function") qaModelSync();
450
+ /* ⚠️ qaPickerSync 是**绑定函数内部的局部 const**(在下面定义),全局作用域的
451
+ qaTaskApplyModel 直接取会抛 ReferenceError —— 后果是「点侧栏任务毫无反应」:
452
+ 异常在切视图/还原画布/重画左栏那几行之前就把整条链打断了。
453
+ 实测(2026-09-18 用户:「点击任务对话的任务时 现在顺序会变化 不应该变化」):
454
+ 点一条**引擎与当前不同**的会话(例:当前 codebuddy,点一条 claude 的)时抛
455
+ 「qaMdlMenuBuild is not defined」—— qaActiveId 已经改了、qaEngine 也切了,但画布没还原、
456
+ 视图没切、左栏也没重画 → 列表里那条行尾时间/高亮与真实状态错位,看起来像「点了顺序变了」。
457
+ 按本项目既有惯例(见 showView / __qaMenuClose)守卫 + 定义处补一次全局暴露,
458
+ 两头都做 → 无论谁先执行都不会抛。更彻底的根治仍是把它提到全局作用域。 */
459
+ if(typeof qaPickerSync === "function") qaPickerSync(); // 同步两处按钮文案 + 菜单 .on 高亮
460
+ }
461
+ /** 把**某条任务**的模型改成主进程实际在跑的那个(2026-09-21)。
462
+ * 什么时候会发生:① 配置里的模型链自动降级了 —— 主进程换了下一个候选并跑通
463
+ * (`start` 事件带回实际模型);② 用户在那条任务的对话里手选了一个(`qaModelSet` 走这里)。
464
+ * 必须把任务上记的模型改掉,否则:
465
+ * · 任务对话那枚下拉还写着那个已经跑不通的模型(用户看到的与真跑的对不上);
466
+ * · 下次追问又先试那个死掉的模型,白烧一次失败尝试。
467
+ * 与需求派发侧 `reqboard::promoteWorkingModel` 是同一条口径(那边同步的是需求自己的 model)。
468
+ * ⚠️ 只在**真的不同**时才写库(每轮都写一次是纯噪声,还会把 ts 搅动)。
469
+ * ⚠️ `model === ""` 是**合法值**(用户在那条任务的下拉里选了「自动」)→ 把任务上的模型清空,
470
+ * 之后按 `qaModelFor` 回落「自动」(配置链的链首)。别拿「空串 = 没传」把它挡掉。 */
471
+ function qaTaskSetModel(taskId, model){
472
+ const t = Array.isArray(qaTasks) ? (qaTasks.find(x=>x.id === taskId) || null) : null;
473
+ const m = String(model || "");
474
+ if(!t || String(t.model || "") === m) return false;
475
+ t.model = m;
476
+ qaTaskPersist(); qaDbUpdate(t, { touch:false }); // 改模型不算「一次新活动」→ 不动 updated_at(左栏顺序不跳)
477
+ if(t.id === qaActiveId) qaModelSync(); // 正在显示的这条 → 下拉文案与选中项当场跟着改
478
+ return true;
479
+ }
480
+ /** 无快照时的兜底画面:按这条会话所属的**需求**(`reqId`)现拼一份简版。
481
+ * 为什么需要(2026-09-20):左栏的条数与计数自本次起 = **库内全量**(见 04-task-list.js
482
+ * 的文件头),而 `QA_TK_MAX` 只决定「几条会话**带快照**」—— 被摘掉快照的历史会话
483
+ * (以及刚重启还没从 localStorage 取回快照的那几条)点开时快照是空的,
484
+ * 没有这层兜底就是一片空白,看着像「会话内容丢了」。
485
+ * 兜底只对**需求派发**的会话成立(`qa-req-<需求id>`,需求正文与逐轮答复都在 reqItems 里);
486
+ * 手动对话没有需求可依 → 返回空串,维持原来的空态画布(那是真的没有历史可还原)。 */
487
+ function qaTaskRebuildSnap(t){
488
+ if(!t || !t.reqId) return "";
489
+ try{
490
+ const it = ((typeof reqItems !== "undefined" && reqItems) || []).find(x=>x.id === t.reqId);
491
+ return it ? qaReqConvSnapshot(it) : "";
492
+ }catch(e){ return ""; }
493
+ }
494
+ /** 把**某条会话的历史**画进画布(点当前会话 / 切到别的会话共用这一份)。
495
+ * 三级找回,逐级降级(口径与实现在 27-conv-log.js 的文件头):
496
+ * ① `t.snapshot` —— **本会话内存里的画面**:最快,且**保真**(工具卡 / 思考区 / 截图 / 提问卡都在)。
497
+ * 它是**会话内的画面缓存**,不是历史来源(历史来源只有一处:SQLite 的消息表)。
498
+ * ② `qaTaskHistoryRecover` —— **SQLite 消息表**(按 task_id 读)→ 重画;
499
+ * 需求派发的会话则由需求表现拼(`qaTaskRebuildSnap`,那份数据同样在 SQLite 里)。
500
+ * ③ 都没有 → 画一条**说明**(`qaConvLostText`),说清为什么看不到。
501
+ * ⚠️ **没有第四级兜底**(用户 2026-09-22:「不需要兜底 就是历史读 sqllite」):
502
+ * 曾经读过引擎自己的会话文件(`<会话id>.jsonl`)当最后一级兜底,已按要求删除。
503
+ *
504
+ * ⚠️ ②是异步的:先画一条「正在恢复…」占位,结果回来时**只在用户还停在这条会话上**才落笔
505
+ * (`qaActiveId` 变了就丢弃 —— 否则会把 A 的历史画到 B 的会话里)。
506
+ * ⚠️ 调用方负责传对 `replace`:切到别的会话 = 换掉画布(true);
507
+ * 点当前会话 = 画布已有东西就别清(false,与旧行为一致:非空不动)。
508
+ * ⚠️ 恢复出来的画面**不写回 `t.snapshot`**:那是「这一次运行里真实渲染过的东西」的缓存,
509
+ * 而恢复的是「按消息重画的近似画面」—— 混进缓存里会让「哪份是真的」说不清。
510
+ * @returns {boolean} 画布上现在是否有内容 */
511
+ function qaTaskPaint(t, s, opts){
512
+ if(!t || !s) return false;
513
+ const replace = !!(opts && opts.replace);
514
+ if(!replace && s.innerHTML) return true; // 点当前会话:画布非空就不动(旧口径)
515
+ if(t.snapshot){ s.innerHTML = t.snapshot; s.hidden = false; qaDropRunPills(s); return true; }
516
+ const reqSnap = qaTaskRebuildSnap(t); // ② 需求派发:按需求现拼(数据在 SQLite 需求表里)
517
+ if(reqSnap){ t.snapshot = reqSnap; s.innerHTML = reqSnap; s.hidden = false; qaDropRunPills(s); return true; }
518
+ /* ② 异步读 SQLite 的消息表:台账先读起来(顺带把这条会话的历史种进内存台账,用户接着追问时接得上) */
519
+ if(typeof qaMsgLogLoad === "function") qaMsgLogLoad(t.id);
520
+ const id = t.id;
521
+ s.innerHTML = qaConvNoticeHtml("正在读取这条对话的历史…", "wait");
522
+ s.hidden = false;
523
+ qaTaskHistoryRecover(t).then((html)=>{
524
+ if(qaActiveId !== id) return; // 用户已经切走 → 丢弃这次结果
525
+ const box = $("#qa-stream"); if(!box) return;
526
+ if(html){
527
+ /* 直接把消息画出来 —— **不挂任何来源声明**(用户 2026-09-22:「以下内容来自本地消息库
528
+ (按当前界面重新绘制) 这个声明不需要」):历史本来就是这条会话自己的,读者不需要
529
+ 被告知它是从哪儿读出来的;只有**真的没有**历史时才说话(见下面的 lost 分支)。 */
530
+ box.innerHTML = html;
531
+ box.hidden = false; qaDropRunPills(box);
532
+ }else{
533
+ box.innerHTML = qaConvNoticeHtml(qaConvLostText(t), "lost");
534
+ box.hidden = false;
535
+ }
536
+ qaSyncChatMode(); qaScrollIntoView();
537
+ }).catch((e)=>{ console.warn("[conv] 历史读取失败:", (e && e.message) || e); });
538
+ return true;
539
+ }
540
+ function qaTaskSwitch(id){
541
+ if(id === qaActiveId){
542
+ qaTaskRevealId = ""; // 定位的临时名额已无用(它本来就是当前会话)
543
+ qaTaskApplyModel(qaTasks.find(x=>x.id===id)); // 当前任务也确保选择器显示其模型
544
+ // 点击当前活动任务 ≠ 无操作:仍要保证「看见对话」——切视图 + 快照就地恢复。
545
+ // (2026-09-16 用户反馈:仅一条任务时点侧栏毫无反应,历史看不见 —— 早退太激进)
546
+ const t = qaTasks.find(x=>x.id===id);
547
+ const s = $("#qa-stream");
548
+ if(t && s) qaTaskPaint(t, s, { replace:false });
549
+ if(typeof showView === "function" && state && state.view !== "chat") showView("chat");
550
+ qaSyncChatMode();
551
+ qaScrollIntoView();
552
+ qaAskFlush(id); // ⑤ 切回这条会话:排队等发的提问卡答案现在可以发了
553
+ qaMsgQueueRender(id); // 队列条目跟着会话走:画回这一条的待发清单
554
+ qaMsgFlush(id); // 排队的下一条:这条已空闲就自动提交
555
+ return;
556
+ }
557
+ /* 生成中**也允许切换**(2026-09-18 用户:「任务列表点击显示回答生成中 不能切换 为什么呢?
558
+ 肯定可以切换另1个会话去啊」)。老代码在这里硬拦,因为画布只有一份、增量会写串。
559
+ 现在把「正在跑的那条」的画布**整份摘到它自己的 holder**:节点不销毁 →
560
+ 后台增量继续写进这些节点,切回来直接挂回 #qa-stream,内容比快照还新。
561
+ ⚠️ 不许在这里把「还在跑的那条」状态改写成 done —— qaSaveSnapshotFor 按「有没有在飞的 run」判断。 */
562
+ const prevRun = qaRunOfTask(qaActiveId);
563
+ qaTaskSaveSnapshot(null, { touch:false }); // 先存旧的(纯切会话:不刷新 ts → 左栏顺序不动)
564
+ if(prevRun){
565
+ if(!prevRun.holder) prevRun.holder = document.createElement("div");
566
+ const prev = $("#qa-stream");
567
+ if(prev) while(prev.firstChild) prevRun.holder.appendChild(prev.firstChild); // 画布跟着会话走
568
+ }
569
+ const t = qaTasks.find(x=>x.id===id); if(!t) return;
570
+ qaActiveId = id;
571
+ qaTaskRevealId = ""; // 定位的临时名额交还给 qaActiveId(保留规则里的同一档待遇)
572
+ qaTaskApplyModel(t); // 还原该任务的 engine/model 到选择器
573
+ const s = $("#qa-stream");
574
+ const run = qaRunOfTask(id);
575
+ if(s){
576
+ if(run && run.holder){ // 切回一条**还在跑**的会话:把它的画布挂回来
577
+ while(run.holder.firstChild) s.appendChild(run.holder.firstChild);
578
+ run.holder = null; // 回到可见画布 → holder 交还(qaRunHost 自动改回落笔处)
579
+ s.hidden = false;
580
+ } else qaTaskPaint(t, s, { replace:true });
581
+ }
582
+ qaSyncBusy(); // 撰写区文案跟着「当前显示的是哪条」重算
583
+ // 切到「任务对话」视图(用户在别的视图里点会话也要看见聊天窗口)
584
+ if(typeof showView === "function" && state && state.view !== "chat") showView("chat");
585
+ // 按快照有无内容决定两态(空态气泡引导 / 对话态撑满)
586
+ qaSyncChatMode();
587
+ qaScrollIntoView();
588
+ qaTaskRender();
589
+ /* ⑤ 提问卡排队等发的答案:**放在画布切换之后**(早于它,qaSend 画的气泡会被
590
+ 紧接着的 `s.innerHTML = t.snapshot` 整份抹掉)。此刻 qaActiveId 已是这条会话,
591
+ qaAskFlush 的「正看着这条吗」判据成立。
592
+ ⚠️ 待发队列同一条道理,也放在这里:先重画「这一条的待发清单」,空闲才自动提交下一条。 */
593
+ qaMsgQueueRender(id);
594
+ qaAskFlush(id);
595
+ qaMsgFlush(id);
596
+ }
597
+ /* ---------- 左栏定位(需求看板点卡片 → 跳到那条对话,2026-09-20)----------
598
+ 用户原话:「点击需求看板中的任务可以定位跳到对话」。
599
+ 「定位」不只是 qaTaskSwitch(它负责切视图 + 还原画布 + 给行挂 .active)——
600
+ 还得**把那一行真的露出来**:左栏有两层(tab 切档 / 20 条上限),
601
+ 一条旧会话完全可能既不在「处理中」的前 20 条里、又躺在默认不显示的那两档里
602
+ (审查中 / 已完成)。只说「切过去了」而那一行看不见,用户会认为没反应。
603
+ ⚠️ 归属哪一档**必须问 qaTaskGroupOf**,别自己按 archived 判 —— 「已完成」现在是
604
+ 「跑完的 ∪ 归档的」,「审查中」还优先于它(见该函数)。
605
+ ⚠️ 20 条上限是**硬的**(2026-09-22 起没有「查看更多」能突破它)→ 露出来的办法是
606
+ ① 切到它所在的档、② 让 qaTaskRevealId 在本次渲染里优先占一个名额(qaTaskFold 的保留规则)。
607
+ 不做「临时把上限撑大」这种事:那会让「永远最多 20 个」这条契约当场失效。
608
+ ⚠️ 顺带**清掉搜索词**:带着搜索条件跳过去,目标行会被过滤掉(左栏空、画布却切了)。 */
609
+ function qaTaskReveal(id){
610
+ const t = qaTasks.find(x=>x.id===id); if(!t) return false;
611
+ qaTkQueryReset();
612
+ qaTaskTab = qaTaskGroupOf(t); // ① 切到它所在的那一档
613
+ /* ② 让它在本次渲染里**优先占一个名额**(qaTaskFold 的保留规则)——本档 >20 条时,
614
+ 它可能落在上限之外,不置位就是「切过去了但那一行看不见」。 */
615
+ qaTaskRevealId = id;
616
+ qaTaskRender();
617
+ return true;
618
+ }
619
+ let qaTkFlashTimer = null;
620
+ /** 把定位到的那一行滚进视口并闪一下(1.5s 后自动褪去)。
621
+ * 为什么要闪:跳转后左栏可能只是多了一行 `.active`(几十行里很难一眼认出是哪条)。
622
+ * ⚠️ 每次先摘类再强制重排,否则连点两条时第二条不会重播动画(动画只在加类的那一刻触发)。 */
623
+ function qaTaskFlash(id){
624
+ const el = document.querySelector(`#nav-tasks .tk-item[data-id="${id}"]`);
625
+ if(!el) return false;
626
+ try{ el.scrollIntoView({ block:"nearest", behavior:"smooth" }); }
627
+ catch(e){ try{ el.scrollIntoView(); }catch(err){ /* 老引擎没有这个 API */ } }
628
+ el.classList.remove("tk-flash");
629
+ void el.offsetWidth;
630
+ el.classList.add("tk-flash");
631
+ clearTimeout(qaTkFlashTimer);
632
+ qaTkFlashTimer = setTimeout(()=>{
633
+ const n = document.querySelector("#nav-tasks .tk-item.tk-flash");
634
+ if(n) n.classList.remove("tk-flash");
635
+ }, 1500);
636
+ return true;
637
+ }
638
+ function qaTaskDelete(id){
639
+ const i = qaTasks.findIndex(x=>x.id===id); if(i < 0) return;
640
+ const gone = qaTasks[i];
641
+ qaTasks.splice(i, 1);
642
+ qaDbDelete(id);
643
+ if(gone && gone.reqId) qaReqConvDropMark(gone.reqId); // 需求派发出来的会话:删了别再自动建回来
644
+ qaMsgQueueDropFor(id); // 这条会话排的待发条目一并丢掉(不留悬挂队列)
645
+ if(qaActiveId === id){
646
+ qaActiveId = null;
647
+ const s = $("#qa-stream"); if(s){ s.innerHTML = ""; s.hidden = true; }
648
+ qaSyncChatMode();
649
+ // 自动切到最近一条(若有)
650
+ if(qaTasks.length) qaTaskSwitch(qaTasks[0].id);
651
+ }
652
+ qaTaskRender(); qaTaskPersist();
653
+ toast("已删除该对话");
654
+ }
655
+ /* ---------- 置顶 / 归档(用户 2026-09-18:「也支持置顶」+「列表支持分组 处理中和已归档」)----------
656
+ 两者都**只改显示归属,不动 ts**:行尾时间与「最新 5 条」的基准必须原样保留,
657
+ 所以落库走 patch.touch:false(同「切会话只存快照」那条契约)—— 动了 updated_at,
658
+ 重启后这条会话的时间戳就变成了「你点置顶的那一刻」,明显是错的。
659
+ 归档也不改 status:它表达的是「用户把这条收起来了」,不是「这条跑成什么样了」。
660
+ ⚠️ 2026-09-20 起那一档改叫「已完成」(用户:「已归档改成已完成」):**跑完的(status=done)
661
+ 本来就在里面**,归档是给「还没跑完但我不想再看见它」的那几条留的出路(失败 / 被停 / 排队中)
662
+ —— 所以这枚动作依旧有用,不是个空按钮。归档过的会话归属见 qaTaskGroupOf。
663
+
664
+ ⚠️ 落库**必须验结果**(用户 2026-09-20 报障:「点击归档无效果 重启又丢失了
665
+ 没更新sqllite中状态吗」):这一支以前不接 qaDbUpdate 的返回值,而主进程回 {ok:false}
666
+ (典型原因:tasks.db 里没有这一行 —— 老库没同步 / 上次 create 失败 / 库被清过)
667
+ 时界面照样归了档、库里还是 0,重启时 qaDbLoad 以库为准一覆盖,归档就**静默没了**。
668
+ 现在的三步:① 照常 update;② 回 {ok:false} → 用 create 把这一行**补建**出来再 update 一次;
669
+ ③ 还写不进去 → 内存与 localStorage 一起**回滚**(不留「界面归了档、库里没有」的错位),
670
+ 并明说这次没写进库 —— 宁可让用户看见失败,也不假装成功。 */
671
+ async function qaTaskFlag(id, field, on, msg){
672
+ const t = qaTasks.find(x=>x.id===id); if(!t) return;
673
+ const prev = t[field];
674
+ if(on) t[field] = 1; else delete t[field];
675
+ qaTaskPersist();
676
+ qaTaskRender();
677
+ const patch = { touch:false }; patch[field] = on ? 1 : 0;
678
+ let r = await qaDbUpdate(t, patch);
679
+ if(!r.ok && !r.skipped){
680
+ await qaDbCreate(t); // 库里没这一行 → 补建(已存在则它自己回 ok:false,无害)
681
+ r = await qaDbUpdate(t, patch);
682
+ }
683
+ if(!r.ok && !r.skipped){
684
+ if(prev === undefined) delete t[field]; else t[field] = prev;
685
+ qaTaskPersist();
686
+ qaTaskRender();
687
+ toast(actionLabel(field, on) + "未写入本地数据库,已撤销");
688
+ return;
689
+ }
690
+ if(msg) toast(msg);
691
+ }
692
+ /** 动作文案(成功 toast 与失败提示同一份口径,别两处各写一遍) */
693
+ function actionLabel(field, on){
694
+ return field === "pinned" ? (on ? "置顶" : "取消置顶") : (on ? "归档" : "移出归档");
695
+ }
696
+ function qaTaskPin(id, on){ qaTaskFlag(id, "pinned", on, on ? "已置顶该对话" : "已取消置顶"); }
697
+ function qaTaskArchive(id, on){ qaTaskFlag(id, "archived", on, on ? "已归档该对话" : "已移出归档"); }
698
+ /* ---------- 行尾下拉菜单(#tk-menu,挂 body 的单例)----------
699
+ 2026-09-18 用户:「每一个对话行尾有下拉操作选择」—— 行尾 ⋯ 点开 = 置顶 / 归档 / 删除。
700
+ ⚠️ 单例而不是「每行一只菜单」:菜单必须挂 body(侧栏 #nav 是 overflow:hidden,absolute 层
701
+ 会被整块裁掉),一屏几十行就几十个浮动节点;单例在开合之间只换内容 + 重定位。
702
+ ⚠️ 定位必须走 qaMenuPlace(在 bindHomeEntry 里定义,经 window.__qaMenuPlace 取用),
703
+ 不在这里手写 left/top:翻转 / 视口夹持 / 滚动与尺寸变化的 autoUpdate 都在那儿统一实现。 */
704
+ const TK_MENU_SEL = "#tk-menu";
705
+ const TK_MENU_PLACE = { placement:"bottom-start", strategy:"fixed" };
706
+ let qaTkMenuFor = null; // 当前开着的行尾菜单属于哪条会话(null = 没开)
707
+ /** 菜单条目 [动作, 文案, 是否危险色]:按该行当前状态给「反向」文案
708
+ * (已置顶 → 取消置顶 / 已归档 → 移出归档),删除恒为危险色。
709
+ * ⚠️ 「归档」那一项**只在这枚动作真会改变归属时才出**(2026-09-20 起左栏分档是**状态优先**,
710
+ * 归档只能把「不再跑、又不在已完成档」的那两类(失败 / 已停止)收进「已完成」):
711
+ * · `done` 的行本来就在「已完成」档里 —— 再给它一枚「归档」= 点了什么都不动
712
+ * (用户 2026-09-20 报过的那种「点击归档无效果」);
713
+ * · 排队中 / 在跑 / 审查中的行由**状态**说了算(见 qaTaskGroupOf),归档挪不动它。
714
+ * 所以只有 `error` / `stopped` 且未归档的行才出这一项。 */
715
+ function qaTkMenuItems(t){
716
+ const s = qaTaskStatusOf(t);
717
+ const items = [[t.pinned ? "unpin" : "pin", t.pinned ? "取消置顶" : "置顶", false]];
718
+ if(t.archived) items.push(["unarchive", "移出归档", false]);
719
+ else if(!s.live && s.st !== "done" && s.st !== "verify") items.push(["archive", "归档", false]);
720
+ items.push(["del", "删除", true]);
721
+ return items;
722
+ }
723
+ function qaTkMenuOpen(id, dots){
724
+ const menu = $(TK_MENU_SEL), t = qaTasks.find(x=>x.id===id);
725
+ if(!menu || !t) return;
726
+ qaTkMenuClose();
727
+ if(typeof window.__qaMenusClose === "function") window.__qaMenusClose(null); // 别的弹出菜单先收掉
728
+ menu.innerHTML = qaTkMenuItems(t).map(([a, label, danger])=>
729
+ `<button class="ag-model-opt${danger?" danger":""}" type="button" role="menuitem"
730
+ data-tk-act="${a}" data-tk-id="${escHtml(id)}">${label}</button>`).join("");
731
+ menu.hidden = false;
732
+ qaTkMenuFor = id;
733
+ if(dots) dots.dataset.open = "1";
734
+ if(typeof window.__qaMenuPlace === "function") window.__qaMenuPlace(dots, menu, TK_MENU_PLACE);
735
+ }
736
+ function qaTkMenuClose(){
737
+ const menu = $(TK_MENU_SEL);
738
+ const id = qaTkMenuFor;
739
+ qaTkMenuFor = null;
740
+ if(id){
741
+ const dots = $(`#nav-tasks .tk-dots[data-menu="${id}"]`);
742
+ if(dots) delete dots.dataset.open;
743
+ }
744
+ if(!menu || menu.hidden) return;
745
+ // 关闭统一走 __qaMenuClose(隐藏 + 撤 autoUpdate 监听);库缺失时退化为自己隐藏
746
+ if(typeof window.__qaMenuClose === "function") window.__qaMenuClose(menu);
747
+ else menu.hidden = true;
748
+ menu.innerHTML = "";
749
+ }
750
+ function qaTkMenuAct(act, id){
751
+ qaTkMenuClose();
752
+ if(act === "pin") return qaTaskPin(id, true);
753
+ if(act === "unpin") return qaTaskPin(id, false);
754
+ if(act === "archive") return qaTaskArchive(id, true);
755
+ if(act === "unarchive") return qaTaskArchive(id, false);
756
+ if(act === "del") return qaTaskDelete(id);
757
+ }
758
+ /* ---------- 截图放大查看(lightbox · 单例)----------
759
+ 2026-09-18 用户:「对话框中发送的截图的显示要支持」—— 气泡里 168×120 的缩略图看不清内容,
760
+ 而截图恰恰是「要看细节」的东西,点一下看原图(对话框附件条上的 40px 缩略图同理)。
761
+ 挂 body 的单例,任何视图里都能用;媒体库那个 #bm-viewer 是**视图内部**的(`.bm-viewer{flex:1}`
762
+ 顶掉网格),切到对话视图就不可见,不能复用。
763
+ ⚠️ 绑定必须走**事件委托**:#qa-stream 的 innerHTML 会被快照整份重建(同工具卡那条注释)。 */
764
+ function openShotViewer(url, name){
765
+ const v = $("#shot-viewer"), im = $("#shot-viewer-img");
766
+ if(!v || !im || !url) return;
767
+ im.src = url;
768
+ im.alt = name || "截图";
769
+ const nm = $("#shot-viewer-name"); if(nm) nm.textContent = name || "";
770
+ v.hidden = false;
771
+ }
772
+ function closeShotViewer(){
773
+ const v = $("#shot-viewer"), im = $("#shot-viewer-img");
774
+ if(!v || v.hidden) return;
775
+ v.hidden = true;
776
+ if(im) im.removeAttribute("src"); // 大图别一直占着内存
777
+ }
778
+ let qaShotViewerBound = false;
779
+ function bindShotViewer(){
780
+ if(qaShotViewerBound) return;
781
+ const v = $("#shot-viewer"); if(!v) return;
782
+ qaShotViewerBound = true;
783
+ v.addEventListener("click", (e)=>{
784
+ if(e.target.closest && e.target.closest("#shot-viewer-close")){ closeShotViewer(); return; }
785
+ if(e.target === v) closeShotViewer(); // 点背景关;点图本身不关
786
+ });
787
+ document.addEventListener("keydown", (e)=>{
788
+ if(e.key === "Escape" && !v.hidden) closeShotViewer();
789
+ });
790
+ // ① 气泡里发出的截图 + **助手回复正文里的截图**(#qa-stream 会被整份重建 → 只能委托)。
791
+ // `.qa-t img` 本身已涵盖 `.qa-atts img`(附件条就挂在 .qa-t 里),两个选择器都写是为了
792
+ // 语义自明 —— 将来任一处结构调整时,不必回头重新推导覆盖关系。
793
+ // 2026-09-20:正文图加了尺寸封顶(app.css 的 .qa-t.md-body img),「看原图」就落在这条链上。
794
+ const s = $("#qa-stream");
795
+ if(s) s.addEventListener("click", (e)=>{
796
+ const im = e.target.closest && e.target.closest(".qa-atts img, .qa-t img");
797
+ if(im) openShotViewer(im.getAttribute("src"), im.getAttribute("alt"));
798
+ });
799
+ // ② 对话框附件条上的缩略图(同一个组件两个实例 → 实例清单只有 ASK_INSTANCES 一份)。
800
+ // 绑**宿主挂载点** `#ask-<p>`(它从不被替换,只换 innerHTML)—— 绑 `#<p>-atts` 会在
801
+ // 组件重挂时随旧节点一起丢掉。
802
+ ASK_INSTANCES.forEach((p)=>{
803
+ const host = $("#ask-" + p); if(!host) return;
804
+ host.addEventListener("click", (e)=>{
805
+ const im = e.target.closest && e.target.closest(".ag-att .thumb");
806
+ if(!im) return;
807
+ const nm = im.closest(".ag-att").querySelector(".nm");
808
+ openShotViewer(im.getAttribute("src"), (nm && nm.textContent) || "");
809
+ });
810
+ });
811
+ }
812
+