cardbuddy 0.13.3 → 0.14.1

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 (61) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +4 -4
  3. package/dist/config.js +7 -7
  4. package/dist/init.js +15 -4
  5. package/dist/portGuard.js +2 -2
  6. package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
  7. package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +14 -6
  10. package/dist/vendored/card-editor/api.js +6 -6
  11. package/dist/vendored/mcp/client.mjs +53 -3
  12. package/dist/vendored/mcp/tools.mjs +17 -1
  13. package/dist/vendored/server/core/ai-chat.js +1443 -271
  14. package/dist/vendored/server/core/cardFiles.js +271 -39
  15. package/dist/vendored/server/core/cardImportMeta.js +16 -0
  16. package/dist/vendored/server/core/chatHistory.js +122 -0
  17. package/dist/vendored/server/core/index.js +10 -3
  18. package/dist/vendored/server/core/turnJournal.js +386 -0
  19. package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
  20. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
  21. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
  22. package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
  23. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
  24. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
  26. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
  27. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
  28. package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
  29. package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
  31. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
  33. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
  34. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
  37. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
  38. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
  44. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
  45. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
  46. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
  47. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
  49. package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
  50. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
  51. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
  52. package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
  53. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
  54. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
  55. package/dist/vendored/tools/finmall-env.mjs +6 -6
  56. package/package.json +1 -1
  57. package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
  58. package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
  59. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
  60. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
  61. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
@@ -14,7 +14,10 @@
14
14
  * 并附浅层清单:模型不必再自己试错形态,省下的步数用于真正的建卡动作。
15
15
  * - system prompt 为精简协议 + 参考索引;模型用 skill_read 工具按需取
16
16
  * skills/dsl-card-generator/references 下的文档(避免整份 skill 十万字进上下文放大空转思考)。
17
- * - 客户端断开(前端「停止」按钮 / 关页)即中止 LLM 请求与工具循环,不再白跑。
17
+ * - 客户端断开(刷新 / 断网 / 关标签页)不再等于终止:回合转 detached 在服务端继续跑,
18
+ * 事件全在回合日志里,重连按 seq 续订即可;真正「停止」是显式 POST /__ai/stop。
19
+ * - 回合日志按卡片落盘 .cache/ai-turns/<cardId>.jsonl(跨回合追加,行内带 turnId + seq):
20
+ * 断线续跑的回放源,也是回合收尾时「服务端权威历史」的唯一事实源(turnJournal.js)。
18
21
  * - 对话记录按卡片落盘 .cache/ai-chats/<cardId>.json(.cache/ 不入库),切卡 / 重开可续。
19
22
  * - 本地文件挂载:不上传,只解析绝对路径 + 元信息;图片预览由本机读盘直出。
20
23
  *
@@ -22,7 +25,10 @@
22
25
  * GET/POST /__ai/config 读取(脱敏;?reveal=1 回明文供设置面板回显)/ 保存模型配置
23
26
  * POST /__ai/ping 连通性自测(设置面板「测试连接」)
24
27
  * POST /__ai/chat SSE 流式对话
25
- * GET/POST/DELETE /__ai/history 按 cardId 读 / 存 / 清对话记录
28
+ * GET/POST/DELETE /__ai/history 按 cardId 读 / 存(与服务端权威历史按 turnId 合并去重)/ 清对话记录
29
+ * GET /__ai/turns 这张卡的回合状态(活跃回合 turnId/seq/detached + 最近回合清单)
30
+ * POST /__ai/stop 显式终止当前回合(断开连接不再隐式终止)
31
+ * GET /__ai/turn SSE 续订 / 回放:?cardId=&afterSeq=[&turnId=],先补发断点后的行再跟新事件
26
32
  * POST /__ai/fs/pick 调起系统选择器,返回绝对路径
27
33
  * GET /__ai/fs/stat 路径元信息(文件 / 文件夹摘要)
28
34
  * GET /__ai/fs/raw 本地图片直出(仅预览用)
@@ -30,7 +36,10 @@
30
36
  * POST /__ai/fs/stash 剪贴板截图落盘为本机路径(同样只存本地 .cache/)
31
37
  * POST /__ai/approve 对话内审批回落(skill_exec / shell_exec 的「允许 / 本回合不再询问 / 拒绝」)
32
38
  *
33
- * /__ai/chat SSE 事件:
39
+ * /__ai/chat SSE 事件(每条都带自增 seq;断线后按 seq 向 GET /__ai/turn 要「断点之后」的事件,不重不漏):
40
+ * data: {"type":"turn-start","turnId":"t...","cardId":"...","startedAt":1700000000000,"seq":1}
41
+ * 回合开跑:turnId 是日志 / 续订 / 权威历史的对齐键
42
+ * data: {"type":"client-detached","turnId":"t...","seq":N} 客户端断开、回合在服务端继续(只落日志)
34
43
  * data: {"type":"reasoning-delta","text":"..."}
35
44
  * data: {"type":"text-delta","text":"..."}
36
45
  * data: {"type":"tool-call","toolCallId":"...","toolName":"card_write","args":{...}}
@@ -39,10 +48,17 @@
39
48
  * data: {"type":"approval-request","approvalId":"...","toolName":"skill_exec","command":"python3 skills/...","args":{...}}
40
49
  * data: {"type":"approval-decided","approvalId":"...","ok":true,"scope":"once|turn"}
41
50
  * data: {"type":"approval-auto","toolName":"skill_exec","command":"node skills/..."} 免确认策略命中:没弹审批,直接执行
42
- * data: {"type":"notice","message":"..."} 横幅提示(重复调用警告 / 上下文折叠 / 收敛催促)
51
+ * data: {"type":"notice","level":"info|warn|error","code":"...","message":"..."} 过程事件(上下文折叠 / 自动续跑 / 收敛催促 / 网关重试…)
52
+ * 前端落进消息「过程事件」时间线持久可见,横幅只保留最近一条
53
+ * data: {"type":"round-retry","reason":"stall|reasoning","attempt":1,"attempts":3,"dropReasoning":0,"dropText":0,"dropTools":0,"message":"..."}
54
+ * 本步自动重试:前端据 drop* 回滚已渲染的半截产物
55
+ * data: {"type":"stream-alive"} 收到字节但无增量(长思考 / 网关缓冲工具参数),前端据此判断连接是否还活着
43
56
  * data: {"type":"context-usage","used":12345,"total":96000,"window":65536} 每轮上下文占用(输入框圆环)
57
+ * data: {"type":"usage","step":3,"promptTokens":123456,"cachedTokens":98765,"cacheMissTokens":24691,"hitRate":80.0,"reported":true}
58
+ * 累计用量与**前缀缓存命中率**:网关不报缓存字段时 reported=false(界面显示「未提供」)
44
59
  * data: {"type":"turn-ended","reason":"loop|timeout|steps","message":"..."} 熔断收尾原因(前端落进对话记录,持久可见)
45
- * data: {"type":"error","message":"..."}
60
+ * data: {"type":"error","message":"...","code":"length|stall|empty|context|network|auth|wrap-failed|aborted|unknown"}
61
+ * 前端按 code 给定向动作(打开设置 / 重试本轮 / 复制错误)
46
62
  * data: {"type":"aborted"}
47
63
  * data: {"type":"done"}
48
64
  */
@@ -51,6 +67,20 @@ import fs from 'node:fs'
51
67
  import os from 'node:os'
52
68
  import { execFile, spawn, spawnSync } from 'node:child_process'
53
69
  import { SNAPSHOT_ROOT, skillsDirFor, resolveSkillPath, normalizeSkillRel } from './skillPaths.js'
70
+ // 对话记录落盘(.cache/ai-chats/<cardId>.json):读写清 + 删卡联动都在 chatHistory.js,
71
+ // 本文件只负责 HTTP 端点编排,路径约定不再在这里重复一份。
72
+ import {
73
+ chatHistoryDir,
74
+ safeCardId,
75
+ readHistory,
76
+ writeHistory,
77
+ deleteHistory,
78
+ listHistories,
79
+ mergeServerTurn,
80
+ } from './chatHistory.js'
81
+ // 回合日志(P2):事件流按 seq 落盘 .cache/ai-turns/<cardId>.jsonl —— 断线续跑的回放源、
82
+ // 收尾时 reduce 成服务端权威历史;客户端崩溃 / 关标签页不再丢整轮轨迹。
83
+ import { openJournal, getLiveJournal, readJournalLines, listTurns, deleteJournal } from './turnJournal.js'
54
84
 
55
85
  /**
56
86
  * 快照根:仓库内运行 = 真仓库根;cardbuddy 发布态 = 包内 dist/vendored(构建期内联的迷你仓库快照,
@@ -59,8 +89,31 @@ import { SNAPSHOT_ROOT, skillsDirFor, resolveSkillPath, normalizeSkillRel } from
59
89
  */
60
90
  const REPO_ROOT = SNAPSHOT_ROOT
61
91
 
62
- /** 单卡片最多保留的消息条数,超出从头部裁剪(避免历史无限增长撑爆上下文与磁盘) */
63
- const MAX_HISTORY_MESSAGES = 200
92
+ // ---- 提示词前缀缓存纪律(prompt cache) ------------------------------------------------------
93
+ /**
94
+ * 网关的前缀缓存(DeepSeek / GLM 系都是「完全相同的前缀 + 64 token 块对齐」才有折扣)只认逐字节
95
+ * 相同的前缀:只要我们**改写了历史里的任何一条消息**,从那条起往后的全部 token 都要按原价重算。
96
+ * 实测(tools/tests 之外的线上探针):同一个 570 token 前缀第二次请求命中 384 token(67%),
97
+ * 命中量正好是 64 的整数倍 —— 所以「别动历史」比「少带点内容」值钱得多。
98
+ *
99
+ * 由此定下三条纪律,后面所有逻辑都得遵守:
100
+ * ① 同一个东西只有一个截断口径(TOOL_RESULT_MAX_CHARS)——轮内 live 上下文与下一回合前端回传的
101
+ * 历史必须是同一串字符,否则每过一轮前缀就在「第一个超长工具结果」处整段漂移。
102
+ * ② 历史只追加、不回改:裁剪只能从「上次折到哪」继续往后折(粘滞),不允许把已折叠的还原、
103
+ * 也不允许为了省 token 回头改写更早的消息。
104
+ * ③ 真发生改写时要留痕(prefixDrift):命中率掉下来得能一眼看出是哪一步、哪一条开始漂的。
105
+ */
106
+ const TOOL_RESULT_MAX_CHARS = 12000
107
+
108
+ /**
109
+ * 让网关在流末尾回 usage(OpenAI 兼容网关默认**不回**):没有 usage 就看不到缓存命中量,
110
+ * 「提高命中率」只能是空话。实测本仓两个网关都接受该字段(其中一个还会回
111
+ * prompt_cache_hit_tokens / prompt_tokens_details.cached_tokens)。
112
+ * 不认识的网关会 400 —— 由 callLLM 的扩展字段回退逻辑逐个摘掉后重试,不会把整轮打成失败。
113
+ */
114
+ const USAGE_BODY = { stream_options: { include_usage: true } }
115
+ /** 网关拒绝扩展字段时的摘除顺序:先摘观测字段(无副作用),再摘思考开关(有行为影响) */
116
+ const EXT_PEEL_ORDER = ['stream_options', 'chat_template_kwargs']
64
117
 
65
118
  // ---- 工具定义(延迟加载) ----
66
119
  let _toolsCache = null
@@ -197,6 +250,12 @@ S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有
197
250
  ② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--indices","<n>","--build"] 只产该画板(多个逗号;按名称子串用 "--only","<子串>");
198
251
  ③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
199
252
  - 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
253
+ S1.5 选卡门禁(多画板 / 多卡画板必需;SKILL.md §13 + rules.md §13.4):目标目录定下后先跑 skill_exec script=skills/dsl-card-generator/scripts/spec_tree.mjs,args=["--list-cards","<目标目录>"] 拿「画板序号.卡片序号」全量清单(一个画板含 N 张卡展开为 K.1~K.N,行下自带 "图: <card-N.png 绝对路径>");S0 挂载段已带同一份命令就直接复用,不要重复跑。
254
+ - 只有 1 张卡、或用户已明说目标(序号如 2.3 / 画板名 / 卡片名)⇒ 直接进 S2,不要为走流程停下来问。
255
+ - 多张卡且用户未指定 ⇒ **本轮到此为止**:把清单写进正文 + 明确提问,然后**停止输出、等用户回序号再继续**。聊天面板**没有**弹选项工具(不存在 request_user_input),不要去调用或寻找弹窗工具,也不要替用户挑。
256
+ 清单**逐行配图**:每个卡片行下紧跟它自己的聚焦图——把 "图:" 那行的绝对路径原样写成内联 Markdown 图片(让它直接显示出来,**不要只贴路径**),与序号一一对应;缺图显式标缺失,禁止把图堆到清单末尾。末尾追加一项「全部」,用户回「全部」才算显式批量授权(产了几张卡要在正文报)。
257
+ 只回画板序号默认取该画板第 1 张卡。
258
+ - **禁止**自行挑画板 / 挑卡、**禁止**「先建一张看看」、**禁止**未经同意批量产卡。
200
259
  S2 审计(一步代替反复探索):skill_exec script=skills/dsl-card-generator/scripts/spec_audit.mjs,args=["--spec","<bundle>/spec.json","--card","N","--slices-dir","<bundle>/slices","--project-slices","src/slices","--design","<bundle>/card-N.png","--save",".cache/ai-scratch/<id>.audit.json"]。
201
260
  它交叉审计 spec + 聚焦截图 + 切图目录,给出:bands(水平行与行内元素序)、gaps(padLeft / padRight / padTop / padBottom / rowGaps)、texts(文案 / 字号 / 字重 / 颜色 / 行高 / 对齐)、shapes(装饰条)、slices(存在性 / 复用 / 是否烘焙背景与标签文字)、colors(背景采样点)、missing(缺哪张切图 + 建议裁剪矩形)。
202
261
  它交叉审计 spec + 聚焦截图 + 切图目录,另给 bgGradientHint(顶部渐变带的起止色/跨度 + IR 正确写法)与 slices[].crops(reused-differs 时「矩形 → 该用哪张 _alt 图」的映射,exists=true 直接引用、false 才需裁)。
@@ -229,6 +288,13 @@ S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/check_
229
288
  ${vision ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。' : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'}
230
289
  步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾 ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
231
290
 
291
+ ## 收尾协议(不要停下来等「继续」)
292
+ - 任务没做完就不要交还控制权:接着调用工具推进到下一个门禁。**禁止**输出「下一步(请确认或直接说「继续」)」这类等待式收尾——用户已开启自动续跑,停手只会让任务白等一轮。
293
+ - 只有两种情况允许「不调工具、只吐正文」结束本轮:①用户的要求已全部落地且门禁通过 ⇒ 正文最后单独一行 ${DONE_MARK};②确实缺只有用户能给的信息(缺密钥 / 需求二义 / 要动工程外文件)⇒ 正文最后单独一行 ${ASK_MARK},并写清要用户回答什么。
294
+ - 两个标记都要逐字原样输出,且只在真正满足条件时用:没有标记又没调用工具,会被判定为「未完成却停手」并自动注入提醒续跑。
295
+ - 中途被网关静默重试 / 思考催促打断过的步骤,按上下文里的提醒从中断处接着做,不要从零重做,也不要重复已经落盘的工作。
296
+ - 别因为「怕超时」就把活儿切成「下一轮再做」:网关瞬时静默会自动重试本步、纯思考超时只会收到催促而不会中断任务、墙钟默认 60 分钟——一次把该跑的工具跑完,比拆成几轮等用户说「继续」又快又省。
297
+
232
298
  ## 按需参考索引(skill_read)
233
299
  ${lines.join('\n')}`
234
300
  _systemPromptCache = { key: cacheKey, text: _text }
@@ -1194,7 +1260,14 @@ async function runGuarded(root, res, { toolName, command, approved, auto = false
1194
1260
  }
1195
1261
  const approvalId = `ap_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
1196
1262
  const decision = await waitForApproval(res, { approvalId, toolName, command: label, args: { command: label } })
1197
- sseWrite(res, { type: 'approval-decided', approvalId, ok: decision.ok, scope: decision.scope })
1263
+ // reason:审批超时(APPROVAL_TIMEOUT_MS)时带「超过 N 秒未确认,按拒绝处理」,前端据此区分「用户点了拒绝」与「走神超时」
1264
+ sseWrite(res, {
1265
+ type: 'approval-decided',
1266
+ approvalId,
1267
+ ok: decision.ok,
1268
+ scope: decision.scope,
1269
+ reason: decision.reason || '',
1270
+ })
1198
1271
  if (!decision.ok) {
1199
1272
  auditShell(root, label, false)
1200
1273
  throw new Error(decision.reason || '用户拒绝了这次执行:请把命令原样写给用户自行运行,或改用结构化卡片工具')
@@ -1351,6 +1424,29 @@ function parseEnvFile(file) {
1351
1424
  const DEFAULT_TURN_TIMEOUT_MS = 60 * 60 * 1000
1352
1425
  /** 连续多少轮完全相同的工具调用即判定死循环(第 2 轮先警告,第 3 轮熔断) */
1353
1426
  const LOOP_BREAK_REPEATS = 3
1427
+ /**
1428
+ * 保活三阈值:长程任务(设计稿建卡一轮几十次调用、几十分钟)只应被「用户主动中止」或
1429
+ * 「模型服务明确报错」结束,网关瞬时抖动与模型长时间思考都不该整轮报废。
1430
+ * - stallTimeoutMs:上游一个字节都不回多久算「网关静默」(静默后自动重试本步,不是报错收尾)
1431
+ * - stallRetries:网关静默的自动重试次数,耗尽才报错
1432
+ * - reasoningTimeoutMs:单轮「纯思考」(只吐 reasoning、无正文无工具)多久算迟迟不产出,
1433
+ * 到点先催促 + 关思考重试;催促若干次仍无产出就**停用该熔断**改由墙钟兜底,不再中断任务
1434
+ */
1435
+ const DEFAULT_STALL_TIMEOUT_MS = 120 * 1000
1436
+ const DEFAULT_STALL_RETRIES = 3
1437
+ const DEFAULT_REASONING_TIMEOUT_MS = 600 * 1000
1438
+ /** 纯思考催促上限:超过后停用思考熔断(继续跑,交给墙钟 / 死循环熔断兜底) */
1439
+ const REASONING_NUDGE_MAX = 3
1440
+ /**
1441
+ * 收尾协议标记:模型只有显式给出其中之一,才允许「不调工具、只吐正文」就把控制权交回用户。
1442
+ * 实测没有协议时模型极爱输出「下一步(请确认或直接说「继续」)」——任务根本没做完(IR 写了但
1443
+ * expand_ir 没跑、门禁没过),却把用户变成「继续」按钮。没有标记就自动续跑。
1444
+ */
1445
+ const DONE_MARK = '[任务完成]'
1446
+ const ASK_MARK = '[需用户确认]'
1447
+ const TASK_SETTLED_RE = /\[\s*(?:任务完成|需用户确认)\s*\]/
1448
+ /** 单轮自动续跑上限(AI_AUTO_CONTINUE 可覆盖,0 = 关闭回到「等用户说继续」) */
1449
+ const DEFAULT_AUTO_CONTINUE_MAX = 12
1354
1450
 
1355
1451
  /** 时长人话:分钟为主,亚分钟(测试里常把墙钟压到几十毫秒)回落秒,别显示「0 分钟」 */
1356
1452
  function fmtDuration(ms) {
@@ -1409,25 +1505,56 @@ function readAiConfig(root = REPO_ROOT) {
1409
1505
  const isRealOverride = fromProcess !== undefined && fromProcess !== injected[key] && fromProcess !== shared[key]
1410
1506
  return String(isRealOverride ? fromProcess : (local[key] || shared[key] || ''))
1411
1507
  }
1508
+ // 「模型身份 + 能力」字段唯一来源 = 模型库 AI_MODELS 中的生效项(不再在库外另存一份扁平键)。
1509
+ // 若环境变量显式覆盖了该键(CI / 命令行导出),以真实覆盖为准;否则完全跟随模型库。
1510
+ // 模型库为空(老用户未迁移)时回落扁平键,保持向后兼容。
1511
+ let activeModel = null
1512
+ try {
1513
+ const { models, activeId } = readAiModels(root)
1514
+ activeModel = models.find((m) => m.id === activeId) || models[0] || null
1515
+ } catch {
1516
+ activeModel = null
1517
+ }
1518
+ const fromModel = (key, flatKey) => {
1519
+ const realEnv = process.env[key]
1520
+ const isRealOverride = realEnv !== undefined && realEnv !== injected[key] && realEnv !== shared[key]
1521
+ if (isRealOverride) return String(realEnv)
1522
+ if (activeModel) return activeModel[key]
1523
+ return local[flatKey] || shared[flatKey] || ''
1524
+ }
1412
1525
  return {
1413
- baseUrl: pick('AI_BASE_URL'),
1414
- apiKey: pick('AI_API_KEY'),
1415
- model: pick('AI_MODEL'),
1526
+ baseUrl: String(fromModel('baseUrl', 'AI_BASE_URL') || ''),
1527
+ apiKey: String(fromModel('apiKey', 'AI_API_KEY') || ''),
1528
+ model: String(fromModel('model', 'AI_MODEL') || ''),
1416
1529
  // 本地命令执行开关(默认开):shell_exec 工具默认注册;是否逐次确认由 AI_AUTO_APPROVE 决定(默认 all=免确认)。
1417
1530
  // 显式 AI_ALLOW_SHELL=0 才关闭(本工具只监听 127.0.0.1,且命令过白名单 + 全量审计)
1418
1531
  shellAllowed: pick('AI_ALLOW_SHELL') !== '0',
1419
- // 单次输出上限(0 = 用默认值):推理模型的思考也算 completion,太小会被截断
1420
- maxTokens: Number(pick('AI_MAX_TOKENS')) > 0 ? Number(pick('AI_MAX_TOKENS')) : 0,
1421
- // 深度思考开关(默认开):关掉可显著提速,但仅在 vLLM / SGLang 系网关生效
1422
- thinking: pick('AI_THINKING') !== '0',
1423
- // 多模态视觉开关(默认关):开启后先探测网关是否真支持图像,不支持自动降级数值闭环
1424
- vision: pick('AI_VISION') === '1',
1532
+ // 单次输出上限(0 = 用默认值):模型能力参数,跟随生效模型
1533
+ maxTokens: Number(fromModel('maxTokens', 'AI_MAX_TOKENS')) > 0 ? Number(fromModel('maxTokens', 'AI_MAX_TOKENS')) : 0,
1534
+ // 深度思考开关(默认开):模型能力开关,跟随生效模型
1535
+ thinking: activeModel ? activeModel.thinking !== false : pick('AI_THINKING') !== '0',
1536
+ // 多模态视觉开关(默认关):模型能力开关,跟随生效模型
1537
+ vision: activeModel ? activeModel.vision === true : pick('AI_VISION') === '1',
1425
1538
  // 免确认执行策略(默认 all):建卡管线连跑十几次脚本 / 命令,不该每次都等用户点「允许」
1426
1539
  autoApprove: normalizeAutoApprove(pick('AI_AUTO_APPROVE')),
1427
1540
  // 单轮工具调用步数硬顶(0 = 不限,默认):>0 时到顶收尾汇报并提示「继续」,上下文不丢
1428
1541
  maxSteps: Number(pick('AI_MAX_STEPS')) > 0 ? Number(pick('AI_MAX_STEPS')) : 0,
1429
1542
  // 单轮墙钟上限(毫秒):不限步数后唯一能兜住「无限跑下去」的闸门,到点收尾汇报而非无声挂着
1430
1543
  turnTimeoutMs: Number(pick('AI_TURN_TIMEOUT_MS')) > 0 ? Number(pick('AI_TURN_TIMEOUT_MS')) : DEFAULT_TURN_TIMEOUT_MS,
1544
+ // 保活三阈值(env 可调,此前只能靠请求体传,磁盘配置改了也不生效)
1545
+ stallTimeoutMs: Number(pick('AI_STALL_TIMEOUT_MS')) > 0 ? Number(pick('AI_STALL_TIMEOUT_MS')) : DEFAULT_STALL_TIMEOUT_MS,
1546
+ // AI_STALL_RETRIES=0 显式关闭静默重试(回到「一次静默即报错」的旧行为)
1547
+ stallRetries:
1548
+ pick('AI_STALL_RETRIES') === ''
1549
+ ? DEFAULT_STALL_RETRIES
1550
+ : Math.max(0, Math.min(20, Number(pick('AI_STALL_RETRIES')) || 0)),
1551
+ reasoningTimeoutMs:
1552
+ Number(pick('AI_REASONING_TIMEOUT_MS')) > 0 ? Number(pick('AI_REASONING_TIMEOUT_MS')) : DEFAULT_REASONING_TIMEOUT_MS,
1553
+ // 自动续跑:模型没给完成标记就停手时自动注入提醒接着跑的次数上限(0 = 关闭,回到「等用户说继续」)
1554
+ autoContinueMax:
1555
+ pick('AI_AUTO_CONTINUE') === ''
1556
+ ? DEFAULT_AUTO_CONTINUE_MAX
1557
+ : Math.max(0, Math.min(200, Number(pick('AI_AUTO_CONTINUE')) || 0)),
1431
1558
  }
1432
1559
  }
1433
1560
 
@@ -1438,40 +1565,41 @@ function maskKey(key) {
1438
1565
  return `${'*'.repeat(Math.min(8, key.length - 4))}${key.slice(-4)}`
1439
1566
  }
1440
1567
 
1441
- /**
1442
- * 保存 AI 配置到 .env.cb.local(个人覆盖层,.gitignore 已忽略,密钥不入库)。
1443
- * 只写传入且非 undefined 的键;apiKey 传空串表示清除本地覆盖。
1444
- * 未传的键**保持原值原样重写**:早先的实现会把没传的键从个人覆盖层删掉,
1445
- * 于是「手写 env 只改一项」或「局部 POST」会连带清掉别的配置(与本文档相悖,实测 AI_ALLOW_SHELL 被抹掉)。
1446
- */
1568
+ /** 全局公共配置(执行策略 / 环境):allowShell / autoApprove / maxSteps / 各类超时 / 重试 / 续跑。
1569
+ * 这些键与「模型身份」无关,所有模型共享一份;与 writeAiModelsLocal 管理的模型专属键(AI_BASE_URL 等)互不重叠。
1570
+ * 只写传入且非 undefined 的键;未传的键保持原值原样重写(避免局部保存抹掉别的配置)。 */
1447
1571
  function writeAiConfigLocal(
1448
1572
  root = REPO_ROOT,
1449
- { baseUrl, apiKey, model, allowShell, maxTokens, thinking, vision, autoApprove, maxSteps } = {},
1573
+ { allowShell, autoApprove, maxSteps, turnTimeoutMs, stallTimeoutMs, stallRetries, reasoningTimeoutMs, autoContinueMax } = {},
1450
1574
  ) {
1451
1575
  const envPath = path.join(root, '.env.cb.local')
1452
1576
  const lines = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8').split('\n') : []
1453
1577
  const KEYS = [
1454
- 'AI_BASE_URL',
1455
- 'AI_API_KEY',
1456
- 'AI_MODEL',
1457
1578
  'AI_ALLOW_SHELL',
1458
- 'AI_MAX_TOKENS',
1459
- 'AI_THINKING',
1460
- 'AI_VISION',
1461
1579
  'AI_AUTO_APPROVE',
1462
1580
  'AI_MAX_STEPS',
1581
+ 'AI_TURN_TIMEOUT_MS',
1582
+ 'AI_STALL_TIMEOUT_MS',
1583
+ 'AI_STALL_RETRIES',
1584
+ 'AI_REASONING_TIMEOUT_MS',
1585
+ 'AI_AUTO_CONTINUE',
1463
1586
  ]
1587
+ const clamp = (v, lo, hi, dflt) => {
1588
+ const n = Number(v)
1589
+ if (!Number.isFinite(n) || n < 0) return dflt
1590
+ return String(Math.max(lo, Math.min(hi, n)))
1591
+ }
1464
1592
  const incoming = {
1465
- AI_BASE_URL: baseUrl,
1466
- AI_API_KEY: apiKey,
1467
- AI_MODEL: model,
1468
1593
  AI_ALLOW_SHELL: allowShell === undefined ? undefined : allowShell ? '1' : '0',
1469
- // 传空串 / 0 = 清除本地覆盖,回到内置默认
1470
- AI_MAX_TOKENS: maxTokens === undefined ? undefined : String(Number(maxTokens) > 0 ? Number(maxTokens) : ''),
1471
- AI_THINKING: thinking === undefined ? undefined : thinking ? '1' : '0',
1472
- AI_VISION: vision === undefined ? undefined : vision ? '1' : '0',
1473
1594
  AI_AUTO_APPROVE: autoApprove === undefined ? undefined : normalizeAutoApprove(autoApprove),
1595
+ // 传空串 / 0 = 清除本地覆盖,回到内置默认
1474
1596
  AI_MAX_STEPS: maxSteps === undefined ? undefined : String(Number(maxSteps) > 0 ? Number(maxSteps) : ''),
1597
+ AI_TURN_TIMEOUT_MS: turnTimeoutMs === undefined ? undefined : String(Number(turnTimeoutMs) > 0 ? Number(turnTimeoutMs) : ''),
1598
+ AI_STALL_TIMEOUT_MS: stallTimeoutMs === undefined ? undefined : String(Number(stallTimeoutMs) > 0 ? Number(stallTimeoutMs) : ''),
1599
+ // -1/空串 = 用内置默认;>=0 才是真实生效值
1600
+ AI_STALL_RETRIES: stallRetries === undefined ? undefined : (Number(stallRetries) < 0 ? '' : clamp(stallRetries, 0, 20, '')),
1601
+ AI_REASONING_TIMEOUT_MS: reasoningTimeoutMs === undefined ? undefined : String(Number(reasoningTimeoutMs) > 0 ? Number(reasoningTimeoutMs) : ''),
1602
+ AI_AUTO_CONTINUE: autoContinueMax === undefined ? undefined : (Number(autoContinueMax) < 0 ? '' : clamp(autoContinueMax, 0, 200, '')),
1475
1603
  }
1476
1604
 
1477
1605
  // 文件里已有的受管键值:incoming 为 undefined 时按原值重写,实现「只改传入的键」
@@ -1514,6 +1642,208 @@ function writeAiConfigLocal(
1514
1642
  }
1515
1643
  }
1516
1644
 
1645
+ /**
1646
+ * 模型库:把「多个模型配置」存进 .env.cb.local 的 AI_MODELS(JSON 数组),
1647
+ * 当前生效模型以 AI_MODEL_ACTIVE 记录其 id。为兼容既有 chat / 探测 / ping
1648
+ * (它们只读扁平 AI_*),每次写模型库都会把「生效模型」重新投影成扁平 AI_*,
1649
+ * readAiConfig 无需改动即可继续工作。
1650
+ *
1651
+ * 迁移:首次打开(无 AI_MODELS)但旧版有单条扁平配置时,合入一条名为该 model 的默认模型,
1652
+ * 避免老用户看到空列表、且旧配置不丢。
1653
+ */
1654
+ function genModelId() {
1655
+ return 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
1656
+ }
1657
+
1658
+ /** 单条模型结构归一(缺省值兜底),不在此处做掩码。
1659
+ * 仅存「模型身份 + 能力」字段;执行策略(allowShell / autoApprove / maxSteps / 各类超时 / 重试 / 续跑)是全局公共配置,见 writeAiConfigLocal,不随模型复制。 */
1660
+ function normalizeModel(m = {}) {
1661
+ return {
1662
+ id: String(m.id || genModelId()),
1663
+ name: String(m.name || m.model || '未命名模型'),
1664
+ baseUrl: String(m.baseUrl || ''),
1665
+ apiKey: String(m.apiKey || ''),
1666
+ model: String(m.model || ''),
1667
+ // 单次输出上限(0 = 自动,按网关窗口协商):不同模型窗口不同,属模型能力参数
1668
+ maxTokens: Number(m.maxTokens) > 0 ? Number(m.maxTokens) : 0,
1669
+ // 深度思考 / 多模态视觉:模型能力开关,随模型走
1670
+ thinking: m.thinking === true || m.thinking === '1',
1671
+ vision: m.vision === true || m.vision === '1',
1672
+ createdAt: Number(m.createdAt) > 0 ? Number(m.createdAt) : Date.now(),
1673
+ updatedAt: Number(m.updatedAt) > 0 ? Number(m.updatedAt) : Date.now(),
1674
+ }
1675
+ }
1676
+
1677
+ /** 读取模型库:{ models, activeId }(models 为脱敏前的完整结构) */
1678
+ function readAiModels(root = REPO_ROOT) {
1679
+ const shared = parseEnvFile(path.join(root, '.env.cb'))
1680
+ const local = parseEnvFile(path.join(root, '.env.cb.local'))
1681
+ const get = (k) => (local[k] !== undefined ? local[k] : shared[k] !== undefined ? shared[k] : '')
1682
+ let models = []
1683
+ let rawExists = false
1684
+ const raw = get('AI_MODELS')
1685
+ if (raw) {
1686
+ rawExists = true
1687
+ try {
1688
+ const parsed = JSON.parse(raw)
1689
+ if (Array.isArray(parsed)) models = parsed
1690
+ if (!Array.isArray(parsed)) rawExists = false
1691
+ } catch {
1692
+ models = []
1693
+ rawExists = false
1694
+ }
1695
+ }
1696
+ if (!Array.isArray(models)) models = []
1697
+ // 迁移:旧版单条扁平配置 → 一条默认模型(只搬「模型身份 + 能力」;执行策略类公共键留在全局扁平键,readAiConfig 照常读)。
1698
+ // 仅当 AI_MODELS 从未写过(rawExists=false)时才迁移:否则「用户删空模型库」会被遗留扁平键复活成一条「幽灵模型」。
1699
+ if (!rawExists && !models.length && get('AI_MODEL')) {
1700
+ models = [{
1701
+ id: 'default',
1702
+ name: get('AI_MODEL'),
1703
+ baseUrl: get('AI_BASE_URL'),
1704
+ apiKey: get('AI_API_KEY'),
1705
+ model: get('AI_MODEL'),
1706
+ maxTokens: Number(get('AI_MAX_TOKENS')) > 0 ? Number(get('AI_MAX_TOKENS')) : 0,
1707
+ thinking: get('AI_THINKING') !== '0',
1708
+ vision: get('AI_VISION') === '1',
1709
+ createdAt: Date.now(),
1710
+ updatedAt: Date.now(),
1711
+ }]
1712
+ }
1713
+ models = models.map((m) => normalizeModel(m))
1714
+ let activeId = get('AI_MODEL_ACTIVE') || (models[0] ? models[0].id : null)
1715
+ if (!models.some((m) => m.id === activeId)) activeId = models[0] ? models[0].id : null
1716
+ return { models, activeId }
1717
+ }
1718
+
1719
+ /** 模型输入校验(后端兜底):返回错误文案(不合法)或 null(合法)。
1720
+ * requireKey=true 时 apiKey 必填(新增 / 编辑时清空了密钥)。 */
1721
+ function validateModelInput(body = {}, { requireKey = false } = {}) {
1722
+ const baseUrl = String(body.baseUrl || '').trim()
1723
+ const model = String(body.model || '').trim()
1724
+ if (!baseUrl) return 'Base URL 不能为空'
1725
+ if (!/^https?:\/\/[^\s]+$/i.test(baseUrl)) return 'Base URL 需以 http:// 或 https:// 开头'
1726
+ if (!model) return '模型名不能为空'
1727
+ if (requireKey && !String(body.apiKey || '').trim()) return 'API Key 不能为空'
1728
+ return null
1729
+ }
1730
+
1731
+ /** 模型脱敏:列表 / 详情默认不回明文 key */
1732
+ function maskModel(m) {
1733
+ return {
1734
+ ...m,
1735
+ apiKey: undefined,
1736
+ hasKey: Boolean(m.apiKey),
1737
+ apiKeyMasked: maskKey(m.apiKey),
1738
+ }
1739
+ }
1740
+
1741
+ /** 模型专属扁平键(旧式配置遗留):模型库非空时由 AI_MODELS 取代,写入时一并清除,避免「库里一份、外面一份」。
1742
+ * 仅当模型库为空(未迁移 / 用户手写)时才作为回退来源。 */
1743
+ const LEGACY_MODEL_FLAT_KEYS = [
1744
+ 'AI_BASE_URL', 'AI_API_KEY', 'AI_MODEL', 'AI_MAX_TOKENS', 'AI_THINKING', 'AI_VISION',
1745
+ ]
1746
+
1747
+ /** 写模型库到 .env.cb.local:只写 AI_MODELS(JSON) + AI_MODEL_ACTIVE。
1748
+ * 模型身份 / 能力字段**只存于库内**,不再在库外投影扁平键(避免双份配置漂移);
1749
+ * 清掉遗留的 AI_BASE_URL 等扁平键(AI_ALLOW_SHELL / AI_AUTO_APPROVE 等执行策略类公共键不在此列,由 writeAiConfigLocal 管理)。
1750
+ * 其它个人配置(非受管键)原样保留。 */
1751
+ function writeAiModelsLocal(root, models, activeId) {
1752
+ const envPath = path.join(root, '.env.cb.local')
1753
+ const lines = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8').split('\n') : []
1754
+ const MANAGED = new Set([...LEGACY_MODEL_FLAT_KEYS, 'AI_MODELS', 'AI_MODEL_ACTIVE'])
1755
+
1756
+ const kept = lines.filter((l) => {
1757
+ const t = l.trim()
1758
+ if (!t || t.startsWith('#')) return true
1759
+ const i = t.indexOf('=')
1760
+ if (i < 0) return true
1761
+ return !MANAGED.has(t.slice(0, i).trim())
1762
+ })
1763
+ while (kept.length && !kept[kept.length - 1].trim()) kept.pop()
1764
+
1765
+ const block = []
1766
+ if (!kept.some((l) => l.startsWith('# AI 开发助手'))) {
1767
+ block.push('# AI 开发助手模型配置(CardBuddy 设置面板写入;本文件为个人覆盖层,勿提交)')
1768
+ }
1769
+ block.push(`AI_MODELS=${JSON.stringify(models)}`)
1770
+ block.push(`AI_MODEL_ACTIVE=${activeId || ''}`)
1771
+
1772
+ const out = [...kept, ...(kept.length ? [''] : []), ...block]
1773
+ fs.writeFileSync(envPath, out.join('\n') + '\n', 'utf8')
1774
+
1775
+ // 清掉 dotenv 注入的旧扁平值,确保 readAiConfig 以文件 / 模型库实时读取为准
1776
+ for (const k of LEGACY_MODEL_FLAT_KEYS) delete process.env[k]
1777
+ }
1778
+
1779
+ /**
1780
+ * 从 cc-switch(~/.cc-switch/cc-switch.db,SQLite)读取 Codex 类 provider 列表,
1781
+ * 解析其 settings_config(JSON)里的 config(TOML 文本)+ auth(含密钥),提取 baseUrl / apiKey / model,
1782
+ * 映射成本仓库模型库所需的 { name, baseUrl, apiKey, model } 结构。
1783
+ * 只读 + 短事务(WAL 并发安全);DB 不存在 / sqlite3 CLI 不可用时返回空列表 + reason。
1784
+ * @returns {{ providers: Array, reason?: string }}
1785
+ * providers: [{ id, name, baseUrl, apiKey, model, isCurrent, wireApi }]
1786
+ */
1787
+ function listCcSwitchProviders() {
1788
+ const dbPath = path.join(os.homedir(), '.cc-switch', 'cc-switch.db')
1789
+ if (!fs.existsSync(dbPath)) {
1790
+ return { providers: [], reason: '未检测到 cc-switch 数据库(~/.cc-switch/cc-switch.db),请确认已安装并运行过 cc-switch' }
1791
+ }
1792
+ let raw
1793
+ try {
1794
+ const r = spawnSync('sqlite3', ['-json', dbPath,
1795
+ `SELECT id, name, settings_config, is_current FROM providers WHERE app_type='codex' ORDER BY is_current DESC, sort_index`],
1796
+ { encoding: 'utf8', timeout: 5000 })
1797
+ if (r.error || r.status !== 0) {
1798
+ const msg = r.error ? String(r.error.message) : (r.stderr || '').trim()
1799
+ return { providers: [], reason: `sqlite3 执行失败${msg ? ':' + msg : ''}(macOS 自带 sqlite3,若环境异常请检查 PATH)` }
1800
+ }
1801
+ raw = (r.stdout || '').trim()
1802
+ } catch (e) {
1803
+ return { providers: [], reason: `读取 cc-switch 数据库异常:${String((e && e.message) || e)}` }
1804
+ }
1805
+ let rows
1806
+ try {
1807
+ rows = raw ? JSON.parse(raw) : []
1808
+ } catch {
1809
+ return { providers: [], reason: 'cc-switch 数据库返回格式异常(无法解析 JSON)' }
1810
+ }
1811
+ if (!Array.isArray(rows) || !rows.length) {
1812
+ return { providers: [], reason: 'cc-switch 中尚未配置任何 Codex 类供应商' }
1813
+ }
1814
+ const providers = []
1815
+ for (const row of rows) {
1816
+ let sc
1817
+ try {
1818
+ sc = JSON.parse(row.settings_config || '{}')
1819
+ } catch {
1820
+ continue
1821
+ }
1822
+ const apiKey = String((sc.auth && sc.auth.OPENAI_API_KEY) || '').trim()
1823
+ const toml = String(sc.config || '')
1824
+ // TOML 是固定格式,用正则提取 model 与 base_url 即可(避免引入 TOML 解析依赖)
1825
+ const modelMatch = toml.match(/^\s*model\s*=\s*"([^"]+)"/m)
1826
+ const baseUrlMatch = toml.match(/^\s*base_url\s*=\s*"([^"]+)"/m)
1827
+ const wireMatch = toml.match(/^\s*wire_api\s*=\s*"([^"]+)"/m)
1828
+ let baseUrl = (baseUrlMatch && baseUrlMatch[1]) || ''
1829
+ // Codex 网关常把 base_url 拼成 .../chat/completions,本仓库调用的是 ${baseUrl}/chat/completions,
1830
+ // 导入时剥掉末尾的 /chat/completions(及尾部斜杠),避免拼出双段路径
1831
+ baseUrl = baseUrl.replace(/\/chat\/completions\/?$/i, '').replace(/\/+$/, '')
1832
+ const model = (modelMatch && modelMatch[1]) || ''
1833
+ if (!baseUrl || !model) continue
1834
+ providers.push({
1835
+ id: String(row.id || ''),
1836
+ name: String(row.name || model),
1837
+ baseUrl,
1838
+ apiKey,
1839
+ model,
1840
+ isCurrent: row.is_current === 1 || row.is_current === true,
1841
+ wireApi: (wireMatch && wireMatch[1]) || '',
1842
+ })
1843
+ }
1844
+ return { providers }
1845
+ }
1846
+
1517
1847
  // ---- 请求体 / 响应辅助 ----
1518
1848
 
1519
1849
  function readBody(req, limitBytes = 8 * 1024 * 1024) {
@@ -1578,89 +1908,47 @@ function sendJson(req, res, code, payload) {
1578
1908
  }
1579
1909
 
1580
1910
  function sseWrite(res, obj) {
1581
- if (res.writableEnded || res.destroyed) return
1582
- res.write(`data: ${JSON.stringify(obj)}\n\n`)
1583
- }
1584
-
1585
- // ---- 对话记录落盘 ----
1586
-
1587
- function safeCardId(cardId) {
1588
- const id = String(cardId || '').trim()
1589
- if (!id) return ''
1590
- // 只允许文件名安全字符,杜绝 ../ 路径穿越
1591
- return id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
1592
- }
1593
-
1594
- function historyFile(chatDir, cardId) {
1595
- const id = safeCardId(cardId)
1596
- return id ? path.join(chatDir, `${id}.json`) : ''
1597
- }
1598
-
1599
- function readHistory(chatDir, cardId) {
1600
- const file = historyFile(chatDir, cardId)
1601
- if (!file || !fs.existsSync(file)) return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
1602
- try {
1603
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
1604
- return {
1605
- cardId: String(raw.cardId || cardId || ''),
1606
- messages: Array.isArray(raw.messages) ? raw.messages : [],
1607
- updatedAt: Number(raw.updatedAt || 0),
1608
- }
1609
- } catch {
1610
- return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
1911
+ let payload = obj
1912
+ const journal = res.__turnJournal
1913
+ if (journal && !journal.closed) {
1914
+ const line = journal.append(obj)
1915
+ // seq 随事件下发:客户端重连时按它向 /__ai/turn 要「断点之后」的事件,不重不漏
1916
+ if (line) payload = { ...obj, seq: line.seq }
1611
1917
  }
1918
+ if (res.writableEnded || res.destroyed) return
1919
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
1612
1920
  }
1613
1921
 
1614
- function writeHistory(chatDir, cardId, messages) {
1615
- const file = historyFile(chatDir, cardId)
1616
- if (!file) return { ok: false, error: 'missing-card-id' }
1617
- fs.mkdirSync(chatDir, { recursive: true })
1618
- const list = Array.isArray(messages) ? messages.slice(-MAX_HISTORY_MESSAGES) : []
1619
- const payload = { cardId: safeCardId(cardId), messages: list, updatedAt: Date.now() }
1620
- const tmp = `${file}.tmp`
1621
- fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
1622
- fs.renameSync(tmp, file)
1623
- return { ok: true, count: list.length, updatedAt: payload.updatedAt }
1922
+ /** 回放日志行给续订连接:行本身已带 seq,不再进日志(否则回放一次 seq 翻倍) */
1923
+ function sseRaw(res, line) {
1924
+ if (res.writableEnded || res.destroyed) return
1925
+ res.write(`data: ${JSON.stringify(line)}\n\n`)
1624
1926
  }
1625
1927
 
1626
- function deleteHistory(chatDir, cardId) {
1627
- const file = historyFile(chatDir, cardId)
1628
- if (!file) return { ok: false, error: 'missing-card-id' }
1629
- try {
1630
- if (fs.existsSync(file)) fs.unlinkSync(file)
1631
- } catch (e) {
1632
- return { ok: false, error: String((e && e.message) || e) }
1633
- }
1634
- return { ok: true }
1928
+ /**
1929
+ * notice 的统一出口:除文案外附带严重级(info / warn / error)与机器码。
1930
+ * level 供前端决定横幅配色与是否打扰,code 供前端做定向提示;两字段都可缺省(旧前端只读 message 照常工作)。
1931
+ * 所有 notice 前端都会落进该条消息的「过程事件」时间线并随对话记录落盘,不再阅后即焚。
1932
+ */
1933
+ function sseNotice(res, message, level = 'warn', code = '') {
1934
+ sseWrite(res, { type: 'notice', message, level, code })
1635
1935
  }
1636
1936
 
1637
- /** 历史卡片清单(面板「最近对话」用,按更新时间倒序) */
1638
- function listHistories(chatDir, limit = 30) {
1639
- try {
1640
- if (!fs.existsSync(chatDir)) return []
1641
- return fs
1642
- .readdirSync(chatDir)
1643
- .filter((f) => f.endsWith('.json'))
1644
- .map((f) => {
1645
- try {
1646
- const raw = JSON.parse(fs.readFileSync(path.join(chatDir, f), 'utf8'))
1647
- const messages = Array.isArray(raw.messages) ? raw.messages : []
1648
- return {
1649
- cardId: String(raw.cardId || f.replace(/\.json$/, '')),
1650
- updatedAt: Number(raw.updatedAt || 0),
1651
- count: messages.length,
1652
- preview: String(messages.find((m) => m.role === 'user')?.content || '').slice(0, 60),
1653
- }
1654
- } catch {
1655
- return null
1656
- }
1657
- })
1658
- .filter(Boolean)
1659
- .sort((a, b) => b.updatedAt - a.updatedAt)
1660
- .slice(0, limit)
1661
- } catch {
1662
- return []
1663
- }
1937
+ /**
1938
+ * 把上游 / 网络异常归一成前端能给「定向动作」的错误码。
1939
+ * 归一化而不是让前端猜文案:文案随时会改,code 是契约。
1940
+ * network / context / stall / empty → 重试本轮;auth → 打开设置;length → 调高单次输出上限;其余 → 复制错误
1941
+ */
1942
+ function classifyErrorCode(e) {
1943
+ const msg = String((e && e.message) || e || '')
1944
+ const raw = String((e && e.code) || '')
1945
+ if (raw === 'context-overflow' || /maximum context|context (length|overflow)|too many tokens/i.test(msg)) return 'context'
1946
+ if (/aborted|abort/i.test(msg) || raw === 'AbortError' || raw === 'ABORT_ERR') return 'aborted'
1947
+ if (/\b(401|403)\b|invalid api key|incorrect api key|unauthorized|forbidden/i.test(msg)) return 'auth'
1948
+ if (/\b(429)\b|rate limit|quota/i.test(msg)) return 'rate-limit'
1949
+ if (raw === 'network-error' || /fetch failed|network|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|terminated/i.test(msg)) return 'network'
1950
+ if (/max_tokens|max tokens/i.test(msg)) return 'length'
1951
+ return 'unknown'
1664
1952
  }
1665
1953
 
1666
1954
  // ---- 本地文件挂载(只读,不上传) ----
@@ -1939,6 +2227,60 @@ const NO_THINKING_BODY = { chat_template_kwargs: { enable_thinking: false } }
1939
2227
  const MAX_TOKENS_400_RE = /max_tokens[^\n]{0,160}(range|exceed|too (?:large|big)|must be|should be|limit|<=)|range of max_tokens/i
1940
2228
  /** 400 详情判定②:prompt / 上下文超窗(减半救不了,交给调用方裁剪重试) */
1941
2229
  const CONTEXT_400_RE = /(context|prompt|input)[^\n]{0,160}(length|long|exceed|limit|too (?:large|big|many))|maximum context/i
2230
+ /** 错误详情里没有任何可分类信号:详情为空 / HTML 错误页 > 300 字被 llmHttpError 截断后不留关键词。
2231
+ 这类必须原样抛出——旧的「详情不含 max_tokens 就 throw」在这里是靠反证成立的,
2232
+ 改成「按可修复性分流」后若不显式排除,HTML 错误页会被误判成「可以用改 body 修好」 */
2233
+ function isTypeless400(detail) {
2234
+ const t = String(detail || '')
2235
+ return t === '' || /<\s*(html|head|body|title|center)\b/i.test(t)
2236
+ }
2237
+
2238
+ /**
2239
+ * 窥一眼新响应是不是「流内 400」:DashScope 系网关对超限参数返回 HTTP 200 + text/event-stream,
2240
+ * 但流里只有一条 data: {"error":{...}},没有任何 choices。
2241
+ * 只能 peek 不能 re-read:因此把已建立的 reader 与首块字节一并交还调用方,
2242
+ * 真流时由调用方从首块起接着解析(不丢内容),出错时 reader 被丢弃即可。
2243
+ * @param {Response} resp
2244
+ * @returns {Promise<{ error: string|null, reader: ReadableStreamDefaultReader|null, first: Uint8Array|null }>}
2245
+ */
2246
+ async function peekStreamError(resp) {
2247
+ const ct = String(resp.headers?.get?.('content-type') || '')
2248
+ if (!ct.includes('text/event-stream') || !resp.body) return { error: null, reader: null, first: null }
2249
+ let reader = null
2250
+ try {
2251
+ reader = resp.body.getReader()
2252
+ const { value } = await reader.read()
2253
+ const first = value || null
2254
+ const text = new TextDecoder().decode(first || new Uint8Array())
2255
+ // 按行取 data: 载荷:不跨行匹配,避免把后续正常 chunk 的 "error" 字段也吞进来
2256
+ let parsed = null
2257
+ for (const line of text.split('\n')) {
2258
+ const trimmed = line.trim()
2259
+ if (!trimmed.startsWith('data:')) continue
2260
+ const payload = trimmed.slice(5).trim()
2261
+ if (!payload || payload === '[DONE]') continue
2262
+ try {
2263
+ const j = JSON.parse(payload)
2264
+ if (j?.error) {
2265
+ parsed = j
2266
+ break
2267
+ }
2268
+ } catch {
2269
+ /* 半截 JSON:可能首块被切断,交给外层解析器处理,这里不当错误 */
2270
+ }
2271
+ }
2272
+ if (!parsed) return { error: null, reader, first }
2273
+ // 确认是错误流:这条 reader 不再有用,释放掉让 body 可被丢弃
2274
+ try {
2275
+ reader.releaseLock?.()
2276
+ } catch {
2277
+ /* 忽略 */
2278
+ }
2279
+ return { error: JSON.stringify(parsed), reader: null, first: null }
2280
+ } catch {
2281
+ return { error: null, reader, first: null }
2282
+ }
2283
+ }
1942
2284
 
1943
2285
  /**
1944
2286
  * 瞬时网关错误(超时 / 限流 / 5xx):这类错误重试一次往往就过,整轮直接报错收尾代价太大——
@@ -1957,6 +2299,32 @@ function transientRetryDelays() {
1957
2299
 
1958
2300
  const nap = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1959
2301
 
2302
+ /** 网关静默后的重试间隔:AI_STALL_RETRY_MS 可覆盖(单测压成 0 提速);次数多于间隔就沿用最后一个 */
2303
+ function stallRetryDelays() {
2304
+ const list = String(process.env.AI_STALL_RETRY_MS || '2000,5000,10000')
2305
+ .split(',')
2306
+ .map((x) => Number(x.trim()))
2307
+ .filter((x) => Number.isFinite(x) && x >= 0)
2308
+ return list.length ? list : [2000, 5000, 10000]
2309
+ }
2310
+
2311
+ /** 可被用户中止打断的等待:等满返回 true,中途被中止返回 false(调用方据此立刻收手) */
2312
+ function napUnlessAborted(ms, signal) {
2313
+ if (signal?.aborted) return Promise.resolve(false)
2314
+ if (!(Number(ms) > 0)) return Promise.resolve(true)
2315
+ return new Promise((resolve) => {
2316
+ const timer = setTimeout(() => {
2317
+ signal?.removeEventListener?.('abort', onAbort)
2318
+ resolve(!signal?.aborted)
2319
+ }, Number(ms))
2320
+ function onAbort() {
2321
+ clearTimeout(timer)
2322
+ resolve(false)
2323
+ }
2324
+ signal?.addEventListener?.('abort', onAbort, { once: true })
2325
+ })
2326
+ }
2327
+
1960
2328
  /**
1961
2329
  * 把网关错误摘成一句话:错误页多是 HTML(nginx 504 / 502),原样甩给用户或回喂给模型都是噪音。
1962
2330
  * 保留 `LLM <status>` 前缀——轮循环靠它识别 400 上下文溢出。
@@ -2008,16 +2376,22 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2008
2376
  const resp = await post(extra, maxTok)
2009
2377
  if (resp.ok || !TRANSIENT_STATUS.has(resp.status) || !retryable) return resp
2010
2378
  const wait = delays[attempt]
2011
- pendingNotices.push(
2012
- `网关返回 HTTP ${resp.status}(瞬时错误):${wait / 1000} 秒后自动重试本步(第 ${attempt + 1}/${delays.length} 次),任务不中断`,
2013
- )
2379
+ pendingNotices.push({
2380
+ level: 'warn',
2381
+ code: 'gateway-retry',
2382
+ text: `网关返回 HTTP ${resp.status}(瞬时错误):${wait / 1000} 秒后自动重试本步(第 ${attempt + 1}/${delays.length} 次),任务不中断`,
2383
+ })
2014
2384
  await nap(wait)
2015
2385
  } catch (e) {
2016
2386
  if (signal?.aborted) throw e
2017
2387
  lastErr = e
2018
2388
  if (!retryable) break
2019
2389
  const wait = delays[attempt]
2020
- pendingNotices.push(`网关连接失败(${String(e?.message || e).slice(0, 80)}):${wait / 1000} 秒后自动重试本步`)
2390
+ pendingNotices.push({
2391
+ level: 'warn',
2392
+ code: 'gateway-retry',
2393
+ text: `网关连接失败(${String(e?.message || e).slice(0, 80)}):${wait / 1000} 秒后自动重试本步`,
2394
+ })
2021
2395
  await nap(wait)
2022
2396
  }
2023
2397
  }
@@ -2026,47 +2400,111 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2026
2400
  throw err
2027
2401
  }
2028
2402
 
2029
- let curExtra = extraBody
2403
+ // 扩展字段合并成一份:观测用的 stream_options(看缓存命中)+ 可选的思考开关
2404
+ const baseExtra = { ...USAGE_BODY, ...(extraBody || {}) }
2405
+ let curExtra = Object.keys(baseExtra).length ? baseExtra : undefined
2030
2406
  let resp = await postRetry(curExtra)
2031
- for (const text of pendingNotices.splice(0)) yield { type: 'notice', text }
2032
- const hasExtra = Boolean(extraBody && Object.keys(extraBody).length)
2033
- if (!resp.ok && hasExtra) {
2034
- // 网关不认扩展字段(非 vLLM/SGLang 系):去掉后原样重试一次,别让整轮直接失败
2407
+ for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
2408
+ const hasExtra = Boolean(curExtra && Object.keys(curExtra).length)
2409
+ /** 扩展字段已摘干净:之后的 400 不再是「网关不认扩展字段」那类,别再无脑拿减半去赌 */
2410
+ let extrasPeeled = false
2411
+ /** 已读过的 400 详情。响应体只能读一次,下面减半分支复用它——二次读会拿到空串,
2412
+ 把「max_tokens 超限」误判成「不可分类错误」直接抛掉 */
2413
+ let pre400Detail = ''
2414
+ // 这两个分支只处理「只能改 body 才能修」的错误:上下文溢出由轮循环裁剪上下文(改 messages)修,
2415
+ // 在这里重试毫无意义,必须原样抛回去
2416
+ const isBodyFixable400 = (text) =>
2417
+ MAX_TOKENS_400_RE.test(text) || (hasExtra && !extrasPeeled && !CONTEXT_400_RE.test(text))
2418
+ if (!resp.ok && resp.status === 400 && hasExtra) {
2419
+ // 网关不认扩展字段(非 vLLM/SGLang 系 / 严格校验的 OpenAI 兼容实现):按顺序逐个摘掉再试,
2420
+ // 别让「可选的观测字段」把整轮打成失败。
2421
+ // 摘除顺序:网关在 400 详情里点名了哪个字段就先摘哪个(见下面 peelOrder 的说明);
2422
+ // 详情没点名时才按 EXT_PEEL_ORDER 的固定顺序——先摘 stream_options(纯观测、摘掉只损失缓存可见性),
2423
+ // 再摘 chat_template_kwargs(摘掉会恢复深度思考)。
2035
2424
  const detail = await resp.text().catch(() => '')
2036
- curExtra = undefined
2037
- resp = await postRetry(curExtra)
2038
- if (!resp.ok) {
2039
- const text = await resp.text().catch(() => '')
2040
- throw llmHttpError(resp.status, text)
2041
- }
2042
- yield {
2043
- type: 'notice',
2044
- text: `当前网关不支持「关闭深度思考」参数(HTTP ${resp.status} 已回退):${detail.slice(0, 120)}`,
2425
+ // 若详情本身就指向 max_tokens 超限,去额外字段救不了它——留给下面的减半分支。
2426
+ // detail 必须带过去(响应体只能读一次),否则减半分支读到空串、被 isTypeless400 判成不可分类直接抛
2427
+ pre400Detail = detail
2428
+ if (!MAX_TOKENS_400_RE.test(detail)) {
2429
+ const removed = []
2430
+ // 严格校验的 OpenAI 兼容网关通常**认得 stream_options**(OpenAI 规范字段,只影响观测),
2431
+ // 却不认 vLLM 系的 chat_template_kwargs —— 它会在 400 详情里直接点名后者。
2432
+ // 若不分青红皂白按固定顺序先摘 stream_options,本轮之后所有请求都拿不到 usage,
2433
+ // 前端只能显示「缓存未提供」——为了关个思考,把整轮的缓存可见性一起赔进去,不值。
2434
+ // 所以:被点名的先摘,没点名才退回固定顺序。
2435
+ const named = EXT_PEEL_ORDER.filter((k) => String(detail).toLowerCase().includes(k))
2436
+ const peelOrder = named.length ? [...named, ...EXT_PEEL_ORDER.filter((k) => !named.includes(k))] : EXT_PEEL_ORDER
2437
+ for (const key of peelOrder) {
2438
+ if (!curExtra || !(key in curExtra)) continue
2439
+ const next = { ...curExtra }
2440
+ delete next[key]
2441
+ curExtra = Object.keys(next).length ? next : undefined
2442
+ removed.push(key)
2443
+ resp = await postRetry(curExtra)
2444
+ for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
2445
+ if (resp.ok) {
2446
+ pre400Detail = ''
2447
+ break
2448
+ }
2449
+ // 非 400(5xx / 网络):不读 body,交给下面的错误归一分支
2450
+ if (resp.status !== 400) {
2451
+ pre400Detail = ''
2452
+ break
2453
+ }
2454
+ pre400Detail = await resp.text().catch(() => '')
2455
+ if (MAX_TOKENS_400_RE.test(pre400Detail)) break
2456
+ }
2457
+ extrasPeeled = true
2458
+ if (removed.length) {
2459
+ const thinkingGone = removed.includes('chat_template_kwargs')
2460
+ yield {
2461
+ type: 'notice',
2462
+ level: 'warn',
2463
+ code: thinkingGone ? 'thinking-unsupported' : 'extra-unsupported',
2464
+ text: `当前网关不接受请求扩展字段(HTTP 400):已摘除 ${removed.join(' / ')} 后重试${
2465
+ resp.ok ? '成功' : '仍失败'
2466
+ }${thinkingGone ? '——本轮的「关闭深度思考」参数失效,思考耗时可能变长' : '——本轮看不到缓存命中统计'}:${String(detail).slice(0, 120)}`,
2467
+ }
2468
+ }
2045
2469
  }
2046
2470
  }
2047
2471
 
2048
- // max_tokens 超网关硬上限 / 窗口:自动减半重试(最多 2 次),避免手填过大或探测值过时时整轮直接失败。
2472
+ // max_tokens 超网关硬上限:自动减半重试(最多 2 次),避免手填过大或探测值过时时整轮直接失败。
2049
2473
  // 分类收紧:只有详情明确指向 max_tokens 才减半;prompt 超窗类 400 减半无意义,
2050
- // 打 context-overflow 标记抛出,由轮循环走「压缩上下文重试」分支
2474
+ // 打 context-overflow 标记抛出,由轮循环走「压缩上下文重试」分支。
2475
+ // 注意这里只处理「响应头阶段」的 400(真 HTTP 400)。DashScope 系把 400 包在 HTTP 200 的流里,
2476
+ // 那种只能在流解析时发现,由下面的 halveAndReopen 负责——两者共用 effMax / halved 计数。
2051
2477
  let effMax = maxTokens
2478
+ /** 已完成的减半次数(上限 2):响应头阶段与流内阶段共用同一预算,不能各扣 2 次 */
2479
+ let halved = 0
2052
2480
  for (let i = 0; i < 2 && !resp.ok && resp.status === 400; i++) {
2053
- const detail = await resp.text().catch(() => '')
2054
- if (!MAX_TOKENS_400_RE.test(detail)) {
2481
+ // 扩展字段回退分支可能已经读过这个 400 的 body:复用,别二次读(二次读只会拿到空串)
2482
+ const detail = pre400Detail || (await resp.text().catch(() => ''))
2483
+ pre400Detail = ''
2484
+ if (!isBodyFixable400(detail) || isTypeless400(detail)) {
2055
2485
  const err = llmHttpError(400, detail)
2056
2486
  if (CONTEXT_400_RE.test(detail)) err.code = 'context-overflow'
2057
2487
  throw err
2058
2488
  }
2059
2489
  const prev = effMax
2490
+ halved++
2060
2491
  effMax = Math.max(2048, Math.floor(effMax / 2))
2061
2492
  resp = await postRetry(curExtra, effMax)
2062
- for (const text of pendingNotices.splice(0)) yield { type: 'notice', text }
2063
- yield { type: 'notice', text: `单次输出上限 ${prev} 被网关拒绝(HTTP 400):已自动减半为 ${effMax} 重试` }
2493
+ for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
2494
+ yield {
2495
+ type: 'notice',
2496
+ level: 'warn',
2497
+ code: 'max-tokens-halved',
2498
+ text: `单次输出上限 ${prev} 被网关拒绝(HTTP 400):已自动减半为 ${effMax} 重试`,
2499
+ }
2064
2500
  }
2065
2501
 
2066
2502
  if (!resp.ok) {
2067
- const text = await resp.text().catch(() => '')
2503
+ const text = pre400Detail || (await resp.text().catch(() => ''))
2504
+ pre400Detail = ''
2068
2505
  throw llmHttpError(resp.status, text)
2069
2506
  }
2507
+
2070
2508
  // 减半后的值被网关接受 = 「网关接受的单次输出上限」的证据:回写 capCache(**不是窗口缓存**)。
2071
2509
  // 窗口与输出上限是两回事:本网关实测 max_tokens 硬上限 131072、/models 又无窗口字段,
2072
2510
  // 把接受值当窗口会污染圆环分母与单轮上下文预算
@@ -2078,9 +2516,11 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2078
2516
  if (!resp.body) throw new Error('LLM 返回空 body')
2079
2517
 
2080
2518
  const decoder = new TextDecoder()
2081
- const reader = resp.body.getReader()
2519
+ let reader = resp.body.getReader()
2082
2520
  let buffer = ''
2083
2521
  let toolCallsAccum = null
2522
+ /** halveAndReopen 换过流后置真:外层循环据此清空半截缓冲,避免新旧流内容混在一起 */
2523
+ let reopened = false
2084
2524
 
2085
2525
  const flushToolCalls = () => {
2086
2526
  if (!toolCallsAccum) return null
@@ -2092,11 +2532,80 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2092
2532
  return { type: 'tool-calls', toolCalls: finalized }
2093
2533
  }
2094
2534
 
2535
+ /**
2536
+ * 流内 400 的减半重试:换更小的 max_tokens 重新发起,并把新响应交回外层循环解析。
2537
+ * 复用响应头阶段那套逻辑,但必须重新探测新流是不是又是一条 error——
2538
+ * 不探的话外层循环会把它当真流读一遍,白跑一轮还把同一个错误重复抛出去。
2539
+ * @returns {Promise<boolean>} true = 已换好新流可重新解析;false = 减半已用尽 / 新流仍是错误
2540
+ */
2541
+ const halveAndReopen = async function* () {
2542
+ if (halved >= 2) return false
2543
+ try {
2544
+ reader.releaseLock?.()
2545
+ } catch {
2546
+ /* 已读完释放,忽略 */
2547
+ }
2548
+ // 上一版流的半截行/累积与新版无关:留着会把两版内容拼在一起
2549
+ buffer = ''
2550
+ toolCallsAccum = null
2551
+ const prev = effMax
2552
+ halved++
2553
+ effMax = Math.max(2048, Math.floor(effMax / 2))
2554
+ const next = await postRetry(curExtra, effMax)
2555
+ for (const n of pendingNotices.splice(0)) yield { type: 'notice', text: n.text, level: n.level, code: n.code }
2556
+ if (!next.ok || !next.body) {
2557
+ const text = await next.text().catch(() => '')
2558
+ const err = llmHttpError(next.status, text)
2559
+ if (CONTEXT_400_RE.test(text)) err.code = 'context-overflow'
2560
+ throw err
2561
+ }
2562
+ yield {
2563
+ type: 'notice',
2564
+ level: 'warn',
2565
+ code: 'max-tokens-halved',
2566
+ text: `单次输出上限 ${prev} 被网关拒绝(流内错误):已自动减半为 ${effMax} 重试`,
2567
+ }
2568
+ const { error: peek, reader: nextReader, first } = await peekStreamError(next)
2569
+ if (peek) {
2570
+ // 减半后还是同一条错误:继续减(最多到 2048)或原样抛出,交给轮循环
2571
+ if (halved < 2 && isBodyFixable400(peek)) {
2572
+ resp = next
2573
+ reader = next.body.getReader()
2574
+ return yield* halveAndReopen()
2575
+ }
2576
+ const err = llmHttpError(400, peek)
2577
+ if (CONTEXT_400_RE.test(peek)) err.code = 'context-overflow'
2578
+ throw err
2579
+ }
2580
+ // 真流:沿用 peek 阶段已建立的 reader,把首块字节塞回缓冲后交外层继续读
2581
+ resp = next
2582
+ reader = nextReader || next.body.getReader()
2583
+ if (first) buffer = new TextDecoder().decode(first)
2584
+ reopened = true
2585
+ return true
2586
+ }
2587
+
2588
+ // 外层循环的存在只有一个理由:流内 400 无法在响应头阶段发现,只能读到那一行才知道。
2589
+ // halveAndReopen 换好新流后把 buffer 重置为「新流已 peek 出的首块字节」并置 reopened,
2590
+ // 这里只需丢掉上一版流残留的半截行与工具累积,不能碰 buffer(里面是新流的首块)。
2591
+ // reader 的释放放在 finally:不能放循环体内——continue parse 要用新流的 reader 继续读,
2592
+ // 在循环里释放会把新流的锁也解掉(实测报「not attached to a stream」)。
2593
+ // 换流时旧的 reader 已由 peekStreamError 释放,这里的 releaseLock 对它是空操作。
2095
2594
  try {
2595
+ parse: while (true) {
2596
+ if (reopened) {
2597
+ toolCallsAccum = null
2598
+ reopened = false
2599
+ }
2096
2600
  while (true) {
2097
2601
  if (signal?.aborted) return
2098
2602
  const { done, value } = await reader.read()
2099
2603
  if (done) break
2604
+ // 收到字节就冒一个「连接还活着」事件:轮循环的静默看门狗据此区分「网关彻底不回应」与
2605
+ // 「模型正在长思考 / 正在拼超长工具参数,中转网关只在末尾批量 flush」。
2606
+ // 此前看门狗只认 reasoning / content / tool_calls 增量:网关缓冲工具参数时连接明明是活的,
2607
+ // 也被判成「上游 N 秒没有返回任何数据」并整轮报废(实测设计稿建卡连续两轮死在 fs_write 参数半途)。
2608
+ yield { type: 'stream-alive' }
2100
2609
  buffer += decoder.decode(value, { stream: true })
2101
2610
 
2102
2611
  const lines = buffer.split('\n')
@@ -2117,8 +2626,31 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2117
2626
  } catch {
2118
2627
  continue
2119
2628
  }
2629
+ // 部分网关(DashScope 系实测)把 4xx 包在 HTTP 200 的 SSE 流里回:body 只有一条
2630
+ // data: {"error":{"code":...,"message":"<400> ... Range of max_tokens should be [1, 8192]"}}。
2631
+ // 这类行没有 choices,旧逻辑会静默 continue → 整轮零产出 → 被下游误判成「网关返回空响应」,
2632
+ // 真实原因(max_tokens 超限 / 参数非法 / 上下文溢出)全部丢失,且 max_tokens 减半兜底
2633
+ // 因 !resp.ok 为假而永不触发。
2634
+ // 处置:能靠改 body 修的(max_tokens 超限)当场减半并重开流解析;其余原样抛出交轮循环处理
2635
+ if (chunk.error) {
2636
+ const detail = JSON.stringify(chunk)
2637
+ // 一次都没减半过才允许重来:吐过字节后再换 body 重放会造成前端内容重复
2638
+ if (halved === 0 && isBodyFixable400(detail) && !isTypeless400(detail)) {
2639
+ const ok = yield* halveAndReopen()
2640
+ if (ok) continue parse
2641
+ }
2642
+ const err = llmHttpError(Number(chunk.error.code) || 400, detail)
2643
+ if (CONTEXT_400_RE.test(detail)) err.code = 'context-overflow'
2644
+ throw err
2645
+ }
2646
+ // usage 可能挂在收尾块(choices 为空)上,也可能挂在最后一个带 choice 的块上:
2647
+ // 两个位置都取,命中率统计才不会因为网关风格不同而丢
2648
+ if (chunk.usage) {
2649
+ const usage = normalizeUsage(chunk.usage)
2650
+ if (usage) yield { type: 'usage', usage }
2651
+ }
2120
2652
  const choice = chunk.choices?.[0]
2121
- if (!choice) continue // usage chunk 等
2653
+ if (!choice) continue // 只剩 usage 的收尾块
2122
2654
  const delta = choice.delta || {}
2123
2655
  // reasoning 字段各家命名不一:reasoning_content(DeepSeek/智谱)/ reasoning(部分网关)
2124
2656
  const reasoning = delta.reasoning_content ?? delta.reasoning ?? ''
@@ -2156,12 +2688,13 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2156
2688
  if (evt) yield evt
2157
2689
  }
2158
2690
  }
2159
- }
2691
+ }
2692
+ }
2160
2693
  } finally {
2161
2694
  try {
2162
2695
  reader.releaseLock?.()
2163
2696
  } catch {
2164
- /* 已中止时忽略 */
2697
+ /* 已释放 / 已中止:忽略 */
2165
2698
  }
2166
2699
  }
2167
2700
 
@@ -2185,6 +2718,31 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
2185
2718
  /** 工具结果里内联图片的体积上限(超出只留摘要,避免把上下文塞爆) */
2186
2719
  const TOOL_IMAGE_MAX_BYTES = 2 * 1024 * 1024
2187
2720
 
2721
+ /**
2722
+ * 工具调用参数归一:网关原样吐的 arguments 字符串,与「下一回合回放时由前端 args 重新序列化」
2723
+ * 的结果**未必逐字节相同**(多余空格、数字写法 1.0→1、转义差异)。只要差一个字节,
2724
+ * 那条 assistant.tool_calls 消息就在轮边界被改写 ⇒ 前缀缓存从它起全部失效。
2725
+ * 统一走 parse → stringify(解析失败保留原文),两侧就长得一模一样了。
2726
+ */
2727
+ function normalizeToolArgs(raw) {
2728
+ const s = typeof raw === 'string' ? raw : raw == null ? '' : String(raw)
2729
+ if (!s) return '{}'
2730
+ try {
2731
+ return JSON.stringify(JSON.parse(s))
2732
+ } catch {
2733
+ return s
2734
+ }
2735
+ }
2736
+
2737
+ /** 落进模型上下文的工具调用:与 toOpenAiMessages 恢复出来的形状保持同一序列化口径 */
2738
+ function contextToolCalls(calls) {
2739
+ return (calls || []).map((tc, i) => ({
2740
+ id: tc?.id || `call_${i}`,
2741
+ type: 'function',
2742
+ function: { name: tc?.function?.name || '', arguments: normalizeToolArgs(tc?.function?.arguments) },
2743
+ }))
2744
+ }
2745
+
2188
2746
  /**
2189
2747
  * 拆出工具结果中的 base64 图片(card_screenshot 等):
2190
2748
  * 图片对「文本模型」毫无价值却极占上下文,故从回传 LLM 的文本里剔除,只留给前端做预览。
@@ -2489,6 +3047,46 @@ function toolRoundSignature(toolCalls) {
2489
3047
  return toolCalls.map((tc) => `${tc?.function?.name || '?'}${stableStringify(safeParseArgs(tc?.function?.arguments))}`).join('|')
2490
3048
  }
2491
3049
 
3050
+ // ---- 前缀缓存观测:usage 归一 + 前缀漂移定位 ----
3051
+
3052
+ /** 各家网关的缓存字段命名不一,统一摘成一份口径 */
3053
+ function normalizeUsage(u) {
3054
+ if (!u || typeof u !== 'object') return null
3055
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
3056
+ const promptTokens = num(u.prompt_tokens ?? u.input_tokens)
3057
+ const completionTokens = num(u.completion_tokens ?? u.output_tokens)
3058
+ const details = u.prompt_tokens_details || u.input_tokens_details || {}
3059
+ const rawCached = details.cached_tokens ?? u.prompt_cache_hit_tokens ?? u.cache_read_input_tokens ?? u.cached_tokens
3060
+ const cachedTokens = num(rawCached)
3061
+ const cacheMissTokens = u.prompt_cache_miss_tokens !== undefined ? num(u.prompt_cache_miss_tokens) : Math.max(0, promptTokens - cachedTokens)
3062
+ return {
3063
+ promptTokens,
3064
+ completionTokens,
3065
+ cachedTokens,
3066
+ cacheMissTokens,
3067
+ hitRate: promptTokens > 0 ? Math.round((cachedTokens / promptTokens) * 1000) / 10 : 0,
3068
+ // reported=false ⇒ 该网关压根不报缓存字段(如本仓的微信 GLM 网关),界面只能显示「未提供」
3069
+ reported: rawCached !== undefined,
3070
+ }
3071
+ }
3072
+
3073
+ /**
3074
+ * 定位前缀漂移:逐条比对上一次实际发给网关的消息与本次的,返回第一处不同的下标。
3075
+ * - 下标落在尾部(= 只是追加了新消息)⇒ 正常,缓存继续命中;
3076
+ * - 下标落在中段(rewrote=true)⇒ 历史被改写,从那一条起缓存全部失效,应当留痕告警。
3077
+ * 只比对序列化结果,不看语义:网关也是这么判的。
3078
+ */
3079
+ function prefixDrift(prev, next) {
3080
+ const a = Array.isArray(prev) ? prev : []
3081
+ const b = Array.isArray(next) ? next : []
3082
+ const limit = Math.min(a.length, b.length)
3083
+ let i = 0
3084
+ for (; i < limit; i++) {
3085
+ if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) break
3086
+ }
3087
+ return { index: i, tail: limit, rewrote: i < limit, shared: i }
3088
+ }
3089
+
2492
3090
  // ---- 上下文预算与压缩 ----
2493
3091
 
2494
3092
  /** 输入上下文默认预算(token 粗估):够装下几万字节卡片片段 + 十几轮对话,又不至于拖慢推理 */
@@ -2601,41 +3199,54 @@ function openAiMsgTokens(m) {
2601
3199
  }
2602
3200
 
2603
3201
  /**
2604
- * 单轮内上下文裁剪:超预算时从**最早**的消息开始折叠,直到落回预算内。
3202
+ * 单轮内上下文裁剪:超预算时折叠超长工具结果 / 注入图像,直到落回预算内。
2605
3203
  * 只改 content、绝不删消息 —— tool_calls 与 role:'tool' 的配对一旦被破坏,网关会直接 400。
2606
- * @returns {{messages:any[], trimmed:number, tokens:number, before:number}}
3204
+ *
3205
+ * **粘滞推进(前缀缓存的关键)**:`from` = 上次已经处理到的下标,本次只从它往后折。
3206
+ * 老实现每轮都从 index 1 重新扫,`floor = length - keepRecent` 又随消息增长不断右移,
3207
+ * 于是一条「上一步还在最近窗口里、保持原文」的工具结果,下一步就被折叠改写 ——
3208
+ * 历史中段被改写 ⇒ 从那条起往后的前缀缓存全部失效(实测每步都在丢命中)。
3209
+ * 传 `from` 之后:折过的不会还原、没折过的不会被回头补折,前缀保持逐字节稳定。
3210
+ * 只有「预算真的兜不住」(整个 [from, floor) 都折完仍超)才会回改早期区域,这是罕见且有提示的路径。
3211
+ *
3212
+ * @param {{budgetTokens?:number, keepRecent?:number, stubChars?:number, from?:number}} [opts]
3213
+ * @returns {{messages:any[], trimmed:number, tokens:number, before:number, from:number}} from = 下次的起点
2607
3214
  */
2608
- function trimTurnContext(messages, { budgetTokens = DEFAULT_TURN_CONTEXT_TOKENS, keepRecent = TRIM_KEEP_RECENT, stubChars = TRIM_STUB_CHARS } = {}) {
3215
+ function trimTurnContext(messages, { budgetTokens = DEFAULT_TURN_CONTEXT_TOKENS, keepRecent = TRIM_KEEP_RECENT, stubChars = TRIM_STUB_CHARS, from = 1 } = {}) {
2609
3216
  const list = Array.isArray(messages) ? messages.slice() : []
2610
3217
  const before = list.reduce((a, m) => a + openAiMsgTokens(m), 0)
2611
3218
  let total = before
2612
- if (!(budgetTokens > 0) || total <= budgetTokens) return { messages: list, trimmed: 0, tokens: total, before }
2613
3219
  // index 0 是系统提示,永远保留;最近 keepRecent 条也保持原文(当前工作记忆)
2614
3220
  const floor = Math.max(1, list.length - keepRecent)
3221
+ const start = Math.max(1, Math.min(Number(from) || 1, floor))
3222
+ if (!(budgetTokens > 0) || total <= budgetTokens) return { messages: list, trimmed: 0, tokens: total, before, from: start }
2615
3223
  let trimmed = 0
2616
- // ① 折叠早期超长工具结果:留下头部 + 明确的「重新调用可取全文」指引
2617
- for (let i = 1; i < floor && total > budgetTokens; i++) {
2618
- const m = list[i]
2619
- if (m?.role !== 'tool' || typeof m.content !== 'string' || m.content.length <= stubChars * 2) continue
2620
- const prev = openAiMsgTokens(m)
2621
- list[i] = {
2622
- ...m,
2623
- content: `${m.content.slice(0, stubChars)}…\n[工具结果已折叠以腾出单轮上下文预算;需要完整内容请重新调用该工具,不要凭这段摘要猜结论]`,
2624
- }
3224
+ const stub = (i, content) => {
3225
+ const prev = openAiMsgTokens(list[i])
3226
+ list[i] = { ...list[i], content }
2625
3227
  total -= prev - openAiMsgTokens(list[i])
2626
3228
  trimmed++
2627
3229
  }
2628
- // ② 仍超预算:丢掉早期注入图像(图 token 很贵),保留其文字说明
2629
- for (let i = 1; i < floor && total > budgetTokens; i++) {
3230
+ // ① 从 from 起顺序折叠:超长工具结果留头部 + 「重新调用可取全文」指引
3231
+ let i = start
3232
+ while (i < floor && total > budgetTokens) {
2630
3233
  const m = list[i]
2631
- if (m?.role !== 'user' || !Array.isArray(m.content) || !m.content.some((part) => part?.type === 'image_url')) continue
2632
- const prev = openAiMsgTokens(m)
2633
- const texts = m.content.filter((part) => part?.type === 'text').map((part) => part.text)
2634
- list[i] = { ...m, content: `${texts.join(' ')}\n[图像已折叠以腾出单轮上下文预算;需要看图请重新跑一次渲染 / 对比工具]` }
2635
- total -= prev - openAiMsgTokens(list[i])
2636
- trimmed++
3234
+ if (m?.role === 'tool' && typeof m.content === 'string' && m.content.length > stubChars * 2) {
3235
+ stub(i, `${m.content.slice(0, stubChars)}…\n[工具结果已折叠以腾出单轮上下文预算;需要完整内容请重新调用该工具,不要凭这段摘要猜结论]`)
3236
+ } else if (m?.role === 'user' && Array.isArray(m.content) && m.content.some((part) => part?.type === 'image_url')) {
3237
+ // 注入图像(图 token 很贵)折叠成文字说明
3238
+ const texts = m.content.filter((part) => part?.type === 'text').map((part) => part.text)
3239
+ stub(i, `${texts.join(' ')}\n[图像已折叠以腾出单轮上下文预算;需要看图请重新跑一次渲染 / 对比工具]`)
3240
+ }
3241
+ i++
3242
+ }
3243
+ // ② 整个活跃区都折完仍超预算:只能回改早期区域(会改写历史前缀,调用方据此告警)
3244
+ for (let j = 1; j < start && total > budgetTokens; j++) {
3245
+ const m = list[j]
3246
+ if (m?.role !== 'tool' || typeof m.content !== 'string' || m.content.length <= stubChars * 2) continue
3247
+ stub(j, `${m.content.slice(0, stubChars)}…\n[工具结果已折叠以腾出单轮上下文预算;需要完整内容请重新调用该工具,不要凭这段摘要猜结论]`)
2637
3248
  }
2638
- return { messages: list, trimmed, tokens: total, before }
3249
+ return { messages: list, trimmed, tokens: total, before, from: i }
2639
3250
  }
2640
3251
 
2641
3252
  /** 深度压缩后最近多少条保持原文(含进行中的 assistant/tool 配对与最新证据) */
@@ -2717,7 +3328,9 @@ function toOpenAiMessages(messages) {
2717
3328
  out.push(msg)
2718
3329
  for (const tc of calls) {
2719
3330
  const result = tc.error ? `Error: ${tc.error}` : typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result ?? '')
2720
- out.push({ role: 'tool', tool_call_id: tc.toolCallId || tc.id || `call_${calls.indexOf(tc)}`, content: result.slice(0, 12000) })
3331
+ // 截断口径必须与轮内 live 上下文(下方 currentMessages.push)逐字一致:
3332
+ // 前端回传的历史是按同一上限截过的,两边不同就会在轮边界改写历史 ⇒ 前缀缓存整段失效
3333
+ out.push({ role: 'tool', tool_call_id: tc.toolCallId || tc.id || `call_${calls.indexOf(tc)}`, content: result.slice(0, TOOL_RESULT_MAX_CHARS) })
2721
3334
  }
2722
3335
  continue
2723
3336
  }
@@ -2736,7 +3349,37 @@ export function createAiChatMiddleware(opts = {}) {
2736
3349
  // 仓库内运行由「root === 仓库根」自动成立;cardbuddy 发布态快照根在包内,由宿主显式开启。
2737
3350
  const allowDispatch = opts.allowDispatch === true
2738
3351
  /** 对话记录落盘目录(.cache/ 已在 .gitignore):随 root 走,测试可用临时根隔离 */
2739
- const chatDir = path.join(root, '.cache', 'ai-chats')
3352
+ const chatDir = chatHistoryDir(root)
3353
+ /**
3354
+ * 同卡单飞锁:一张卡同时只允许一个回合在跑。
3355
+ * 并发回合会互相覆盖 card_write 落盘、把 .history 快照顺序打乱,前端也会渲染出两条「生成中」。
3356
+ * 挂在中间件实例上(不是模块级):测试里各自 createAiChatMiddleware() 互不串味。
3357
+ * key = cardContext.id;没有卡上下文时用固定 key,仍然单飞。
3358
+ */
3359
+ const activeTurns = new Map()
3360
+ /** 锁的兜底过期时间:墙钟熔断保证回合会在 turnTimeoutMs 附近结束,超时残留(进程异常 / 监听器漏放)就允许接管 */
3361
+ const STALE_TURN_LOCK_MS = 30 * 60 * 1000
3362
+
3363
+ /**
3364
+ * 是否有回合正在跑(跨所有卡片)。
3365
+ * 用于「对话进行中禁止改动生效模型配置」:每轮对话在开跑时把 baseUrl/apiKey/model/maxTokens 快照进
3366
+ * finalConfig,中途改配置不会影响这一轮(已验证),但会让用户误以为改了立刻生效——下一轮才切,
3367
+ * 长任务里这种「改了没反应」极易被当成 bug。故运行期一律拒绝写生效模型。
3368
+ * 与单飞锁同一套过期判定:残留锁不会永久锁死配置面板。
3369
+ */
3370
+ const anyTurnRunning = () => {
3371
+ const now = Date.now()
3372
+ for (const t of activeTurns.values()) {
3373
+ if (now - t.since < Math.max(STALE_TURN_LOCK_MS, Number(t.turnTimeoutMs || 0) + 5 * 60 * 1000)) return true
3374
+ }
3375
+ return false
3376
+ }
3377
+ /** 运行期改动生效模型配置的统一拒绝响应 */
3378
+ const rejectIfTurnRunning = (req, res, message) => {
3379
+ if (!anyTurnRunning()) return false
3380
+ sendJson(req, res, 409, { error: 'turn-in-flight', message })
3381
+ return true
3382
+ }
2740
3383
 
2741
3384
  return async (req, res, next) => {
2742
3385
  const rawUrl = req.url || ''
@@ -2777,13 +3420,21 @@ export function createAiChatMiddleware(opts = {}) {
2777
3420
  // maxTokens = 本轮实际会用的值;maxTokensAuto = 用户没填(面板据此留空 + 显示自动值)
2778
3421
  maxTokens: effectiveMaxTokens(cfg),
2779
3422
  maxTokensAuto: !(cfg.maxTokens > 0),
3423
+ // cap = 网关**实测接受**的上限(唯一测量值);ceiling = 自动模式封顶的**假设值**,两者不可混用
2780
3424
  maxTokensCap: capCache.get(maxTokensSig(cfg))?.cap || 0,
2781
3425
  maxTokensWindow: windowCache.get(maxTokensSig(cfg))?.window || 0,
3426
+ maxTokensCeiling: AUTO_MAX_TOKENS_CEILING,
3427
+ // 有回合在跑(跨卡片):前端据此禁用模型库的切换 / 编辑生效项(后端另有 409 兜底)
3428
+ turnRunning: anyTurnRunning(),
2782
3429
  thinking: cfg.thinking !== false,
2783
3430
  vision: cfg.vision === true,
2784
3431
  // 0 = 不限步数(默认);>0 = 用户自设硬顶。面板据此留空并显示「不限 + 熔断器」说明
2785
3432
  maxSteps: cfg.maxSteps,
2786
3433
  turnTimeoutMs: cfg.turnTimeoutMs,
3434
+ stallTimeoutMs: cfg.stallTimeoutMs,
3435
+ stallRetries: cfg.stallRetries,
3436
+ reasoningTimeoutMs: cfg.reasoningTimeoutMs,
3437
+ autoContinueMax: cfg.autoContinueMax,
2787
3438
  }
2788
3439
  // 设置面板要「回显」已存密钥:仅 ?reveal=1 回明文(本系列端点只放行可信来源)
2789
3440
  if (query.get('reveal') === '1') out.apiKey = cfg.apiKey
@@ -2796,20 +3447,60 @@ export function createAiChatMiddleware(opts = {}) {
2796
3447
  } catch {
2797
3448
  return sendJson(req, res, 400, { error: 'invalid-body' })
2798
3449
  }
3450
+ // 本端点只接受「执行策略类公共字段」。收到别的字段(尤其遗留的扁平模型键
3451
+ // baseUrl / apiKey / model / maxTokens / thinking / vision)必须显式报错:
3452
+ // 此前是静默忽略后返回 200 {ok:true},调用方以为存成功,其实一个字节都没写
3453
+ // (实测:POST {baseUrl,model} 返回 200,但 .env.cb.local 不变、回读仍是旧值)。
3454
+ // 模型身份 / 能力字段走 /__ai/models,这里直接给出正确去处。
3455
+ const ALLOWED = new Set([
3456
+ 'allowShell',
3457
+ 'autoApprove',
3458
+ 'maxSteps',
3459
+ 'turnTimeoutMs',
3460
+ 'stallTimeoutMs',
3461
+ 'stallRetries',
3462
+ 'reasoningTimeoutMs',
3463
+ 'autoContinueMax',
3464
+ ])
3465
+ const MODEL_FIELDS = new Set([
3466
+ 'id',
3467
+ 'name',
3468
+ 'baseUrl',
3469
+ 'apiKey',
3470
+ 'model',
3471
+ 'maxTokens',
3472
+ 'thinking',
3473
+ 'vision',
3474
+ 'createdAt',
3475
+ 'updatedAt',
3476
+ ])
3477
+ const unknown = Object.keys(body || {}).filter((k) => !ALLOWED.has(k))
3478
+ if (unknown.length) {
3479
+ const modelish = unknown.filter((k) => MODEL_FIELDS.has(k))
3480
+ return sendJson(req, res, 400, {
3481
+ error: 'unknown-fields',
3482
+ message:
3483
+ modelish.length > 0
3484
+ ? `本端点只写执行策略类公共字段,不接受模型字段:${modelish.join(' / ')}。模型身份与能力请改用 /__ai/models(POST 新增 / PUT 更新)。`
3485
+ : `未知字段:${unknown.join(' / ')}。本端点只接受:${[...ALLOWED].join(' / ')}。`,
3486
+ unknown,
3487
+ ...(modelish.length > 0 ? { modelFields: modelish, useInstead: '/__ai/models' } : {}),
3488
+ })
3489
+ }
2799
3490
  try {
2800
3491
  // apiKey 为空串 = 清除本地覆盖;未传(undefined)= 保持不变
2801
3492
  // 设置变更(换网关 / 换模型 / 换密钥 / 动视觉开关)→ 视觉探测结论作废,下轮重探
2802
3493
  visionProbeCache.clear()
3494
+ // 全局公共配置:仅执行策略类字段(与模型身份无关,所有模型共享)。模型身份/能力字段走 /__ai/models。
2803
3495
  writeAiConfigLocal(root, {
2804
- baseUrl: body.baseUrl,
2805
- apiKey: body.apiKey === '' ? '' : body.apiKey,
2806
- model: body.model,
2807
3496
  allowShell: body.allowShell === undefined ? undefined : Boolean(body.allowShell),
2808
- maxTokens: body.maxTokens === undefined ? undefined : body.maxTokens,
2809
- thinking: body.thinking === undefined ? undefined : Boolean(body.thinking),
2810
- vision: body.vision === undefined ? undefined : Boolean(body.vision),
2811
3497
  autoApprove: body.autoApprove === undefined ? undefined : normalizeAutoApprove(body.autoApprove),
2812
3498
  maxSteps: body.maxSteps === undefined ? undefined : body.maxSteps,
3499
+ turnTimeoutMs: body.turnTimeoutMs === undefined ? undefined : body.turnTimeoutMs,
3500
+ stallTimeoutMs: body.stallTimeoutMs === undefined ? undefined : body.stallTimeoutMs,
3501
+ stallRetries: body.stallRetries === undefined ? undefined : body.stallRetries,
3502
+ reasoningTimeoutMs: body.reasoningTimeoutMs === undefined ? undefined : body.reasoningTimeoutMs,
3503
+ autoContinueMax: body.autoContinueMax === undefined ? undefined : body.autoContinueMax,
2813
3504
  })
2814
3505
  const cfg = readAiConfig(root)
2815
3506
  return sendJson(req, res, 200, {
@@ -2823,11 +3514,17 @@ export function createAiChatMiddleware(opts = {}) {
2823
3514
  maxTokens: effectiveMaxTokens(cfg),
2824
3515
  maxTokensAuto: !(cfg.maxTokens > 0),
2825
3516
  maxTokensCap: capCache.get(maxTokensSig(cfg))?.cap || 0,
2826
- maxTokensWindow: windowCache.get(maxTokensSig(cfg))?.window || 0,
3517
+ maxTokensWindow: windowCache.get(maxTokensSig(cfg))?.window || 0,
3518
+ maxTokensCeiling: AUTO_MAX_TOKENS_CEILING,
3519
+ turnRunning: anyTurnRunning(),
2827
3520
  thinking: cfg.thinking !== false,
2828
3521
  vision: cfg.vision === true,
2829
3522
  maxSteps: cfg.maxSteps,
2830
3523
  turnTimeoutMs: cfg.turnTimeoutMs,
3524
+ stallTimeoutMs: cfg.stallTimeoutMs,
3525
+ stallRetries: cfg.stallRetries,
3526
+ reasoningTimeoutMs: cfg.reasoningTimeoutMs,
3527
+ autoContinueMax: cfg.autoContinueMax,
2831
3528
  })
2832
3529
  } catch (e) {
2833
3530
  return sendJson(req, res, 500, { error: 'write-failed', message: String((e && e.message) || e) })
@@ -2836,24 +3533,154 @@ export function createAiChatMiddleware(opts = {}) {
2836
3533
  return sendJson(req, res, 405, { error: 'method-not-allowed' })
2837
3534
  }
2838
3535
 
3536
+ // ---------- 模型库(多模型管理)----------
3537
+ {
3538
+ const mm = pathname.match(/^\/__ai\/models(?:\/([^/]+)(?:\/activate)?)?$/)
3539
+ if (mm) {
3540
+ const id = mm[1] || null
3541
+ const activate = /\/activate$/.test(pathname)
3542
+ if (req.method === 'GET' && !id) {
3543
+ const { models, activeId } = readAiModels(root)
3544
+ // turnRunning:有回合在跑时前端禁用「切换」与「编辑生效项」,与后端 409 兜底同源
3545
+ return sendJson(req, res, 200, { models: models.map(maskModel), activeId, turnRunning: anyTurnRunning() })
3546
+ }
3547
+ if (req.method === 'GET' && id) {
3548
+ const { models } = readAiModels(root)
3549
+ const m = models.find((x) => x.id === id)
3550
+ if (!m) return sendJson(req, res, 404, { error: 'model-not-found' })
3551
+ // ?reveal=1 仅可信来源回明文 key(供编辑面板回显)
3552
+ const reveal = query.get('reveal') === '1'
3553
+ return sendJson(req, res, 200, reveal ? m : maskModel(m))
3554
+ }
3555
+ if (req.method === 'POST' && !id) {
3556
+ // 新增:生成 id 入列。仅在「列表原本为空」时自动设为生效模型——
3557
+ // 已有生效模型时新增不应抢夺生效权(新增多个模型不该来回切当前模型)。
3558
+ let body
3559
+ try {
3560
+ body = await readBody(req)
3561
+ } catch {
3562
+ return sendJson(req, res, 400, { error: 'invalid-body' })
3563
+ }
3564
+ // 必填校验(后端兜底,前端已校验;防止绕过 UI 写入无法运行的模型)
3565
+ const vErr = validateModelInput(body, { requireKey: true })
3566
+ if (vErr) return sendJson(req, res, 400, { error: 'invalid-model', message: vErr })
3567
+ const base = normalizeModel({ ...body, id: genModelId(), createdAt: Date.now(), updatedAt: Date.now() })
3568
+ const { models, activeId: prevActive } = readAiModels(root)
3569
+ models.push(base)
3570
+ // 首个模型必激活(否则「只有一个模型却未启用」);已有生效模型则保持不动
3571
+ const activeId = models.length === 1 || !prevActive ? base.id : prevActive
3572
+ writeAiModelsLocal(root, models, activeId)
3573
+ return sendJson(req, res, 200, { model: maskModel(base), activeId })
3574
+ }
3575
+ if (req.method === 'PUT' && id) {
3576
+ // 运行期禁止改动「正在使用」的模型:本轮已把配置快照进 finalConfig,改了不影响这一轮,
3577
+ // 用户却会以为改动已生效 → 下一轮才切。长任务里这种「改了没反应」最容易被当成 bug。
3578
+ if (id === readAiModels(root).activeId) {
3579
+ if (rejectIfTurnRunning(req, res, '有对话正在进行,无法修改正在使用的模型:请先等这一轮结束,或在输入区点「停止」后再改'))
3580
+ return
3581
+ }
3582
+ let body
3583
+ try {
3584
+ body = await readBody(req)
3585
+ } catch {
3586
+ return sendJson(req, res, 400, { error: 'invalid-body' })
3587
+ }
3588
+ const { models, activeId } = readAiModels(root)
3589
+ const idx = models.findIndex((x) => x.id === id)
3590
+ if (idx < 0) return sendJson(req, res, 404, { error: 'model-not-found' })
3591
+ const prev = models[idx]
3592
+ // apiKey 空串 = 清除;未传(undefined) = 沿用原值
3593
+ const apiKey = body.apiKey === undefined ? prev.apiKey : body.apiKey === '' ? '' : body.apiKey
3594
+ // 必填校验:合并后仍须满足(模型名/Base URL 非空;密钥在最终可沿用已存时不强制重填)
3595
+ const vErr = validateModelInput({ ...prev, ...body }, { requireKey: !apiKey })
3596
+ if (vErr) return sendJson(req, res, 400, { error: 'invalid-model', message: vErr })
3597
+ const merged = normalizeModel({
3598
+ ...prev,
3599
+ ...body,
3600
+ id: prev.id,
3601
+ createdAt: prev.createdAt,
3602
+ updatedAt: Date.now(),
3603
+ apiKey,
3604
+ })
3605
+ models[idx] = merged
3606
+ writeAiModelsLocal(root, models, activeId)
3607
+ return sendJson(req, res, 200, { model: maskModel(merged), activeId })
3608
+ }
3609
+ if (req.method === 'DELETE' && id) {
3610
+ const { models, activeId } = readAiModels(root)
3611
+ const idx = models.findIndex((x) => x.id === id)
3612
+ if (idx < 0) return sendJson(req, res, 404, { error: 'model-not-found' })
3613
+ // 删掉正在使用的模型会让 activeId 静默回落,正在跑的回合与下一轮用到的是两个模型
3614
+ if (id === activeId) {
3615
+ if (rejectIfTurnRunning(req, res, '有对话正在进行,无法删除正在使用的模型:请先等这一轮结束,或在输入区点「停止」后再删'))
3616
+ return
3617
+ }
3618
+ models.splice(idx, 1)
3619
+ const newActive = activeId === id ? (models[0] ? models[0].id : null) : activeId
3620
+ writeAiModelsLocal(root, models, newActive)
3621
+ return sendJson(req, res, 200, { ok: true, activeId: newActive })
3622
+ }
3623
+ if (req.method === 'POST' && id && activate) {
3624
+ const { models, activeId } = readAiModels(root)
3625
+ if (!models.some((x) => x.id === id)) return sendJson(req, res, 404, { error: 'model-not-found' })
3626
+ // 这就是核心那条:对话进行中不允许切换生效模型(在跑的回合与新回合会用不同模型)
3627
+ if (id !== activeId) {
3628
+ if (rejectIfTurnRunning(req, res, '有对话正在进行,无法切换模型:请先等这一轮结束,或在输入区点「停止」后再切换'))
3629
+ return
3630
+ }
3631
+ writeAiModelsLocal(root, models, id)
3632
+ return sendJson(req, res, 200, { ok: true, activeId: id })
3633
+ }
3634
+ return sendJson(req, res, 405, { error: 'method-not-allowed' })
3635
+ }
3636
+ }
3637
+
3638
+ // ---------- cc-switch 供应商导入(从 ~/.cc-switch/cc-switch.db 读 Codex 类 provider)----------
3639
+ if (pathname === '/__ai/cc-switch-providers' && req.method === 'GET') {
3640
+ const result = listCcSwitchProviders()
3641
+ return sendJson(req, res, 200, result)
3642
+ }
3643
+
2839
3644
  // ---------- 单次输出上限探测(设置面板「探测」按钮)----------
2840
3645
  if (pathname === '/__ai/probe-maxtokens') {
2841
3646
  if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
2842
- const cfg = readAiConfig(root)
3647
+ let body = {}
3648
+ try {
3649
+ body = await readBody(req, 64 * 1024)
3650
+ } catch {
3651
+ return sendJson(req, res, 400, { error: 'invalid-body' })
3652
+ }
3653
+ const saved = readAiConfig(root)
3654
+ // 优先探「表单里正在编辑的那个模型」:新增 / 改地址 / 改模型名时磁盘上的生效配置还是旧的,
3655
+ // 只读 saved 会探错对象(实测:编辑未生效模型时探到的是另一个模型的上限)。
3656
+ // 用 undefined 判定「未传」而非 `||`:`apiKey:''`(新增模型尚未填密钥)是**明确的空值**,
3657
+ // 若回落 saved 就会拿别人的密钥去探别人的模型,把结果记到这个模型的名下
3658
+ const pick = (v, fallback) => (v === undefined || v === null ? fallback : String(v))
3659
+ const cfg = {
3660
+ baseUrl: pick(body.baseUrl, saved.baseUrl || ''),
3661
+ apiKey: pick(body.apiKey, saved.apiKey || ''),
3662
+ model: pick(body.model, saved.model || ''),
3663
+ }
2843
3664
  if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) {
2844
- return sendJson(req, res, 400, { error: 'missing-config', message: '请先填写 Base URL / API Key / 模型名' })
3665
+ return sendJson(req, res, 400, {
3666
+ error: 'missing-config',
3667
+ message: '请先填写 Base URL / API Key / 模型名(新增模型需先填好密钥才能探测)',
3668
+ })
2845
3669
  }
2846
3670
  capCache.delete(maxTokensSig(cfg))
2847
3671
  windowCache.delete(maxTokensSig(cfg))
2848
3672
  maxTokensMetaMiss.delete(maxTokensSig(cfg))
2849
3673
  const probed = await probeMaxTokensSupport(cfg)
2850
- // maxTokens = 留空时后端真正会用的值(封顶后);window = 上下文窗口(仅元数据);cap = 接受的单次输出上限(试探 / 减半)
3674
+ // cap = 网关**实测接受**的单次输出上限(二分试探 / 400 减半回写)——探测的唯一「测量值」
3675
+ // window = 上下文窗口(仅来自 /v1/models 元数据,拿不到就是 0)
3676
+ // ceiling = 自动模式的封顶**假设值**,不是测量结果;单独回传让前端明示,勿与 cap 混淆
3677
+ // autoMax = 留空(自动)时后端实际会用的值 = min(cap || window, ceiling)
2851
3678
  return sendJson(req, res, 200, {
2852
3679
  ok: true,
2853
- maxTokens: autoMaxTokens(probed.cap, probed.window),
2854
- window: probed.window,
2855
3680
  cap: probed.cap,
3681
+ window: probed.window,
2856
3682
  ceiling: AUTO_MAX_TOKENS_CEILING,
3683
+ autoMax: autoMaxTokens(probed.cap, probed.window),
2857
3684
  source: probed.source || 'fallback',
2858
3685
  })
2859
3686
  }
@@ -2904,6 +3731,69 @@ export function createAiChatMiddleware(opts = {}) {
2904
3731
  }
2905
3732
  }
2906
3733
 
3734
+ // ---------- 回合日志:状态 / 终止 / 续订(P2-12 断线续跑) ----------
3735
+ if (pathname === '/__ai/turns') {
3736
+ if (req.method !== 'GET') return sendJson(req, res, 405, { error: 'method-not-allowed' })
3737
+ const cardId = query.get('cardId') || ''
3738
+ if (!safeCardId(cardId)) return sendJson(req, res, 400, { error: 'missing-card-id' })
3739
+ const live = getLiveJournal(cardId)
3740
+ return sendJson(req, res, 200, {
3741
+ cardId,
3742
+ active: live
3743
+ ? { turnId: live.turnId, startedAt: live.startedAt, seq: live.seq, detached: live.detached, status: live.status }
3744
+ : null,
3745
+ recent: listTurns(root, cardId, 10),
3746
+ })
3747
+ }
3748
+ if (pathname === '/__ai/stop') {
3749
+ if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
3750
+ let body
3751
+ try {
3752
+ body = await readBody(req, 1024 * 1024)
3753
+ } catch {
3754
+ return sendJson(req, res, 400, { error: 'invalid-body' })
3755
+ }
3756
+ const live = getLiveJournal(body.cardId || '')
3757
+ // 断开连接不再等于终止:停止必须是显式动作,否则一次断网就抹掉几十分钟的建卡
3758
+ if (!live || live.closed || typeof live.stop !== 'function') {
3759
+ return sendJson(req, res, 404, { error: 'no-turn', message: '这张卡当前没有正在跑的回合' })
3760
+ }
3761
+ live.stop()
3762
+ return sendJson(req, res, 200, { ok: true, turnId: live.turnId })
3763
+ }
3764
+ if (pathname === '/__ai/turn') {
3765
+ if (req.method !== 'GET') return sendJson(req, res, 405, { error: 'method-not-allowed' })
3766
+ const cardId = query.get('cardId') || ''
3767
+ if (!safeCardId(cardId)) return sendJson(req, res, 400, { error: 'missing-card-id' })
3768
+ const afterSeq = Number(query.get('afterSeq')) || 0
3769
+ const wantTurn = query.get('turnId') || ''
3770
+ res.statusCode = 200
3771
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
3772
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
3773
+ res.setHeader('Connection', 'keep-alive')
3774
+ res.setHeader('X-Accel-Buffering', 'no')
3775
+ const turnOrigin = trustedOrigin(req)
3776
+ if (turnOrigin) res.setHeader('Access-Control-Allow-Origin', turnOrigin)
3777
+ res.flushHeaders?.()
3778
+ const live = getLiveJournal(cardId)
3779
+ if (live && (!wantTurn || wantTurn === live.turnId)) {
3780
+ // 活跃回合:先回放 afterSeq 之后的行,再续订新事件;turn-end 时 journal.close 会收尾连接
3781
+ for (const l of live.replay(afterSeq)) sseRaw(res, l)
3782
+ if (live.closed) return res.end()
3783
+ live.subscribe(res)
3784
+ req.on('close', () => live.unsubscribe(res))
3785
+ return
3786
+ }
3787
+ // 没有活跃回合(服务重启过 / 回合已结束):文件里的历史行一次性回放完就收尾
3788
+ for (const l of readJournalLines(root, cardId)) {
3789
+ if (l.seq <= afterSeq) continue
3790
+ if (wantTurn && l.turnId !== wantTurn) continue
3791
+ sseRaw(res, l)
3792
+ }
3793
+ return res.end()
3794
+ }
3795
+
3796
+
2907
3797
  // ---------- 对话记录 ----------
2908
3798
  if (pathname === '/__ai/history') {
2909
3799
  const cardId = query.get('cardId') || ''
@@ -2922,7 +3812,30 @@ export function createAiChatMiddleware(opts = {}) {
2922
3812
  const id = body.cardId || cardId
2923
3813
  if (!safeCardId(id)) return sendJson(req, res, 400, { error: 'missing-card-id' })
2924
3814
  try {
2925
- return sendJson(req, res, 200, writeHistory(chatDir, id, body.messages))
3815
+ // 服务端权威历史(P2-13):浏览器这份是 live 视图副本,回合收尾时服务端已按 turnId 写过。
3816
+ // 这里做「合并校对」而不是整表覆盖:
3817
+ // ① 同 turnId 的 assistant 消息以服务端那份(serverAuthored)为准,客户端副本丢掉;
3818
+ // ② 其余按 id 去重(后到的覆盖先到的,客户端补的编辑不丢),无 id 的原样保留;
3819
+ // ③ 合并后按 ts 稳定排序,用户消息与回答的先后不乱。
3820
+ const stored = readHistory(chatDir, id)
3821
+ const storedMsgs = Array.isArray(stored.messages) ? stored.messages : []
3822
+ const srvTurns = new Set(storedMsgs.filter((m) => m && m.serverAuthored && m.turnId).map((m) => m.turnId))
3823
+ const byId = new Map()
3824
+ const anon = []
3825
+ const put = (m) => {
3826
+ if (!m || typeof m !== 'object') return
3827
+ if (m.id) byId.set(m.id, m)
3828
+ else anon.push(m)
3829
+ }
3830
+ for (const m of storedMsgs) put(m)
3831
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
3832
+ if (!m || typeof m !== 'object') continue
3833
+ // 客户端的回合中副本:同 turnId 且非服务端权威 → 日志 reduce 出来的那份才是完整轨迹
3834
+ if (m.role === 'assistant' && m.turnId && srvTurns.has(m.turnId) && !m.serverAuthored) continue
3835
+ put(m)
3836
+ }
3837
+ const merged = [...byId.values(), ...anon].sort((x, y) => (Number(x.ts) || 0) - (Number(y.ts) || 0))
3838
+ return sendJson(req, res, 200, writeHistory(chatDir, id, merged))
2926
3839
  } catch (e) {
2927
3840
  return sendJson(req, res, 500, { error: 'write-failed', message: String((e && e.message) || e) })
2928
3841
  }
@@ -2940,6 +3853,8 @@ export function createAiChatMiddleware(opts = {}) {
2940
3853
  const failed = []
2941
3854
  for (const id of cardIds) {
2942
3855
  const r = deleteHistory(chatDir, id)
3856
+ // 回合日志与对话记录同生命周期:只清记录会把 .cache/ai-turns 留成孤儿证据链
3857
+ deleteJournal(root, id)
2943
3858
  if (!r.ok) failed.push({ cardId: id, error: r.error })
2944
3859
  }
2945
3860
  return sendJson(req, res, failed.length ? 207 : 200, { ok: !failed.length, failed })
@@ -3106,6 +4021,18 @@ export function createAiChatMiddleware(opts = {}) {
3106
4021
  maxSteps: Number(config.maxSteps) > 0 ? Number(config.maxSteps) : defaults.maxSteps,
3107
4022
  // 单轮墙钟上限(毫秒):不限步数后的失控兜底
3108
4023
  turnTimeoutMs: Number(config.turnTimeoutMs) > 0 ? Number(config.turnTimeoutMs) : defaults.turnTimeoutMs,
4024
+ // 保活阈值同样「本次请求 > 设置/环境变量」,与 maxSteps / turnTimeoutMs 一致走 finalConfig
4025
+ stallTimeoutMs: Number(config.stallTimeoutMs) > 0 ? Number(config.stallTimeoutMs) : defaults.stallTimeoutMs,
4026
+ stallRetries:
4027
+ config.stallRetries === undefined
4028
+ ? defaults.stallRetries
4029
+ : Math.max(0, Math.min(20, Number(config.stallRetries) || 0)),
4030
+ reasoningTimeoutMs:
4031
+ Number(config.reasoningTimeoutMs) > 0 ? Number(config.reasoningTimeoutMs) : defaults.reasoningTimeoutMs,
4032
+ autoContinueMax:
4033
+ config.autoContinueMax === undefined
4034
+ ? defaults.autoContinueMax
4035
+ : Math.max(0, Math.min(200, Number(config.autoContinueMax) || 0)),
3109
4036
  }
3110
4037
 
3111
4038
  // 手填值按「网关接受的单次输出上限」主动夹取:与其等网关 400 再减半(白白失败一次请求),
@@ -3124,6 +4051,26 @@ export function createAiChatMiddleware(opts = {}) {
3124
4051
  })
3125
4052
  }
3126
4053
 
4054
+ // 同卡单飞:第二个并发回合直接 409,前端提示「上一回合还在跑」,而不是两条流互相覆盖落盘
4055
+ const turnKey = String(cardContext?.id || '__no-card__')
4056
+ const prevTurn = activeTurns.get(turnKey)
4057
+ // 兜底过期:墙钟上限(turnTimeoutMs)+ 收尾汇总余量,比固定 30 分钟更贴合用户配置
4058
+ const turnLockStaleMs = Math.max(STALE_TURN_LOCK_MS, Number(finalConfig.turnTimeoutMs || 0) + 5 * 60 * 1000)
4059
+ if (prevTurn && Date.now() - prevTurn.since < turnLockStaleMs) {
4060
+ return sendJson(req, res, 409, {
4061
+ error: 'turn-in-flight',
4062
+ message: `这张卡片已有一个回合在跑(${fmtDuration(Date.now() - prevTurn.since)}):请先终止或等它结束,再发新消息`
4063
+ })
4064
+ }
4065
+ const turnLock = { since: Date.now(), turnTimeoutMs: finalConfig.turnTimeoutMs }
4066
+ activeTurns.set(turnKey, turnLock)
4067
+ /** 释放本回合的单飞锁(幂等:只删自己那把,避免误删接管者的锁) */
4068
+ const releaseTurnLock = () => {
4069
+ if (activeTurns.get(turnKey) === turnLock) activeTurns.delete(turnKey)
4070
+ }
4071
+ // 探测窗口 / 加载工具阶段客户端就断开:锁必须立刻放掉,否则这张卡再也开不了新回合
4072
+ req.once('close', releaseTurnLock)
4073
+
3127
4074
  // 多模态视觉:开关开启 → 先探测网关是否真支持图像(带缓存);不支持自动降级并在对话里提示一次
3128
4075
  const visionWanted = config.vision === undefined ? defaults.vision === true : Boolean(config.vision)
3129
4076
  const visionProbe = visionWanted ? await probeVisionSupport(finalConfig) : { ok: false, reason: '' }
@@ -3139,6 +4086,7 @@ export function createAiChatMiddleware(opts = {}) {
3139
4086
  root,
3140
4087
  })
3141
4088
  } catch (e) {
4089
+ releaseTurnLock()
3142
4090
  return sendJson(req, res, 500, { error: 'tools-load-failed', message: String((e && e.message) || e) })
3143
4091
  }
3144
4092
  // skill_exec 常驻注册:它只能跑 skills/*/scripts/ 下的脚本(路径形状硬校验、参数不经 shell),
@@ -3189,33 +4137,60 @@ export function createAiChatMiddleware(opts = {}) {
3189
4137
  if (allow) res.setHeader('Access-Control-Allow-Origin', allow)
3190
4138
  res.flushHeaders?.()
3191
4139
 
3192
- // 前端「停止」= abort fetch = 连接关闭:立刻中止 LLM 请求与工具循环
4140
+ // 回合日志开跑:从这一刻起每个 SSE 事件都带 seq 落盘(sseWrite 里挂钩)。
4141
+ // 它同时是断线续跑的回放源与收尾时服务端权威历史的唯一事实源。
4142
+ const turnId = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
4143
+ // 本回合的「活卡 id」:模型用 card_rename 改掉当前卡 id 后必须跟着换,
4144
+ // 否则回合收尾写服务端权威历史时会把内容写进已经改名的旧 id 下(凭空造一份孤儿 .cache/ai-chats/<旧id>.json)。
4145
+ let liveCardId = cardContext?.id || ''
4146
+ /** 本回合内发生过 card_rename 时的 {from,to}:用于给模型注入「旧 id 作废」的硬提醒 */
4147
+ let renamedInTurn = null
4148
+ const journal = openJournal({
4149
+ root,
4150
+ cardId: liveCardId,
4151
+ turnId,
4152
+ startedAt: Date.now(),
4153
+ meta: { model: finalConfig.model },
4154
+ })
4155
+ if (journal) res.__turnJournal = journal
4156
+ // 探测 / 加载工具阶段「客户端断开就放锁」到此为止:回合已真实开跑,锁交给 finally 释放,
4157
+ // 否则 detached 续跑期间锁被提前放掉,同卡能再开一轮互相踩 schema
4158
+ req.off('close', releaseTurnLock)
4159
+ sseWrite(res, { type: 'turn-start', turnId, cardId: cardContext?.id || '', startedAt: journal ? journal.startedAt : Date.now() })
4160
+
4161
+ // 传输层断开(刷新 / 断网 / 关标签页)≠ 用户要停:回合转 detached 在服务端继续跑,
4162
+ // 事件全在日志里,重连按 seq 续订即可。真正「停止」是 POST /__ai/stop(置 aborted + abort 上游)。
4163
+ // 旧行为「断开即 abort」会把跑了几十分钟的建卡被一次网络抖动抹掉,代价不对等。
3193
4164
  const abortCtl = new AbortController()
3194
4165
  let aborted = false
3195
- const onClose = () => {
4166
+ let detached = false
4167
+ /** 主动终止(/__ai/stop):与客户端断开区分开,界面与日志都如实记「用户停了」 */
4168
+ const stopTurn = () => {
3196
4169
  if (aborted) return
3197
4170
  aborted = true
3198
4171
  abortCtl.abort()
3199
4172
  }
4173
+ if (journal) journal.stop = stopTurn
4174
+ const onClose = () => {
4175
+ if (aborted) return
4176
+ detached = true
4177
+ if (journal) journal.markDetached()
4178
+ }
3200
4179
  req.on('close', onClose)
3201
4180
  req.on('aborted', onClose)
3202
4181
  res.on('close', onClose)
4182
+ /** 本轮是否以报错收尾(finally 里决定日志状态与权威历史的 status) */
4183
+ let turnError = ''
3203
4184
 
3204
4185
  // 上下文预算:多轮对话 + 大工具结果超限时,把早期轮次折叠成摘要(按轮折叠,
3205
4186
  // 保证 assistant.tool_calls 与 tool 结果成对保留),并给前端一条 notice 说明
3206
4187
  const contextBudget = Number(config.contextTokens) > 0 ? Number(config.contextTokens) : DEFAULT_CONTEXT_TOKENS
3207
4188
  const compacted = compactContext(messages, contextBudget)
3208
4189
  if (compacted.droppedTurns) {
3209
- sseWrite(res, {
3210
- type: 'notice',
3211
- message: `对话较长:早期 ${compacted.droppedTurns} 轮已压缩为摘要(最近轮次保留原文),需要细节时 AI 会用工具重读`,
3212
- })
4190
+ sseNotice(res, `对话较长:早期 ${compacted.droppedTurns} 轮已压缩为摘要(最近轮次保留原文),需要细节时 AI 会用工具重读`, 'info', 'context-compact')
3213
4191
  }
3214
4192
  if (visionWanted && !visionOn) {
3215
- sseWrite(res, {
3216
- type: 'notice',
3217
- message: `已开启多模态视觉,但本轮用不了图像,降级为数值闭环(pixelDiff.match)。原因:${visionProbe.reason || '探测失败'}`,
3218
- })
4193
+ sseNotice(res, `已开启多模态视觉,但本轮用不了图像,降级为数值闭环(pixelDiff.match)。原因:${visionProbe.reason || '探测失败'}`, 'warn', 'vision-off')
3219
4194
  }
3220
4195
  const fullMessages = [{ role: 'system', content: contextPrompt }, ...toOpenAiMessages(compacted.messages)]
3221
4196
  // 视觉注入点①:用户挂载的图片随本轮 user 消息进上下文(落盘历史不含 base64,重开不重复注入)
@@ -3256,12 +4231,17 @@ export function createAiChatMiddleware(opts = {}) {
3256
4231
  : knownWindow > 0
3257
4232
  ? Math.max(8000, Math.min(DEFAULT_TURN_CONTEXT_TOKENS, Math.floor(knownWindow * 0.7)))
3258
4233
  : DEFAULT_TURN_CONTEXT_TOKENS
3259
- // 单轮「纯思考」预算:推理网关空转时(只吐 reasoning、迟迟不产出)中止上游并催促重试。
3260
- // 90s 对设计稿建卡太紧——实测前处理管线跑完、正要建卡时连续思考 90s+ 就被熔断,整轮白跑;
3261
- // 调到 240s(仍可 config.reasoningTimeoutMs 覆盖),另用 stallTimeoutMs 兜「网关彻底静默」。
3262
- const reasoningTimeoutMs = Number(config.reasoningTimeoutMs) > 0 ? Number(config.reasoningTimeoutMs) : 240000
3263
- // 网关静默判定:一个增量都不来才算挂起(与「正在思考」区分开),避免大上下文首 token 慢被误杀
3264
- const stallTimeoutMs = Number(config.stallTimeoutMs) > 0 ? Number(config.stallTimeoutMs) : 120000
4234
+ // 单轮「纯思考」预算:只吐 reasoning、迟迟不产出时中止上游并催促重试。90s → 240s → 600s 的放宽
4235
+ // 都是实测逼出来的:设计稿建卡单轮思考 200~250s 属正常产出前的酝酿,240s 会误伤健康轮次
4236
+ // (实测一次 246s 思考被判超时,催促后模型只写了一份 IR 草稿就收尾,整轮价值大打折扣)。
4237
+ // 催促到 REASONING_NUDGE_MAX 次仍无产出即**停用**该熔断,改由墙钟兜底:思考时长不再能中断长程任务。
4238
+ const reasoningTimeoutMs =
4239
+ Number(finalConfig.reasoningTimeoutMs) > 0 ? Number(finalConfig.reasoningTimeoutMs) : DEFAULT_REASONING_TIMEOUT_MS
4240
+ // 网关静默判定:一个**字节**都不来才算挂起(与「正在思考 / 正在拼超长工具参数」区分开),
4241
+ // 避免大上下文首 token 慢被误杀。静默后自动重试本步(stallRetries 次),重试耗尽才报错收尾。
4242
+ const stallTimeoutMs =
4243
+ Number(finalConfig.stallTimeoutMs) > 0 ? Number(finalConfig.stallTimeoutMs) : DEFAULT_STALL_TIMEOUT_MS
4244
+ const stallRetries = Math.max(0, Number(finalConfig.stallRetries) || 0)
3265
4245
  let step = 0
3266
4246
  /** 本轮开始时刻:墙钟熔断的基准 */
3267
4247
  const turnStartedAt = Date.now()
@@ -3282,13 +4262,29 @@ export function createAiChatMiddleware(opts = {}) {
3282
4262
  let contextRetries = 0
3283
4263
  /** 网关静默空回复(无正文 / 无工具 / 无 finish 标记)的重试次数:最多 1 次 */
3284
4264
  let emptyRetries = 0
4265
+ /** 网关静默后已自动重试本步的次数:中转网关瞬时静默不该让跑了几十分钟的长程任务整轮报废 */
4266
+ let stallRetriesUsed = 0
4267
+ /** 纯思考已催促次数:到 REASONING_NUDGE_MAX 仍无产出就停用思考熔断(reasoningGuardOff) */
4268
+ let reasoningNudges = 0
4269
+ /** 思考熔断是否已停用:停用后「只思考不产出」不再中止上游,交给墙钟 / 死循环熔断兜底 */
4270
+ let reasoningGuardOff = false
4271
+ /** 自动续跑剩余额度:模型没给完成标记就停手时,自动注入提醒接着跑,不用用户手打「继续」 */
4272
+ let autoContinueLeft = Math.max(0, Number(finalConfig.autoContinueMax) || 0)
4273
+ /** 连续「只吐正文不调工具」的轮数:连续两次就尊重模型停手,避免纯问答被反复续跑 */
4274
+ let textOnlyRounds = 0
3285
4275
  /** 熔断收尾的人话原因:随 turn-ended 事件落进对话记录,重开面板仍看得见「这轮为什么停」 */
3286
4276
  let budgetWhy = ''
3287
4277
  let currentMessages = fullMessages
3288
4278
  /** 本轮工具产出的待注入图像(截图 / 并排对比图):下一次 callLLM 前打包成一条 user 消息 */
3289
4279
  const visionQueue = []
3290
- /** 思考熔断后是否已催促重试过一次:重试仍超时才报错,给推理网关一次收敛机会 */
3291
- let thinkingNudged = false
4280
+ /** 单轮折叠的粘滞游标:只从上次折到的地方往后折,绝不回头改写(前缀缓存纪律②) */
4281
+ let trimFrom = 1
4282
+ /** 上一次真正发给网关的消息序列(只用于比对漂移点,不参与业务) */
4283
+ let lastSentMessages = null
4284
+ /** 前缀被回改的告警一回合只发一次,免得刷屏 */
4285
+ let prefixWarned = false
4286
+ /** 本回合累计用量 + 缓存命中(网关不回 usage 时为 reported=false) */
4287
+ const usageTotal = { steps: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cacheMissTokens: 0, hitRate: 0, reported: false }
3292
4288
  /** 重试轮关闭深度思考:实测推理模型会在思考里逐字拼完整 IR / schema,两轮 240s 都产不出工具调用 */
3293
4289
  let retryNoThinking = false
3294
4290
  /** 深度思考总开关(设置为关时每轮都走 NO_THINKING_BODY) */
@@ -3307,10 +4303,7 @@ export function createAiChatMiddleware(opts = {}) {
3307
4303
  }
3308
4304
  if (!timeNudged && elapsedMs > turnTimeoutMs * 0.8) {
3309
4305
  timeNudged = true
3310
- sseWrite(res, {
3311
- type: 'notice',
3312
- message: `本轮已跑 ${fmtDuration(elapsedMs)}(墙钟上限 ${fmtDuration(turnTimeoutMs)}):已要求模型优先落盘 / 给结论`,
3313
- })
4306
+ sseNotice(res, `本轮已跑 ${fmtDuration(elapsedMs)}(墙钟上限 ${fmtDuration(turnTimeoutMs)}):已要求模型优先落盘 / 给结论`, 'warn', 'wall-clock')
3314
4307
  currentMessages.push({
3315
4308
  role: 'user',
3316
4309
  content: `[系统提醒] 本轮墙钟预算只剩 20%(上限 ${fmtDuration(turnTimeoutMs)}):立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料、不要开新战线。`,
@@ -3318,10 +4311,7 @@ export function createAiChatMiddleware(opts = {}) {
3318
4311
  }
3319
4312
  // 熔断③软提醒:每 SOFT_NUDGE_EVERY 步催一次收敛,但**不打断**(步数多不等于空转)
3320
4313
  if (SOFT_NUDGE_EVERY > 0 && step % SOFT_NUDGE_EVERY === 0) {
3321
- sseWrite(res, {
3322
- type: 'notice',
3323
- message: `本轮已用 ${step} 步工具调用(步数不设硬顶):提醒模型收敛,别在同类信息上重复取数`,
3324
- })
4314
+ sseNotice(res, `本轮已用 ${step} 步工具调用(步数不设硬顶):提醒模型收敛,别在同类信息上重复取数`, 'info', 'soft-nudge')
3325
4315
  currentMessages.push({
3326
4316
  role: 'user',
3327
4317
  content: `[系统提醒] 本轮已进行 ${step} 步工具调用(不设硬顶,但别空转):自查是否已拿到足够证据——能落盘就 card_write / card_patch / iterate.mjs 落盘并推进到下一门禁;同类信息只读一次,不要重复读同一份资料、不要重跑同一条命令。`,
@@ -3329,19 +4319,18 @@ export function createAiChatMiddleware(opts = {}) {
3329
4319
  }
3330
4320
 
3331
4321
  // 熔断④上下文:步数不限后工具结果会一路累积,撑爆网关窗口 = 整轮 400 报废。
3332
- // 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)
3333
- const turnTrim = trimTurnContext(currentMessages, { budgetTokens: turnContextBudget })
4322
+ // 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)。
4323
+ // from = 上次折到哪:折叠只前进不回退,历史已折叠的部分保持原样 ⇒ 前缀缓存继续命中
4324
+ const turnTrim = trimTurnContext(currentMessages, { budgetTokens: turnContextBudget, from: trimFrom })
4325
+ trimFrom = turnTrim.from
3334
4326
  // 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),有窗口认知时前端按窗口画环
3335
4327
  sseWrite(res, { type: 'context-usage', used: turnTrim.tokens, total: turnContextBudget, window: knownWindow })
3336
- if (clampNotice) sseWrite(res, { type: 'notice', message: clampNotice })
4328
+ if (clampNotice) sseNotice(res, clampNotice, 'warn', 'max-tokens-clamp')
3337
4329
  if (turnTrim.trimmed) {
3338
4330
  currentMessages = turnTrim.messages
3339
4331
  if (!trimNoticed) {
3340
4332
  trimNoticed = true
3341
- sseWrite(res, {
3342
- type: 'notice',
3343
- message: `单轮上下文接近预算(约 ${turnTrim.before} token,上限 ${turnContextBudget}):已折叠 ${turnTrim.trimmed} 条早期工具结果 / 图像,最近 ${TRIM_KEEP_RECENT} 条保持原文`,
3344
- })
4333
+ sseNotice(res, `单轮上下文接近预算(约 ${turnTrim.before} token,上限 ${turnContextBudget}):已折叠 ${turnTrim.trimmed} 条早期工具结果 / 图像,最近 ${TRIM_KEEP_RECENT} 条保持原文`, 'warn', 'context-trim')
3345
4334
  }
3346
4335
  }
3347
4336
  // 深度压缩:折叠工具结果后仍超预算 ⇒ 整组丢弃最早的工具调用并留摘要。
@@ -3351,12 +4340,12 @@ export function createAiChatMiddleware(opts = {}) {
3351
4340
  const deep = deepCompactTurn(currentMessages, turnContextBudget)
3352
4341
  if (deep.droppedGroups) {
3353
4342
  currentMessages = deep.messages
4343
+ // 丢消息会让下标整体左移:粘滞游标与漂移基准一起作废重来(这一步本来就改写了历史前缀)
4344
+ trimFrom = 1
4345
+ lastSentMessages = null
3354
4346
  if (!deepNoticed) {
3355
4347
  deepNoticed = true
3356
- sseWrite(res, {
3357
- type: 'notice',
3358
- message: `单轮上下文约 ${deep.before} token 超预算 ${turnContextBudget}:已深度压缩最早 ${deep.droppedGroups} 组工具调用为摘要(任务与最近 ${DEEP_KEEP_RECENT} 条保持原文),任务继续`,
3359
- })
4348
+ sseNotice(res, `单轮上下文约 ${deep.before} token 超预算 ${turnContextBudget}:已深度压缩最早 ${deep.droppedGroups} 组工具调用为摘要(任务与最近 ${DEEP_KEEP_RECENT} 条保持原文),任务继续`, 'warn', 'context-deep')
3360
4349
  }
3361
4350
  }
3362
4351
  }
@@ -3367,7 +4356,12 @@ export function createAiChatMiddleware(opts = {}) {
3367
4356
  let lastDeltaAt = Date.now()
3368
4357
  let roundTimedOut = false
3369
4358
  let roundStalled = false
4359
+ /** 本轮被墙钟打断:墙钟原本只在每轮开头检查,上游永不收尾时循环回不到轮首,整轮会挂住 */
4360
+ let roundWallClock = false
3370
4361
  let finishReason = ''
4362
+ /** 本轮已推给前端的思考字符数 / 工具行数:重试本步时让前端回滚这半截产物,别留下重复正文与幽灵工具行 */
4363
+ let roundReasoningChars = 0
4364
+ let roundToolRows = 0
3371
4365
 
3372
4366
  // 轮级 AbortController:思考熔断 / 静默看门狗只杀当前轮;客户端断开经 AbortSignal.any 一并生效
3373
4367
  const roundCtl = new AbortController()
@@ -3377,8 +4371,39 @@ export function createAiChatMiddleware(opts = {}) {
3377
4371
  if (Date.now() - lastDeltaAt > stallTimeoutMs) {
3378
4372
  roundStalled = true
3379
4373
  roundCtl.abort()
4374
+ return
4375
+ }
4376
+ // 迟迟不产出:超过纯思考预算仍没有正文 / 工具调用(只吐 reasoning,或网关只发心跳字节)。
4377
+ // 判定放在巡检里而不是 reasoning-delta 分支里——stream-alive 让「有字节但无增量」不再触发静默看门狗,
4378
+ // 若仍只在收到思考增量时判定,纯心跳流就能一路挂到墙钟(60 分钟)才被发现。
4379
+ if (!reasoningGuardOff && Date.now() - roundProgressAt > reasoningTimeoutMs) {
4380
+ roundTimedOut = true
4381
+ roundCtl.abort()
4382
+ return
3380
4383
  }
3381
- }, Math.min(5000, Math.max(500, Math.floor(stallTimeoutMs / 4))))
4384
+ // 墙钟到点也要能打断「一轮永不结束」的流(例如网关一直发心跳却永不收尾):
4385
+ // 墙钟是文档承诺的「唯一能兜住无限跑下去的闸门」,不能因为卡在流里就失效
4386
+ if (Date.now() - turnStartedAt > turnTimeoutMs) {
4387
+ roundWallClock = true
4388
+ roundCtl.abort()
4389
+ }
4390
+ // 巡检间隔跟随两个阈值里更紧的那个缩放:既不误杀首 token 慢的大上下文请求,
4391
+ // 也能在测试把阈值压到几百毫秒时及时判定
4392
+ }, Math.min(5000, Math.max(250, Math.floor(Math.min(stallTimeoutMs, reasoningTimeoutMs) / 4))))
4393
+ // 前缀漂移自检:命中率掉下来必须能定位到「哪一步开始改了历史」。
4394
+ // 只在改写历史(漂移点落在中段而非追加尾部)时告警,正常追加不打扰用户
4395
+ const drift = prefixDrift(lastSentMessages, currentMessages)
4396
+ const hadPrev = Boolean(lastSentMessages)
4397
+ lastSentMessages = currentMessages
4398
+ if (hadPrev && drift.rewrote && !prefixWarned) {
4399
+ prefixWarned = true
4400
+ sseNotice(
4401
+ res,
4402
+ `本轮历史在第 ${drift.index} 条被改写(前缀缓存从该处起失效,后续 token 按原价重算):这是上下文超预算后的压缩行为,属预期内的代价`,
4403
+ 'info',
4404
+ 'prompt-cache-rewrite',
4405
+ )
4406
+ }
3382
4407
  const gen = callLLM({
3383
4408
  baseUrl: finalConfig.baseUrl,
3384
4409
  apiKey: finalConfig.apiKey,
@@ -3398,11 +4423,7 @@ export function createAiChatMiddleware(opts = {}) {
3398
4423
  lastDeltaAt = Date.now()
3399
4424
  switch (evt.type) {
3400
4425
  case 'reasoning-delta':
3401
- if (Date.now() - roundProgressAt > reasoningTimeoutMs) {
3402
- roundTimedOut = true
3403
- roundCtl.abort()
3404
- break
3405
- }
4426
+ roundReasoningChars += String(evt.text || '').length
3406
4427
  sseWrite(res, { type: 'reasoning-delta', text: evt.text })
3407
4428
  break
3408
4429
  case 'text-delta':
@@ -3412,6 +4433,7 @@ export function createAiChatMiddleware(opts = {}) {
3412
4433
  break
3413
4434
  case 'tool-call-start':
3414
4435
  roundProgressAt = Date.now()
4436
+ roundToolRows++
3415
4437
  sseWrite(res, {
3416
4438
  type: 'tool-call-start',
3417
4439
  toolName: evt.toolName,
@@ -3422,13 +4444,32 @@ export function createAiChatMiddleware(opts = {}) {
3422
4444
  case 'finish':
3423
4445
  if (evt.reason) finishReason = evt.reason
3424
4446
  break
4447
+ case 'usage': {
4448
+ // 缓存命中率的唯一观测口径:把网关回的 usage 累加成本轮总计,再推给前端
4449
+ const u = evt.usage || {}
4450
+ usageTotal.steps++
4451
+ usageTotal.promptTokens += Number(u.promptTokens) || 0
4452
+ usageTotal.completionTokens += Number(u.completionTokens) || 0
4453
+ usageTotal.cachedTokens += Number(u.cachedTokens) || 0
4454
+ usageTotal.cacheMissTokens += Number(u.cacheMissTokens) || 0
4455
+ if (u.reported) usageTotal.reported = true
4456
+ usageTotal.hitRate =
4457
+ usageTotal.promptTokens > 0 ? Math.round((usageTotal.cachedTokens / usageTotal.promptTokens) * 1000) / 10 : 0
4458
+ sseWrite(res, { type: 'usage', step, ...usageTotal })
4459
+ break
4460
+ }
3425
4461
  case 'notice':
3426
- sseWrite(res, { type: 'notice', message: evt.text })
4462
+ sseNotice(res, evt.text, evt.level || 'warn', evt.code || '')
3427
4463
  break
3428
4464
  case 'tool-calls':
3429
4465
  roundProgressAt = Date.now()
3430
- assistantToolCalls = evt.toolCalls
3431
- for (const tc of evt.toolCalls) {
4466
+ // 工具调用 id 归一:部分网关不回 id,落空串会让「轮内 live」与「下一回合回放」
4467
+ // (回放侧用 call_<下标> 兜底)长出不同形状的同一条消息 ⇒ 前缀缓存从该条起失效。
4468
+ // 在事件发出前补好,前端行匹配与上下文序列化看到的是同一个 id
4469
+ assistantToolCalls = evt.toolCalls.map((tc, i) => (tc?.id ? tc : { ...tc, id: `call_${i}` }))
4470
+ // 前端一行一个调用:没有 tool-call-start 直接来 tool-calls 的网关也要能回滚
4471
+ roundToolRows = Math.max(roundToolRows, assistantToolCalls.length)
4472
+ for (const tc of assistantToolCalls) {
3432
4473
  sseWrite(res, {
3433
4474
  type: 'tool-call',
3434
4475
  toolName: tc.function.name,
@@ -3442,8 +4483,9 @@ export function createAiChatMiddleware(opts = {}) {
3442
4483
  }
3443
4484
  } catch (e) {
3444
4485
  // 熔断 / 看门狗主动 abort 时,上游生成器会抛 AbortError:这不是「用户中止」,
3445
- // 吞掉后交给下面的 roundStalled / roundTimedOut 分支给出准确原因
3446
- if (roundStalled || roundTimedOut) {
4486
+ // 吞掉后交给下面的 roundWallClock / roundStalled / roundTimedOut 分支给出准确原因
4487
+ // (漏了 roundWallClock 会让墙钟打断变成一条「请求已中止」错误,丢掉收尾汇总)
4488
+ if (roundStalled || roundTimedOut || roundWallClock) {
3447
4489
  /* 吞掉,走下面分支 */
3448
4490
  } else if (
3449
4491
  contextRetries < 2 &&
@@ -3458,10 +4500,10 @@ export function createAiChatMiddleware(opts = {}) {
3458
4500
  currentMessages = deep.droppedGroups
3459
4501
  ? deep.messages
3460
4502
  : trimTurnContext(currentMessages, { budgetTokens: tight, keepRecent: 4, stubChars: 200 }).messages
3461
- sseWrite(res, {
3462
- type: 'notice',
3463
- message: `网关报上下文溢出(HTTP 400):已${deep.droppedGroups ? `深度压缩 ${deep.droppedGroups} 组早期工具调用` : '加强折叠早期工具结果'}后自动重试本步,任务不中断`,
3464
- })
4503
+ // 这一步必然改写了历史(否则不会超窗):粘滞游标与漂移基准一起作废重来
4504
+ trimFrom = 1
4505
+ lastSentMessages = null
4506
+ sseNotice(res, `网关报上下文溢出(HTTP 400):已${deep.droppedGroups ? `深度压缩 ${deep.droppedGroups} 组早期工具调用` : '加强折叠早期工具结果'}后自动重试本步,任务不中断`, 'warn', 'context-overflow')
3465
4507
  continue
3466
4508
  } else {
3467
4509
  throw e
@@ -3474,28 +4516,67 @@ export function createAiChatMiddleware(opts = {}) {
3474
4516
  // 输出被截断:本轮的正文 / 工具参数都不可信,别拿去执行(半截 JSON 会得到莫名其妙的报错)
3475
4517
  sseWrite(res, {
3476
4518
  type: 'error',
4519
+ code: 'length',
3477
4520
  message: `本轮输出被单次输出上限(max_tokens=${finalConfig.maxTokens})截断,结果不可用。请在设置里调高「单次输出上限」,或要求 AI 走紧凑 IR 路线(fs_write 写 .cache/ai-scratch/*.ir.json → expand_ir.mjs 展开),不要一次性输出整份 schema`,
3478
4521
  })
3479
4522
  break
3480
4523
  }
3481
4524
 
4525
+ // 墙钟优先于静默 / 思考重试:到点就走「收尾汇总」这条体面路径,不再叠加重试
4526
+ if (roundWallClock) {
4527
+ budgetReason = 'timeout'
4528
+ break
4529
+ }
3482
4530
  if (roundStalled) {
4531
+ // 网关静默不再「一刀结束整轮」:工具是在流结束后才执行的,此刻本步零副作用,
4532
+ // 原样重试即可——已执行的几十次调用、已落盘的产物、上下文全都不丢。
4533
+ // 只有重试耗尽才报错(用户主动中止走 aborted 分支,上游明确报错走 throw 分支)。
4534
+ if (!aborted && stallRetriesUsed < stallRetries) {
4535
+ const delays = stallRetryDelays()
4536
+ const wait = delays[Math.min(stallRetriesUsed, delays.length - 1)] || 0
4537
+ stallRetriesUsed++
4538
+ sseWrite(res, {
4539
+ type: 'round-retry',
4540
+ reason: 'stall',
4541
+ attempt: stallRetriesUsed,
4542
+ attempts: stallRetries,
4543
+ // 前端据此回滚本轮已渲染的半截产物:重试会重新流式吐一遍,不回滚就是重复正文 + 幽灵工具行
4544
+ dropReasoning: roundReasoningChars,
4545
+ dropText: assistantContent.length,
4546
+ dropTools: roundToolRows,
4547
+ message: `上游 ${Math.round(stallTimeoutMs / 1000)} 秒没有返回任何数据(网关静默):${
4548
+ wait > 0 ? `${Math.round(wait / 1000)} 秒后` : '立即'
4549
+ }自动重试本步(第 ${stallRetriesUsed}/${stallRetries} 次),已执行的工具与上下文都不丢,任务不中断`,
4550
+ })
4551
+ if (await napUnlessAborted(wait, abortCtl.signal)) continue
4552
+ break
4553
+ }
3483
4554
  sseWrite(res, {
3484
4555
  type: 'error',
3485
- message: `上游 ${Math.round(stallTimeoutMs / 1000)} 秒没有返回任何数据(网关静默),已中止本轮:请重试或检查模型服务`,
4556
+ code: 'stall',
4557
+ message: `上游 ${Math.round(stallTimeoutMs / 1000)} 秒没有返回任何数据(网关静默)${
4558
+ stallRetries > 0 ? `,已自动重试 ${stallRetriesUsed} 次仍未恢复` : ''
4559
+ },本轮已停止:请重试,或检查模型服务 / 网络(阈值可在 .env.cb.local 用 AI_STALL_TIMEOUT_MS / AI_STALL_RETRIES 调)`,
3486
4560
  })
3487
4561
  break
3488
4562
  }
3489
4563
 
3490
4564
  if (roundTimedOut) {
3491
- if (!thinkingNudged) {
3492
- // 第一次熔断:不报错,插入催促 + **关掉深度思考**再给一轮机会。
3493
- // 只催促不关思考实测无效:模型继续用思考逐字草拟 schema,第二轮同样超时,整轮 10 分钟零产出。
3494
- thinkingNudged = true
4565
+ // 纯思考熔断不再「两轮就报错」:长程任务里思考几分钟是正常的,中断的代价远大于多等一会。
4566
+ // 每次都催促 + **关掉深度思考**重试(只催促不关思考实测无效:模型继续在思考里逐字草拟 schema);
4567
+ // 催促 REASONING_NUDGE_MAX 次仍无产出就停用本熔断,改由墙钟兜底——思考时长不再能中断任务。
4568
+ if (reasoningNudges < REASONING_NUDGE_MAX) {
4569
+ reasoningNudges++
3495
4570
  retryNoThinking = true
3496
4571
  sseWrite(res, {
3497
- type: 'notice',
3498
- message: `本轮思考超过 ${Math.round(reasoningTimeoutMs / 1000)} 秒仍无产出:已关闭深度思考并催促模型直接落盘,重试一次`,
4572
+ type: 'round-retry',
4573
+ reason: 'reasoning',
4574
+ attempt: reasoningNudges,
4575
+ attempts: REASONING_NUDGE_MAX,
4576
+ dropReasoning: roundReasoningChars,
4577
+ dropText: assistantContent.length,
4578
+ dropTools: roundToolRows,
4579
+ message: `本轮纯思考超过 ${fmtDuration(reasoningTimeoutMs)} 仍无产出:已关闭深度思考并催促模型直接落盘(第 ${reasoningNudges}/${REASONING_NUDGE_MAX} 次),任务不中断`,
3499
4580
  })
3500
4581
  currentMessages = [
3501
4582
  ...currentMessages,
@@ -3506,11 +4587,12 @@ export function createAiChatMiddleware(opts = {}) {
3506
4587
  ]
3507
4588
  continue
3508
4589
  }
3509
- sseWrite(res, {
3510
- type: 'error',
3511
- message: `两轮思考均超过 ${Math.round(reasoningTimeoutMs / 1000)} 秒仍无产出,已停止。可重试、简化需求或拆成更小步骤`,
3512
- })
3513
- break
4590
+ if (!reasoningGuardOff) {
4591
+ reasoningGuardOff = true
4592
+ sseNotice(res, `纯思考已催促 ${reasoningNudges} 次仍无产出:停用思考熔断,本轮改由墙钟上限(${fmtDuration(turnTimeoutMs)})兜底,不再因思考时长中断`, 'warn', 'reasoning-guard-off')
4593
+ }
4594
+ // 熔断刚停用的这一轮已被中止(零产出):原样重跑一次,之后不会再因思考时长中止
4595
+ continue
3514
4596
  }
3515
4597
  if (aborted) break
3516
4598
  // 网关静默空回复:无正文、无工具调用、连 finish 标记都没有——多是上游连接中途被切 / 空流。
@@ -3519,19 +4601,44 @@ export function createAiChatMiddleware(opts = {}) {
3519
4601
  if (!assistantToolCalls?.length && !assistantContent && !finishReason && !roundStalled && !roundTimedOut) {
3520
4602
  if (emptyRetries < 1) {
3521
4603
  emptyRetries++
3522
- sseWrite(res, {
3523
- type: 'notice',
3524
- message: '网关返回空响应(无正文、无结束标记,连接可能被切断):自动重试本步一次',
3525
- })
4604
+ sseNotice(res, '网关返回空响应(无正文、无结束标记,连接可能被切断):自动重试本步一次', 'warn', 'empty-retry')
3526
4605
  continue
3527
4606
  }
3528
4607
  sseWrite(res, {
3529
4608
  type: 'error',
4609
+ code: 'empty',
3530
4610
  message: '网关连续两次返回空响应(无正文、无结束标记):本轮已停止。可重试,或检查模型服务 / 网络',
3531
4611
  })
3532
4612
  break
3533
4613
  }
3534
- if (!assistantToolCalls?.length) break
4614
+ // 催促重试只为打断「空转思考」:本轮一旦有产出(正文 / 工具调用)就恢复深度思考,
4615
+ // 否则一次思考熔断会把整轮后续所有步骤都降级成无思考模式,长程任务质量明显下滑
4616
+ if (assistantContent || assistantToolCalls?.length) retryNoThinking = false
4617
+
4618
+ if (!assistantToolCalls?.length) {
4619
+ // 自动续跑:模型只吐正文就停手——实测最常见形态是输出「下一步(请确认或直接说「继续」)」
4620
+ // 把控制权甩回用户,而任务其实没做完(IR 写了但 expand_ir 没跑、门禁没过)。
4621
+ // 除非它显式给了 [任务完成] / [需用户确认] 标记,或这是本回合第一次开口(纯问答不该被续跑),
4622
+ // 或已经连续停手两次(尊重模型判断),否则自动注入提醒接着推进。
4623
+ textOnlyRounds++
4624
+ const settled = TASK_SETTLED_RE.test(assistantContent)
4625
+ if (!settled && !aborted && step > 1 && textOnlyRounds < 2 && autoContinueLeft > 0) {
4626
+ autoContinueLeft--
4627
+ sseNotice(res, `任务未完成就停手(没有 ${DONE_MARK} / ${ASK_MARK} 标记):已自动续跑,不用手动输入「继续」(本回合剩余 ${autoContinueLeft} 次)`, 'info', 'auto-continue')
4628
+ currentMessages = [
4629
+ ...currentMessages,
4630
+ { role: 'assistant', content: assistantContent || null },
4631
+ {
4632
+ role: 'user',
4633
+ content: `[系统提醒] 你没有完成任务就停下了,也没给出 ${DONE_MARK} / ${ASK_MARK} 标记。不要征求确认、不要输出「下一步请说继续」——直接接着调用工具推进到下一个门禁。全部要求都已落地且门禁通过时,才在正文最后单独一行输出 ${DONE_MARK};确实缺只有用户能提供的信息时,才在正文最后单独一行输出 ${ASK_MARK} 并写清要用户回答什么。${designFlow ? '建卡未跑完 expand_ir + iterate --gate 就不算完成。' : ''}`,
4634
+ },
4635
+ ]
4636
+ continue
4637
+ }
4638
+ break
4639
+ }
4640
+ // 有工具调用 = 任务在推进:重置「只吐正文」计数,下一次停手仍可自动续跑一次
4641
+ textOnlyRounds = 0
3535
4642
 
3536
4643
  // 熔断①原地打转:同工具 + 同参数连续重复(实测最烧钱的失控形态,如反复 fs_read 同一份 spec.json、
3537
4644
  // 反复跑同一条 iterate --set)。第 2 轮先警告,第 3 轮不再执行、直接收尾汇报。
@@ -3547,19 +4654,22 @@ export function createAiChatMiddleware(opts = {}) {
3547
4654
  // 协议要求每个 tool_call 都有对应的 tool 结果:回填「已跳过」再收尾,别留下悬空 tool_calls
3548
4655
  currentMessages = [
3549
4656
  ...currentMessages,
3550
- { role: 'assistant', content: assistantContent || null, tool_calls: assistantToolCalls },
4657
+ { role: 'assistant', content: assistantContent || null, tool_calls: contextToolCalls(assistantToolCalls) },
3551
4658
  ]
4659
+ // 跳过的原因只写一份、两处共用:前端把它存进对话记录、下一回合原样回传成 `Error: <原因>`,
4660
+ // 与这里推给模型的 live 文本必须逐字节相同(两处各写一份文案 = 又一处前缀漂移)
4661
+ const skipNote = `已跳过——这是连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数),说明当前思路拿不到新信息,本轮到此为止。`
3552
4662
  for (const tc of assistantToolCalls) {
3553
4663
  sseWrite(res, {
3554
4664
  type: 'tool-result',
3555
4665
  toolName: tc.function.name,
3556
4666
  toolCallId: tc.id,
3557
- error: `已跳过:连续 ${repeatRounds} 轮完全相同的调用(同工具同参数),触发死循环熔断`,
4667
+ error: skipNote,
3558
4668
  })
3559
4669
  currentMessages.push({
3560
4670
  role: 'tool',
3561
4671
  tool_call_id: tc.id,
3562
- content: `Error: 已跳过——这是连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数),说明当前思路拿不到新信息,本轮到此为止。`,
4672
+ content: `Error: ${skipNote}`,
3563
4673
  })
3564
4674
  }
3565
4675
  break
@@ -3567,7 +4677,7 @@ export function createAiChatMiddleware(opts = {}) {
3567
4677
 
3568
4678
  currentMessages = [
3569
4679
  ...currentMessages,
3570
- { role: 'assistant', content: assistantContent || null, tool_calls: assistantToolCalls },
4680
+ { role: 'assistant', content: assistantContent || null, tool_calls: contextToolCalls(assistantToolCalls) },
3571
4681
  ]
3572
4682
 
3573
4683
  for (const tc of assistantToolCalls) {
@@ -3586,6 +4696,16 @@ export function createAiChatMiddleware(opts = {}) {
3586
4696
  case 'card_patch':
3587
4697
  result = await cardPatch(root, parsedArgs, dispatch, { allowDispatch })
3588
4698
  break
4699
+ case 'card_rename': {
4700
+ result = await dispatch('card_rename', parsedArgs)
4701
+ // 改名后本卡 id 已变:跟住活卡 id(回合收尾历史落点),并给模型一条硬提醒——
4702
+ // system prompt 里的「当前卡片 ID」是本回合开始时冻结的,它不会自己更新。
4703
+ if (result && result.ok && result.renamed && result.id) {
4704
+ liveCardId = result.id
4705
+ renamedInTurn = { from: result.oldId, to: result.id }
4706
+ }
4707
+ break
4708
+ }
3589
4709
  case 'fs_read':
3590
4710
  result = fsRead(root, parsedArgs, mountedRoots)
3591
4711
  break
@@ -3608,15 +4728,17 @@ export function createAiChatMiddleware(opts = {}) {
3608
4728
  result = await dispatch(toolName, parsedArgs)
3609
4729
  }
3610
4730
  const { text, image } = splitToolResult(result)
4731
+ // 推给前端的 result 与下面的模型上下文**同一截断口径**:前端会把它原样存进对话记录、
4732
+ // 下一回合再回传。给前端截得更短(旧值 4000)等于每轮都改写一次历史,前缀缓存从该条起全废
3611
4733
  sseWrite(res, {
3612
4734
  type: 'tool-result',
3613
4735
  toolName,
3614
4736
  toolCallId: tc.id,
3615
- result: text.slice(0, 4000),
3616
- truncated: text.length > 4000,
4737
+ result: text.slice(0, TOOL_RESULT_MAX_CHARS),
4738
+ truncated: text.length > TOOL_RESULT_MAX_CHARS,
3617
4739
  image,
3618
4740
  })
3619
- currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: text.slice(0, 12000) })
4741
+ currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: text.slice(0, TOOL_RESULT_MAX_CHARS) })
3620
4742
  // 视觉注入点②:工具产出的图像先入队,循环末尾打包注入(文本模型下 image 恒为 undefined,零开销)
3621
4743
  if (visionOn && image) {
3622
4744
  visionQueue.push({ caption: `${toolName} 产出的渲染截图`, part: dataUrlPart(Buffer.from(image.base64, 'base64'), image.mime) })
@@ -3631,10 +4753,29 @@ export function createAiChatMiddleware(opts = {}) {
3631
4753
  } catch (e) {
3632
4754
  const errMsg = String(e?.message || e)
3633
4755
  sseWrite(res, { type: 'tool-result', toolName, toolCallId: tc.id, error: errMsg })
3634
- currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}` })
4756
+ // 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
4757
+ // 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
4758
+ // 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
4759
+ // 报错文本同样按统一口径截断:回放侧(toOpenAiMessages)会 slice 到同一上限,
4760
+ // 两处不一致时,一条超长报错(路径回显 / 网关长详情)就会在轮边界改写历史、
4761
+ // 把「这条之后」的前缀缓存整段打掉。Error: 前缀在两侧都算进同一个上限里
4762
+ currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}`.slice(0, TOOL_RESULT_MAX_CHARS) })
3635
4763
  }
3636
4764
  }
3637
4765
 
4766
+ // 本步里改过卡片 id:必须显式告知模型新 id 已生效、旧 id 作废。
4767
+ // 不加这条,模型会拿 system prompt 里那份冻结的「当前卡片 ID(旧)」继续调 card_write,
4768
+ // 换来一串 404,然后反复重试同样的旧 id 直到熔断——这是改名工具最容易踩的坑。
4769
+ if (renamedInTurn) {
4770
+ const { from, to } = renamedInTurn
4771
+ renamedInTurn = null
4772
+ sseNotice(res, `卡片已重命名:${from} → ${to}(后续调用一律使用新 id)`, 'info', 'card-renamed')
4773
+ currentMessages.push({
4774
+ role: 'user',
4775
+ content: `[系统提醒] 卡片已重命名为「${to}」(原 id「${from}」已不存在)。后续所有卡片工具调用(card_read / card_grep / card_patch / card_write / card_validate / card_screenshot / card_node_models / card_rename 等)**必须改用新 id「${to}」**;对话记录、回合日志与封面已随目录一并迁移,不需要重建。`,
4776
+ })
4777
+ }
4778
+
3638
4779
  // 视觉注入点②收尾:把本轮工具产出的图像打包成一条 user 消息回喂(数值指标仍是收敛判据)
3639
4780
  if (visionOn && visionQueue.length) {
3640
4781
  const batch = visionQueue.splice(0, VISION_INJECT_MAX)
@@ -3652,10 +4793,7 @@ export function createAiChatMiddleware(opts = {}) {
3652
4793
 
3653
4794
  // 连续第 2 轮相同调用:先给一次明确的「换证据链」警告(第 3 轮才熔断),多数模型这一句就能纠偏
3654
4795
  if (!aborted && repeatRounds >= 2) {
3655
- sseWrite(res, {
3656
- type: 'notice',
3657
- message: `连续第 ${repeatRounds} 轮完全相同的工具调用(${loopToolNames}):已要求换证据链,再重复将熔断收尾`,
3658
- })
4796
+ sseNotice(res, `连续第 ${repeatRounds} 轮完全相同的工具调用(${loopToolNames}):已要求换证据链,再重复将熔断收尾`, 'warn', 'loop-warn')
3659
4797
  currentMessages.push({
3660
4798
  role: 'user',
3661
4799
  content: `[系统提醒] 你已连续 ${repeatRounds} 轮发起**完全相同**的工具调用(${loopToolNames},参数一字不差),结果不会变。立刻换证据链:改参数 / 换工具(如 diff_bands.mjs 取逐带 dy、references/generation_pitfalls.md 查静默丢弃清单),或直接落盘 card_write / card_patch。再重复一次本轮就会被熔断收尾。`,
@@ -3664,7 +4802,7 @@ export function createAiChatMiddleware(opts = {}) {
3664
4802
 
3665
4803
  // 用户设了硬顶且额度快用尽:提前催收敛,别等超限才发现整轮没产出
3666
4804
  if (!aborted && HARD_STEPS > 0 && step === HARD_STEPS - 2) {
3667
- sseWrite(res, { type: 'notice', message: `本轮工具额度只剩 2 步:已要求模型优先落盘 / 给结论` })
4805
+ sseNotice(res, `本轮工具额度只剩 2 步:已要求模型优先落盘 / 给结论`, 'warn', 'steps-left')
3668
4806
  currentMessages.push({
3669
4807
  role: 'user',
3670
4808
  content: `[系统提醒] 本轮工具调用额度只剩 2 步:立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料。`,
@@ -3686,10 +4824,16 @@ export function createAiChatMiddleware(opts = {}) {
3686
4824
  ? `本轮时长已达墙钟上限 ${fmtDuration(turnTimeoutMs)}(共用 ${step} 步工具调用)`
3687
4825
  : `已达单轮工具调用上限(${HARD_STEPS} 步)`
3688
4826
  budgetWhy = why
3689
- sseWrite(res, {
3690
- type: 'notice',
3691
- message: `${why}:正在汇总进度与下一步,回复「继续」可接着做`,
3692
- })
4827
+ sseNotice(res, `${why}:正在汇总进度与下一步,回复「继续」可接着做`, 'warn', 'budget-wrap')
4828
+ // 收尾汇总同样要挂静默看门狗:此前只挂用户中止信号,网关在汇总阶段静默就会把整轮永久挂住
4829
+ // (界面一直「生成中…」,既拿不到汇总也拿不到报错)。汇总只是锦上添花,静默就放弃并明示原因。
4830
+ const wrapCtl = new AbortController()
4831
+ // 汇总只认「正文 / 思考增量」为活着:只发心跳字节的网关同样要能被掐掉,
4832
+ // 否则界面会永久停在「生成中…」(汇总调用没有墙钟与思考熔断兜底)
4833
+ let wrapAliveAt = Date.now()
4834
+ const wrapWatchdog = setInterval(() => {
4835
+ if (Date.now() - wrapAliveAt > stallTimeoutMs) wrapCtl.abort()
4836
+ }, Math.min(5000, Math.max(250, Math.floor(stallTimeoutMs / 4))))
3693
4837
  try {
3694
4838
  const wrap = callLLM({
3695
4839
  baseUrl: finalConfig.baseUrl,
@@ -3708,18 +4852,30 @@ export function createAiChatMiddleware(opts = {}) {
3708
4852
  ],
3709
4853
  maxTokens: 2048,
3710
4854
  extraBody: NO_THINKING_BODY,
3711
- signal: abortCtl.signal,
4855
+ signal: AbortSignal.any([abortCtl.signal, wrapCtl.signal]),
3712
4856
  })
3713
4857
  for await (const evt of wrap) {
3714
4858
  if (aborted) break
3715
- if (evt.type === 'text-delta') sseWrite(res, { type: 'text-delta', text: evt.text })
3716
- else if (evt.type === 'reasoning-delta') sseWrite(res, { type: 'reasoning-delta', text: evt.text })
4859
+ if (evt.type === 'text-delta') {
4860
+ wrapAliveAt = Date.now()
4861
+ sseWrite(res, { type: 'text-delta', text: evt.text })
4862
+ } else if (evt.type === 'reasoning-delta') {
4863
+ wrapAliveAt = Date.now()
4864
+ sseWrite(res, { type: 'reasoning-delta', text: evt.text })
4865
+ }
3717
4866
  }
3718
4867
  } catch (e) {
4868
+ const msg = String((e && e.message) || e)
3719
4869
  sseWrite(res, {
3720
4870
  type: 'error',
3721
- message: `${why},收尾汇总也失败:${String((e && e.message) || e)}`,
4871
+ code: 'wrap-failed',
4872
+ message:
4873
+ /abort/i.test(msg) && !aborted
4874
+ ? `${why},收尾汇总因上游 ${Math.round(stallTimeoutMs / 1000)} 秒静默被放弃(上下文已保留,回复「继续」可接着做)`
4875
+ : `${why},收尾汇总也失败:${msg}`,
3722
4876
  })
4877
+ } finally {
4878
+ clearInterval(wrapWatchdog)
3723
4879
  }
3724
4880
  }
3725
4881
  // 熔断 / 硬顶收尾都补一条持久事件:notice 横幅 6 秒就没了,对话记录里要留得住「为什么停」
@@ -3729,19 +4885,31 @@ export function createAiChatMiddleware(opts = {}) {
3729
4885
  if (!aborted) {
3730
4886
  console.error('[ai-chat] failed:', e)
3731
4887
  const msg = String((e && e.message) || e)
3732
- sseWrite(res, { type: 'error', message: /aborted/i.test(msg) ? '请求已中止' : msg })
4888
+ turnError = msg
4889
+ sseWrite(res, { type: 'error', message: /aborted/i.test(msg) ? '请求已中止' : msg, code: classifyErrorCode(e) })
3733
4890
  }
3734
4891
  sseWrite(res, { type: 'done' })
3735
4892
  } finally {
4893
+ // 回合日志收尾 + 服务端权威历史(P2-13):日志 reduce 出的 assistant 消息合并进
4894
+ // .cache/ai-chats/<cardId>.json —— 客户端崩溃 / 关标签页 / 断网不再丢整轮轨迹;
4895
+ // 浏览器随后的 POST 按 turnId 认亲去重,它那份只是 live 视图的副本
4896
+ if (journal) {
4897
+ const finalStatus = aborted ? 'aborted' : turnError ? 'error' : 'done'
4898
+ // usage 随 turn-end 落盘:事后复盘「这轮花了多少、命中多少」不用再翻网关账单
4899
+ await journal.close(finalStatus, { error: turnError, detached, usage: usageTotal.steps ? usageTotal : undefined })
4900
+ if (liveCardId) mergeServerTurn(chatHistoryDir(root), liveCardId, journal.reduce())
4901
+ }
3736
4902
  req.off('close', onClose)
3737
4903
  req.off('aborted', onClose)
3738
4904
  res.off('close', onClose)
4905
+ // 单飞锁在 res.end() 前释放:这张卡立刻能接下一个回合
4906
+ releaseTurnLock()
3739
4907
  if (!res.writableEnded) res.end()
3740
4908
  }
3741
4909
  }
3742
4910
  }
3743
4911
 
3744
- // 单测入口(tools/tests/ai-chat-shell-gate.test.mjs):网关与压缩是纯函数,直接断言
4912
+ // 单测入口(tools/tests/ai-chat-shell-gate.test.mjs / ai-chat.test.mjs):网关、压缩与错误归一都是纯函数,直接断言
3745
4913
  export {
3746
4914
  assertShellAllowlist,
3747
4915
  segmentCommandWord,
@@ -3749,6 +4917,10 @@ export {
3749
4917
  trimTurnContext,
3750
4918
  deepCompactTurn,
3751
4919
  compactContext,
4920
+ classifyErrorCode,
4921
+ normalizeUsage,
4922
+ prefixDrift,
4923
+ TOOL_RESULT_MAX_CHARS,
3752
4924
  }
3753
4925
 
3754
4926
  export default createAiChatMiddleware