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.
- package/README.md +7 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -7
- package/dist/init.js +15 -4
- package/dist/portGuard.js +2 -2
- package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
- package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +14 -6
- package/dist/vendored/card-editor/api.js +6 -6
- package/dist/vendored/mcp/client.mjs +53 -3
- package/dist/vendored/mcp/tools.mjs +17 -1
- package/dist/vendored/server/core/ai-chat.js +1443 -271
- package/dist/vendored/server/core/cardFiles.js +271 -39
- package/dist/vendored/server/core/cardImportMeta.js +16 -0
- package/dist/vendored/server/core/chatHistory.js +122 -0
- package/dist/vendored/server/core/index.js +10 -3
- package/dist/vendored/server/core/turnJournal.js +386 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
- package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
- package/dist/vendored/tools/finmall-env.mjs +6 -6
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
- package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
- 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
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1414
|
-
apiKey:
|
|
1415
|
-
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 =
|
|
1420
|
-
maxTokens: Number(
|
|
1421
|
-
//
|
|
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
|
-
*
|
|
1443
|
-
*
|
|
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
|
-
{
|
|
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
|
-
|
|
1582
|
-
res.
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
if (
|
|
1617
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
2032
|
-
const hasExtra = Boolean(
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
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
|
|
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
|
-
|
|
2054
|
-
|
|
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
|
|
2063
|
-
yield {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
2618
|
-
|
|
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
|
-
//
|
|
2629
|
-
|
|
3230
|
+
// ① 从 from 起顺序折叠:超长工具结果留头部 + 「重新调用可取全文」指引
|
|
3231
|
+
let i = start
|
|
3232
|
+
while (i < floor && total > budgetTokens) {
|
|
2630
3233
|
const m = list[i]
|
|
2631
|
-
if (m?.role
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3260
|
-
//
|
|
3261
|
-
//
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4462
|
+
sseNotice(res, evt.text, evt.level || 'warn', evt.code || '')
|
|
3427
4463
|
break
|
|
3428
4464
|
case 'tool-calls':
|
|
3429
4465
|
roundProgressAt = Date.now()
|
|
3430
|
-
|
|
3431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
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: '
|
|
3498
|
-
|
|
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
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
}
|
|
3513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
4667
|
+
error: skipNote,
|
|
3558
4668
|
})
|
|
3559
4669
|
currentMessages.push({
|
|
3560
4670
|
role: 'tool',
|
|
3561
4671
|
tool_call_id: tc.id,
|
|
3562
|
-
content: `Error:
|
|
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,
|
|
3616
|
-
truncated: text.length >
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
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')
|
|
3716
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|