cardbuddy 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/cli.js +14 -6
- package/dist/config.js +13 -2
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +3 -0
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/vendored/App.vue +269 -79
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/DebugPanel.vue +30 -33
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +107 -3
- package/dist/vendored/styles/card-gallery.css +45 -7
- package/dist/vendored/styles/node-inspector.css +11 -0
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_UKS0NF.css +0 -1
- package/dist/preview-editor/assets/index-D1iIJ4sl.js +0 -81
|
@@ -0,0 +1,3710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 开发助手后端:流式调用任意 OpenAI 兼容 LLM,工具调度复用 mcp/tools.mjs。
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - 不依赖 AI SDK(v7 对 reasoning_content 等扩展字段兼容差,直接丢弃导致无输出),
|
|
6
|
+
* 用原生 fetch + ReadableStream 手写 OpenAI chat/completions 流式客户端。
|
|
7
|
+
* - 模型配置(baseUrl / apiKey / model)读取优先级:
|
|
8
|
+
* 真实环境变量 > .env.cb.local(个人覆盖,不入库)> .env.cb(共享默认)。
|
|
9
|
+
* 密钥不回传前端(只回 hasKey + 掩码),保存时写入 .env.cb.local,避免密钥进版本库。
|
|
10
|
+
* - 工具调度复用 mcp/tools.mjs 的 TOOLS + dispatch;另加本地工具
|
|
11
|
+
* skill_read(skills/ 文档按需读)/ card_grep + card_patch(大卡最小改)/ fs_read + fs_list(仓库与挂载目录)
|
|
12
|
+
* / skill_exec(跑 skills 下各技能的 scripts 脚本,设计稿前处理管线的唯一执行手段)/ shell_exec(需显式开启)。
|
|
13
|
+
* - 挂载目录进上下文时,服务端顺手跑 card_route_input 做「建卡输入路由」机械判定(Workflow 0 前置),
|
|
14
|
+
* 并附浅层清单:模型不必再自己试错形态,省下的步数用于真正的建卡动作。
|
|
15
|
+
* - system prompt 为精简协议 + 参考索引;模型用 skill_read 工具按需取
|
|
16
|
+
* skills/dsl-card-generator/references 下的文档(避免整份 skill 十万字进上下文放大空转思考)。
|
|
17
|
+
* - 客户端断开(前端「停止」按钮 / 关页)即中止 LLM 请求与工具循环,不再白跑。
|
|
18
|
+
* - 对话记录按卡片落盘 .cache/ai-chats/<cardId>.json(.cache/ 不入库),切卡 / 重开可续。
|
|
19
|
+
* - 本地文件挂载:不上传,只解析绝对路径 + 元信息;图片预览由本机读盘直出。
|
|
20
|
+
*
|
|
21
|
+
* 端点(均只放行可信来源:回环 / 局域网 / 本机网卡地址,避免公网页面跨源读本机文件):
|
|
22
|
+
* GET/POST /__ai/config 读取(脱敏;?reveal=1 回明文供设置面板回显)/ 保存模型配置
|
|
23
|
+
* POST /__ai/ping 连通性自测(设置面板「测试连接」)
|
|
24
|
+
* POST /__ai/chat SSE 流式对话
|
|
25
|
+
* GET/POST/DELETE /__ai/history 按 cardId 读 / 存 / 清对话记录
|
|
26
|
+
* POST /__ai/fs/pick 调起系统选择器,返回绝对路径
|
|
27
|
+
* GET /__ai/fs/stat 路径元信息(文件 / 文件夹摘要)
|
|
28
|
+
* GET /__ai/fs/raw 本地图片直出(仅预览用)
|
|
29
|
+
* POST /__ai/fs/locate 拖拽落点的绝对路径反查(浏览器拿不到 path)
|
|
30
|
+
* POST /__ai/fs/stash 剪贴板截图落盘为本机路径(同样只存本地 .cache/)
|
|
31
|
+
* POST /__ai/approve 对话内审批回落(skill_exec / shell_exec 的「允许 / 本回合不再询问 / 拒绝」)
|
|
32
|
+
*
|
|
33
|
+
* /__ai/chat SSE 事件:
|
|
34
|
+
* data: {"type":"reasoning-delta","text":"..."}
|
|
35
|
+
* data: {"type":"text-delta","text":"..."}
|
|
36
|
+
* data: {"type":"tool-call","toolCallId":"...","toolName":"card_write","args":{...}}
|
|
37
|
+
* data: {"type":"tool-call-start","toolCallId":"...","toolName":"..."} 工具名先到、参数还在流
|
|
38
|
+
* data: {"type":"tool-result","toolCallId":"...","toolName":"...","result":"..."}
|
|
39
|
+
* data: {"type":"approval-request","approvalId":"...","toolName":"skill_exec","command":"python3 skills/...","args":{...}}
|
|
40
|
+
* data: {"type":"approval-decided","approvalId":"...","ok":true,"scope":"once|turn"}
|
|
41
|
+
* data: {"type":"approval-auto","toolName":"skill_exec","command":"node skills/..."} 免确认策略命中:没弹审批,直接执行
|
|
42
|
+
* data: {"type":"notice","message":"..."} 横幅提示(重复调用警告 / 上下文折叠 / 收敛催促)
|
|
43
|
+
* data: {"type":"context-usage","used":12345,"total":96000,"window":65536} 每轮上下文占用(输入框圆环)
|
|
44
|
+
* data: {"type":"turn-ended","reason":"loop|timeout|steps","message":"..."} 熔断收尾原因(前端落进对话记录,持久可见)
|
|
45
|
+
* data: {"type":"error","message":"..."}
|
|
46
|
+
* data: {"type":"aborted"}
|
|
47
|
+
* data: {"type":"done"}
|
|
48
|
+
*/
|
|
49
|
+
import path from 'node:path'
|
|
50
|
+
import fs from 'node:fs'
|
|
51
|
+
import os from 'node:os'
|
|
52
|
+
import { execFile, spawn, spawnSync } from 'node:child_process'
|
|
53
|
+
import { fileURLToPath } from 'node:url'
|
|
54
|
+
|
|
55
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
56
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..')
|
|
57
|
+
const SKILLS_DIR = path.join(REPO_ROOT, 'skills')
|
|
58
|
+
|
|
59
|
+
/** 单卡片最多保留的消息条数,超出从头部裁剪(避免历史无限增长撑爆上下文与磁盘) */
|
|
60
|
+
const MAX_HISTORY_MESSAGES = 200
|
|
61
|
+
|
|
62
|
+
// ---- 工具定义(延迟加载) ----
|
|
63
|
+
let _toolsCache = null
|
|
64
|
+
let _dispatchRef = null
|
|
65
|
+
|
|
66
|
+
async function loadTools() {
|
|
67
|
+
if (_toolsCache) return { tools: _toolsCache, dispatch: _dispatchRef }
|
|
68
|
+
const toolsModule = await import('../../mcp/tools.mjs')
|
|
69
|
+
const { TOOLS, dispatch } = toolsModule
|
|
70
|
+
// OpenAI tools 格式
|
|
71
|
+
const openaiTools = TOOLS.map((t) => ({
|
|
72
|
+
type: 'function',
|
|
73
|
+
function: {
|
|
74
|
+
name: t.name,
|
|
75
|
+
description: t.description,
|
|
76
|
+
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
77
|
+
},
|
|
78
|
+
}))
|
|
79
|
+
_toolsCache = openaiTools
|
|
80
|
+
_dispatchRef = dispatch
|
|
81
|
+
return { tools: openaiTools, dispatch }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- System prompt:精简协议 + 按需参考索引 ----
|
|
85
|
+
|
|
86
|
+
const SKILL_REFS_DIR = path.join(SKILLS_DIR, 'dsl-card-generator', 'references')
|
|
87
|
+
/** 参考索引的一句话说明:够模型选对文件即可,全文不进上下文 */
|
|
88
|
+
const SKILL_REF_HINTS = {
|
|
89
|
+
'rules.md': '卡片生成铁律(card_write 前必读:结构 / 绑定 / 样式红线)',
|
|
90
|
+
'expression_engine.md': '$ 表达式绑定引擎:语法与金额 / 文本格式化表达式(「文本用表达式实现」首选)',
|
|
91
|
+
'amount_formatting.md': '金额千分位 / 货币符号格式化约定与示例',
|
|
92
|
+
'dsl_structure.md': 'schema 骨架:组件树 / 插槽 / 样式 / v-show 字段结构',
|
|
93
|
+
'component_specs.md': '可渲染组件的属性、事件、插槽与样式默认值规范',
|
|
94
|
+
'component_rules.md': '组件使用规则与常见禁忌',
|
|
95
|
+
'event_patterns.md': '事件脚本范式:this.state 直读、globalSet / postMessage 透传',
|
|
96
|
+
'field_naming.md': '变量命名规范与中文名映射',
|
|
97
|
+
'field_name_dict.json': '高频变量名字典(JSON)',
|
|
98
|
+
'card_to_summary.md': '从 schema 解释建卡逻辑的要点',
|
|
99
|
+
'quickref.md': '速查卡:组件 / 表达式 / 样式 / 工作流一页纸(拿不准先看这个)',
|
|
100
|
+
'recipes.md': '通用开发范式库:Canvas 标题头 / 列表 / 金额 / 弹窗等组合配方(建卡先套最接近的范式)',
|
|
101
|
+
'spec_to_schema_patterns.md': 'spec 标注 → schema 的映射范式(设计稿建卡照抄字段形状)',
|
|
102
|
+
'card_index.json': '全卡索引(组件 / 范式标签 / 名称):找最相似的已有卡当范本',
|
|
103
|
+
'card_templates.json': '卡片模板库:card_scaffold 的 from 模板来源',
|
|
104
|
+
'generation_pitfalls.md': '生成高频坑位清单(IR / schema 写完自查一遍)',
|
|
105
|
+
'previewer_contract.md': '预览器渲染契约:绝对布局 AbsoluteLayout 等字段形状的唯一权威说明',
|
|
106
|
+
'ir-format.md': '紧凑 IR 字段契约(大卡建新卡时省 token 的中间表示)',
|
|
107
|
+
'ui_fidelity_rules.md': '保真规则:间距 / 字号 / 行高 / 媒体降级等还原度纪律',
|
|
108
|
+
'image_to_card.md': '设计稿 → 卡片的读图与语义块划分规则',
|
|
109
|
+
'slices_convention.md': '切图资产落点与引用约定(Image 节点用哪张图)',
|
|
110
|
+
}
|
|
111
|
+
/** 跨 skill 的固定入口:路径相对 skills/,与动态列出的 references 一起进「按需参考索引」 */
|
|
112
|
+
const SKILL_DOC_ENTRIES = [
|
|
113
|
+
{ path: 'dsl-card-generator/SKILL.md', hint: '建卡工作流总纲(Workflow 0 输入路由 / A 读图生成 / A.5 弹窗分流 / B 改卡)' },
|
|
114
|
+
{ path: 'sketch-spec-to-card/SKILL.md', hint: 'MeaXure 导出 → 要素包管线:产物结构、画板分类、卡片边界判定' },
|
|
115
|
+
{ path: 'sketch-spec-to-card/references/spec-schema.md', hint: 'spec.json 字段契约(cards / elements / rect / slices 怎么读)' },
|
|
116
|
+
]
|
|
117
|
+
let _systemPromptCache = null
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 系统提示词只放「工作协议 + 参考索引」。
|
|
121
|
+
* 此前整份注入两份 SKILL.md + rules.md(约 125KB / 数万 token),上下文臃肿会显著放大
|
|
122
|
+
* 推理模型的空转思考(实测单轮 reasoning 4 分钟不收敛、界面一直「思考中」)。
|
|
123
|
+
*/
|
|
124
|
+
function buildSystemPrompt({ shellAllowed = false, vision = false, autoApprove = DEFAULT_AUTO_APPROVE } = {}) {
|
|
125
|
+
const cacheKey = `${shellAllowed ? 'shell' : 'base'}${vision ? '+vision' : ''}+aa:${autoApprove}`
|
|
126
|
+
if (_systemPromptCache && _systemPromptCache.key === cacheKey) return _systemPromptCache.text
|
|
127
|
+
// 免确认策略要如实写进协议:模型若以为「每次都要用户点确认」,会主动停下来等批准 / 把命令甩给用户
|
|
128
|
+
const skillAuto = autoApproves(autoApprove, 'skill_exec')
|
|
129
|
+
const shellAuto = autoApproves(autoApprove, 'shell_exec')
|
|
130
|
+
const lines = []
|
|
131
|
+
for (const entry of SKILL_DOC_ENTRIES) {
|
|
132
|
+
const abs = path.join(SKILLS_DIR, entry.path)
|
|
133
|
+
let size = 0
|
|
134
|
+
try {
|
|
135
|
+
size = fs.statSync(abs).size
|
|
136
|
+
} catch {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
lines.push(`- ${entry.path}(${Math.max(1, Math.round(size / 1024))}KB):${entry.hint}`)
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
for (const name of fs.readdirSync(SKILL_REFS_DIR)) {
|
|
143
|
+
let st
|
|
144
|
+
try {
|
|
145
|
+
st = fs.statSync(path.join(SKILL_REFS_DIR, name))
|
|
146
|
+
} catch {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (!st.isFile()) continue
|
|
150
|
+
lines.push(`- ${name}(${Math.max(1, Math.round(st.size / 1024))}KB):${SKILL_REF_HINTS[name] || '特定主题参考'}`)
|
|
151
|
+
}
|
|
152
|
+
// 范式片段是子目录,不列出来模型根本不知道存在(曾经为此反推 expand_ir 源码猜字段形状)
|
|
153
|
+
const snippets = fs
|
|
154
|
+
.readdirSync(path.join(SKILL_REFS_DIR, 'recipe_snippets'))
|
|
155
|
+
.filter((f) => f.endsWith('.json'))
|
|
156
|
+
.sort()
|
|
157
|
+
if (snippets.length) {
|
|
158
|
+
lines.push(
|
|
159
|
+
`- recipe_snippets/(按主题的现成范式片段,skill_read path=dsl-card-generator/references/recipe_snippets/<文件名> 取用):${snippets.join(' / ')}`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* references 目录缺失时只保留协议 */
|
|
164
|
+
}
|
|
165
|
+
const _text = `你是 CardBuddy 编辑器内置的卡片编辑助手:帮用户读取、修改、生成低代码卡片 schema.json。
|
|
166
|
+
工作协议:
|
|
167
|
+
1. 先用 card_read / card_node_models 了解卡片现状;修改保持最小 diff,不要重写无关节点。
|
|
168
|
+
2. 写之前按需 skill_read 参考文档(path = 下面索引里的文件名):常规修改只读最相关的 1-2 个文件——表达式 / 格式化问题先读 expression_engine.md;card_write 前读 rules.md;不要一次读全部。
|
|
169
|
+
2b. 大卡(card_read 回喂被截断者)修改流程:card_grep 定位 JSON 路径 → card_patch 按路径最小写;严禁 card_write 整包重写大卡。小卡可直接 card_write。
|
|
170
|
+
2c. **从零建卡(尤其设计稿建卡)不要在工具参数里吐整份 schema**:光参数就上万 token,会被单次输出上限截断,整轮白跑。正路是——先 skill_read ir-format.md 看字段契约 → fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json(比 schema 小 4-5 倍)→ skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<id>/"](自带 autofix + 真 validate,报错会原样回传)→ 报错就修。**IR 全文只写这一次**:之后所有调整都用 iterate.mjs 的 --set 点路径原子微调(一轮可带多条);整份重写 IR 一次要 40-110s 纯输出浪费,只有结构级推翻才允许重写。
|
|
171
|
+
2e. **flex / 尺寸红线(validate 与 lint 直接拦,别试错)**:枚举值 kebab-case(flex-start / flex-end / space-between;写 flexEnd 即 validate ERROR);Column / Row 默认 gap=4,不需要间距就显式 gap:"0";justifyContent:space-between 的 Row 必须 rect.width:"100%"(lint R19);Row 父级里的 Row / AbsoluteLayout 子节点默认 width:100%,会互相把对方挤出画布——定宽子簇要显式 rect.width,且并列子节点至少一个 flexShrink:"1"(lint R35)。
|
|
172
|
+
2d. **先抄范式,再动手写**:建卡 / 大改布局前按序取范式——① skill_read recipes.md 或 spec_to_schema_patterns.md 找最接近的组合配方;② 布局与自绘的字段形状(绝对布局 AbsoluteLayout、Canvas 自绘、列表展开收起、金额格式化、弹窗配对等)直接 skill_read recipe_snippets/ 下对应片段或 previewer_contract.md 照抄;③ 需要整卡范本时查 card_index.json,按组件 / 范式标签 / 名称找最相似的已有卡,card_read 或 fs_read 它的关键片段照着写。**禁止反推脚本源码(rg 翻 expand_ir.mjs 之类)或凭记忆猜字段形状**;范式里确实没有才允许自己设计,并在正文里说明为什么没有套用范式。
|
|
173
|
+
3. 改变量类型时要同步处理引用它的地方(绑定表达式 / mock 数据);金额字段配格式化表达式生成展示文本。
|
|
174
|
+
4. 改完用人话说明:改了什么、为什么、需要用户确认什么。
|
|
175
|
+
5. 信息足够后立即收敛:直接调用工具落盘或回复方案;同一内容不要重复读取。**思考里不要逐字草拟 schema / IR / 长 JSON,也不要复述卡片数据**——那会同时吃掉思考与输出额度,想清结构就立刻调工具把内容写出去。
|
|
176
|
+
6. 读文件用 fs_read(仓库内任意文本文件 / 挂载附件),列目录用 fs_list —— fs_read 只能读文件,对目录一定报错,别拿它试目录;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
177
|
+
7. 跑技能脚本用 skill_exec(script 传 skills/<skill>/scripts/<脚本>,args 传参数数组):设计稿前处理管线与 spec_audit / crop_slices / expand_ir / iterate / check_fidelity 等建卡脚本都靠它。${skillAuto ? '本仓库已设「技能脚本免确认」:调用即执行(审计写 .cache/ai-audit.log),不要停下来等用户批准,也不要因为怕要授权而改用 shell_exec 绕路、或把命令甩给用户自己跑。' : '每次调用会先请用户确认(用户可选「本回合不再询问」),被拒就把命令原样写给用户自行运行。'}${shellAllowed ? `\n8. 技能脚本之外的本地命令(npm / node / git / rg / cd 等)用 shell_exec:经真实 shell 执行,管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell 都可用(其中真正执行的每个外部命令需在白名单),不必再为元字符把命令拆开;复制文件用 fs_copy。${shellAuto ? '本仓库已设「本地命令免确认」:调用即执行。' : '每次调用都会弹给用户确认,被拒返回错误。'}cwd 已是仓库根、技能脚本自己按仓库根推算落点,不要写 cd;白名单不含 sips / convert / magick / python3 —— 图像裁剪派生一律 crop_slices.mjs,Python 脚本一律 skill_exec,别去试系统图像命令。` : ''}
|
|
178
|
+
|
|
179
|
+
## 设计稿建卡协议(Stage Machine:逐阶段推进,上一阶段产物齐了才进下一阶段;用户挂载设计稿目录 / 图片并要求建卡时严格执行,不要自由发挥)
|
|
180
|
+
S0 路由:输入形态已由服务端机械判定,写在「用户挂载的本地文件」段(kind / preprocess / next):照它执行,不要再调 card_route_input;该段缺失才自己调 card_route_input({ path })。
|
|
181
|
+
S1 要素包:kind=meaxure-export(Sketch MeaXure 标注导出)必须先有 sketch-spec-to-card 要素包,禁止直接读 preview/ 整图建卡。
|
|
182
|
+
- fs_read 判定里给出的 manifest(通常 UI/manifest.json)找目标画板条目:status=generated ⇒ 产物已在;status=pending ⇒ 先看 preprocess.alsoFound(同一导出的其它产物目录,平铺与按稿分片常并存),有就直接用;确实都没有才补跑:
|
|
183
|
+
① skill_exec script=skills/sketch-spec-to-card/scripts/parse_meaxure.py,args=["--src","<导出目录>","--list"] 拿画板序号与名称;
|
|
184
|
+
② args=["--src","<导出目录>","--out","UI","--draft","<稿名>","--indices","<n>","--build"] 只产该画板(多个逗号;按名称子串用 "--only","<子串>");
|
|
185
|
+
③ script=skills/sketch-spec-to-card/scripts/cluster_states.py,args=["--out","UI/<稿名>"]。
|
|
186
|
+
- 要素包落点记作 <bundle>:传了 --draft 在 UI/<稿名>/<画板名>/,否则 UI/<画板名>/;以 parse_meaxure.py 打印的落盘路径为准。仓库可能两种布局并存,manifest 说的画板在 UI/ 下找不到就用 fs_list UI 找同名目录,别急着判定产物缺失。
|
|
187
|
+
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"]。
|
|
188
|
+
它交叉审计 spec + 聚焦截图 + 切图目录,给出:bands(水平行与行内元素序)、gaps(padLeft / padRight / padTop / padBottom / rowGaps)、texts(文案 / 字号 / 字重 / 颜色 / 行高 / 对齐)、shapes(装饰条)、slices(存在性 / 复用 / 是否烘焙背景与标签文字)、colors(背景采样点)、missing(缺哪张切图 + 建议裁剪矩形)。
|
|
189
|
+
它交叉审计 spec + 聚焦截图 + 切图目录,另给 bgGradientHint(顶部渐变带的起止色/跨度 + IR 正确写法)与 slices[].crops(reused-differs 时「矩形 → 该用哪张 _alt 图」的映射,exists=true 直接引用、false 才需裁)。
|
|
190
|
+
IR 的几何与样式一律照抄审计值;**禁止 fs_read spec.json**(几百 KB 只会拿到截断片段,重复读纯浪费步数),禁止反复 fs_list 摸索结构。要看层级树才跑 spec_tree.mjs。
|
|
191
|
+
**审计 JSON 只认落盘文件**:--save 已把整份审计写到 .cache/ai-scratch/<id>.audit.json,下游脚本(crop_slices --from-audit)一律引用该文件。**禁止用 fs_write 手抄 / 节选审计内容**——实测手抄的 156 字节「摘要」让 crop_slices 裁出错名错位的切图,还自造了违反命名约定的新切片。
|
|
192
|
+
S3 补切图:S2 的 missing 非空 ⇒ skill_exec script=skills/dsl-card-generator/scripts/crop_slices.mjs,args=["--design","<bundle>/card-N.png","--card-width","<卡宽>","--out","src/slices","--from-audit",".cache/ai-scratch/<id>.audit.json"],从聚焦截图派生缺失切图(审计已落盘,直接引用)。切图唯一落点 src/slices/ 扁平命名,IR 里 Image.value 用 /slices/<名>。
|
|
193
|
+
只缺一两张时用 --crop <名>@x,y,w,h(可多条),x/y/w/h 直接抄审计 missing[].crop 的**卡片本地 1x 坐标**,脚本自己按 design 宽 ÷ 卡宽换算像素。**禁止用 sips / convert / magick 等系统图像命令裁图**:它们不在 shell 白名单,且要手算缩放比与裁剪偏移,实测必错。
|
|
194
|
+
切图用法看 slices[].baked:label=true ⇒ 切图已含标签文字,tile 用单个 Image(96×84 之类照 rect 抄);label=false ⇒ 需要 Image + Text 组合;verdict=reused-differs ⇒ 同一张切图被标到内容不同的多个位置,除第一个外必须用审计 slices[].crops 给出的 _alt 名(exists=true 直接引用,exists=false 由 crop_slices 派生)——**禁止自造新名字**(实测自造 icon_xx_左/右 让同一设计内容在 src/slices 下出现三套名字)。
|
|
195
|
+
S4 写 IR(全文只写这一次):fs_write .cache/ai-scratch/<id>.ir.json。结构照 bands 一条一段;gaps.rowGaps 映射成 flex.gap / padding(paddingType:"1" + 四边值);texts 逐条转 Text(fontSize / fontWeight / color / lineHeight 全抄,**lineHeight 必写**,否则渲染器按 1.5 倍字号撑高、整卡累计漂移几个像素);标题 + 底部装饰条照抄 recipe_snippets/title-underline-overlay.json(AbsoluteLayout + position.top,禁负 margin)。
|
|
196
|
+
**类型开关铁律(渲染器静默丢弃类缺陷的唯一防线)**:margin / padding 组写单边值(marginTop 等)必须同组写 marginType:"1" / paddingType:"1"(expand_ir 会自动补;手写 schema 漏了会被 lint 报 r44-*-no-type);背景三选一:纯色 backgroundType:"01"+backgroundColor、图片 "02"+backgroundImage、渐变 "03"+backgroundGradient。审计给了 bgGradientHint 就照抄它的 ir 字段;**禁止自造 gradientColor / gradientDirection,禁止把 linear-gradient(...) 塞进 backgroundColor**——这三者都会静默失效(实测模型连试 3 轮全无效后放弃并编造理由)。
|
|
197
|
+
落点卡默认 = 当前正在编辑的卡片 ID(见「当前卡片上下文」):IR 的 id 与 iterate 的 --out 一律 src/cards/<当前卡片ID>/,即覆盖当前卡 / 空卡是默认行为;只有用户明说「新建一张」才自拟 ID。当前卡已有内容时,动手前一句正文说明将覆盖、原内容留 .history 快照可回滚。
|
|
198
|
+
S4.5 变量抽取(真实卡片不是截图:这步漏了,产物就是 dictionary / data.json 全空的死图):先 skill_read ir-format.md 的「2b. 变量绑定」看契约,再决定哪些文本绑变量——
|
|
199
|
+
下限 = S2 审计的 bindHints[](amount / percent / date / masked / count / level),再按语义补正则认不出的动态值(用户名 / 客户层级 / 卡片名称 / 状态词);静态标签(区块标题 / 按钮 / 说明文字)才写字面量。
|
|
200
|
+
- 动态值:Text 主值用 v:"$key"(条件文案写表达式如 "$showAll ? '收起' : '查看更多'",金额展示照 recipe_snippets/money-format.json,别把「¥7,000.00」拆成字面量)。
|
|
201
|
+
- IR 顶层**显式写 dictionary.inputParams**:{key,name,type,defaultValue,desc,rule:{type:"",options:{}},children:[]},defaultValue 一律取设计稿字面量——expand_ir 用它生成 data.json,渲染即设计稿文案,S5 像素闭环才不受绑定影响。
|
|
202
|
+
- 重复行 / tile 组(审计 listCandidates[]、或用户明说要列表):数组变量 + listScope 插槽,子树内 $item.xxx / $_index;范式照 recipe_snippets/list-expand-collapse.json 或 src/cards/account 的 account_details。结构确实不规则时允许保持静态,但要在正文说明理由。
|
|
203
|
+
- 命名语义化(memberLevel / totalAmount / assetRows),禁 text1 / val2;写完在正文列一句绑定清单(key → 设计稿文案)。
|
|
204
|
+
S5 收敛循环(唯一门禁 = iterate.mjs 报告里的 gate 字段:内部已跑 autofix + 真 validate + lint + preflight + render + 并集像素对比,别再单独跑 validate_schema.py):
|
|
205
|
+
skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--ir",".cache/ai-scratch/<id>.ir.json","--out","src/cards/<落点卡>/","--design","<bundle>/card-N.png","--width","<卡宽>","--spec","<bundle>/spec.json"]。
|
|
206
|
+
修 IR 一律 --set 点路径原子微调(数字段=数组下标,一轮可多条;中段不存在会响亮报错,按报错改路径),例:--set children.0.s.flex={"gap":"0"}。
|
|
207
|
+
**收敛 = 报告 gate.passed === true**(validate/lint/preflight 零 error ∧ match ≥ 95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x| ≤ 2)。gate 是脚本判的,正文只能引用它的 verdict / failed,不得自行宣布达标。像素对比按并集画布算,删内容 / 高度塌缩只会降分,不可能凑分。
|
|
208
|
+
报告还带:contentOffset1x + contentOffsetHint(内容整体平移时先修对齐,别逐行调间距)、lint.warningDetails、preflight.details、renderImage(渲染原图路径)、diffImageNote(并排图顶部色条是工具标题栏,不是卡片内容)、stalled(连续多轮 match 与热区不变)。
|
|
209
|
+
**stalled 出现 ⇒ 停止 --set 盲调,照 stalled.hint 换证据链**:跑 diff_bands.mjs(args=["--id","<落点卡>","--design","<bundle>/card-N.png","--render","<报告 renderImage>"])拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。实测事故:match 卡 73% 连调 6 轮无效,根因是 6 处 margin 漏 marginType,diff_bands 一轮就能指认。
|
|
210
|
+
match 不升反降、heightDelta 非 0、或 hotspots 集中在原本已有内容的区域 ⇒ 说明在删内容凑分:回退该轮 --set 换思路。diffImage 每轮重写(报告里的路径),只给用户预览。
|
|
211
|
+
**取证禁令**:禁止用 shell_exec 写 node -e pngjs 像素脚本(行扫描 / 取色 / 自算 diff)——diff_bands.mjs、报告 hotspots、contentOffset1x、审计 colors/bgGradientHint 已经覆盖全部取证需求,实测手写脚本 4 次全是浪费步数;禁止 fs_read 任何图片;禁止在 S5 之前调 card_screenshot(渲染还没意义);禁止把 src/cards/<卡>/<卡>.png 当渲染图(那是设计稿归档,渲染原图是报告里的 renderImage)。
|
|
212
|
+
每轮用一两句正文汇报:gate.verdict / match% / heightDelta / 这轮修了什么,不要闷头连调。
|
|
213
|
+
S6 收尾门禁:① skill_exec script=skills/dsl-card-generator/scripts/check_fidelity.mjs,args=["--spec","<bundle>/spec.json","--schema","src/cards/<落点卡>/schema.json","--card","N"],按它报的文本缺失 / 字号偏差 / 颜色偏差修到无 ERROR;② skill_exec script=skills/dsl-card-generator/scripts/iterate.mjs,args=["--gate","--out","src/cards/<落点卡>/"] 拿机器判定(读最近一轮落盘报告,schema 在报告之后被改过会直接判 FAIL)。
|
|
214
|
+
正文汇报必须**逐字引用 --gate 的 verdict 与 failed 列表** + 落点卡 + 补了哪些切图 + 回滚方式。gate 未过禁止写「门禁全绿 / 已完成 / 验收通过」,必须列出未达标项、原因与下一步;禁止把未达标归因于「测量噪声 / 工具合成产物」——pixelDiff 只用渲染原图与设计原图,合成图色条不参与计算(见 diffImageNote)。
|
|
215
|
+
绑定对齐:check_fidelity 会按 data.json / defaultValue 把 $ 绑定解析回文案再对齐——报 missing-text 而该文本已绑定 ⇒ 是 mock 与设计稿不一致,改 defaultValue / data.json,**不是拆回字面量**。收尾自查:bindHints 每一项要么已绑定、要么正文说明为何保持字面量。
|
|
216
|
+
${vision ? '你有视觉能力:S5 里 match 不达标可直接读注入的并排对比图定位差异(哪一行 / 哪个颜色 / 哪段间距)再 --set;数值指标仍是收敛判据,图只用于定位。' : '当前模型无视觉:不要声称看过图;S2 的审计值 + S5 的数值指标就是你的眼睛,所有尺寸颜色文案取审计值,缺时按 references/ui_fidelity_rules.md 降级规则处理,禁止凭空估数值。'}
|
|
217
|
+
步数不设硬顶(除非用户在设置里配了上限),但有四道闸:①连续多轮「同工具 + 同参数」会被判定原地打转并强制收尾 ②单轮墙钟到点会强制收尾 ③每 24 步会收到一次收敛提醒 ④单轮上下文超预算时**早期工具结果会被折叠成摘要**并标注「已折叠」——看到折叠标记就用工具重读,不要拿摘要当结论。所以别把额度当免费:S2 / S3 / S5 各尽量一次调用解决;同类信息只读一次;收到重复调用警告就立刻换证据链(diff_bands.mjs / generation_pitfalls.md),不要把同一条调用原样再发一次;被中止过的轮次按上下文里的中止标记从中断处继续,不要从零重做。
|
|
218
|
+
|
|
219
|
+
## 按需参考索引(skill_read)
|
|
220
|
+
${lines.join('\n')}`
|
|
221
|
+
_systemPromptCache = { key: cacheKey, text: _text }
|
|
222
|
+
return _text
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** skill_read:模型按需取参考文档,替代把整份 skill 塞进 system prompt */
|
|
226
|
+
const SKILL_READ_TOOL = {
|
|
227
|
+
type: 'function',
|
|
228
|
+
function: {
|
|
229
|
+
name: 'skill_read',
|
|
230
|
+
description: '读取系统提示词「按需参考索引」中列出的建卡参考文档(文件名),返回正文(超长截断)。只在需要时读,不要全读。',
|
|
231
|
+
parameters: {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: { path: { type: 'string', description: '索引中的文件名,如 expression_engine.md' } },
|
|
234
|
+
required: ['path'],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
const SKILL_READ_MAX = 30000
|
|
239
|
+
|
|
240
|
+
/** 大卡定位/最小写工具:card_read 只回喂 12KB、card_write 要整包替换,
|
|
241
|
+
* 400KB 级卡片上二者都不可行(模型只能空转)。card_grep 给 JSON 路径,card_patch 按路径最小写。 */
|
|
242
|
+
const CARD_GREP_TOOL = {
|
|
243
|
+
type: 'function',
|
|
244
|
+
function: {
|
|
245
|
+
name: 'card_grep',
|
|
246
|
+
description: '在卡片 schema/data 里按关键字搜键名与字符串值,返回匹配处的 JSON 点路径 + 片段(大卡定位修改点首选;card_read 的回喂有截断,看不到全卡)。',
|
|
247
|
+
parameters: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
id: { type: 'string', description: '卡片 id' },
|
|
251
|
+
pattern: { type: 'string', description: '关键字,如变量名 externalText(大小写不敏感)' },
|
|
252
|
+
target: { type: 'string', enum: ['schema', 'data'], description: '默认 schema' },
|
|
253
|
+
},
|
|
254
|
+
required: ['id', 'pattern'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
const CARD_PATCH_TOOL = {
|
|
259
|
+
type: 'function',
|
|
260
|
+
function: {
|
|
261
|
+
name: 'card_patch',
|
|
262
|
+
description: '按 JSON 点路径(数组用数字段)对卡片 schema/data 做最小修改并写回;大卡必用本工具,禁止用 card_write 整包重写。路径必须先用 card_grep 拿真实路径——schema 的节点树是 view.components[].slots[].components[](不是 view.children)。路径中段不存在会直接报错(不再静默新建分支),确要新建传 create:true。仓库根下走 card_write 管线(带历史快照与校验)。',
|
|
263
|
+
parameters: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
id: { type: 'string', description: '卡片 id' },
|
|
267
|
+
patches: {
|
|
268
|
+
type: 'array',
|
|
269
|
+
items: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
path: { type: 'string', description: 'JSON 点路径(card_grep 返回值可直接用)' },
|
|
273
|
+
value: { description: '新值(任意 JSON)' },
|
|
274
|
+
},
|
|
275
|
+
required: ['path', 'value'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
deletes: { type: 'array', items: { type: 'string' }, description: '要删除的 JSON 点路径' },
|
|
279
|
+
target: { type: 'string', enum: ['schema', 'data'], description: '默认 schema' },
|
|
280
|
+
create: { type: 'boolean', description: '路径中段不存在时是否新建分支(默认 false = 响亮报错)' },
|
|
281
|
+
},
|
|
282
|
+
required: ['id', 'patches'],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
const GREP_MAX_HITS = 40
|
|
287
|
+
|
|
288
|
+
function cardGrep(root, { id, pattern, target = 'schema' }) {
|
|
289
|
+
const file = path.join(root, 'src', 'cards', String(id || ''), target === 'data' ? 'data.json' : 'schema.json')
|
|
290
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
291
|
+
const needle = String(pattern || '').toLowerCase()
|
|
292
|
+
if (!needle) throw new Error('缺少 pattern')
|
|
293
|
+
const hits = []
|
|
294
|
+
const walk = (node, p) => {
|
|
295
|
+
if (hits.length >= GREP_MAX_HITS) return
|
|
296
|
+
if (Array.isArray(node)) {
|
|
297
|
+
node.forEach((v, i) => walk(v, `${p}.${i}`))
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (node && typeof node === 'object') {
|
|
301
|
+
for (const [k, v] of Object.entries(node)) {
|
|
302
|
+
const cp = p ? `${p}.${k}` : k
|
|
303
|
+
if (k.toLowerCase().includes(needle)) {
|
|
304
|
+
hits.push({ path: cp, kind: 'key', snippet: typeof v === 'string' ? v.slice(0, 120) : typeof v })
|
|
305
|
+
} else if (typeof v === 'string' && v.toLowerCase().includes(needle)) {
|
|
306
|
+
hits.push({ path: cp, kind: 'value', snippet: v.slice(0, 120) })
|
|
307
|
+
}
|
|
308
|
+
if (hits.length >= GREP_MAX_HITS) return
|
|
309
|
+
walk(v, cp)
|
|
310
|
+
}
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
walk(doc, '')
|
|
315
|
+
return { id, target, pattern, hits, truncated: hits.length >= GREP_MAX_HITS }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function applyByPath(doc, p, setter, { create = false } = {}) {
|
|
319
|
+
const segs = String(p).split('.').filter(Boolean)
|
|
320
|
+
if (!segs.length) throw new Error(`非法路径:${p}`)
|
|
321
|
+
let cur = doc
|
|
322
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
323
|
+
const k = segs[i]
|
|
324
|
+
const next = Array.isArray(cur) ? cur[Number(k)] : cur[k]
|
|
325
|
+
if (next == null || typeof next !== 'object') {
|
|
326
|
+
// 历史事故:静默 auto-create 让猜错的路径(如 view.children.…)往 schema 里塞垃圾分支,
|
|
327
|
+
// 补丁「成功」但目标节点纹丝不动。现在响亮报错,逼模型回 card_grep 拿真实路径。
|
|
328
|
+
if (!create) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`路径中段不存在:${segs.slice(0, i + 1).join('.')}(完整路径 ${p})。schema 节点树是 view.components[].slots[].components[],先 card_grep 定位再 patch;确要新建分支传 create:true`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
cur[k] = /^\d+$/.test(segs[i + 1]) ? [] : {}
|
|
334
|
+
}
|
|
335
|
+
cur = Array.isArray(cur) ? cur[Number(k)] : cur[k]
|
|
336
|
+
}
|
|
337
|
+
setter(cur, segs[segs.length - 1])
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function cardPatch(root, args, dispatch) {
|
|
341
|
+
const { id, patches = [], deletes = [], target = 'schema' } = args || {}
|
|
342
|
+
if (!Array.isArray(patches) || !patches.length) throw new Error('缺少 patches')
|
|
343
|
+
const file = path.join(root, 'src', 'cards', String(id || ''), target === 'data' ? 'data.json' : 'schema.json')
|
|
344
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
345
|
+
const create = args.create === true
|
|
346
|
+
for (const pt of patches) applyByPath(doc, pt.path, (obj, k) => (obj[k] = pt.value), { create })
|
|
347
|
+
for (const d of deletes) applyByPath(doc, d, (obj, k) => delete obj[k], { create })
|
|
348
|
+
// 仓库根:走 card_write 管线(历史快照 + validate/render_status);其它根(测试/自定义)直接落盘
|
|
349
|
+
if (path.resolve(root) === REPO_ROOT && dispatch) {
|
|
350
|
+
const res = await dispatch('card_write', { id, [target]: doc })
|
|
351
|
+
return { ok: true, via: 'card_write', patches: patches.length, deletes: deletes.length, write: res }
|
|
352
|
+
}
|
|
353
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n', 'utf8')
|
|
354
|
+
return { ok: true, via: 'disk', patches: patches.length, deletes: deletes.length, file }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
/** 仓库内任意文件只读:补齐 card_read / skill_read 覆盖不到的 AGENTS.md、docs、tools 等 */
|
|
359
|
+
const FS_READ_TOOL = {
|
|
360
|
+
type: 'function',
|
|
361
|
+
function: {
|
|
362
|
+
name: 'fs_read',
|
|
363
|
+
description: '读取仓库根(或用户挂载目录)内的任意文本文件,返回正文(超长截断)。读卡片优先 card_read / card_grep;读参考文档优先 skill_read;本工具用于其余仓库文件与挂载附件。',
|
|
364
|
+
parameters: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
path: { type: 'string', description: '相对仓库根的路径或绝对路径(挂载目录内)' },
|
|
368
|
+
maxChars: { type: 'number', description: '返回上限,默认 20000 字符' },
|
|
369
|
+
},
|
|
370
|
+
required: ['path'],
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 本地命令执行(默认注册;是否逐次确认由 AI_AUTO_APPROVE 决定,默认 all=免确认;AI_ALLOW_SHELL=0 可整体关闭)。
|
|
377
|
+
* 描述里的白名单实时取自 SHELL_ALLOW_BIN —— 硬编码一份清单注定和实现漂移(曾经漏了 cd,
|
|
378
|
+
* 模型照着旧清单写命令反被自己的工具描述误导)。
|
|
379
|
+
*/
|
|
380
|
+
function shellTool(auto = false) {
|
|
381
|
+
return {
|
|
382
|
+
type: 'function',
|
|
383
|
+
function: {
|
|
384
|
+
name: 'shell_exec',
|
|
385
|
+
description: `在仓库根经真实 shell(/bin/sh -c)执行一条本地命令并返回输出(60s 超时)。cwd 已是仓库根,命令里不要写 cd(要切目录用脚本自己的 --src / --out 参数)。支持管道 |、链接 && / ;、重定向、引号与循环 / 条件 / 子shell / $( ) 等 shell 语法;其中真正执行的每个外部命令(含管道各段与替换体内部)必须在白名单(${[...SHELL_ALLOW_BIN].join(' / ')})。复制文件用 fs_copy,不要试 cp / mv。白名单**不含**图像处理命令(sips / convert / magick:裁切图用 skill_exec 跑 crop_slices.mjs)与解释器(python / python3:Python 脚本用 skill_exec)。${auto ? '本仓库已设为免确认:调用即执行,不要停下来等用户批准。' : '每次调用都会先弹给用户确认,被拒会返回错误。'}用于跑测试、构建、git 等结构化卡片工具覆盖不了的操作。`,
|
|
386
|
+
parameters: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
command: { type: 'string', description: '命令与参数,如:node tools/version-bump.mjs --check "fix(x): y"' },
|
|
390
|
+
},
|
|
391
|
+
required: ['command'],
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* skill_exec:跑 skills/ 下的技能脚本(node / python3)。
|
|
399
|
+
*
|
|
400
|
+
* 为什么必须有它:设计稿建卡的上游管线(sketch-spec-to-card 的 parse_meaxure.py / cluster_states.py)
|
|
401
|
+
* 是 Python,而 shell_exec 默认不注册、白名单里也没有 python —— 网页助手此前**根本无法**执行
|
|
402
|
+
* 「先 sketch-spec-to-card 前处理、再 dsl-card-generator 生成」这条链路,只能干读 preview/ 整图。
|
|
403
|
+
* 与 shell_exec 的区别:script 必须是 skills/<skill>/scripts/ 下的 .mjs/.js/.py(路径形状硬校验),
|
|
404
|
+
* 参数以数组传入、不经 shell 解析,cwd 固定仓库根 —— 风险面小得多;默认策略(AI_AUTO_APPROVE=all)
|
|
405
|
+
* 让它免确认执行,建卡流水线才不必每步都等用户点「允许」;shell_exec 默认仍逐次审批。
|
|
406
|
+
*/
|
|
407
|
+
function skillExecTool(auto = false) {
|
|
408
|
+
return {
|
|
409
|
+
type: 'function',
|
|
410
|
+
function: {
|
|
411
|
+
name: 'skill_exec',
|
|
412
|
+
description: `执行 skills/<skill>/scripts/ 下的技能脚本(.mjs/.js 用 node、.py 用 python3),cwd=仓库根,返回 exit code + stdout/stderr(默认 180s 超时)。设计稿建卡必用:sketch-spec-to-card 的 parse_meaxure.py(--list 列画板 / --indices 选画板 / --build 出要素包)与 cluster_states.py;dsl-card-generator 的 spec_audit.mjs(一步审计带出 bands/gaps/texts/slices/missing)、crop_slices.mjs(从聚焦截图派生缺失切图:--from-audit 或 --crop 名@x,y,w,h)、spec_tree.mjs(spec.json 压缩成层级树)、expand_ir.mjs(IR 展开成 schema,自带 autofix + validate)、iterate.mjs(--set 点路径微调 + 渲染 + 并集像素对比 + preflight + gate 机器门禁的唯一入口;--gate 单独判收敛)、diff_bands.mjs(渲染 vs 设计的逐带 dy/dh / 内容底边 / 分区主色,凡「看起来不一致」先跑它拿证据,支持 --render 复用渲染原图)、preflight.mjs(Pre-flight Checklist 的机械化 10 条)、render_card.mjs(离线渲染原图)、describe_card.mjs / extract_subtree.mjs(反向读存量卡 / 抽子树范式,替代手写 node -e 遍历 schema)、check_fidelity.mjs(还原度门禁)等。${auto ? '本仓库已设为免确认:调用即执行,按协议连跑到底,不要停下来等用户批准,也不要因为怕要授权而改用 shell_exec。' : '每次调用先请用户确认(用户可选「本回合不再询问」)。'}`,
|
|
413
|
+
parameters: {
|
|
414
|
+
type: 'object',
|
|
415
|
+
properties: {
|
|
416
|
+
script: { type: 'string', description: '仓库根相对路径,如 skills/sketch-spec-to-card/scripts/parse_meaxure.py' },
|
|
417
|
+
args: {
|
|
418
|
+
type: 'array',
|
|
419
|
+
items: { type: 'string' },
|
|
420
|
+
description: '参数数组,如 ["--src","/path/含空格 也作为一项","--list"];不要自己加引号,也不要拼成一整条命令字符串',
|
|
421
|
+
},
|
|
422
|
+
timeoutMs: { type: 'number', description: '超时毫秒,默认 180000,最大 600000' },
|
|
423
|
+
},
|
|
424
|
+
required: ['script'],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 脚本路径形状:只认 skills/<skill>/scripts/ 下的 .mjs/.cjs/.js/.py,杜绝拿它当通用命令执行器 */
|
|
431
|
+
const SKILL_SCRIPT_RE = /^skills\/[^/]+\/scripts\/[^/].*\.(mjs|cjs|js|py)$/
|
|
432
|
+
const SKILL_EXEC_MAX_ARGS = 60
|
|
433
|
+
const SKILL_EXEC_DEFAULT_TIMEOUT = 180000
|
|
434
|
+
const SKILL_EXEC_MAX_TIMEOUT = 600000
|
|
435
|
+
let _pythonBin = ''
|
|
436
|
+
|
|
437
|
+
/** Python 解释器:尊重 DSL_CARD_PYTHON,否则探测 python3 / python(管线脚本只用标准库,无需装依赖) */
|
|
438
|
+
function pythonBin() {
|
|
439
|
+
if (process.env.DSL_CARD_PYTHON) return process.env.DSL_CARD_PYTHON
|
|
440
|
+
if (_pythonBin) return _pythonBin
|
|
441
|
+
for (const bin of ['python3', 'python']) {
|
|
442
|
+
try {
|
|
443
|
+
if (spawnSync(bin, ['--version'], { stdio: 'ignore' }).status === 0) {
|
|
444
|
+
_pythonBin = bin
|
|
445
|
+
return _pythonBin
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
/* 换下一个候选 */
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
_pythonBin = 'python3'
|
|
452
|
+
return _pythonBin
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function resolveSkillScript(root, script) {
|
|
456
|
+
const rel = String(script || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
|
|
457
|
+
if (!rel) throw new Error('缺少 script')
|
|
458
|
+
if (!SKILL_SCRIPT_RE.test(rel)) {
|
|
459
|
+
throw new Error(`只能执行 skills/<skill>/scripts/ 下的 .mjs / .js / .py 脚本(收到:${rel});其余命令用 shell_exec`)
|
|
460
|
+
}
|
|
461
|
+
const abs = path.resolve(root, rel)
|
|
462
|
+
if (!abs.startsWith(path.join(root, 'skills') + path.sep)) throw new Error('非法路径:脚本必须在仓库 skills/ 目录内')
|
|
463
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`脚本不存在:${rel}`)
|
|
464
|
+
return { rel, abs, bin: path.extname(abs).toLowerCase() === '.py' ? pythonBin() : 'node' }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function normalizeSkillArgs(args) {
|
|
468
|
+
const list = Array.isArray(args) ? args : args === undefined || args === null || args === '' ? [] : [args]
|
|
469
|
+
if (list.length > SKILL_EXEC_MAX_ARGS) throw new Error(`参数过多(上限 ${SKILL_EXEC_MAX_ARGS})`)
|
|
470
|
+
return list.map((a) => {
|
|
471
|
+
if (typeof a === 'number' || typeof a === 'boolean') return String(a)
|
|
472
|
+
if (typeof a !== 'string') throw new Error('args 必须是字符串数组')
|
|
473
|
+
if (/[\r\n\0]/.test(a)) throw new Error('args 含非法控制字符(换行 / NUL)')
|
|
474
|
+
return a
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 命令白名单:只放读多写少、可预期的入口(含常用只读过滤器,管道才有意义);真正的安全闸是审批策略。
|
|
480
|
+
* cd 在白名单里:它自己不执行任何东西,而模型很爱写 `cd <仓库根> && node …`(训练语料习惯),
|
|
481
|
+
* 拦掉只会换来一轮无谓的重试;`cd x && rm -rf y` 依旧被 rm 那一段挡住,逐段校验没有被削弱。
|
|
482
|
+
*/
|
|
483
|
+
const SHELL_ALLOW_BIN = new Set([
|
|
484
|
+
'npm', 'node', 'npx', 'git', 'rg', 'grep', 'cat', 'ls', 'pwd', 'cd', 'find',
|
|
485
|
+
'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'sed', 'awk', 'jq', 'diff', 'echo', 'mkdir',
|
|
486
|
+
])
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 白名单外的高频误用 → 指回范式工具。
|
|
490
|
+
* 报错文本是模型唯一的路标:只说「不在白名单」它下一轮会换个同类命令再撞一次
|
|
491
|
+
* (实测:裁切图被拒后仍想用 sips / convert 硬来,而正路是 crop_slices.mjs)。
|
|
492
|
+
*/
|
|
493
|
+
const SHELL_BIN_HINTS = {
|
|
494
|
+
sips: '图像裁剪 / 缩放 / 转格式一律用 skill_exec skills/dsl-card-generator/scripts/crop_slices.mjs(--crop <名>@x,y,w,h 传卡片本地 1x 坐标,或 --from-audit <审计JSON>),它自己按 design 宽 ÷ 卡宽换算像素;渲染截图由 iterate.mjs 产出',
|
|
495
|
+
convert: '同上:图像处理走 skill_exec crop_slices.mjs,不要用 ImageMagick',
|
|
496
|
+
magick: '同上:图像处理走 skill_exec crop_slices.mjs,不要用 ImageMagick',
|
|
497
|
+
identify: '图片尺寸 / 切图信息看 spec_audit.mjs 的审计 JSON(slices[].px、missing[].crop),不要自己跑图像命令',
|
|
498
|
+
ffmpeg: '本工具链不处理音视频',
|
|
499
|
+
python: 'Python 脚本用 skill_exec(script=skills/<skill>/scripts/x.py,args 传参数数组):白名单不含解释器',
|
|
500
|
+
python3: 'Python 脚本用 skill_exec(script=skills/<skill>/scripts/x.py,args 传参数数组):白名单不含解释器',
|
|
501
|
+
rm: '不要删文件:覆盖写用 fs_write,卡片改动用 card_patch / card_write,历史版本由 .history 快照保管',
|
|
502
|
+
mv: '移动 = fs_copy 复制到新落点;删旧文件不支持(留给用户或 .history 快照)',
|
|
503
|
+
cp: '复制文件用 fs_copy 工具(from / to 两个路径参数,二进制安全、自动建父目录);改写文本内容才用 fs_write(仅草稿区)',
|
|
504
|
+
dir: 'Windows 下用 ls 列目录(Git Bash 自带);或直接用自己的领域知识判断文件位置',
|
|
505
|
+
mkdir: '目录由写入工具自动创建(fs_write / fs_copy / crop_slices.mjs --out),不需要手动 mkdir',
|
|
506
|
+
touch: '写空文件用 fs_write(content 传空串,仅草稿区);仓库内落占位文件用 fs_copy',
|
|
507
|
+
curl: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
|
|
508
|
+
wget: '助手不联网:网关调用由后端代理,不要自己发 HTTP 请求',
|
|
509
|
+
open: '不要打开 GUI 程序:产物路径写进正文回复,用户自己点开',
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 替换体在「扁平段」里的占位符:整块替换体已单独收集校验,段内定位命令词时不该再看它内部 */
|
|
513
|
+
const SUBST_PLACEHOLDER = '\u0000sub\u0000'
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* sh 结构走查:一趟扫出「引号 / 转义 / 替换体嵌套」三态下的分段与替换体,静态闸的几个口径共用它。
|
|
517
|
+
* 返回:
|
|
518
|
+
* - flatSegs:顶层按 ; && || | 与换行的分段,且每个替换体整块折叠成占位符 ——
|
|
519
|
+
* 替换体 / 子shell 内部的分隔符是它的参数、不切段;折叠后才不会把 `M=$(find vendor x | head -1)`
|
|
520
|
+
* 赋值体里的 vendor 当段首命令拦掉(实测误拒源)
|
|
521
|
+
* - subs:所有替换体 / 子shell 正文(含嵌套、含双引号内的),quoted 标记供报错话术区分
|
|
522
|
+
*
|
|
523
|
+
* sh 语义三条(旧实现三处各漏一条,实测各自造成一次误拒或一次白名单绕过):
|
|
524
|
+
* 1. 反斜杠转义:单引号内 \ 是字面量,其余场景 \ 吃掉下一个字符 —— `grep "\"a\"|b"` 的引号是闭合的
|
|
525
|
+
* 2. 双引号不阻止展开:`"$(cmd)"` 与引号内反引号照跑,必须当替换体收走(漏了就是绕过)
|
|
526
|
+
* 3. 替换体 / 子shell 内部的 ; | && 不是顶层分隔符
|
|
527
|
+
*/
|
|
528
|
+
function scanShellText(text) {
|
|
529
|
+
const s = String(text)
|
|
530
|
+
const flatSegs = []
|
|
531
|
+
const subs = []
|
|
532
|
+
let cur = ''
|
|
533
|
+
let flat = ''
|
|
534
|
+
let quote = ''
|
|
535
|
+
/** 替换体栈:{ closer, buf, quoted, savedQuote };非空即处于替换体 / 子shell 内部 */
|
|
536
|
+
const frames = []
|
|
537
|
+
/** 写进原文(cur 供 & / 重定向判定 + 所有未闭合替换体正文):开合符属于外层正文,不属于替换体自身 */
|
|
538
|
+
const verb = (str) => {
|
|
539
|
+
cur += str
|
|
540
|
+
for (const f of frames) f.buf += str
|
|
541
|
+
}
|
|
542
|
+
/** 写进原文与扁平两份(扁平只收顶层:替换体内部在扁平段里已整块折叠成占位符) */
|
|
543
|
+
const lit = (str) => {
|
|
544
|
+
verb(str)
|
|
545
|
+
if (!frames.length) flat += str
|
|
546
|
+
}
|
|
547
|
+
const splitTop = () => {
|
|
548
|
+
flatSegs.push(flat)
|
|
549
|
+
cur = ''
|
|
550
|
+
flat = ''
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (let i = 0; i < s.length; i++) {
|
|
554
|
+
const ch = s[i]
|
|
555
|
+
// ① 转义:单引号内 \ 才是字面量,其余场景 \ 吃掉下一个字符(\" \\ \$ \; \| \( 一律按字面走)
|
|
556
|
+
if (ch === '\\' && quote !== "'") {
|
|
557
|
+
const next = s[i + 1]
|
|
558
|
+
if (next === undefined) {
|
|
559
|
+
lit(ch)
|
|
560
|
+
continue
|
|
561
|
+
}
|
|
562
|
+
lit(ch + next)
|
|
563
|
+
i++
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
// ② 引号内:只有「双引号里的 $( 与反引号」是特殊结构,其余字符原样
|
|
567
|
+
if (quote) {
|
|
568
|
+
if (ch === quote) {
|
|
569
|
+
quote = ''
|
|
570
|
+
lit(ch)
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
if (quote !== '"' || !(ch === '`' || s.startsWith('$(', i))) {
|
|
574
|
+
lit(ch)
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
} else if (ch === '"' || ch === "'") {
|
|
578
|
+
quote = ch
|
|
579
|
+
lit(ch)
|
|
580
|
+
continue
|
|
581
|
+
}
|
|
582
|
+
const frame = frames.length ? frames[frames.length - 1] : null
|
|
583
|
+
// ③ 替换体 / 子shell 开合(引号外,或双引号内命中 ②)
|
|
584
|
+
if (frame && ch === frame.closer) {
|
|
585
|
+
subs.push({ text: frame.buf, quoted: frame.quoted })
|
|
586
|
+
frames.pop()
|
|
587
|
+
quote = frame.savedQuote // 出替换体:恢复外层引号状态("$(…)" 的收尾双引号还要靠它配对)
|
|
588
|
+
verb(ch)
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
const dollar = s.startsWith('$(', i)
|
|
592
|
+
const back = ch === '`'
|
|
593
|
+
const paren = !quote && ch === '('
|
|
594
|
+
if (dollar || back || paren) {
|
|
595
|
+
const first = !frames.length
|
|
596
|
+
verb(dollar ? '$(' : ch)
|
|
597
|
+
// 进替换体:外层引号状态挂起(sh 里 "$(…)" 的替换体按新上下文重新解析,
|
|
598
|
+
// 外层双引号不跟着进去 —— 否则替换体里的 ) 会被当普通字符,整条命令误报「未闭合」)
|
|
599
|
+
frames.push({ closer: back ? '`' : ')', buf: '', quoted: Boolean(quote), savedQuote: quote })
|
|
600
|
+
quote = ''
|
|
601
|
+
if (dollar) i++
|
|
602
|
+
if (first) flat += SUBST_PLACEHOLDER
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
// ④ 顶层分隔符与后台 &(替换体内部不切段)
|
|
606
|
+
if (!frames.length && !quote) {
|
|
607
|
+
const two = s.slice(i, i + 2)
|
|
608
|
+
if (two === '&&' || two === '||') {
|
|
609
|
+
splitTop()
|
|
610
|
+
i++
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if (ch === ';' || ch === '|' || ch === '\n') {
|
|
614
|
+
splitTop()
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
if (ch === '&') {
|
|
618
|
+
// 2>&1 / >&2 / <&3 里的 & 是重定向语法不是后台:前一个字符是 > / < 时原样保留
|
|
619
|
+
const head = cur.trimEnd()
|
|
620
|
+
if (head.endsWith('>') || head.endsWith('<')) {
|
|
621
|
+
lit(ch)
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
if (s[i + 1] === '>') {
|
|
625
|
+
throw new Error('不支持 &> 写法(bash 专属):请改成 > file 2>&1')
|
|
626
|
+
}
|
|
627
|
+
throw new Error('不支持后台运行(单独的 &):去掉 & 同步执行,或把长任务交给 dev server')
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
lit(ch)
|
|
631
|
+
}
|
|
632
|
+
if (quote) throw new Error('命令引号未闭合:请补全或去掉引号')
|
|
633
|
+
if (frames.length) throw new Error('命令替换 / 子shell 未闭合:$( 与 ( 要有配对的 ),反引号要成对')
|
|
634
|
+
flatSegs.push(flat)
|
|
635
|
+
return { flatSegs: flatSegs.map((x) => x.trim()).filter(Boolean), subs }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 顶层分段的扁平版:替换体折叠成占位符,供 segmentCommandWord 定位段首真正执行的外部命令 */
|
|
639
|
+
function shellFlatSegments(command) {
|
|
640
|
+
return scanShellText(command).flatSegs
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* shell 语法词:控制结构与纯内置词。静态闸要查的是「真正被执行的外部命令」,
|
|
645
|
+
* 这些词自己不启动外部进程,不能当段首命令拦——否则 `for f in *.png; do node crop.mjs "$f"; done`
|
|
646
|
+
* 会在 for / do / done 段连撞三次,模型每撞一次换一种写法再撞(实测复发源)。
|
|
647
|
+
*/
|
|
648
|
+
const SHELL_SYNTAX_WORDS = new Set([
|
|
649
|
+
'for', 'in', 'do', 'done', 'while', 'until', 'if', 'then', 'elif', 'else', 'fi',
|
|
650
|
+
'case', 'esac', 'select', 'function', 'time', '{', '}', '(', ')', '!',
|
|
651
|
+
'export', 'local', 'readonly', 'declare', 'typeset', 'set', 'unset', 'shift',
|
|
652
|
+
'source', '.', 'return', 'break', 'continue', 'true', 'false', ':',
|
|
653
|
+
])
|
|
654
|
+
|
|
655
|
+
/** 这些词之后本段其余部分全是「条件 / 列表 / 内置参数」,不再出现命令词;$( ) 替换仍由全局扫描兜住 */
|
|
656
|
+
const SHELL_TAIL_WORDS = new Set(['in', 'test', '[', '[[', 'read', 'printf'])
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 取一个分段里「真正被执行的外部命令」:跳过语法词、for 循环变量、赋值前缀、重定向及其目标。
|
|
660
|
+
* 入参应是扁平段(shellFlatSegments 的产物:替换体已折叠成占位符),
|
|
661
|
+
* `case` 后第一个词是主语、`for` 后第一个词是循环变量,都跳过。整段无外部命令返回 null
|
|
662
|
+
* (纯语法 / 纯赋值 / 纯注释段不该触发白名单)。
|
|
663
|
+
*/
|
|
664
|
+
function segmentCommandWord(seg) {
|
|
665
|
+
// 也接受原文段:含替换体先折叠,否则赋值体里的词会被当成段首命令(M=$(find vendor x) → 无外部命令)
|
|
666
|
+
const flat = seg.includes('$(') || seg.includes('`') ? shellFlatSegments(seg)[0] || '' : seg
|
|
667
|
+
const toks = tokenizeCommand(flat)
|
|
668
|
+
let skipNext = false
|
|
669
|
+
let tail = false
|
|
670
|
+
for (let i = 0; i < toks.length; i++) {
|
|
671
|
+
if (tail) return null
|
|
672
|
+
let t = toks[i]
|
|
673
|
+
if (skipNext) {
|
|
674
|
+
skipNext = false
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
// 子shell 左括号可能粘在命令词上:(node x) → (node
|
|
678
|
+
t = t.replace(/^\(+/, '').replace(/\)+$/, '')
|
|
679
|
+
if (!t) continue
|
|
680
|
+
// 替换体占位符不是命令词:整块替换体由 subs 那组单独过闸
|
|
681
|
+
if (t.startsWith(SUBST_PLACEHOLDER)) return null // 段首就是替换体:真正跑什么由替换结果决定,静态闸不猜
|
|
682
|
+
if (t.includes(SUBST_PLACEHOLDER)) continue // 赋值 / 参数里带替换体:M=$(…)、--out=$(…)
|
|
683
|
+
// TAIL 先判:in / [ / test 等同时是语法词,但语义是「本段其余全是列表 / 条件参数」
|
|
684
|
+
if (SHELL_TAIL_WORDS.has(t)) {
|
|
685
|
+
tail = true
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
if (SHELL_SYNTAX_WORDS.has(t)) {
|
|
689
|
+
if (t === 'for' || t === 'case') skipNext = true
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) continue // VAR=value 前缀(含整段纯赋值)
|
|
693
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(t)) continue // 函数定义 f()
|
|
694
|
+
if (/^[0-9]*(>>|>|<)/.test(t)) {
|
|
695
|
+
if (/^[0-9]*(>>|>|<)$/.test(t)) i++ // 独立重定向运算符:下一个 token 是目标
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
return t
|
|
699
|
+
}
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 提取命令替换 / 子shell 体:$( ... )、`...`、( ... )(引号 / 转义感知、支持嵌套)。
|
|
705
|
+
* 替换体内的命令同样要过白名单——`echo $(rm -rf x)` 不能靠段首检查漏过去;
|
|
706
|
+
* 双引号内的 `"$(rm -rf x)"` 同理(sh 里双引号不阻止展开,旧实现漏了这条 = 白名单可绕)。
|
|
707
|
+
*/
|
|
708
|
+
function shellSubCommands(command) {
|
|
709
|
+
return scanShellText(command).subs.map((sub) => sub.text)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 白名单静态闸:查「每个真正被执行的外部命令」而非「每段第一个词」。
|
|
714
|
+
* 循环 / 条件 / 子shell / $( ) 等 shell 语法都可用,但语法之内跑的外部命令(含管道各段、
|
|
715
|
+
* 替换体内部)逐个过白名单,ls; rm -rf x 依旧在 rm 处被拦。
|
|
716
|
+
*/
|
|
717
|
+
function assertShellAllowlist(command) {
|
|
718
|
+
const top = scanShellText(command)
|
|
719
|
+
// 顶层用扁平段(替换体折叠成占位符,别把赋值体里的词当段首命令);每个替换体再走一遍同样的闸
|
|
720
|
+
const segGroups = [
|
|
721
|
+
{ segs: top.flatSegs, quotedSub: false },
|
|
722
|
+
...top.subs.map((sub) => ({ segs: scanShellText(sub.text).flatSegs, quotedSub: sub.quoted })),
|
|
723
|
+
]
|
|
724
|
+
for (const group of segGroups) {
|
|
725
|
+
for (const seg of group.segs) {
|
|
726
|
+
const head = segmentCommandWord(seg)
|
|
727
|
+
if (!head) continue
|
|
728
|
+
// 整段是 shell 注释(# 开头):/bin/sh 会忽略它,静态闸不该把它当命令拦
|
|
729
|
+
// (模型爱在多条命令之间插 `# 步骤说明` 注释行,此前每插一行就撞一次白名单)
|
|
730
|
+
if (head.startsWith('#')) continue
|
|
731
|
+
if (/^[<>]/.test(head)) {
|
|
732
|
+
throw new Error(`不支持以重定向开头的片段:${seg.slice(0, 60)}(把 > / < 放到命令后面)`)
|
|
733
|
+
}
|
|
734
|
+
if (!SHELL_ALLOW_BIN.has(head)) {
|
|
735
|
+
const hint = SHELL_BIN_HINTS[head]
|
|
736
|
+
throw new Error(
|
|
737
|
+
`命令不在白名单:${head}(允许:${[...SHELL_ALLOW_BIN].join(' / ')})。循环 / 条件 / 子shell / $( ) 等 shell 语法都支持,` +
|
|
738
|
+
`但其中真正执行的每个外部命令(含管道各段与替换体内部)都必须在白名单内` +
|
|
739
|
+
(hint ? `\n正路:${hint}` : '') +
|
|
740
|
+
(group.quotedSub
|
|
741
|
+
? '\n(它写在双引号里:双引号内的 $( ) 与反引号会被 shell 真实执行,所以同样过闸;只想写字面文本请转义或改用单引号)'
|
|
742
|
+
: '') +
|
|
743
|
+
'\n(cwd 已是仓库根,命令里不需要 cd;换正路重试,不要把同一条命令原样再发一次)',
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const SHELL_MAX_OUT = 200000
|
|
750
|
+
|
|
751
|
+
/** 子进程输出回收:合并 stdout/stderr,超时杀进程,输出限量回喂 */
|
|
752
|
+
function captureChild(child, timeoutMs) {
|
|
753
|
+
return new Promise((resolve, reject) => {
|
|
754
|
+
let out = ''
|
|
755
|
+
let killed = false
|
|
756
|
+
const timer = setTimeout(() => {
|
|
757
|
+
killed = true
|
|
758
|
+
child.kill('SIGKILL')
|
|
759
|
+
}, timeoutMs)
|
|
760
|
+
const onChunk = (c) => {
|
|
761
|
+
if (out.length < SHELL_MAX_OUT) out += c.toString('utf8')
|
|
762
|
+
}
|
|
763
|
+
child.stdout.on('data', onChunk)
|
|
764
|
+
child.stderr.on('data', onChunk)
|
|
765
|
+
child.on('error', (e) => {
|
|
766
|
+
clearTimeout(timer)
|
|
767
|
+
reject(new Error(`启动失败:${e.message}`))
|
|
768
|
+
})
|
|
769
|
+
child.on('close', (code) => {
|
|
770
|
+
clearTimeout(timer)
|
|
771
|
+
const truncated = out.length >= SHELL_MAX_OUT ? '\n…(输出过长已截断)' : ''
|
|
772
|
+
resolve(`exit=${code}${killed ? '(超时已杀)' : ''}\n${out.slice(0, 20000)}${truncated}`)
|
|
773
|
+
})
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const SPAWN_ENV = { FORCE_COLOR: '0', NO_COLOR: '1' }
|
|
778
|
+
|
|
779
|
+
/** 通用子进程执行(不经 shell):skill_exec 等「二进制 + 参数数组」场景 */
|
|
780
|
+
function spawnCapture(root, bin, argv, timeoutMs = 60000, extraEnv = {}) {
|
|
781
|
+
const child = spawn(bin, argv, { cwd: root, shell: false, env: { ...process.env, ...SPAWN_ENV, ...extraEnv } })
|
|
782
|
+
return captureChild(child, timeoutMs)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function findGitBash() {
|
|
786
|
+
try {
|
|
787
|
+
const pf = process.env.ProgramFiles || "C:\\Program Files"
|
|
788
|
+
const base = path.join(pf, "Git", "usr", "bin")
|
|
789
|
+
const exe = path.join(base, "bash.exe")
|
|
790
|
+
if (fs.existsSync(exe)) return { exe, usrBin: base }
|
|
791
|
+
} catch { /* ignore */ }
|
|
792
|
+
const alts = [
|
|
793
|
+
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
|
794
|
+
"C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe",
|
|
795
|
+
path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "usr", "bin", "bash.exe"),
|
|
796
|
+
]
|
|
797
|
+
for (const p of alts) { if (fs.existsSync(p)) return { exe: p, usrBin: path.dirname(p) } }
|
|
798
|
+
return null
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 经真实 shell 执行(posix = /bin/sh -c):管道 / 链接 / 重定向 / 引号都按 shell 语义来 */
|
|
802
|
+
function spawnShell(root, command, timeoutMs = 60000, extraEnv = {}) {
|
|
803
|
+
const isWin = process.platform === "win32"
|
|
804
|
+
const bash = isWin ? findGitBash() : null
|
|
805
|
+
const env = { ...process.env, ...SPAWN_ENV, ...extraEnv }
|
|
806
|
+
if (bash) {
|
|
807
|
+
env.PATH = bash.usrBin + ";" + (process.env.PATH || "")
|
|
808
|
+
const child = spawn(bash.exe, ["-c", command], { cwd: root, shell: false, env })
|
|
809
|
+
return captureChild(child, timeoutMs)
|
|
810
|
+
}
|
|
811
|
+
const child = spawn(command, { cwd: root, shell: true, env })
|
|
812
|
+
return captureChild(child, timeoutMs)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 空白分词但识别单 / 双引号与反斜杠转义(引号成对剥掉、\x 取字面 x):
|
|
817
|
+
* 朴素 split 会让 `cat "a b.png"` 的路径带上字面引号而 ENOENT;
|
|
818
|
+
* 不认转义则会把 `grep -nE "\"a\"|b" f` 判成引号未闭合(sh 里它合法,实测误拒源)。
|
|
819
|
+
*/
|
|
820
|
+
function tokenizeCommand(command) {
|
|
821
|
+
const s = String(command)
|
|
822
|
+
const out = []
|
|
823
|
+
let cur = ''
|
|
824
|
+
let quote = ''
|
|
825
|
+
let open = false
|
|
826
|
+
for (let i = 0; i < s.length; i++) {
|
|
827
|
+
const ch = s[i]
|
|
828
|
+
// 单引号内 \ 是字面量,其余场景 \ 吃掉下一个字符
|
|
829
|
+
if (ch === '\\' && quote !== "'") {
|
|
830
|
+
const next = s[i + 1]
|
|
831
|
+
cur += next === undefined ? ch : next
|
|
832
|
+
if (next !== undefined) i++
|
|
833
|
+
open = true
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
if (quote) {
|
|
837
|
+
if (ch === quote) quote = ''
|
|
838
|
+
else cur += ch
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
if (ch === '"' || ch === "'") {
|
|
842
|
+
quote = ch
|
|
843
|
+
open = true
|
|
844
|
+
continue
|
|
845
|
+
}
|
|
846
|
+
if (/\s/.test(ch)) {
|
|
847
|
+
if (open || cur) {
|
|
848
|
+
out.push(cur)
|
|
849
|
+
cur = ''
|
|
850
|
+
open = false
|
|
851
|
+
}
|
|
852
|
+
continue
|
|
853
|
+
}
|
|
854
|
+
cur += ch
|
|
855
|
+
open = true
|
|
856
|
+
}
|
|
857
|
+
if (quote) throw new Error('命令引号未闭合:请补全或去掉引号')
|
|
858
|
+
if (open || cur) out.push(cur)
|
|
859
|
+
return out
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function execShell(root, command, timeoutMs = 60000) {
|
|
863
|
+
const cmd = String(command || '').trim()
|
|
864
|
+
if (!cmd) throw new Error('命令为空')
|
|
865
|
+
// 静态闸只查「每段首个命令」:引号里的分号 / 管道符是参数不是元字符,不再误伤 node -e "…;…"
|
|
866
|
+
assertShellAllowlist(cmd)
|
|
867
|
+
return spawnShell(root, cmd, timeoutMs)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** 路径闸:解析成绝对路径并确认落在「仓库根 / 本次挂载目录」内,越界即拒(杜绝任意路径读盘) */
|
|
871
|
+
function resolveInsideRoots(root, raw, extraRoots = [], verb = '读') {
|
|
872
|
+
const s = String(raw || '').trim()
|
|
873
|
+
if (!s) throw new Error('缺少 path')
|
|
874
|
+
const abs = path.resolve(path.isAbsolute(s) ? s : path.join(root, s))
|
|
875
|
+
const roots = [root, ...extraRoots.filter(Boolean)]
|
|
876
|
+
if (!roots.some((r) => abs === r || abs.startsWith(r + path.sep))) {
|
|
877
|
+
throw new Error(`只能${verb}仓库根或本次挂载目录内的路径`)
|
|
878
|
+
}
|
|
879
|
+
return abs
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** 只读文件:限定仓库根 + 用户挂载目录;目录 / 图片给「换哪个工具」的可操作提示,不浪费步数 */
|
|
883
|
+
function fsRead(root, { path: p, maxChars = 20000 }, extraRoots = []) {
|
|
884
|
+
const abs = resolveInsideRoots(root, p, extraRoots)
|
|
885
|
+
if (!fs.existsSync(abs)) throw new Error(`路径不存在:${p}`)
|
|
886
|
+
const st = fs.statSync(abs)
|
|
887
|
+
if (st.isDirectory()) throw new Error(`这是目录不是文件:${p}(列目录内容请用 fs_list)`)
|
|
888
|
+
if (IMAGE_EXT.has(path.extname(abs).toLowerCase())) {
|
|
889
|
+
return `图片文件:${abs}(${Math.round(st.size / 1024)}KB)。图像不会注入模型上下文(当前网关不支持多模态),已在对话面板中给用户预览;需要它的信息请读同目录的 spec.json / spec.md / prompt.md 标注。`
|
|
890
|
+
}
|
|
891
|
+
if (st.size > 4 * 1024 * 1024) throw new Error('文件大于 4MB:请改用 card_grep 定位或分段读取')
|
|
892
|
+
const text = fs.readFileSync(abs, 'utf8')
|
|
893
|
+
const cap = Number(maxChars) > 0 ? Number(maxChars) : 20000
|
|
894
|
+
return text.length <= cap ? text : `${text.slice(0, cap)}\n…(已截断,省略 ${text.length - cap} 字符)`
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* fs_write:只写 .cache/ai-scratch/ 草稿区。
|
|
899
|
+
*
|
|
900
|
+
* 为什么需要:设计稿建卡若让模型一次性吐整份 schema.json,光参数就要上万 token,
|
|
901
|
+
* 实测会被 max_tokens 截断(工具参数 JSON 半截 → 解析失败 → 整轮白跑)。
|
|
902
|
+
* 正路是模型写「紧凑 IR」(比 schema 小 4-5 倍)到草稿区,再由 expand_ir.mjs 展开成
|
|
903
|
+
* schema.json + data.json(自带 autofix / validate / lint)。草稿区在 .cache/ 下,不入库。
|
|
904
|
+
*/
|
|
905
|
+
const FS_WRITE_TOOL = {
|
|
906
|
+
type: 'function',
|
|
907
|
+
function: {
|
|
908
|
+
name: 'fs_write',
|
|
909
|
+
description:
|
|
910
|
+
'把 UTF-8 文本写入仓库草稿区 .cache/ai-scratch/(不入库)。主要用途:写「紧凑 IR」JSON(字段契约见 skill_read ir-format.md),再用 skill_exec 跑 skills/dsl-card-generator/scripts/expand_ir.mjs --ir <草稿路径> --out src/cards/<id>/ 展开成完整 schema(自带 autofix+validate+lint)。只能写草稿区;改卡片内容用 card_write / card_patch。',
|
|
911
|
+
parameters: {
|
|
912
|
+
type: 'object',
|
|
913
|
+
properties: {
|
|
914
|
+
name: { type: 'string', description: '草稿文件名,如 riskAssessTip.ir.json' },
|
|
915
|
+
content: { type: 'string', description: '文件内容(文本)' },
|
|
916
|
+
},
|
|
917
|
+
required: ['name', 'content'],
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const SCRATCH_RELPATH = path.join('.cache', 'ai-scratch')
|
|
923
|
+
const FS_WRITE_MAX_BYTES = 2 * 1024 * 1024
|
|
924
|
+
|
|
925
|
+
function fsWrite(root, { name, content = '' }) {
|
|
926
|
+
const rel = String(name || '').trim().replace(/^[/\\]+/, '')
|
|
927
|
+
if (!rel) throw new Error('缺少 name')
|
|
928
|
+
const scratch = path.join(root, SCRATCH_RELPATH)
|
|
929
|
+
const abs = path.resolve(scratch, rel)
|
|
930
|
+
if (abs !== scratch && !abs.startsWith(scratch + path.sep)) {
|
|
931
|
+
throw new Error('非法文件名:只能写 .cache/ai-scratch/ 草稿区内的路径')
|
|
932
|
+
}
|
|
933
|
+
const text = String(content ?? '')
|
|
934
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
935
|
+
if (bytes > FS_WRITE_MAX_BYTES) throw new Error('内容超过 2MB 上限')
|
|
936
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
937
|
+
fs.writeFileSync(abs, text, 'utf8')
|
|
938
|
+
const relRepo = path.relative(root, abs)
|
|
939
|
+
const slicesNote = text.includes('/slices/') ? ';IR 引用 /slices/ 切图时加 --slices-dir <要素包>/slices,免拷贝' : ''
|
|
940
|
+
return `已写入 ${relRepo}(${bytes} 字节)。下一步可用 skill_exec 跑 expand_ir.mjs --ir ${relRepo} --out src/cards/<id>/` + slicesNote
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* fs_copy:服务端单文件复制(二进制安全)。补「cp 不在白名单、而 fs_write 只写草稿区」的缺口——
|
|
945
|
+
* 此前 cp 被拒后的提示把模型指去 fs_read + fs_write,但 fs_write 只能写 .cache/ai-scratch/,
|
|
946
|
+
* 复制到 src/slices/ 等仓库落点必然再撞墙,于是 cp → fs_write → cp 原地打转(实测复发源)。
|
|
947
|
+
* 源允许仓库根 + 本次挂载目录(与 fs_read 同口径),目的限仓库根内且禁入保护目录。
|
|
948
|
+
*/
|
|
949
|
+
const FS_COPY_TOOL = {
|
|
950
|
+
type: 'function',
|
|
951
|
+
function: {
|
|
952
|
+
name: 'fs_copy',
|
|
953
|
+
description:
|
|
954
|
+
'在仓库根 / 挂载目录内复制单个文件(二进制安全,自动创建目的父目录):from 为源路径(仓库相对或绝对,挂载目录内亦可),to 为目的路径(仓库相对,含文件名,如 src/slices/x.png、.cache/ai-scratch/x.json)。文件复制 / 搬落点一律用本工具(shell 白名单不含 cp / mv);改写文本内容用 fs_write(仅草稿区),卡片内容用 card_write / card_patch。',
|
|
955
|
+
parameters: {
|
|
956
|
+
type: 'object',
|
|
957
|
+
properties: {
|
|
958
|
+
from: { type: 'string', description: '源文件路径(仓库相对或绝对;挂载目录内亦可)' },
|
|
959
|
+
to: { type: 'string', description: '目的文件路径(仓库相对,含文件名)' },
|
|
960
|
+
},
|
|
961
|
+
required: ['from', 'to'],
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const FS_COPY_FORBID_SEG = new Set(['.git', 'node_modules', '.history'])
|
|
967
|
+
|
|
968
|
+
function fsCopy(root, { from, to }, extraRoots = []) {
|
|
969
|
+
const src = resolveInsideRoots(root, from, extraRoots, '读')
|
|
970
|
+
if (!fs.existsSync(src)) throw new Error(`源不存在:${from}`)
|
|
971
|
+
if (fs.statSync(src).isDirectory()) {
|
|
972
|
+
throw new Error(`fs_copy 只复制单个文件:${from} 是目录(批量切图裁切用 skill_exec crop_slices.mjs)`)
|
|
973
|
+
}
|
|
974
|
+
const relTo = String(to || '').trim().replace(/^[/\\]+/, '')
|
|
975
|
+
if (!relTo) throw new Error('缺少 to')
|
|
976
|
+
const dst = path.resolve(root, relTo)
|
|
977
|
+
if (dst !== root && !dst.startsWith(root + path.sep)) throw new Error('非法目的:只能复制到仓库根内')
|
|
978
|
+
const relSegs = path.relative(root, dst).split(path.sep)
|
|
979
|
+
if (relSegs.some((seg) => FS_COPY_FORBID_SEG.has(seg))) {
|
|
980
|
+
throw new Error(`目的落在保护目录(${relSegs.find((seg) => FS_COPY_FORBID_SEG.has(seg))}):拒绝`)
|
|
981
|
+
}
|
|
982
|
+
if (fs.existsSync(dst) && fs.statSync(dst).isDirectory()) {
|
|
983
|
+
throw new Error(`目的是目录:${to}(请给含扩展名的文件名)`)
|
|
984
|
+
}
|
|
985
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
986
|
+
fs.copyFileSync(src, dst)
|
|
987
|
+
return `已复制 ${path.relative(root, src) || from} → ${relTo}(${fs.statSync(dst).size} 字节)`
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** fs_list:列目录(fs_read 读不了目录,模型此前会反复撞墙浪费步数) */
|
|
991
|
+
const FS_LIST_TOOL = {
|
|
992
|
+
type: 'function',
|
|
993
|
+
function: {
|
|
994
|
+
name: 'fs_list',
|
|
995
|
+
description: '列出目录内容(仓库根或用户挂载目录内):文件夹带 / 后缀、图片带 [img] 标记、文件带体积。要看目录里有什么就用本工具,不要用 fs_read(它只读文件)。',
|
|
996
|
+
parameters: {
|
|
997
|
+
type: 'object',
|
|
998
|
+
properties: {
|
|
999
|
+
path: { type: 'string', description: '相对仓库根的路径或绝对路径(挂载目录内)' },
|
|
1000
|
+
depth: { type: 'number', description: '递归深度,默认 1,最大 3' },
|
|
1001
|
+
max: { type: 'number', description: '最多返回条目数,默认 120,最大 600' },
|
|
1002
|
+
},
|
|
1003
|
+
required: ['path'],
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const FS_LIST_HARD_MAX = 600
|
|
1009
|
+
const FS_LIST_SKIP = new Set(['.git', 'node_modules', '.DS_Store', '__pycache__', '.cache'])
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* 列目录为紧凑文本清单(文件夹在前、同名按字典序),带体积与图片标记。
|
|
1013
|
+
* 同时被 /__ai/chat 复用:把挂载目录的浅层清单直接写进上下文,省掉模型自己摸索的步数。
|
|
1014
|
+
*/
|
|
1015
|
+
function fsList(root, { path: p, depth = 1, max = 120 }, extraRoots = []) {
|
|
1016
|
+
const abs = resolveInsideRoots(root, p, extraRoots, '列')
|
|
1017
|
+
if (!fs.existsSync(abs)) throw new Error(`路径不存在:${p}`)
|
|
1018
|
+
if (!fs.statSync(abs).isDirectory()) throw new Error(`这不是目录:${p}(读文件内容请用 fs_read)`)
|
|
1019
|
+
const maxDepth = Math.min(3, Math.max(1, Number(depth) || 1))
|
|
1020
|
+
const cap = Math.min(FS_LIST_HARD_MAX, Math.max(1, Number(max) || 120))
|
|
1021
|
+
const lines = []
|
|
1022
|
+
let dirs = 0
|
|
1023
|
+
let files = 0
|
|
1024
|
+
let images = 0
|
|
1025
|
+
const walk = (dir, level) => {
|
|
1026
|
+
if (level > maxDepth || lines.length >= cap) return
|
|
1027
|
+
let list
|
|
1028
|
+
try {
|
|
1029
|
+
list = fs.readdirSync(dir, { withFileTypes: true })
|
|
1030
|
+
} catch {
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
list.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name, 'zh'))
|
|
1034
|
+
for (const ent of list) {
|
|
1035
|
+
if (lines.length >= cap) return
|
|
1036
|
+
if (FS_LIST_SKIP.has(ent.name)) continue
|
|
1037
|
+
const full = path.join(dir, ent.name)
|
|
1038
|
+
const rel = path.relative(abs, full)
|
|
1039
|
+
if (ent.isDirectory()) {
|
|
1040
|
+
dirs++
|
|
1041
|
+
lines.push(`${rel}/`)
|
|
1042
|
+
walk(full, level + 1)
|
|
1043
|
+
continue
|
|
1044
|
+
}
|
|
1045
|
+
let size = 0
|
|
1046
|
+
try {
|
|
1047
|
+
size = fs.statSync(full).size
|
|
1048
|
+
} catch {
|
|
1049
|
+
/* 读不到体积就按 0 显示 */
|
|
1050
|
+
}
|
|
1051
|
+
files++
|
|
1052
|
+
const isImg = IMAGE_EXT.has(path.extname(ent.name).toLowerCase())
|
|
1053
|
+
if (isImg) images++
|
|
1054
|
+
lines.push(`${rel}${isImg ? ' [img]' : ''} (${size >= 1024 ? `${Math.round(size / 1024)}KB` : `${size}B`})`)
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
walk(abs, 1)
|
|
1058
|
+
const head = `${abs} — 目录 ${dirs} 个 / 文件 ${files} 个(图片 ${images} 张)${lines.length >= cap ? `,已到上限 ${cap} 条(可加大 max 或 depth)` : ''}`
|
|
1059
|
+
return `${head}\n${lines.join('\n')}`
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** 单条附件在上下文里的清单条数上限:够判形态即可,别把几百个文件名塞进 prompt */
|
|
1063
|
+
const ATTACH_LIST_MAX = 40
|
|
1064
|
+
const ATTACH_BRIEF_MAX = 6000
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* 挂载附件的服务端摘要:目录浅层清单 + 「建卡输入路由」机械判定结果。
|
|
1068
|
+
*
|
|
1069
|
+
* 路由判定(card_route_input,离线 ~70ms)提前跑掉是本次修复的关键之一:
|
|
1070
|
+
* 判定结果里已经写明 kind / preprocess.needed / next 命令,模型照抄即可,
|
|
1071
|
+
* 不用再自己试错「这是不是 MeaXure 导出」「要不要先跑 sketch-spec-to-card」。
|
|
1072
|
+
*/
|
|
1073
|
+
async function describeAttachments(root, attachments, dispatch, extraRoots = []) {
|
|
1074
|
+
const blocks = []
|
|
1075
|
+
/** 命中设计稿形态(MeaXure 导出 / 要素包 / 单张设计稿)⇒ 本轮是多阶段建卡管线,需要更多步数额度 */
|
|
1076
|
+
let designFlow = false
|
|
1077
|
+
for (const a of attachments || []) {
|
|
1078
|
+
const target = String(a?.path || '').trim()
|
|
1079
|
+
if (!target) continue
|
|
1080
|
+
const kind = a.isDir ? '文件夹' : a.isImage ? '图片' : '文件'
|
|
1081
|
+
const lines = [`- ${kind}:${target}${a.note ? `(${a.note})` : ''}`]
|
|
1082
|
+
if (a.isDir) {
|
|
1083
|
+
try {
|
|
1084
|
+
const listing = fsList(root, { path: target, depth: 1, max: ATTACH_LIST_MAX }, extraRoots)
|
|
1085
|
+
listing.split('\n').forEach((l, i) => lines.push(i === 0 ? ` 清单:${l}` : ` ${l}`))
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
lines.push(` 清单读取失败:${String((e && e.message) || e)}`)
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (a.isDir || a.isImage) {
|
|
1091
|
+
try {
|
|
1092
|
+
const routed = await dispatch('card_route_input', { path: target })
|
|
1093
|
+
const r = typeof routed === 'string' ? JSON.parse(routed) : routed || {}
|
|
1094
|
+
if (r.error) {
|
|
1095
|
+
lines.push(` 建卡输入路由:判定失败(${r.error}${r.message ? `:${r.message}` : ''})—— 需要建卡时先向用户确认输入形态`)
|
|
1096
|
+
} else if (r.kind) {
|
|
1097
|
+
if (['meaxure-export', 'ui-bundle', 'image'].includes(r.kind)) designFlow = true
|
|
1098
|
+
const parts = [`kind=${r.kind}(置信度 ${r.confidence || '?'})`, r.workflow ? `workflow=${r.workflow}` : '']
|
|
1099
|
+
if (r.preprocess) parts.push(`preprocess=${JSON.stringify(r.preprocess)}`)
|
|
1100
|
+
if (Array.isArray(r.next) && r.next.length) parts.push(`next=${JSON.stringify(r.next)}`)
|
|
1101
|
+
lines.push(` 建卡输入路由(服务端已机械判定,勿重复调 card_route_input):${parts.filter(Boolean).join(';')}`)
|
|
1102
|
+
if (r.note) lines.push(` 路由说明:${r.note}`)
|
|
1103
|
+
}
|
|
1104
|
+
} catch {
|
|
1105
|
+
/* 判定器不可用不阻断对话:模型仍可自行调 card_route_input */
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
blocks.push(lines.join('\n'))
|
|
1109
|
+
}
|
|
1110
|
+
const text = blocks.join('\n')
|
|
1111
|
+
return {
|
|
1112
|
+
designFlow,
|
|
1113
|
+
text: text.length <= ATTACH_BRIEF_MAX ? text : `${text.slice(0, ATTACH_BRIEF_MAX)}\n…(附件摘要过长已截断)`,
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** 执行审计:谁在什么时候让 AI 跑了什么(allow / deny / auto=本回合已授权 / auto-policy=设置里免确认),落 .cache/ 不入库 */
|
|
1118
|
+
function auditShell(root, command, ok, mode = '') {
|
|
1119
|
+
try {
|
|
1120
|
+
const dir = path.join(root, '.cache')
|
|
1121
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1122
|
+
const tag = mode ? mode : ok ? 'allow' : 'deny'
|
|
1123
|
+
fs.appendFileSync(
|
|
1124
|
+
path.join(dir, 'ai-audit.log'),
|
|
1125
|
+
`${new Date().toISOString()}\t${tag}\t${String(command).replace(/[\r\n\t]/g, ' ')}\n`,
|
|
1126
|
+
'utf8',
|
|
1127
|
+
)
|
|
1128
|
+
} catch {
|
|
1129
|
+
/* 审计失败不阻断执行 */
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* 高危执行的审批闸:SSE 发 approval-request → 用户在对话里点「允许 / 本回合不再询问 / 拒绝」→ 回落继续。
|
|
1135
|
+
* 「本回合不再询问」(scope=turn)很关键:设计稿管线要连跑 parse → cluster → spec_tree → check_fidelity
|
|
1136
|
+
* 好几步,逐次点确认体验极差;授权范围只限当前这一轮请求,下一轮重新问。
|
|
1137
|
+
* auto=true(设置里的免确认策略命中)时整段跳过:不弹审批、不挂起流,只补一条 approval-auto 让对话看得见。
|
|
1138
|
+
*/
|
|
1139
|
+
async function runGuarded(root, res, { toolName, command, approved, auto = false }, run) {
|
|
1140
|
+
const label = String(command || '').trim()
|
|
1141
|
+
if (auto) {
|
|
1142
|
+
auditShell(root, label, true, 'auto-policy')
|
|
1143
|
+
sseWrite(res, { type: 'approval-auto', toolName, command: label })
|
|
1144
|
+
return run()
|
|
1145
|
+
}
|
|
1146
|
+
if (approved.has(toolName)) {
|
|
1147
|
+
auditShell(root, label, true, 'auto')
|
|
1148
|
+
return run()
|
|
1149
|
+
}
|
|
1150
|
+
const approvalId = `ap_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
|
1151
|
+
const decision = await waitForApproval(res, { approvalId, toolName, command: label, args: { command: label } })
|
|
1152
|
+
sseWrite(res, { type: 'approval-decided', approvalId, ok: decision.ok, scope: decision.scope })
|
|
1153
|
+
if (!decision.ok) {
|
|
1154
|
+
auditShell(root, label, false)
|
|
1155
|
+
throw new Error(decision.reason || '用户拒绝了这次执行:请把命令原样写给用户自行运行,或改用结构化卡片工具')
|
|
1156
|
+
}
|
|
1157
|
+
if (decision.scope === 'turn') approved.add(toolName)
|
|
1158
|
+
auditShell(root, label, true)
|
|
1159
|
+
return run()
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function runShellExec(root, args, res, approved) {
|
|
1163
|
+
const cfg = readAiConfig(root)
|
|
1164
|
+
if (!cfg.shellAllowed) {
|
|
1165
|
+
throw new Error('未开启本地命令执行:请先在 AI 开发助手设置里打开「允许执行本地命令」开关(技能脚本走 skill_exec,不受此开关影响)')
|
|
1166
|
+
}
|
|
1167
|
+
const cmd = String(args.command || '').trim()
|
|
1168
|
+
if (!cmd) throw new Error('命令为空')
|
|
1169
|
+
// 静态闸前置到审批之前:白名单外的命令根本没资格执行,不该拿它去打扰用户点「允许」;
|
|
1170
|
+
// 更要紧的是模型能**立刻**拿到「正路」提示改走范式脚本,而不是等批准后再撞一次同样的墙。
|
|
1171
|
+
// 审计记 blocked(区别于用户主动 deny),execShell 内的同一道闸保留作纵深防御。
|
|
1172
|
+
try {
|
|
1173
|
+
assertShellAllowlist(cmd)
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
auditShell(root, cmd, false, 'blocked')
|
|
1176
|
+
throw e
|
|
1177
|
+
}
|
|
1178
|
+
return runGuarded(
|
|
1179
|
+
root,
|
|
1180
|
+
res,
|
|
1181
|
+
{
|
|
1182
|
+
toolName: 'shell_exec',
|
|
1183
|
+
command: cmd,
|
|
1184
|
+
approved,
|
|
1185
|
+
auto: autoApproves(cfg.autoApprove, 'shell_exec'),
|
|
1186
|
+
},
|
|
1187
|
+
() => execShell(root, cmd),
|
|
1188
|
+
)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** 技能脚本执行:路径形状校验 → 解释器解析 → 审批(或按策略免确认)→ 跑(cwd=仓库根,脚本自己按仓库根推算落点) */
|
|
1192
|
+
async function runSkillExec(root, args, res, approved) {
|
|
1193
|
+
const script = resolveSkillScript(root, args.script)
|
|
1194
|
+
const argv = normalizeSkillArgs(args.args)
|
|
1195
|
+
const timeoutMs = Math.min(
|
|
1196
|
+
SKILL_EXEC_MAX_TIMEOUT,
|
|
1197
|
+
Math.max(5000, Number(args.timeoutMs) > 0 ? Number(args.timeoutMs) : SKILL_EXEC_DEFAULT_TIMEOUT),
|
|
1198
|
+
)
|
|
1199
|
+
const label = `${script.bin} ${script.rel}${argv.length ? ` ${argv.join(' ')}` : ''}`
|
|
1200
|
+
const cfg = readAiConfig(root)
|
|
1201
|
+
return runGuarded(
|
|
1202
|
+
root,
|
|
1203
|
+
res,
|
|
1204
|
+
{
|
|
1205
|
+
toolName: 'skill_exec',
|
|
1206
|
+
command: label,
|
|
1207
|
+
approved,
|
|
1208
|
+
auto: autoApproves(cfg.autoApprove, 'skill_exec'),
|
|
1209
|
+
},
|
|
1210
|
+
() =>
|
|
1211
|
+
spawnCapture(root, script.bin, [script.abs, ...argv], timeoutMs, {
|
|
1212
|
+
PYTHONIOENCODING: 'utf-8',
|
|
1213
|
+
PYTHONUNBUFFERED: '1',
|
|
1214
|
+
}),
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ---- 对话内审批:未命中免确认策略的高危执行(默认是 shell_exec)每次调用都要用户点确认 ----
|
|
1219
|
+
|
|
1220
|
+
/** approvalId -> { resolve, timer }:SSE 发 approval-request,等 POST /__ai/approve 回落 */
|
|
1221
|
+
const pendingApprovals = new Map()
|
|
1222
|
+
|
|
1223
|
+
/** 审批等待上限:技能脚本管线可能连跑多步,用户走神也不能让整轮挂死 */
|
|
1224
|
+
const APPROVAL_TIMEOUT_MS = 300000
|
|
1225
|
+
|
|
1226
|
+
function waitForApproval(res, { approvalId, toolName, args, command = '' }, timeoutMs = APPROVAL_TIMEOUT_MS) {
|
|
1227
|
+
sseWrite(res, { type: 'approval-request', approvalId, toolName, command, args })
|
|
1228
|
+
return new Promise((resolve) => {
|
|
1229
|
+
const entry = { resolve, timer: 0 }
|
|
1230
|
+
entry.timer = setTimeout(() => {
|
|
1231
|
+
if (pendingApprovals.get(approvalId) === entry) pendingApprovals.delete(approvalId)
|
|
1232
|
+
resolve({ ok: false, reason: `超过 ${Math.round(timeoutMs / 1000)} 秒未确认,按拒绝处理` })
|
|
1233
|
+
}, timeoutMs)
|
|
1234
|
+
pendingApprovals.set(approvalId, entry)
|
|
1235
|
+
})
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function settleApproval(approvalId, ok, scope = 'once') {
|
|
1239
|
+
const entry = pendingApprovals.get(String(approvalId || ''))
|
|
1240
|
+
if (!entry) return false
|
|
1241
|
+
pendingApprovals.delete(String(approvalId))
|
|
1242
|
+
clearTimeout(entry.timer)
|
|
1243
|
+
entry.resolve({ ok: Boolean(ok), scope: scope === 'turn' ? 'turn' : 'once' })
|
|
1244
|
+
return true
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const SKILL_DOC_EXT = new Set(['.md', '.json', '.txt', '.csv'])
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* skill_read:按需取技能文档。
|
|
1251
|
+
* - 裸文件名(rules.md)→ 兼容老用法,落在 dsl-card-generator/references/;
|
|
1252
|
+
* - 带 / 的相对路径(sketch-spec-to-card/SKILL.md、dsl-card-generator/references/ir-format.md)→ 按 skills/ 解析。
|
|
1253
|
+
* 只放行文档类扩展名且必须在 skills/ 内:SKILL.md 是工作流权威,此前读不到,模型只能靠猜。
|
|
1254
|
+
*/
|
|
1255
|
+
function skillRead(root, args) {
|
|
1256
|
+
const name = String(args?.path || '').trim().replace(/^\.?\/+/u, '')
|
|
1257
|
+
if (!name) throw new Error('缺少 path')
|
|
1258
|
+
const refsDir = path.join(root, 'skills', 'dsl-card-generator', 'references')
|
|
1259
|
+
const rel = name.includes('/') ? name.replace(/^skills\//, '') : path.join('dsl-card-generator', 'references', name)
|
|
1260
|
+
const full = path.resolve(path.join(root, 'skills'), rel)
|
|
1261
|
+
const skillsRoot = path.join(root, 'skills')
|
|
1262
|
+
if (!full.startsWith(skillsRoot + path.sep)) throw new Error('非法路径:只能读 skills/ 目录内的文档')
|
|
1263
|
+
if (!SKILL_DOC_EXT.has(path.extname(full).toLowerCase())) throw new Error('只能读文档(.md / .json / .txt / .csv);跑脚本用 skill_exec')
|
|
1264
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
|
|
1265
|
+
// 测试根等场景下 skills/ 可能不存在:退回模块加载时的仓库 references 目录
|
|
1266
|
+
const fallback = path.resolve(fs.existsSync(refsDir) ? refsDir : SKILL_REFS_DIR, name)
|
|
1267
|
+
if (!name.includes('/') && fallback.startsWith(SKILL_REFS_DIR + path.sep) && fs.existsSync(fallback)) {
|
|
1268
|
+
const text = fs.readFileSync(fallback, 'utf8')
|
|
1269
|
+
return text.length <= SKILL_READ_MAX ? text : `${text.slice(0, SKILL_READ_MAX)}\n…(已截断,省略 ${text.length - SKILL_READ_MAX} 字符)`
|
|
1270
|
+
}
|
|
1271
|
+
throw new Error(`文档不存在:skills/${rel}`)
|
|
1272
|
+
}
|
|
1273
|
+
const text = fs.readFileSync(full, 'utf8')
|
|
1274
|
+
if (text.length <= SKILL_READ_MAX) return text
|
|
1275
|
+
return `${text.slice(0, SKILL_READ_MAX)}\n…(已截断,省略 ${text.length - SKILL_READ_MAX} 字符)`
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// ---- 配置读取 / 写入 ----
|
|
1279
|
+
|
|
1280
|
+
/** 极简 .env 解析(与 tools/finmall-env.mjs 同规则,避免 server-core 反向依赖 tools/) */
|
|
1281
|
+
function parseEnvFile(file) {
|
|
1282
|
+
const out = {}
|
|
1283
|
+
try {
|
|
1284
|
+
if (!fs.existsSync(file)) return out
|
|
1285
|
+
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
1286
|
+
const line = raw.trim()
|
|
1287
|
+
if (!line || line.startsWith('#')) continue
|
|
1288
|
+
const i = line.indexOf('=')
|
|
1289
|
+
if (i < 0) continue
|
|
1290
|
+
const key = line.slice(0, i).trim()
|
|
1291
|
+
let val = line.slice(i + 1).trim()
|
|
1292
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
1293
|
+
val = val.slice(1, -1)
|
|
1294
|
+
}
|
|
1295
|
+
out[key] = val
|
|
1296
|
+
}
|
|
1297
|
+
} catch {
|
|
1298
|
+
/* 读取失败按空配置处理 */
|
|
1299
|
+
}
|
|
1300
|
+
return out
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* 单轮(一次用户提问)预算策略:**步数默认不设硬顶**。
|
|
1305
|
+
* 早先的 24 / 32 / 40 步硬顶是纯负担——设计稿建卡一轮要 spec_audit → crop_slices → expand_ir →
|
|
1306
|
+
* iterate × N → check_fidelity 十几到几十次调用,到顶就把用户变成「继续」按钮,而步数多本身
|
|
1307
|
+
* 并不代表模型在空转。真正要拦的是三种失控,各配一道精准熔断:
|
|
1308
|
+
* ① 原地打转:连续 LOOP_BREAK_REPEATS 轮「同工具 + 同参数」⇒ 判定卡死,跳过执行并收尾汇报
|
|
1309
|
+
* ② 墙钟失控:单轮总时长超过 turnTimeoutMs ⇒ 收尾汇报(上下文不丢,回复「继续」接着做)
|
|
1310
|
+
* ③ 迟迟不收敛:每 SOFT_NUDGE_EVERY 步注入一次收敛提醒(只提醒,不打断)
|
|
1311
|
+
* 想控成本仍可设 AI_MAX_STEPS / 设置面板「单轮步数上限」> 0 启用旧硬顶行为。
|
|
1312
|
+
*/
|
|
1313
|
+
const DEFAULT_TURN_TIMEOUT_MS = 60 * 60 * 1000
|
|
1314
|
+
/** 连续多少轮完全相同的工具调用即判定死循环(第 2 轮先警告,第 3 轮熔断) */
|
|
1315
|
+
const LOOP_BREAK_REPEATS = 3
|
|
1316
|
+
|
|
1317
|
+
/** 时长人话:分钟为主,亚分钟(测试里常把墙钟压到几十毫秒)回落秒,别显示「0 分钟」 */
|
|
1318
|
+
function fmtDuration(ms) {
|
|
1319
|
+
const n = Number(ms) > 0 ? Number(ms) : 0
|
|
1320
|
+
return n >= 60000 ? `${Math.round(n / 60000)} 分钟` : `${Math.max(1, Math.round(n / 1000))} 秒`
|
|
1321
|
+
}
|
|
1322
|
+
/** 每多少步注入一次「该收敛了」的软提醒(0 = 关闭) */
|
|
1323
|
+
const SOFT_NUDGE_EVERY = 24
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* 免确认执行策略(AI_AUTO_APPROVE):
|
|
1327
|
+
* none = 每次执行都弹审批(旧行为)
|
|
1328
|
+
* skill = skill_exec 免确认、shell_exec 仍逐次确认(默认)
|
|
1329
|
+
* all = 两者都免确认
|
|
1330
|
+
* 默认 all(技能脚本 + 本地命令都免确认)的理由:建卡 / 改卡一轮要连跑 spec_audit → crop_slices →
|
|
1331
|
+
* expand_ir → iterate × N → check_fidelity 十几次,逐次点「允许」会把流水线打断成手工活(用户明确要求默认免确认);
|
|
1332
|
+
* 真正的安全闸不是审批弹窗,而是「skill_exec 路径形状硬校验 + shell_exec 命令白名单 + 全量审计」:
|
|
1333
|
+
* 每次执行照样写 .cache/ai-audit.log(tag=auto-policy),SSE 也会发 approval-auto 让对话里看得见命令原文。
|
|
1334
|
+
* 需要收紧时在设置面板或 AI_AUTO_APPROVE 改回 skill / none。
|
|
1335
|
+
*/
|
|
1336
|
+
const DEFAULT_AUTO_APPROVE = 'all'
|
|
1337
|
+
const AUTO_APPROVE_MODES = new Set(['none', 'skill', 'all'])
|
|
1338
|
+
|
|
1339
|
+
/** 宽松归一:开关值可能是 0/1/on/off/skill/all,认不出的回落默认(默认值本身可被调用方覆盖) */
|
|
1340
|
+
function normalizeAutoApprove(value, fallback = DEFAULT_AUTO_APPROVE) {
|
|
1341
|
+
const s = String(value ?? '').trim().toLowerCase()
|
|
1342
|
+
if (!s) return fallback
|
|
1343
|
+
if (['0', 'off', 'false', 'no', 'none', 'ask'].includes(s)) return 'none'
|
|
1344
|
+
if (['1', 'on', 'true', 'yes', 'all'].includes(s)) return 'all'
|
|
1345
|
+
return AUTO_APPROVE_MODES.has(s) ? s : fallback
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** 该策略下这个工具是否免确认 */
|
|
1349
|
+
function autoApproves(mode, toolName) {
|
|
1350
|
+
if (mode === 'all') return true
|
|
1351
|
+
if (mode === 'skill') return toolName === 'skill_exec'
|
|
1352
|
+
return false
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* 当前生效的 AI 配置:真实环境变量 > .env.cb.local(个人覆盖)> .env.cb(共享默认)。
|
|
1357
|
+
*
|
|
1358
|
+
* 坑点:cardbuddy CLI 启动时会 dotenv 载入 .env / .env.cb / .env.cb.local,把 AI_* 写进 process.env;
|
|
1359
|
+
* 若简单让 process.env 最高优先,个人覆盖层 .env.cb.local 就永远失效,且启动后改磁盘文件也不生效。
|
|
1360
|
+
* 因此 cli.js 会把「dotenv 实际注入的值」登记到 globalThis.__cardbuddyDotenvInjected;
|
|
1361
|
+
* 这里只承认与「注入值 / 共享文件」都不同的环境变量为真实覆盖(CI / 命令行导出),其余一律实时读文件。
|
|
1362
|
+
*/
|
|
1363
|
+
function readAiConfig(root = REPO_ROOT) {
|
|
1364
|
+
const shared = parseEnvFile(path.join(root, '.env.cb'))
|
|
1365
|
+
const local = parseEnvFile(path.join(root, '.env.cb.local'))
|
|
1366
|
+
const injected = globalThis.__cardbuddyDotenvInjected || {}
|
|
1367
|
+
const pick = (key) => {
|
|
1368
|
+
const fromProcess = process.env[key]
|
|
1369
|
+
// 与 dotenv 启动注入值 / 共享文件同值 ⇒ 不是真实覆盖:以文件实时读取为准(磁盘改动立即生效);
|
|
1370
|
+
// 三者都不同才承认是命令行 / CI 导出的真实环境变量。
|
|
1371
|
+
const isRealOverride = fromProcess !== undefined && fromProcess !== injected[key] && fromProcess !== shared[key]
|
|
1372
|
+
return String(isRealOverride ? fromProcess : (local[key] || shared[key] || ''))
|
|
1373
|
+
}
|
|
1374
|
+
return {
|
|
1375
|
+
baseUrl: pick('AI_BASE_URL'),
|
|
1376
|
+
apiKey: pick('AI_API_KEY'),
|
|
1377
|
+
model: pick('AI_MODEL'),
|
|
1378
|
+
// 本地命令执行开关(默认开):shell_exec 工具默认注册;是否逐次确认由 AI_AUTO_APPROVE 决定(默认 all=免确认)。
|
|
1379
|
+
// 显式 AI_ALLOW_SHELL=0 才关闭(本工具只监听 127.0.0.1,且命令过白名单 + 全量审计)
|
|
1380
|
+
shellAllowed: pick('AI_ALLOW_SHELL') !== '0',
|
|
1381
|
+
// 单次输出上限(0 = 用默认值):推理模型的思考也算 completion,太小会被截断
|
|
1382
|
+
maxTokens: Number(pick('AI_MAX_TOKENS')) > 0 ? Number(pick('AI_MAX_TOKENS')) : 0,
|
|
1383
|
+
// 深度思考开关(默认开):关掉可显著提速,但仅在 vLLM / SGLang 系网关生效
|
|
1384
|
+
thinking: pick('AI_THINKING') !== '0',
|
|
1385
|
+
// 多模态视觉开关(默认关):开启后先探测网关是否真支持图像,不支持自动降级数值闭环
|
|
1386
|
+
vision: pick('AI_VISION') === '1',
|
|
1387
|
+
// 免确认执行策略(默认 all):建卡管线连跑十几次脚本 / 命令,不该每次都等用户点「允许」
|
|
1388
|
+
autoApprove: normalizeAutoApprove(pick('AI_AUTO_APPROVE')),
|
|
1389
|
+
// 单轮工具调用步数硬顶(0 = 不限,默认):>0 时到顶收尾汇报并提示「继续」,上下文不丢
|
|
1390
|
+
maxSteps: Number(pick('AI_MAX_STEPS')) > 0 ? Number(pick('AI_MAX_STEPS')) : 0,
|
|
1391
|
+
// 单轮墙钟上限(毫秒):不限步数后唯一能兜住「无限跑下去」的闸门,到点收尾汇报而非无声挂着
|
|
1392
|
+
turnTimeoutMs: Number(pick('AI_TURN_TIMEOUT_MS')) > 0 ? Number(pick('AI_TURN_TIMEOUT_MS')) : DEFAULT_TURN_TIMEOUT_MS,
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** 密钥脱敏:只暴露「是否已配置 + 尾 4 位」,前端无需也不应拿到明文 */
|
|
1397
|
+
function maskKey(key) {
|
|
1398
|
+
if (!key) return ''
|
|
1399
|
+
if (key.length <= 8) return '*'.repeat(key.length)
|
|
1400
|
+
return `${'*'.repeat(Math.min(8, key.length - 4))}${key.slice(-4)}`
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* 保存 AI 配置到 .env.cb.local(个人覆盖层,.gitignore 已忽略,密钥不入库)。
|
|
1405
|
+
* 只写传入且非 undefined 的键;apiKey 传空串表示清除本地覆盖。
|
|
1406
|
+
* 未传的键**保持原值原样重写**:早先的实现会把没传的键从个人覆盖层删掉,
|
|
1407
|
+
* 于是「手写 env 只改一项」或「局部 POST」会连带清掉别的配置(与本文档相悖,实测 AI_ALLOW_SHELL 被抹掉)。
|
|
1408
|
+
*/
|
|
1409
|
+
function writeAiConfigLocal(
|
|
1410
|
+
root = REPO_ROOT,
|
|
1411
|
+
{ baseUrl, apiKey, model, allowShell, maxTokens, thinking, vision, autoApprove, maxSteps } = {},
|
|
1412
|
+
) {
|
|
1413
|
+
const envPath = path.join(root, '.env.cb.local')
|
|
1414
|
+
const lines = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8').split('\n') : []
|
|
1415
|
+
const KEYS = [
|
|
1416
|
+
'AI_BASE_URL',
|
|
1417
|
+
'AI_API_KEY',
|
|
1418
|
+
'AI_MODEL',
|
|
1419
|
+
'AI_ALLOW_SHELL',
|
|
1420
|
+
'AI_MAX_TOKENS',
|
|
1421
|
+
'AI_THINKING',
|
|
1422
|
+
'AI_VISION',
|
|
1423
|
+
'AI_AUTO_APPROVE',
|
|
1424
|
+
'AI_MAX_STEPS',
|
|
1425
|
+
]
|
|
1426
|
+
const incoming = {
|
|
1427
|
+
AI_BASE_URL: baseUrl,
|
|
1428
|
+
AI_API_KEY: apiKey,
|
|
1429
|
+
AI_MODEL: model,
|
|
1430
|
+
AI_ALLOW_SHELL: allowShell === undefined ? undefined : allowShell ? '1' : '0',
|
|
1431
|
+
// 传空串 / 0 = 清除本地覆盖,回到内置默认
|
|
1432
|
+
AI_MAX_TOKENS: maxTokens === undefined ? undefined : String(Number(maxTokens) > 0 ? Number(maxTokens) : ''),
|
|
1433
|
+
AI_THINKING: thinking === undefined ? undefined : thinking ? '1' : '0',
|
|
1434
|
+
AI_VISION: vision === undefined ? undefined : vision ? '1' : '0',
|
|
1435
|
+
AI_AUTO_APPROVE: autoApprove === undefined ? undefined : normalizeAutoApprove(autoApprove),
|
|
1436
|
+
AI_MAX_STEPS: maxSteps === undefined ? undefined : String(Number(maxSteps) > 0 ? Number(maxSteps) : ''),
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// 文件里已有的受管键值:incoming 为 undefined 时按原值重写,实现「只改传入的键」
|
|
1440
|
+
const existing = {}
|
|
1441
|
+
for (const l of lines) {
|
|
1442
|
+
const t = l.trim()
|
|
1443
|
+
if (!t || t.startsWith('#')) continue
|
|
1444
|
+
const i = t.indexOf('=')
|
|
1445
|
+
if (i < 0) continue
|
|
1446
|
+
const k = t.slice(0, i).trim()
|
|
1447
|
+
if (KEYS.includes(k)) existing[k] = t.slice(i + 1).trim()
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const kept = lines.filter((l) => {
|
|
1451
|
+
const t = l.trim()
|
|
1452
|
+
if (!t || t.startsWith('#')) return true
|
|
1453
|
+
const i = t.indexOf('=')
|
|
1454
|
+
if (i < 0) return true
|
|
1455
|
+
return !KEYS.includes(t.slice(0, i).trim())
|
|
1456
|
+
})
|
|
1457
|
+
while (kept.length && !kept[kept.length - 1].trim()) kept.pop()
|
|
1458
|
+
|
|
1459
|
+
const block = []
|
|
1460
|
+
if (!kept.some((l) => l.startsWith('# AI 开发助手'))) {
|
|
1461
|
+
block.push('# AI 开发助手模型配置(CardBuddy 设置面板写入;本文件为个人覆盖层,勿提交)')
|
|
1462
|
+
}
|
|
1463
|
+
for (const k of KEYS) {
|
|
1464
|
+
// 未传(undefined)= 沿用文件里的原值:只改用户真正动过的那几项
|
|
1465
|
+
const value = incoming[k] !== undefined ? incoming[k] : existing[k]
|
|
1466
|
+
if (value !== undefined) block.push(`${k}=${String(value ?? '')}`)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const out = [...kept, ...(kept.length ? [''] : []), ...block]
|
|
1470
|
+
fs.writeFileSync(envPath, out.join('\n') + '\n', 'utf8')
|
|
1471
|
+
|
|
1472
|
+
// 清掉 dotenv 从 .env.cb 灌进来的旧值:readAiConfig 以「与共享文件同值 = 非真实覆盖」判定,
|
|
1473
|
+
// 留着会让刚写入 .local 的值被误判为环境变量覆盖(清空即回落 .local,语义正确)
|
|
1474
|
+
for (const k of KEYS) {
|
|
1475
|
+
if (incoming[k] !== undefined) delete process.env[k]
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ---- 请求体 / 响应辅助 ----
|
|
1480
|
+
|
|
1481
|
+
function readBody(req, limitBytes = 8 * 1024 * 1024) {
|
|
1482
|
+
return new Promise((resolve, reject) => {
|
|
1483
|
+
let data = ''
|
|
1484
|
+
req.on('data', (chunk) => {
|
|
1485
|
+
data += chunk
|
|
1486
|
+
if (data.length > limitBytes) {
|
|
1487
|
+
reject(new Error('body too large'))
|
|
1488
|
+
req.destroy()
|
|
1489
|
+
}
|
|
1490
|
+
})
|
|
1491
|
+
req.on('end', () => {
|
|
1492
|
+
try {
|
|
1493
|
+
resolve(data ? JSON.parse(data) : {})
|
|
1494
|
+
} catch (e) {
|
|
1495
|
+
reject(e)
|
|
1496
|
+
}
|
|
1497
|
+
})
|
|
1498
|
+
req.on('error', reject)
|
|
1499
|
+
})
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** RFC1918 / 链路本地:局域网页面(vite Network 地址、同网设备)属可信来源 */
|
|
1503
|
+
const PRIVATE_HOST_RE = /^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/
|
|
1504
|
+
let _ownHosts = null
|
|
1505
|
+
/** 本机所有网卡地址:用它们打开编辑器(含 VPN/utun 地址)也算可信来源 */
|
|
1506
|
+
function ownHosts() {
|
|
1507
|
+
if (_ownHosts) return _ownHosts
|
|
1508
|
+
const set = new Set()
|
|
1509
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
1510
|
+
for (const i of list || []) set.add(String(i.address).toLowerCase())
|
|
1511
|
+
}
|
|
1512
|
+
_ownHosts = set
|
|
1513
|
+
return set
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* 可信来源判定:回环 / 局域网私有地址 / 本机网卡地址 才允许跨源读取;
|
|
1517
|
+
* 公网页面(含嵌入 iframe 的第三方站点)一律不放行。
|
|
1518
|
+
* 局域网必须放行:用 vite 的 Network 地址(或经其代理)打开编辑器是文档内的正常用法。
|
|
1519
|
+
*/
|
|
1520
|
+
function trustedOrigin(req) {
|
|
1521
|
+
const origin = req.headers.origin || ''
|
|
1522
|
+
if (!origin) return null // 同源 / 非浏览器请求:由调用方决定是否放行
|
|
1523
|
+
try {
|
|
1524
|
+
const host = new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
1525
|
+
if (host === 'localhost' || host.startsWith('127.') || host === '::1') return origin
|
|
1526
|
+
if (PRIVATE_HOST_RE.test(host)) return origin
|
|
1527
|
+
if (ownHosts().has(host)) return origin
|
|
1528
|
+
} catch {
|
|
1529
|
+
/* 非法 origin 视作不可信 */
|
|
1530
|
+
}
|
|
1531
|
+
return null
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function sendJson(req, res, code, payload) {
|
|
1535
|
+
res.statusCode = code
|
|
1536
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
1537
|
+
const origin = trustedOrigin(req)
|
|
1538
|
+
if (origin) res.setHeader('Access-Control-Allow-Origin', origin)
|
|
1539
|
+
res.end(JSON.stringify(payload))
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function sseWrite(res, obj) {
|
|
1543
|
+
if (res.writableEnded || res.destroyed) return
|
|
1544
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`)
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// ---- 对话记录落盘 ----
|
|
1548
|
+
|
|
1549
|
+
function safeCardId(cardId) {
|
|
1550
|
+
const id = String(cardId || '').trim()
|
|
1551
|
+
if (!id) return ''
|
|
1552
|
+
// 只允许文件名安全字符,杜绝 ../ 路径穿越
|
|
1553
|
+
return id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function historyFile(chatDir, cardId) {
|
|
1557
|
+
const id = safeCardId(cardId)
|
|
1558
|
+
return id ? path.join(chatDir, `${id}.json`) : ''
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function readHistory(chatDir, cardId) {
|
|
1562
|
+
const file = historyFile(chatDir, cardId)
|
|
1563
|
+
if (!file || !fs.existsSync(file)) return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
|
|
1564
|
+
try {
|
|
1565
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
1566
|
+
return {
|
|
1567
|
+
cardId: String(raw.cardId || cardId || ''),
|
|
1568
|
+
messages: Array.isArray(raw.messages) ? raw.messages : [],
|
|
1569
|
+
updatedAt: Number(raw.updatedAt || 0),
|
|
1570
|
+
}
|
|
1571
|
+
} catch {
|
|
1572
|
+
return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function writeHistory(chatDir, cardId, messages) {
|
|
1577
|
+
const file = historyFile(chatDir, cardId)
|
|
1578
|
+
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
1579
|
+
fs.mkdirSync(chatDir, { recursive: true })
|
|
1580
|
+
const list = Array.isArray(messages) ? messages.slice(-MAX_HISTORY_MESSAGES) : []
|
|
1581
|
+
const payload = { cardId: safeCardId(cardId), messages: list, updatedAt: Date.now() }
|
|
1582
|
+
const tmp = `${file}.tmp`
|
|
1583
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
|
|
1584
|
+
fs.renameSync(tmp, file)
|
|
1585
|
+
return { ok: true, count: list.length, updatedAt: payload.updatedAt }
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function deleteHistory(chatDir, cardId) {
|
|
1589
|
+
const file = historyFile(chatDir, cardId)
|
|
1590
|
+
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
1591
|
+
try {
|
|
1592
|
+
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
1593
|
+
} catch (e) {
|
|
1594
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
1595
|
+
}
|
|
1596
|
+
return { ok: true }
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** 历史卡片清单(面板「最近对话」用,按更新时间倒序) */
|
|
1600
|
+
function listHistories(chatDir, limit = 30) {
|
|
1601
|
+
try {
|
|
1602
|
+
if (!fs.existsSync(chatDir)) return []
|
|
1603
|
+
return fs
|
|
1604
|
+
.readdirSync(chatDir)
|
|
1605
|
+
.filter((f) => f.endsWith('.json'))
|
|
1606
|
+
.map((f) => {
|
|
1607
|
+
try {
|
|
1608
|
+
const raw = JSON.parse(fs.readFileSync(path.join(chatDir, f), 'utf8'))
|
|
1609
|
+
const messages = Array.isArray(raw.messages) ? raw.messages : []
|
|
1610
|
+
return {
|
|
1611
|
+
cardId: String(raw.cardId || f.replace(/\.json$/, '')),
|
|
1612
|
+
updatedAt: Number(raw.updatedAt || 0),
|
|
1613
|
+
count: messages.length,
|
|
1614
|
+
preview: String(messages.find((m) => m.role === 'user')?.content || '').slice(0, 60),
|
|
1615
|
+
}
|
|
1616
|
+
} catch {
|
|
1617
|
+
return null
|
|
1618
|
+
}
|
|
1619
|
+
})
|
|
1620
|
+
.filter(Boolean)
|
|
1621
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1622
|
+
.slice(0, limit)
|
|
1623
|
+
} catch {
|
|
1624
|
+
return []
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// ---- 本地文件挂载(只读,不上传) ----
|
|
1629
|
+
|
|
1630
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif', '.ico'])
|
|
1631
|
+
const IMAGE_MIME = {
|
|
1632
|
+
'.png': 'image/png',
|
|
1633
|
+
'.jpg': 'image/jpeg',
|
|
1634
|
+
'.jpeg': 'image/jpeg',
|
|
1635
|
+
'.gif': 'image/gif',
|
|
1636
|
+
'.webp': 'image/webp',
|
|
1637
|
+
'.bmp': 'image/bmp',
|
|
1638
|
+
'.svg': 'image/svg+xml',
|
|
1639
|
+
'.avif': 'image/avif',
|
|
1640
|
+
'.ico': 'image/x-icon',
|
|
1641
|
+
}
|
|
1642
|
+
/** 预览直出的图片体积上限(超过则前端退化为占位图标) */
|
|
1643
|
+
const RAW_MAX_BYTES = 12 * 1024 * 1024
|
|
1644
|
+
|
|
1645
|
+
/** 用户最近选过 / 拖过的目录,作为拖拽路径反查的优先根 */
|
|
1646
|
+
const recentRoots = []
|
|
1647
|
+
|
|
1648
|
+
function rememberRoot(p) {
|
|
1649
|
+
try {
|
|
1650
|
+
const dir = fs.statSync(p).isDirectory() ? p : path.dirname(p)
|
|
1651
|
+
const idx = recentRoots.indexOf(dir)
|
|
1652
|
+
if (idx >= 0) recentRoots.splice(idx, 1)
|
|
1653
|
+
recentRoots.unshift(dir)
|
|
1654
|
+
if (recentRoots.length > 12) recentRoots.pop()
|
|
1655
|
+
} catch {
|
|
1656
|
+
/* 路径已失效则忽略 */
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function expandHome(p) {
|
|
1661
|
+
const s = String(p || '').trim()
|
|
1662
|
+
if (!s) return ''
|
|
1663
|
+
if (s === '~') return os.homedir()
|
|
1664
|
+
if (s.startsWith('~/')) return path.join(os.homedir(), s.slice(2))
|
|
1665
|
+
return s
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function describePath(target) {
|
|
1669
|
+
const abs = path.resolve(expandHome(target))
|
|
1670
|
+
let st
|
|
1671
|
+
try {
|
|
1672
|
+
st = fs.statSync(abs)
|
|
1673
|
+
} catch {
|
|
1674
|
+
return { path: abs, exists: false }
|
|
1675
|
+
}
|
|
1676
|
+
const ext = path.extname(abs).toLowerCase()
|
|
1677
|
+
const info = {
|
|
1678
|
+
path: abs,
|
|
1679
|
+
exists: true,
|
|
1680
|
+
name: path.basename(abs) || abs,
|
|
1681
|
+
isDir: st.isDirectory(),
|
|
1682
|
+
size: st.size,
|
|
1683
|
+
mtime: st.mtimeMs,
|
|
1684
|
+
ext,
|
|
1685
|
+
isImage: !st.isDirectory() && IMAGE_EXT.has(ext),
|
|
1686
|
+
}
|
|
1687
|
+
if (info.isDir) {
|
|
1688
|
+
// 文件夹:给 AI 一份「浅层清单」摘要,避免它盲目递归
|
|
1689
|
+
const entries = []
|
|
1690
|
+
let count = 0
|
|
1691
|
+
try {
|
|
1692
|
+
const walk = (dir, depth) => {
|
|
1693
|
+
if (depth > 2 || count > 400) return
|
|
1694
|
+
let list
|
|
1695
|
+
try {
|
|
1696
|
+
list = fs.readdirSync(dir, { withFileTypes: true })
|
|
1697
|
+
} catch {
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
for (const ent of list) {
|
|
1701
|
+
if (count > 400) return
|
|
1702
|
+
if (ent.name === '.git' || ent.name === 'node_modules' || ent.name === '.DS_Store') continue
|
|
1703
|
+
const rel = path.relative(abs, path.join(dir, ent.name))
|
|
1704
|
+
count++
|
|
1705
|
+
if (ent.isDirectory()) {
|
|
1706
|
+
entries.push(`${rel}/`)
|
|
1707
|
+
walk(path.join(dir, ent.name), depth + 1)
|
|
1708
|
+
} else {
|
|
1709
|
+
entries.push(rel)
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
walk(abs, 0)
|
|
1714
|
+
} catch {
|
|
1715
|
+
/* 无权限目录忽略 */
|
|
1716
|
+
}
|
|
1717
|
+
info.entries = entries
|
|
1718
|
+
info.entryCount = count
|
|
1719
|
+
info.truncated = count > 400
|
|
1720
|
+
}
|
|
1721
|
+
rememberRoot(abs)
|
|
1722
|
+
return info
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* 拖拽落点反查绝对路径。
|
|
1727
|
+
* 浏览器出于安全不给 File.path,这里两条路走:
|
|
1728
|
+
* 1) 直拼:用 webkitRelativePath(如 `设计稿/index.html`)拼上「最近选过的目录 / 其父目录 /
|
|
1729
|
+
* Desktop / Downloads / Documents / 项目根」,命中且大小一致即采用(绝大多数拖拽走这条,零扫描);
|
|
1730
|
+
* 2) 兜底 BFS:按「文件名 + 大小」逐个根目录浅层优先搜索,命中即返回(根列表本身按可能性排序);
|
|
1731
|
+
* 异步分片扫描(每 400 条让出一次事件循环),未命中时也不会把本机服务卡住。
|
|
1732
|
+
* 搜索根先剪掉「被其它根包含」的子根:否则同一文件会在父 / 子根各命中一次,被误判成重名歧义而返回空。
|
|
1733
|
+
* 同一个根里出现多个同名同尺寸文件才算歧义,此时不猜。
|
|
1734
|
+
*/
|
|
1735
|
+
async function locateDropped({ name, relPath = '', size = -1 }) {
|
|
1736
|
+
const fileName = String(name || '').trim()
|
|
1737
|
+
if (!fileName) return null
|
|
1738
|
+
const wantSize = Number(size) >= 0 ? Number(size) : -1
|
|
1739
|
+
const tail = String(relPath || '').split('/').filter(Boolean)
|
|
1740
|
+
const SKIP = new Set(['node_modules', '.git', '.cache', 'dist', '.codex-tmp', '.local', '.history', 'UI', 'demo', 'release'])
|
|
1741
|
+
const home = os.homedir()
|
|
1742
|
+
|
|
1743
|
+
const sizeMatches = (p) => {
|
|
1744
|
+
if (wantSize < 0) return true
|
|
1745
|
+
try {
|
|
1746
|
+
return fs.statSync(p).size === wantSize
|
|
1747
|
+
} catch {
|
|
1748
|
+
return false
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const realOf = (p) => {
|
|
1753
|
+
try {
|
|
1754
|
+
return fs.realpathSync(p)
|
|
1755
|
+
} catch {
|
|
1756
|
+
return p
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const roots = []
|
|
1761
|
+
const pushRoot = (r) => {
|
|
1762
|
+
if (!r) return
|
|
1763
|
+
const abs = path.resolve(r)
|
|
1764
|
+
if (!roots.includes(abs)) roots.push(abs)
|
|
1765
|
+
}
|
|
1766
|
+
for (const r of recentRoots) {
|
|
1767
|
+
pushRoot(r)
|
|
1768
|
+
pushRoot(path.dirname(r))
|
|
1769
|
+
}
|
|
1770
|
+
pushRoot(path.join(home, 'Desktop'))
|
|
1771
|
+
pushRoot(path.join(home, 'Downloads'))
|
|
1772
|
+
pushRoot(path.join(home, 'Documents'))
|
|
1773
|
+
pushRoot(REPO_ROOT)
|
|
1774
|
+
pushRoot(path.dirname(REPO_ROOT))
|
|
1775
|
+
pushRoot(home)
|
|
1776
|
+
|
|
1777
|
+
// 只保留「最外层」的根用于扫描(recentRoots 常同时含某目录与其父目录)
|
|
1778
|
+
const reals = roots.map(realOf)
|
|
1779
|
+
const scanRoots = roots.filter((r, i) => {
|
|
1780
|
+
const real = reals[i]
|
|
1781
|
+
return !reals.some((other, j) => j !== i && other !== real && real.startsWith(other + path.sep))
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
// 1) 直拼相对路径
|
|
1785
|
+
if (tail.length) {
|
|
1786
|
+
for (const root of roots) {
|
|
1787
|
+
const cand = path.join(root, ...tail)
|
|
1788
|
+
try {
|
|
1789
|
+
const st = fs.statSync(cand)
|
|
1790
|
+
if (st.isDirectory() || sizeMatches(cand)) return cand
|
|
1791
|
+
} catch {
|
|
1792
|
+
/* 该根下没有这个相对路径,换下一个 */
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// 2) 逐根浅层优先 BFS(同一层全部展开后再下钻,拖入的目录通常在浅层)
|
|
1798
|
+
const MAX_VISIT = 20000
|
|
1799
|
+
const MAX_DEPTH = 4
|
|
1800
|
+
let visited = 0
|
|
1801
|
+
for (const scanRoot of scanRoots) {
|
|
1802
|
+
if (visited >= MAX_VISIT) break
|
|
1803
|
+
try {
|
|
1804
|
+
if (!fs.statSync(scanRoot).isDirectory()) continue
|
|
1805
|
+
} catch {
|
|
1806
|
+
continue /* 根不存在则跳过 */
|
|
1807
|
+
}
|
|
1808
|
+
const queue = [{ dir: scanRoot, depth: 0 }]
|
|
1809
|
+
/** realpath → 展示路径:同一文件经不同符号链接命中只算一次 */
|
|
1810
|
+
const hits = new Map()
|
|
1811
|
+
while (queue.length && visited < MAX_VISIT && hits.size < 2) {
|
|
1812
|
+
const { dir, depth } = queue.shift()
|
|
1813
|
+
if (depth > MAX_DEPTH) continue
|
|
1814
|
+
let list
|
|
1815
|
+
try {
|
|
1816
|
+
list = fs.readdirSync(dir, { withFileTypes: true })
|
|
1817
|
+
} catch {
|
|
1818
|
+
continue
|
|
1819
|
+
}
|
|
1820
|
+
const subdirs = []
|
|
1821
|
+
for (const ent of list) {
|
|
1822
|
+
visited++
|
|
1823
|
+
if (visited > MAX_VISIT) break
|
|
1824
|
+
// 兜底扫描可能遍历上万条目:定期让出事件循环,别把 dev server 卡住
|
|
1825
|
+
if (visited % 400 === 0) await new Promise((r) => setImmediate(r))
|
|
1826
|
+
if (SKIP.has(ent.name) || ent.name === '.DS_Store') continue
|
|
1827
|
+
const full = path.join(dir, ent.name)
|
|
1828
|
+
if (ent.isDirectory()) {
|
|
1829
|
+
// 拖入文件夹:同名目录直接命中(无需比大小)
|
|
1830
|
+
if (tail.length && ent.name === tail[0] && depth < MAX_DEPTH) {
|
|
1831
|
+
try {
|
|
1832
|
+
if (fs.statSync(full).isDirectory()) return full
|
|
1833
|
+
} catch {
|
|
1834
|
+
/* 忽略不可读目录 */
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
subdirs.push({ dir: full, depth: depth + 1 })
|
|
1838
|
+
} else if (ent.name === fileName && sizeMatches(full)) {
|
|
1839
|
+
const real = realOf(full)
|
|
1840
|
+
if (!hits.has(real)) hits.set(real, full)
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
queue.push(...subdirs)
|
|
1844
|
+
}
|
|
1845
|
+
if (hits.size === 1) return hits.values().next().value
|
|
1846
|
+
if (hits.size > 1) return null /* 同根下重名同尺寸:不猜 */
|
|
1847
|
+
}
|
|
1848
|
+
return null
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/** 系统选择器(macOS 用 osascript;其它平台返回明确提示,前端仍可拖拽 / 粘路径) */
|
|
1852
|
+
function pickPaths({ kind = 'file', multiple = true }) {
|
|
1853
|
+
if (process.platform !== 'darwin') {
|
|
1854
|
+
throw Object.assign(new Error('当前系统不支持原生选择器,请改用拖拽或粘贴绝对路径'), { code: 'unsupported' })
|
|
1855
|
+
}
|
|
1856
|
+
const label = kind === 'folder' ? '选择文件夹' : kind === 'image' ? '选择图片' : '选择文件'
|
|
1857
|
+
const what = kind === 'folder' ? 'folder' : 'file'
|
|
1858
|
+
const ofType = kind === 'image' ? ' of type {"public.image"}' : ''
|
|
1859
|
+
const multi = multiple ? ' with multiple selections allowed' : ''
|
|
1860
|
+
const script = multiple
|
|
1861
|
+
? `set theItems to choose ${what} with prompt "${label}"${ofType}${multi}\nset outText to ""\nrepeat with i in theItems\n set outText to outText & POSIX path of i & "\\n"\nend repeat\nreturn outText`
|
|
1862
|
+
: `return POSIX path of (choose ${what} with prompt "${label}"${ofType})`
|
|
1863
|
+
|
|
1864
|
+
return new Promise((resolve, reject) => {
|
|
1865
|
+
execFile('osascript', ['-e', script], { encoding: 'utf8', timeout: 10 * 60 * 1000 }, (err, stdout, stderr) => {
|
|
1866
|
+
if (err) {
|
|
1867
|
+
const msg = String(stderr || err.message || '')
|
|
1868
|
+
if (/User canceled|用户取消| (-128)/i.test(msg + String(err.code ?? ''))) {
|
|
1869
|
+
return resolve({ cancelled: true, paths: [] })
|
|
1870
|
+
}
|
|
1871
|
+
const e = new Error(`系统选择器调用失败:${msg.slice(0, 200)}`)
|
|
1872
|
+
e.code = 'picker-failed'
|
|
1873
|
+
return reject(e)
|
|
1874
|
+
}
|
|
1875
|
+
const paths = String(stdout || '')
|
|
1876
|
+
.split('\n')
|
|
1877
|
+
.map((s) => s.trim())
|
|
1878
|
+
.filter(Boolean)
|
|
1879
|
+
resolve({ cancelled: false, paths })
|
|
1880
|
+
})
|
|
1881
|
+
})
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ---- LLM 流式客户端 ----
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* 调用 LLM(OpenAI 兼容),流式返回。
|
|
1888
|
+
* @param {{ baseUrl:string, apiKey:string, model:string, messages:any[], tools?:any[], maxTokens?:number, signal?:AbortSignal }} opts
|
|
1889
|
+
* @returns {AsyncGenerator<{type: string, [k: string]: any}>}
|
|
1890
|
+
*/
|
|
1891
|
+
/**
|
|
1892
|
+
* 关闭深度思考的请求体扩展(vLLM / SGLang 系网关的 chat_template 透传)。
|
|
1893
|
+
* 实测本仓网关:带 thinking 时一句话也要 900+ 字思考、建卡时思考 3 万字仍不产出;
|
|
1894
|
+
* 关掉后首字节更快、直接出工具调用。非该类网关会忽略或拒绝本字段——拒绝时自动去掉重试。
|
|
1895
|
+
*/
|
|
1896
|
+
const NO_THINKING_BODY = { chat_template_kwargs: { enable_thinking: false } }
|
|
1897
|
+
|
|
1898
|
+
/** 400 详情判定①:明确指向 max_tokens 过大 / 超范围(减半能救):
|
|
1899
|
+
DashScope 系「Range of max_tokens should be [1, N]」、vLLM 系「max_tokens must be <= max_model_len - prompt_tokens」 */
|
|
1900
|
+
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
|
|
1901
|
+
/** 400 详情判定②:prompt / 上下文超窗(减半救不了,交给调用方裁剪重试) */
|
|
1902
|
+
const CONTEXT_400_RE = /(context|prompt|input)[^\n]{0,160}(length|long|exceed|limit|too (?:large|big|many))|maximum context/i
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* 瞬时网关错误(超时 / 限流 / 5xx):这类错误重试一次往往就过,整轮直接报错收尾代价太大——
|
|
1906
|
+
* 实测一轮建卡跑了 72 次工具调用后被 nginx 504 一刀切,用户只看到一坨 HTML。
|
|
1907
|
+
*/
|
|
1908
|
+
const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 506, 507, 509, 520, 521, 522, 523, 524])
|
|
1909
|
+
|
|
1910
|
+
/** 重试间隔(毫秒,逐个对应一次重试):AI_GATEWAY_RETRY_MS 可覆盖,弱网调参与单测提速用 */
|
|
1911
|
+
function transientRetryDelays() {
|
|
1912
|
+
const list = String(process.env.AI_GATEWAY_RETRY_MS || '1000,4000')
|
|
1913
|
+
.split(',')
|
|
1914
|
+
.map((x) => Number(x.trim()))
|
|
1915
|
+
.filter((x) => Number.isFinite(x) && x >= 0)
|
|
1916
|
+
return list.length ? list : [1000, 4000]
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const nap = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* 把网关错误摘成一句话:错误页多是 HTML(nginx 504 / 502),原样甩给用户或回喂给模型都是噪音。
|
|
1923
|
+
* 保留 `LLM <status>` 前缀——轮循环靠它识别 400 上下文溢出。
|
|
1924
|
+
*/
|
|
1925
|
+
function llmHttpError(status, body) {
|
|
1926
|
+
const raw = String(body ?? '')
|
|
1927
|
+
const isHtml = /<\s*(html|head|body|br|center|hr|div|p|title)\b/i.test(raw)
|
|
1928
|
+
// HTML 错误页优先取 <title>:整页去标签会把 title 与 h1 拼成重复句(nginx 504 实测)
|
|
1929
|
+
const title = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() || ''
|
|
1930
|
+
const brief = raw
|
|
1931
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
1932
|
+
.replace(/<[^>]+>/g, ' ')
|
|
1933
|
+
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
|
|
1934
|
+
.replace(/\s+/g, ' ')
|
|
1935
|
+
.trim()
|
|
1936
|
+
.slice(0, 300)
|
|
1937
|
+
const gist = (isHtml ? title || brief : raw.slice(0, 500)).trim()
|
|
1938
|
+
return new Error(isHtml ? `LLM ${status}:网关返回 HTML 错误页(无模型输出)${gist ? ` — ${gist}` : ''}` : `LLM ${status}: ${gist}`)
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4096, signal, extraBody }) {
|
|
1942
|
+
const url = baseUrl.replace(/\/$/, '') + '/chat/completions'
|
|
1943
|
+
const buildBody = (extra, maxTok = maxTokens) => {
|
|
1944
|
+
const body = { model, messages, stream: true, max_tokens: maxTok, ...(extra || {}) }
|
|
1945
|
+
if (tools?.length) {
|
|
1946
|
+
body.tools = tools
|
|
1947
|
+
body.tool_choice = 'auto'
|
|
1948
|
+
}
|
|
1949
|
+
return body
|
|
1950
|
+
}
|
|
1951
|
+
const post = (extra, maxTok) =>
|
|
1952
|
+
fetch(url, {
|
|
1953
|
+
method: 'POST',
|
|
1954
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1955
|
+
body: JSON.stringify(buildBody(extra, maxTok)),
|
|
1956
|
+
signal,
|
|
1957
|
+
})
|
|
1958
|
+
|
|
1959
|
+
// 生成器里没法从普通函数 yield:重试提示先攒着,拿到响应后统一吐给前端
|
|
1960
|
+
const pendingNotices = []
|
|
1961
|
+
/** 只在「一个字节都还没吐出去」前重试:流一旦开始就不能重来(前端已渲染半截正文) */
|
|
1962
|
+
const postRetry = async (extra, maxTok) => {
|
|
1963
|
+
const delays = transientRetryDelays()
|
|
1964
|
+
let lastErr = null
|
|
1965
|
+
for (let attempt = 0; ; attempt++) {
|
|
1966
|
+
if (signal?.aborted) throw new Error('请求已中止')
|
|
1967
|
+
const retryable = attempt < delays.length
|
|
1968
|
+
try {
|
|
1969
|
+
const resp = await post(extra, maxTok)
|
|
1970
|
+
if (resp.ok || !TRANSIENT_STATUS.has(resp.status) || !retryable) return resp
|
|
1971
|
+
const wait = delays[attempt]
|
|
1972
|
+
pendingNotices.push(
|
|
1973
|
+
`网关返回 HTTP ${resp.status}(瞬时错误):${wait / 1000} 秒后自动重试本步(第 ${attempt + 1}/${delays.length} 次),任务不中断`,
|
|
1974
|
+
)
|
|
1975
|
+
await nap(wait)
|
|
1976
|
+
} catch (e) {
|
|
1977
|
+
if (signal?.aborted) throw e
|
|
1978
|
+
lastErr = e
|
|
1979
|
+
if (!retryable) break
|
|
1980
|
+
const wait = delays[attempt]
|
|
1981
|
+
pendingNotices.push(`网关连接失败(${String(e?.message || e).slice(0, 80)}):${wait / 1000} 秒后自动重试本步`)
|
|
1982
|
+
await nap(wait)
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const err = new Error(`LLM 网络错误:${String(lastErr?.message || lastErr).slice(0, 200)}`)
|
|
1986
|
+
err.code = 'network-error'
|
|
1987
|
+
throw err
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
let curExtra = extraBody
|
|
1991
|
+
let resp = await postRetry(curExtra)
|
|
1992
|
+
for (const text of pendingNotices.splice(0)) yield { type: 'notice', text }
|
|
1993
|
+
const hasExtra = Boolean(extraBody && Object.keys(extraBody).length)
|
|
1994
|
+
if (!resp.ok && hasExtra) {
|
|
1995
|
+
// 网关不认扩展字段(非 vLLM/SGLang 系):去掉后原样重试一次,别让整轮直接失败
|
|
1996
|
+
const detail = await resp.text().catch(() => '')
|
|
1997
|
+
curExtra = undefined
|
|
1998
|
+
resp = await postRetry(curExtra)
|
|
1999
|
+
if (!resp.ok) {
|
|
2000
|
+
const text = await resp.text().catch(() => '')
|
|
2001
|
+
throw llmHttpError(resp.status, text)
|
|
2002
|
+
}
|
|
2003
|
+
yield {
|
|
2004
|
+
type: 'notice',
|
|
2005
|
+
text: `当前网关不支持「关闭深度思考」参数(HTTP ${resp.status} 已回退):${detail.slice(0, 120)}`,
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// max_tokens 超网关硬上限 / 窗口:自动减半重试(最多 2 次),避免手填过大或探测值过时时整轮直接失败。
|
|
2010
|
+
// 分类收紧:只有详情明确指向 max_tokens 才减半;prompt 超窗类 400 减半无意义,
|
|
2011
|
+
// 打 context-overflow 标记抛出,由轮循环走「压缩上下文重试」分支
|
|
2012
|
+
let effMax = maxTokens
|
|
2013
|
+
for (let i = 0; i < 2 && !resp.ok && resp.status === 400; i++) {
|
|
2014
|
+
const detail = await resp.text().catch(() => '')
|
|
2015
|
+
if (!MAX_TOKENS_400_RE.test(detail)) {
|
|
2016
|
+
const err = llmHttpError(400, detail)
|
|
2017
|
+
if (CONTEXT_400_RE.test(detail)) err.code = 'context-overflow'
|
|
2018
|
+
throw err
|
|
2019
|
+
}
|
|
2020
|
+
const prev = effMax
|
|
2021
|
+
effMax = Math.max(2048, Math.floor(effMax / 2))
|
|
2022
|
+
resp = await postRetry(curExtra, effMax)
|
|
2023
|
+
for (const text of pendingNotices.splice(0)) yield { type: 'notice', text }
|
|
2024
|
+
yield { type: 'notice', text: `单次输出上限 ${prev} 被网关拒绝(HTTP 400):已自动减半为 ${effMax} 重试` }
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
if (!resp.ok) {
|
|
2028
|
+
const text = await resp.text().catch(() => '')
|
|
2029
|
+
throw llmHttpError(resp.status, text)
|
|
2030
|
+
}
|
|
2031
|
+
// 减半后的值被网关接受 = 「网关接受的单次输出上限」的证据:回写 capCache(**不是窗口缓存**)。
|
|
2032
|
+
// 窗口与输出上限是两回事:本网关实测 max_tokens 硬上限 131072、/models 又无窗口字段,
|
|
2033
|
+
// 把接受值当窗口会污染圆环分母与单轮上下文预算
|
|
2034
|
+
if (effMax < maxTokens) {
|
|
2035
|
+
const sig = maxTokensSig({ baseUrl, apiKey, model })
|
|
2036
|
+
const known = capCache.get(sig)
|
|
2037
|
+
if (!known || known.cap > effMax) capCache.set(sig, { cap: effMax, source: 'halved' })
|
|
2038
|
+
}
|
|
2039
|
+
if (!resp.body) throw new Error('LLM 返回空 body')
|
|
2040
|
+
|
|
2041
|
+
const decoder = new TextDecoder()
|
|
2042
|
+
const reader = resp.body.getReader()
|
|
2043
|
+
let buffer = ''
|
|
2044
|
+
let toolCallsAccum = null
|
|
2045
|
+
|
|
2046
|
+
const flushToolCalls = () => {
|
|
2047
|
+
if (!toolCallsAccum) return null
|
|
2048
|
+
const finalized = Object.values(toolCallsAccum).map(({ announced, ...tc }) => ({
|
|
2049
|
+
...tc,
|
|
2050
|
+
function: { ...tc.function, arguments: tc.function.arguments || '{}' },
|
|
2051
|
+
}))
|
|
2052
|
+
toolCallsAccum = null
|
|
2053
|
+
return { type: 'tool-calls', toolCalls: finalized }
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
try {
|
|
2057
|
+
while (true) {
|
|
2058
|
+
if (signal?.aborted) return
|
|
2059
|
+
const { done, value } = await reader.read()
|
|
2060
|
+
if (done) break
|
|
2061
|
+
buffer += decoder.decode(value, { stream: true })
|
|
2062
|
+
|
|
2063
|
+
const lines = buffer.split('\n')
|
|
2064
|
+
buffer = lines.pop() || ''
|
|
2065
|
+
|
|
2066
|
+
for (const line of lines) {
|
|
2067
|
+
const trimmed = line.trim()
|
|
2068
|
+
if (!trimmed.startsWith('data:')) continue
|
|
2069
|
+
const payload = trimmed.slice(5).trim()
|
|
2070
|
+
if (payload === '[DONE]') {
|
|
2071
|
+
const evt = flushToolCalls()
|
|
2072
|
+
if (evt) yield evt
|
|
2073
|
+
return
|
|
2074
|
+
}
|
|
2075
|
+
let chunk
|
|
2076
|
+
try {
|
|
2077
|
+
chunk = JSON.parse(payload)
|
|
2078
|
+
} catch {
|
|
2079
|
+
continue
|
|
2080
|
+
}
|
|
2081
|
+
const choice = chunk.choices?.[0]
|
|
2082
|
+
if (!choice) continue // usage chunk 等
|
|
2083
|
+
const delta = choice.delta || {}
|
|
2084
|
+
// reasoning 字段各家命名不一:reasoning_content(DeepSeek/智谱)/ reasoning(部分网关)
|
|
2085
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning ?? ''
|
|
2086
|
+
if (reasoning) yield { type: 'reasoning-delta', text: reasoning }
|
|
2087
|
+
if (delta.content) yield { type: 'text-delta', text: delta.content }
|
|
2088
|
+
if (delta.tool_calls) {
|
|
2089
|
+
if (!toolCallsAccum) toolCallsAccum = {}
|
|
2090
|
+
for (const tc of delta.tool_calls) {
|
|
2091
|
+
const idx = tc.index ?? 0
|
|
2092
|
+
if (!toolCallsAccum[idx]) {
|
|
2093
|
+
toolCallsAccum[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } }
|
|
2094
|
+
}
|
|
2095
|
+
if (tc.id && !toolCallsAccum[idx].id) toolCallsAccum[idx].id = tc.id
|
|
2096
|
+
if (tc.function?.name) toolCallsAccum[idx].function.name += tc.function.name
|
|
2097
|
+
if (tc.function?.arguments) toolCallsAccum[idx].function.arguments += tc.function.arguments
|
|
2098
|
+
// 工具名一到就先冒泡:参数(card_patch 的 JSON 可能很长)还在流式拼装时,
|
|
2099
|
+
// 前端就能立刻显示「正在调用 xxx」,而不是等整轮结束才有反馈(Codex 同款体感)
|
|
2100
|
+
if (tc.function?.name && !toolCallsAccum[idx].announced) {
|
|
2101
|
+
toolCallsAccum[idx].announced = true
|
|
2102
|
+
yield {
|
|
2103
|
+
type: 'tool-call-start',
|
|
2104
|
+
index: idx,
|
|
2105
|
+
toolCallId: toolCallsAccum[idx].id,
|
|
2106
|
+
toolName: toolCallsAccum[idx].function.name,
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (choice.finish_reason) {
|
|
2112
|
+
// 'length' = 被 max_tokens 截断:工具参数会是半截 JSON,必须显式告诉用户而不是静默失败
|
|
2113
|
+
yield { type: 'finish', reason: choice.finish_reason }
|
|
2114
|
+
}
|
|
2115
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
2116
|
+
const evt = flushToolCalls()
|
|
2117
|
+
if (evt) yield evt
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
} finally {
|
|
2122
|
+
try {
|
|
2123
|
+
reader.releaseLock?.()
|
|
2124
|
+
} catch {
|
|
2125
|
+
/* 已中止时忽略 */
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// 流末尾残留(无 [DONE] 的网关)兜底解析
|
|
2130
|
+
const remaining = buffer.trim()
|
|
2131
|
+
if (remaining.startsWith('data:')) {
|
|
2132
|
+
const payload = remaining.slice(5).trim()
|
|
2133
|
+
if (payload && payload !== '[DONE]') {
|
|
2134
|
+
try {
|
|
2135
|
+
const delta = JSON.parse(payload).choices?.[0]?.delta || {}
|
|
2136
|
+
if (delta.content) yield { type: 'text-delta', text: delta.content }
|
|
2137
|
+
} catch {
|
|
2138
|
+
/* 忽略 */
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const tail = flushToolCalls()
|
|
2143
|
+
if (tail) yield tail
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/** 工具结果里内联图片的体积上限(超出只留摘要,避免把上下文塞爆) */
|
|
2147
|
+
const TOOL_IMAGE_MAX_BYTES = 2 * 1024 * 1024
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* 拆出工具结果中的 base64 图片(card_screenshot 等):
|
|
2151
|
+
* 图片对「文本模型」毫无价值却极占上下文,故从回传 LLM 的文本里剔除,只留给前端做预览。
|
|
2152
|
+
* @param {any} result dispatch 返回值
|
|
2153
|
+
* @returns {{ text: string, image?: { mime: string, base64: string } }}
|
|
2154
|
+
*/
|
|
2155
|
+
function splitToolResult(result) {
|
|
2156
|
+
const raw = typeof result === 'string' ? result : JSON.stringify(result)
|
|
2157
|
+
if (!raw || raw.indexOf('base64') < 0) return { text: raw }
|
|
2158
|
+
let parsed
|
|
2159
|
+
try {
|
|
2160
|
+
parsed = typeof result === 'string' ? JSON.parse(result) : result
|
|
2161
|
+
} catch {
|
|
2162
|
+
return { text: raw }
|
|
2163
|
+
}
|
|
2164
|
+
if (!parsed || typeof parsed !== 'object') return { text: raw }
|
|
2165
|
+
const b64 = typeof parsed.base64 === 'string' ? parsed.base64 : ''
|
|
2166
|
+
if (!b64) return { text: raw }
|
|
2167
|
+
const mime = typeof parsed.mime === 'string' ? parsed.mime : 'image/png'
|
|
2168
|
+
const image = b64.length <= TOOL_IMAGE_MAX_BYTES ? { mime, base64: b64 } : undefined
|
|
2169
|
+
const summary = { ...parsed }
|
|
2170
|
+
delete summary.base64
|
|
2171
|
+
summary.note = image ? '截图已生成,图像已在对话面板中预览(未注入文本上下文)' : '截图过大,已省略图像数据'
|
|
2172
|
+
return { text: JSON.stringify(summary), image }
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// ---- 多模态视觉通路(开关开启 → 先探测网关;不支持自动降级数值闭环)----
|
|
2176
|
+
/** 注入上限:单轮最多注入几张图(图 token 很贵,推理模型尤其) */
|
|
2177
|
+
const VISION_INJECT_MAX = 2
|
|
2178
|
+
/** 单图注入体积上限(超出且降采样后仍超 → 放弃注入) */
|
|
2179
|
+
const VISION_MAX_BYTES = 900 * 1024
|
|
2180
|
+
/** 降采样长边上限 */
|
|
2181
|
+
const VISION_MAX_EDGE = 1024
|
|
2182
|
+
/** 32×32 灰底 PNG(95B):探测用最小载荷。
|
|
2183
|
+
曾用 1×1 透明图,实测有网关拒收小图(「height:1 or width:1 must be larger than 10」)→
|
|
2184
|
+
带图请求 400 被误判成「模型不支持视觉」,32×32 满足常见最小边长限制且体积可忽略 */
|
|
2185
|
+
const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAJklEQVR42u3NMQ0AAAwDoEqv9KrYsQQMkB6LQCAQCAQCgUAg+BIMLd4AWwUPN68AAAAASUVORK5CYII='
|
|
2186
|
+
const visionProbeCache = new Map()
|
|
2187
|
+
|
|
2188
|
+
/** usage 里的输入 token 数:兼容 OpenAI 系 prompt_tokens 与部分网关的 input_tokens */
|
|
2189
|
+
function promptTokensOf(j) {
|
|
2190
|
+
const u = j && j.usage
|
|
2191
|
+
if (!u) return NaN
|
|
2192
|
+
const n = Number(u.prompt_tokens ?? u.input_tokens)
|
|
2193
|
+
return Number.isFinite(n) ? n : NaN
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* 探测网关是否真的把图像算进上下文:同一句 prompt 带 / 不带小图各发一次(max_tokens=1),
|
|
2198
|
+
* 比较 usage.prompt_tokens——实测不支持的网关会静默忽略 image_url(token 数不变)。
|
|
2199
|
+
* 带图请求被拒(尺寸 / 格式 / 模态限制)与「无 usage 可比」都记原因:降级照旧,但提示要说清为什么,
|
|
2200
|
+
* 不能把网关拒收探针图误报成「模型不支持多模态」。结果按 baseUrl|model|key 尾缀缓存;保存设置会清缓存重探。
|
|
2201
|
+
*/
|
|
2202
|
+
async function probeVisionSupport({ baseUrl, apiKey, model }) {
|
|
2203
|
+
const sig = `${baseUrl}|${model}|${String(apiKey || '').slice(-8)}`
|
|
2204
|
+
if (visionProbeCache.has(sig)) return visionProbeCache.get(sig)
|
|
2205
|
+
let out = { ok: false, reason: '' }
|
|
2206
|
+
try {
|
|
2207
|
+
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`
|
|
2208
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }
|
|
2209
|
+
const mk = (withImg) => ({
|
|
2210
|
+
model,
|
|
2211
|
+
stream: false,
|
|
2212
|
+
max_tokens: 1,
|
|
2213
|
+
messages: [
|
|
2214
|
+
{
|
|
2215
|
+
role: 'user',
|
|
2216
|
+
content: withImg
|
|
2217
|
+
? [{ type: 'text', text: 'ping' }, { type: 'image_url', image_url: { url: `data:image/png;base64,${TINY_PNG_B64}` } }]
|
|
2218
|
+
: 'ping',
|
|
2219
|
+
},
|
|
2220
|
+
],
|
|
2221
|
+
})
|
|
2222
|
+
const call = async (body) => {
|
|
2223
|
+
const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
2224
|
+
const text = await r.text().catch(() => '')
|
|
2225
|
+
let json = null
|
|
2226
|
+
try {
|
|
2227
|
+
json = JSON.parse(text)
|
|
2228
|
+
} catch {
|
|
2229
|
+
/* 非 JSON 响应:只留状态码与原文片段 */
|
|
2230
|
+
}
|
|
2231
|
+
return { ok: r.ok, status: r.status, json, text }
|
|
2232
|
+
}
|
|
2233
|
+
const [plain, withImage] = await Promise.all([call(mk(false)), call(mk(true))])
|
|
2234
|
+
if (!plain.ok) {
|
|
2235
|
+
out = { ok: false, reason: `纯文本探针请求被拒(HTTP ${plain.status})` }
|
|
2236
|
+
} else if (!withImage.ok) {
|
|
2237
|
+
out = { ok: false, reason: `网关拒绝图像请求(HTTP ${withImage.status}:${(withImage.text || '').slice(0, 100)})` }
|
|
2238
|
+
} else {
|
|
2239
|
+
const a = promptTokensOf(plain.json)
|
|
2240
|
+
const b = promptTokensOf(withImage.json)
|
|
2241
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) out = { ok: false, reason: '网关未回 usage.prompt_tokens,无法验证图像是否入上下文' }
|
|
2242
|
+
else if (b > a) out = { ok: true, reason: '' }
|
|
2243
|
+
else out = { ok: false, reason: '带图与不带图 prompt_tokens 无变化(网关静默忽略图像)' }
|
|
2244
|
+
}
|
|
2245
|
+
} catch (e) {
|
|
2246
|
+
out = { ok: false, reason: String((e && e.message) || e).slice(0, 100) }
|
|
2247
|
+
}
|
|
2248
|
+
visionProbeCache.set(sig, out)
|
|
2249
|
+
return out
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/** 单次输出上限探测候选(降序):二分式极小请求,第一个不 400 的即近似模型窗口 */
|
|
2253
|
+
const MAXTOKENS_CANDIDATES = [262144, 131072, 65536, 32768, 16384, 8192]
|
|
2254
|
+
/** 首轮最多等这么久拿探测结果;超时不取消探测,先用默认值开跑,结果留给后续轮次 */
|
|
2255
|
+
const MAXTOKENS_PROBE_WAIT_MS = 2000
|
|
2256
|
+
/** 网关上下文窗口缓存:仅 /v1/models 元数据写入(单轮上下文预算、输入框圆环分母的唯一来源) */
|
|
2257
|
+
const windowCache = new Map()
|
|
2258
|
+
/** 网关接受的单次输出上限缓存:二分试探与减半回写写入(自动封顶、手填夹取的依据) */
|
|
2259
|
+
const capCache = new Map()
|
|
2260
|
+
const maxTokensProbeInflight = new Map()
|
|
2261
|
+
/** 元数据读不到过的配置:聊天路径不再每轮重复 GET /models(「探测」按钮会清掉重测) */
|
|
2262
|
+
const maxTokensMetaMiss = new Set()
|
|
2263
|
+
|
|
2264
|
+
/** 探测缓存键:同一网关 + 模型 + 密钥共用一份认知 */
|
|
2265
|
+
function maxTokensSig({ baseUrl, apiKey, model }) {
|
|
2266
|
+
return `${baseUrl}|${model}|${String(apiKey || '').slice(-8)}`
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** 某配置的当前认知快照:{ cap: 接受的输出上限, window: 上下文窗口, source } */
|
|
2270
|
+
function probeSnapshot(sig) {
|
|
2271
|
+
const cap = capCache.get(sig)
|
|
2272
|
+
const win = windowCache.get(sig)
|
|
2273
|
+
return { cap: cap?.cap || 0, window: win?.window || 0, source: cap?.source || win?.source || '' }
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* 自动获取「网关窗口」与「接受的单次输出上限」,两级手段、两份缓存:
|
|
2278
|
+
* ① 读 /v1/models 元数据里的窗口字段(vLLM max_model_len、OpenRouter context_length 等)——免费、毫秒级,写 windowCache;
|
|
2279
|
+
* ② binary=true 时再用极小请求降序试探,第一个不 400 的候选是**网关接受的输出上限**(≠ 窗口,
|
|
2280
|
+
* 实测有网关窗口 262144 但 max_tokens 硬上限 131072)——**会产生一次真实计费补全**,
|
|
2281
|
+
* 所以只由设置面板「探测」按钮触发,聊天路径绝不做(避免首轮偷偷发付费请求 / 与对话抢并发),写 capCache。
|
|
2282
|
+
* 并发去重;普通用户无需手填:留空即自动。
|
|
2283
|
+
*/
|
|
2284
|
+
function probeMaxTokensSupport(cfg, { binary = true } = {}) {
|
|
2285
|
+
const sig = maxTokensSig(cfg)
|
|
2286
|
+
const metaKnown = windowCache.has(sig) || maxTokensMetaMiss.has(sig)
|
|
2287
|
+
const capKnown = capCache.has(sig)
|
|
2288
|
+
if (metaKnown && (!binary || capKnown)) return Promise.resolve(probeSnapshot(sig))
|
|
2289
|
+
const key = `${sig}|${binary ? 'b' : 'm'}`
|
|
2290
|
+
if (maxTokensProbeInflight.has(key)) return maxTokensProbeInflight.get(key)
|
|
2291
|
+
const pending = runMaxTokensProbe(cfg, { binary }).finally(() => maxTokensProbeInflight.delete(key))
|
|
2292
|
+
maxTokensProbeInflight.set(key, pending)
|
|
2293
|
+
return pending
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/** 最多等 ms 拿结果:超时返回 null(探测继续在后台跑并写缓存),避免首轮像卡住 */
|
|
2297
|
+
function settleWithin(promise, ms) {
|
|
2298
|
+
return new Promise((resolve) => {
|
|
2299
|
+
const timer = setTimeout(() => resolve(null), ms)
|
|
2300
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
2301
|
+
promise.then(resolve, () => resolve(null)).finally(() => clearTimeout(timer))
|
|
2302
|
+
})
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
async function runMaxTokensProbe({ baseUrl, apiKey, model }, { binary = true } = {}) {
|
|
2306
|
+
const sig = maxTokensSig({ baseUrl, apiKey, model })
|
|
2307
|
+
const base = String(baseUrl).replace(/\/+$/, '')
|
|
2308
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }
|
|
2309
|
+
let source = ''
|
|
2310
|
+
try {
|
|
2311
|
+
if (maxTokensMetaMiss.has(sig)) throw new Error('meta-miss')
|
|
2312
|
+
const r = await fetch(`${base}/models`, { headers })
|
|
2313
|
+
if (r.ok) {
|
|
2314
|
+
const j = await r.json().catch(() => null)
|
|
2315
|
+
const list = Array.isArray(j && j.data) ? j.data : []
|
|
2316
|
+
const hit = list.find((m) => m && (m.id === model || m.model === model)) || list[0]
|
|
2317
|
+
const meta =
|
|
2318
|
+
hit &&
|
|
2319
|
+
(hit.max_model_len ||
|
|
2320
|
+
hit.context_length ||
|
|
2321
|
+
hit.max_context_len ||
|
|
2322
|
+
hit.max_tokens ||
|
|
2323
|
+
(hit.model_info && hit.model_info.context_length))
|
|
2324
|
+
const n = Number(meta)
|
|
2325
|
+
if (hit && Number.isFinite(n) && n > 0) {
|
|
2326
|
+
windowCache.set(sig, { window: Math.floor(n), source: 'models-meta' })
|
|
2327
|
+
source = 'models-meta'
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
} catch {
|
|
2331
|
+
/* 元数据不可用 → 记住,聊天路径下次不再白跑一趟 */
|
|
2332
|
+
maxTokensMetaMiss.add(sig)
|
|
2333
|
+
}
|
|
2334
|
+
if (binary && !capCache.has(sig)) {
|
|
2335
|
+
for (const cand of MAXTOKENS_CANDIDATES) {
|
|
2336
|
+
try {
|
|
2337
|
+
const r = await fetch(`${base}/chat/completions`, {
|
|
2338
|
+
method: 'POST',
|
|
2339
|
+
headers,
|
|
2340
|
+
body: JSON.stringify({ model, stream: false, max_tokens: cand, messages: [{ role: 'user', content: 'ping' }] }),
|
|
2341
|
+
})
|
|
2342
|
+
if (r.ok) {
|
|
2343
|
+
capCache.set(sig, { cap: cand, source: 'probe' })
|
|
2344
|
+
source = source || 'probe'
|
|
2345
|
+
break
|
|
2346
|
+
}
|
|
2347
|
+
if (r.status !== 400) break // 非 400 的错误(鉴权 / 限流)不该继续试探
|
|
2348
|
+
await r.text().catch(() => '')
|
|
2349
|
+
} catch {
|
|
2350
|
+
break
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
// 只缓存有效结果:探测失败不能把「不知道」写死,否则「探测」按钮之外再也拿不到窗口 / 上限
|
|
2355
|
+
return { ...probeSnapshot(sig), source: source || probeSnapshot(sig).source }
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* 自动模式封顶:探测到的是「上下文窗口」,而 max_tokens 是「单次输出预算」。把整段窗口当输出
|
|
2360
|
+
* 预算既没必要(建卡一轮 32k 绰绰有余),又容易被 vLLM 系网关 400(prompt + max_tokens > 窗口)。
|
|
2361
|
+
*/
|
|
2362
|
+
const AUTO_MAX_TOKENS_CEILING = 32768
|
|
2363
|
+
|
|
2364
|
+
/** 留空(自动)时该用多大:min(接受的输出上限 || 窗口, 封顶);都没探测到就用默认值 */
|
|
2365
|
+
function autoMaxTokens(cap, window) {
|
|
2366
|
+
const n = Number(cap) > 0 ? Number(cap) : Number(window) > 0 ? Number(window) : 0
|
|
2367
|
+
return n > 0 ? Math.min(n, AUTO_MAX_TOKENS_CEILING) : DEFAULT_MAX_TOKENS
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/** 留空(自动)时的实际单次输出上限:探测缓存 → 默认值;已配置则以配置为准(轮级再按已知上限夹取) */
|
|
2371
|
+
function effectiveMaxTokens(cfg) {
|
|
2372
|
+
if (Number(cfg.maxTokens) > 0) return Number(cfg.maxTokens)
|
|
2373
|
+
const snap = probeSnapshot(maxTokensSig(cfg))
|
|
2374
|
+
return autoMaxTokens(snap.cap, snap.window)
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function dataUrlPart(buf, mime) {
|
|
2378
|
+
return { type: 'image_url', image_url: { url: `data:${mime};base64,${buf.toString('base64')}` } }
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/** PNG 超限时最近邻降采样到长边 ≤ VISION_MAX_EDGE(pngjs 缺失 / 解码失败 → 原图或放弃) */
|
|
2382
|
+
async function bufToVisionPart(buf, mime) {
|
|
2383
|
+
let out = buf
|
|
2384
|
+
if (mime === 'image/png' && out.length > VISION_MAX_BYTES) {
|
|
2385
|
+
try {
|
|
2386
|
+
const { PNG } = await import('pngjs')
|
|
2387
|
+
const img = PNG.sync.read(out)
|
|
2388
|
+
const long = Math.max(img.width, img.height)
|
|
2389
|
+
if (long > VISION_MAX_EDGE) {
|
|
2390
|
+
const scale = VISION_MAX_EDGE / long
|
|
2391
|
+
const w = Math.max(1, Math.round(img.width * scale))
|
|
2392
|
+
const h = Math.max(1, Math.round(img.height * scale))
|
|
2393
|
+
const dst = new PNG({ width: w, height: h })
|
|
2394
|
+
for (let y = 0; y < h; y++) {
|
|
2395
|
+
for (let x = 0; x < w; x++) {
|
|
2396
|
+
const sx = Math.min(img.width - 1, Math.floor(x / scale))
|
|
2397
|
+
const sy = Math.min(img.height - 1, Math.floor(y / scale))
|
|
2398
|
+
const si = (sy * img.width + sx) << 2
|
|
2399
|
+
const di = (y * w + x) << 2
|
|
2400
|
+
dst.data[di] = img.data[si]
|
|
2401
|
+
dst.data[di + 1] = img.data[si + 1]
|
|
2402
|
+
dst.data[di + 2] = img.data[si + 2]
|
|
2403
|
+
dst.data[di + 3] = img.data[si + 3]
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
out = PNG.sync.write(dst)
|
|
2407
|
+
}
|
|
2408
|
+
} catch {
|
|
2409
|
+
/* 解码失败保留原图,超 cap 则下面放弃 */
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
if (out.length > VISION_MAX_BYTES) return null
|
|
2413
|
+
return dataUrlPart(out, mime)
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
async function fileToVisionPart(abs) {
|
|
2417
|
+
try {
|
|
2418
|
+
const buf = fs.readFileSync(abs)
|
|
2419
|
+
if (!buf.length || buf.length > RAW_MAX_BYTES) return null
|
|
2420
|
+
const mime = IMAGE_MIME[path.extname(abs).toLowerCase()] || 'image/png'
|
|
2421
|
+
return await bufToVisionPart(buf, mime)
|
|
2422
|
+
} catch {
|
|
2423
|
+
return null
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function safeParseArgs(str) {
|
|
2428
|
+
if (!str || typeof str !== 'string') return {}
|
|
2429
|
+
try {
|
|
2430
|
+
return JSON.parse(str)
|
|
2431
|
+
} catch {
|
|
2432
|
+
return { _raw: str }
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/** 键序无关的稳定序列化:死循环签名靠它,避免「同参数不同书写顺序」逃过检测 */
|
|
2437
|
+
function stableStringify(value) {
|
|
2438
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
2439
|
+
if (value && typeof value === 'object') {
|
|
2440
|
+
return `{${Object.keys(value)
|
|
2441
|
+
.sort()
|
|
2442
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
2443
|
+
.join(',')}}`
|
|
2444
|
+
}
|
|
2445
|
+
return JSON.stringify(value) ?? 'null'
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** 一轮工具调用的指纹:同工具 + 同参数(键序无关)⇒ 指纹相同 */
|
|
2449
|
+
function toolRoundSignature(toolCalls) {
|
|
2450
|
+
return toolCalls.map((tc) => `${tc?.function?.name || '?'}${stableStringify(safeParseArgs(tc?.function?.arguments))}`).join('|')
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// ---- 上下文预算与压缩 ----
|
|
2454
|
+
|
|
2455
|
+
/** 输入上下文默认预算(token 粗估):够装下几万字节卡片片段 + 十几轮对话,又不至于拖慢推理 */
|
|
2456
|
+
const DEFAULT_CONTEXT_TOKENS = 48000
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* 单轮(一次提问内)上下文预算:步数不设硬顶后,工具结果会在轮内一路累积(每条最多 12000 字符),
|
|
2460
|
+
* 几十轮就能把网关窗口顶爆 —— 而 prompt 超窗是直接 400,整轮白跑。超预算时折叠**早期**的超长工具
|
|
2461
|
+
* 结果与注入图像(只缩内容、不删消息,assistant.tool_calls ↔ role:tool 的配对始终合法)。
|
|
2462
|
+
* 探测到网关窗口时按 min(窗口 × 0.7, 本默认值) 收紧,给输出与思考留余量。
|
|
2463
|
+
*/
|
|
2464
|
+
const DEFAULT_TURN_CONTEXT_TOKENS = 96000
|
|
2465
|
+
|
|
2466
|
+
/** 注入图像在预算里的计价(长边 ≤1024 降采样后大致这个量级;无法精确算,取固定成本足够触发折叠) */
|
|
2467
|
+
const IMAGE_TOKEN_COST = 1024
|
|
2468
|
+
|
|
2469
|
+
/** 折叠时每条工具结果保留的头部字符数:够看清「调了什么、结论是什么」,细节让模型重新调工具拿 */
|
|
2470
|
+
const TRIM_STUB_CHARS = 400
|
|
2471
|
+
|
|
2472
|
+
/** 最近多少条消息保持原文(含最新一轮的 assistant/tool 配对),不参与折叠 */
|
|
2473
|
+
const TRIM_KEEP_RECENT = 10
|
|
2474
|
+
|
|
2475
|
+
/** 单次输出上限默认值:需容纳「推理模型的思考 + 一次工具调用的完整参数」 */
|
|
2476
|
+
const DEFAULT_MAX_TOKENS = 24576
|
|
2477
|
+
|
|
2478
|
+
/** 粗估 token:CJK ≈ 1 token/字,其余 ≈ 0.3 token/字符,每条消息再加 4 token 结构开销 */
|
|
2479
|
+
function estTokens(text) {
|
|
2480
|
+
const s = String(text ?? '')
|
|
2481
|
+
let cjk = 0
|
|
2482
|
+
for (let i = 0; i < s.length; i++) {
|
|
2483
|
+
const c = s.charCodeAt(i)
|
|
2484
|
+
if ((c >= 0x3400 && c <= 0x9fff) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xffef)) cjk++
|
|
2485
|
+
}
|
|
2486
|
+
return Math.ceil(cjk + (s.length - cjk) * 0.3) + 4
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function msgTokens(m) {
|
|
2490
|
+
let t = estTokens(m.content)
|
|
2491
|
+
for (const tc of m.toolCalls || []) {
|
|
2492
|
+
t += estTokens(JSON.stringify(tc.args ?? {}))
|
|
2493
|
+
t += estTokens(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result ?? ''))
|
|
2494
|
+
}
|
|
2495
|
+
return t
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
const oneLine = (text, max) => {
|
|
2499
|
+
const line = String(text || '').replace(/\s+/g, ' ').trim()
|
|
2500
|
+
return line.length > max ? `${line.slice(0, max)}…` : line || '(无正文)'
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* 多轮对话的上下文压缩:以「轮」(user 开头到下一个 user 之前)为粒度,
|
|
2505
|
+
* 从后往前保留原文直到预算用尽,更早的轮次折叠成一条 system 摘要。
|
|
2506
|
+
* 按轮折叠保证 assistant.tool_calls 与 role:tool 永远成对保留/成对丢弃,上下文始终合法。
|
|
2507
|
+
* @returns {{messages:any[], droppedTurns:number, keptTokens:number}}
|
|
2508
|
+
*/
|
|
2509
|
+
function compactContext(messages, budgetTokens) {
|
|
2510
|
+
const list = Array.isArray(messages) ? messages : []
|
|
2511
|
+
const turns = []
|
|
2512
|
+
for (const m of list) {
|
|
2513
|
+
if (m.role === 'user' || !turns.length) turns.push([m])
|
|
2514
|
+
else turns[turns.length - 1].push(m)
|
|
2515
|
+
}
|
|
2516
|
+
let used = 0
|
|
2517
|
+
let keepFrom = turns.length
|
|
2518
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
2519
|
+
const t = turns[i].reduce((a, m) => a + msgTokens(m), 0)
|
|
2520
|
+
// 最后一轮无论如何保留(当前任务),其余超预算即止
|
|
2521
|
+
if (used + t > budgetTokens && i < turns.length - 1) break
|
|
2522
|
+
used += t
|
|
2523
|
+
keepFrom = i
|
|
2524
|
+
}
|
|
2525
|
+
if (keepFrom <= 0) return { messages: list, droppedTurns: 0, keptTokens: used }
|
|
2526
|
+
const dropped = turns.slice(0, keepFrom)
|
|
2527
|
+
const lines = []
|
|
2528
|
+
for (const turn of dropped) {
|
|
2529
|
+
for (const m of turn) {
|
|
2530
|
+
if (m.role === 'user') {
|
|
2531
|
+
lines.push(`- 用户:${oneLine(m.content, 90)}`)
|
|
2532
|
+
} else {
|
|
2533
|
+
const tools = (m.toolCalls || []).map((tc) => tc.toolName).filter(Boolean).join('、')
|
|
2534
|
+
lines.push(`- 助手${tools ? `(调用 ${tools})` : ''}:${oneLine(m.content, 90)}${m.status === 'aborted' ? '[被中止]' : ''}`)
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
const digest = {
|
|
2539
|
+
role: 'system',
|
|
2540
|
+
content:
|
|
2541
|
+
`[早期对话压缩摘要:共 ${dropped.length} 轮,不再保留原文;其中的工具结果已丢弃,` +
|
|
2542
|
+
`需要细节请用 card_read / card_grep / fs_read 等工具重新读取,不要凭摘要猜测现状]\n${lines.join('\n')}`,
|
|
2543
|
+
}
|
|
2544
|
+
return { messages: [digest, ...turns.slice(keepFrom).flat()], droppedTurns: dropped.length, keptTokens: used }
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/** OpenAI 形态消息的 token 粗估(content 可能是多模态数组:文本按字数、图像按固定成本) */
|
|
2548
|
+
function openAiMsgTokens(m) {
|
|
2549
|
+
const c = m?.content
|
|
2550
|
+
if (typeof c === 'string') return estTokens(c)
|
|
2551
|
+
if (Array.isArray(c)) {
|
|
2552
|
+
let t = 4
|
|
2553
|
+
for (const part of c) {
|
|
2554
|
+
if (part?.type === 'text') t += estTokens(part.text)
|
|
2555
|
+
else if (part?.type === 'image_url') t += IMAGE_TOKEN_COST
|
|
2556
|
+
}
|
|
2557
|
+
return t
|
|
2558
|
+
}
|
|
2559
|
+
let t = 4
|
|
2560
|
+
for (const tc of m?.tool_calls || []) t += estTokens(tc?.function?.arguments)
|
|
2561
|
+
return t
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* 单轮内上下文裁剪:超预算时从**最早**的消息开始折叠,直到落回预算内。
|
|
2566
|
+
* 只改 content、绝不删消息 —— tool_calls 与 role:'tool' 的配对一旦被破坏,网关会直接 400。
|
|
2567
|
+
* @returns {{messages:any[], trimmed:number, tokens:number, before:number}}
|
|
2568
|
+
*/
|
|
2569
|
+
function trimTurnContext(messages, { budgetTokens = DEFAULT_TURN_CONTEXT_TOKENS, keepRecent = TRIM_KEEP_RECENT, stubChars = TRIM_STUB_CHARS } = {}) {
|
|
2570
|
+
const list = Array.isArray(messages) ? messages.slice() : []
|
|
2571
|
+
const before = list.reduce((a, m) => a + openAiMsgTokens(m), 0)
|
|
2572
|
+
let total = before
|
|
2573
|
+
if (!(budgetTokens > 0) || total <= budgetTokens) return { messages: list, trimmed: 0, tokens: total, before }
|
|
2574
|
+
// index 0 是系统提示,永远保留;最近 keepRecent 条也保持原文(当前工作记忆)
|
|
2575
|
+
const floor = Math.max(1, list.length - keepRecent)
|
|
2576
|
+
let trimmed = 0
|
|
2577
|
+
// ① 折叠早期超长工具结果:留下头部 + 明确的「重新调用可取全文」指引
|
|
2578
|
+
for (let i = 1; i < floor && total > budgetTokens; i++) {
|
|
2579
|
+
const m = list[i]
|
|
2580
|
+
if (m?.role !== 'tool' || typeof m.content !== 'string' || m.content.length <= stubChars * 2) continue
|
|
2581
|
+
const prev = openAiMsgTokens(m)
|
|
2582
|
+
list[i] = {
|
|
2583
|
+
...m,
|
|
2584
|
+
content: `${m.content.slice(0, stubChars)}…\n[工具结果已折叠以腾出单轮上下文预算;需要完整内容请重新调用该工具,不要凭这段摘要猜结论]`,
|
|
2585
|
+
}
|
|
2586
|
+
total -= prev - openAiMsgTokens(list[i])
|
|
2587
|
+
trimmed++
|
|
2588
|
+
}
|
|
2589
|
+
// ② 仍超预算:丢掉早期注入图像(图 token 很贵),保留其文字说明
|
|
2590
|
+
for (let i = 1; i < floor && total > budgetTokens; i++) {
|
|
2591
|
+
const m = list[i]
|
|
2592
|
+
if (m?.role !== 'user' || !Array.isArray(m.content) || !m.content.some((part) => part?.type === 'image_url')) continue
|
|
2593
|
+
const prev = openAiMsgTokens(m)
|
|
2594
|
+
const texts = m.content.filter((part) => part?.type === 'text').map((part) => part.text)
|
|
2595
|
+
list[i] = { ...m, content: `${texts.join(' ')}\n[图像已折叠以腾出单轮上下文预算;需要看图请重新跑一次渲染 / 对比工具]` }
|
|
2596
|
+
total -= prev - openAiMsgTokens(list[i])
|
|
2597
|
+
trimmed++
|
|
2598
|
+
}
|
|
2599
|
+
return { messages: list, trimmed, tokens: total, before }
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/** 深度压缩后最近多少条保持原文(含进行中的 assistant/tool 配对与最新证据) */
|
|
2603
|
+
const DEEP_KEEP_RECENT = 8
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* 单轮深度压缩:折叠工具结果仍超预算(或网关已报上下文 400)时的第二道闸。
|
|
2607
|
+
* 以「assistant(tool_calls) + 紧随的全部 role:tool 结果」为整组从最早开始丢弃,换成一条 system 摘要——
|
|
2608
|
+
* 整组丢弃保证 tool_calls ↔ tool 配对协议始终合法;系统提示、本轮首条 user(任务本体)与
|
|
2609
|
+
* 最近 DEEP_KEEP_RECENT 条永远保留,压缩后模型仍知道「这轮在做什么、刚做到哪」,循环继续不中断任务。
|
|
2610
|
+
* @returns {{messages:any[], droppedGroups:number, tokens:number, before:number}}
|
|
2611
|
+
*/
|
|
2612
|
+
function deepCompactTurn(messages, budgetTokens, keepRecent = DEEP_KEEP_RECENT, force = false) {
|
|
2613
|
+
const list = Array.isArray(messages) ? messages.slice() : []
|
|
2614
|
+
const before = list.reduce((a, m) => a + openAiMsgTokens(m), 0)
|
|
2615
|
+
if (list.length <= keepRecent + 2) return { messages: list, droppedGroups: 0, tokens: before, before }
|
|
2616
|
+
const floor = list.length - keepRecent
|
|
2617
|
+
const groups = []
|
|
2618
|
+
for (let i = 2; i < floor; i++) {
|
|
2619
|
+
const m = list[i]
|
|
2620
|
+
if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls) || !m.tool_calls.length) continue
|
|
2621
|
+
let j = i + 1
|
|
2622
|
+
while (j < floor && list[j]?.role === 'tool') j++
|
|
2623
|
+
// 只丢「tool_calls 与 tool 结果齐全」的整组:丢半组会留下悬空 tool_calls,网关直接 400
|
|
2624
|
+
if (j - (i + 1) === m.tool_calls.length) groups.push([i, j])
|
|
2625
|
+
i = j - 1
|
|
2626
|
+
}
|
|
2627
|
+
let total = before
|
|
2628
|
+
let dropped = 0
|
|
2629
|
+
const lines = []
|
|
2630
|
+
const removed = new Set()
|
|
2631
|
+
for (const [a, b] of groups) {
|
|
2632
|
+
if (total <= budgetTokens) break
|
|
2633
|
+
const names = list[a].tool_calls.map((tc) => tc?.function?.name || '?').join('+')
|
|
2634
|
+
const stub = String(list[a + 1]?.content || '').replace(/\s+/g, ' ').slice(0, 80)
|
|
2635
|
+
lines.push(`- 丢弃调用 ${names}:结果头部「${stub}」`)
|
|
2636
|
+
for (let k = a; k < b; k++) {
|
|
2637
|
+
total -= openAiMsgTokens(list[k])
|
|
2638
|
+
removed.add(k)
|
|
2639
|
+
}
|
|
2640
|
+
dropped++
|
|
2641
|
+
}
|
|
2642
|
+
if (!dropped) return { messages: list, droppedGroups: 0, tokens: total, before }
|
|
2643
|
+
// 全丢也到不了预算(如系统提示本身就超):主动路径不空丢(留给折叠与网关 400 自愈),
|
|
2644
|
+
// force(网关已报溢出)才照丢——能减一点是一点
|
|
2645
|
+
if (total > budgetTokens && !force) return { messages: list, droppedGroups: 0, tokens: total, before }
|
|
2646
|
+
const digest = {
|
|
2647
|
+
role: 'system',
|
|
2648
|
+
content:
|
|
2649
|
+
`[单轮上下文深度压缩:已丢弃最早 ${dropped} 组工具调用及其结果,仅留下面摘要;` +
|
|
2650
|
+
`需要细节请重新调用对应工具取,不要凭摘要猜测现状]\n${lines.join('\n')}`,
|
|
2651
|
+
}
|
|
2652
|
+
const kept = list.filter((_, i) => !removed.has(i))
|
|
2653
|
+
const out = [kept[0], kept[1], digest, ...kept.slice(2)]
|
|
2654
|
+
return { messages: out, droppedGroups: dropped, tokens: total + openAiMsgTokens(digest), before }
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* 把前端持久化的消息(含 reasoning / toolCalls)还原成 OpenAI messages。
|
|
2659
|
+
* 关键:assistant 的 tool_calls 必须紧跟对应的 role:'tool' 结果,否则续聊时上下文非法。
|
|
2660
|
+
*/
|
|
2661
|
+
function toOpenAiMessages(messages) {
|
|
2662
|
+
const out = []
|
|
2663
|
+
for (const m of messages || []) {
|
|
2664
|
+
if (m.role === 'user') {
|
|
2665
|
+
out.push({ role: 'user', content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '') })
|
|
2666
|
+
continue
|
|
2667
|
+
}
|
|
2668
|
+
if (m.role === 'assistant') {
|
|
2669
|
+
const calls = Array.isArray(m.toolCalls) ? m.toolCalls : []
|
|
2670
|
+
const msg = { role: 'assistant', content: m.content || (calls.length ? null : '') }
|
|
2671
|
+
if (calls.length) {
|
|
2672
|
+
msg.tool_calls = calls.map((tc, i) => ({
|
|
2673
|
+
id: tc.toolCallId || tc.id || `call_${i}`,
|
|
2674
|
+
type: 'function',
|
|
2675
|
+
function: { name: tc.toolName || tc.name, arguments: JSON.stringify(tc.args ?? {}) },
|
|
2676
|
+
}))
|
|
2677
|
+
}
|
|
2678
|
+
out.push(msg)
|
|
2679
|
+
for (const tc of calls) {
|
|
2680
|
+
const result = tc.error ? `Error: ${tc.error}` : typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result ?? '')
|
|
2681
|
+
out.push({ role: 'tool', tool_call_id: tc.toolCallId || tc.id || `call_${calls.indexOf(tc)}`, content: result.slice(0, 12000) })
|
|
2682
|
+
}
|
|
2683
|
+
continue
|
|
2684
|
+
}
|
|
2685
|
+
if (m.role === 'system') out.push({ role: 'system', content: String(m.content || '') })
|
|
2686
|
+
}
|
|
2687
|
+
return out
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/**
|
|
2691
|
+
* 创建 AI 开发助手中间件。
|
|
2692
|
+
* @param {{ root?: string }} [opts]
|
|
2693
|
+
*/
|
|
2694
|
+
export function createAiChatMiddleware(opts = {}) {
|
|
2695
|
+
const root = opts.root ? path.resolve(opts.root) : REPO_ROOT
|
|
2696
|
+
/** 对话记录落盘目录(.cache/ 已在 .gitignore):随 root 走,测试可用临时根隔离 */
|
|
2697
|
+
const chatDir = path.join(root, '.cache', 'ai-chats')
|
|
2698
|
+
|
|
2699
|
+
return async (req, res, next) => {
|
|
2700
|
+
const rawUrl = req.url || ''
|
|
2701
|
+
const qIdx = rawUrl.indexOf('?')
|
|
2702
|
+
const pathname = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl
|
|
2703
|
+
if (!pathname.startsWith('/__ai/')) return next()
|
|
2704
|
+
const query = new URLSearchParams(qIdx >= 0 ? rawUrl.slice(qIdx + 1) : '')
|
|
2705
|
+
|
|
2706
|
+
// 只放行可信来源(回环 / 局域网 / 本机网卡地址)的跨源请求;公网页面(含嵌入 iframe 的第三方站点)一律 403
|
|
2707
|
+
const origin = req.headers.origin
|
|
2708
|
+
if (origin && !trustedOrigin(req)) {
|
|
2709
|
+
return sendJson(req, res, 403, { error: 'origin-not-allowed' })
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
if (req.method === 'OPTIONS') {
|
|
2713
|
+
res.statusCode = 204
|
|
2714
|
+
const allow = trustedOrigin(req)
|
|
2715
|
+
if (allow) {
|
|
2716
|
+
res.setHeader('Access-Control-Allow-Origin', allow)
|
|
2717
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-dev-token')
|
|
2718
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
|
2719
|
+
}
|
|
2720
|
+
return res.end()
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// ---------- 模型配置 ----------
|
|
2724
|
+
if (pathname === '/__ai/config') {
|
|
2725
|
+
if (req.method === 'GET') {
|
|
2726
|
+
const cfg = readAiConfig(root)
|
|
2727
|
+
const out = {
|
|
2728
|
+
baseUrl: cfg.baseUrl,
|
|
2729
|
+
model: cfg.model,
|
|
2730
|
+
hasKey: Boolean(cfg.apiKey),
|
|
2731
|
+
apiKeyMasked: maskKey(cfg.apiKey),
|
|
2732
|
+
shellEnabled: Boolean(cfg.shellAllowed),
|
|
2733
|
+
// 免确认策略(none / skill / all):面板拆成「技能脚本免确认」「本地命令免确认」两个开关
|
|
2734
|
+
autoApprove: cfg.autoApprove,
|
|
2735
|
+
// maxTokens = 本轮实际会用的值;maxTokensAuto = 用户没填(面板据此留空 + 显示自动值)
|
|
2736
|
+
maxTokens: effectiveMaxTokens(cfg),
|
|
2737
|
+
maxTokensAuto: !(cfg.maxTokens > 0),
|
|
2738
|
+
maxTokensCap: capCache.get(maxTokensSig(cfg))?.cap || 0,
|
|
2739
|
+
maxTokensWindow: windowCache.get(maxTokensSig(cfg))?.window || 0,
|
|
2740
|
+
thinking: cfg.thinking !== false,
|
|
2741
|
+
vision: cfg.vision === true,
|
|
2742
|
+
// 0 = 不限步数(默认);>0 = 用户自设硬顶。面板据此留空并显示「不限 + 熔断器」说明
|
|
2743
|
+
maxSteps: cfg.maxSteps,
|
|
2744
|
+
turnTimeoutMs: cfg.turnTimeoutMs,
|
|
2745
|
+
}
|
|
2746
|
+
// 设置面板要「回显」已存密钥:仅 ?reveal=1 回明文(本系列端点只放行可信来源)
|
|
2747
|
+
if (query.get('reveal') === '1') out.apiKey = cfg.apiKey
|
|
2748
|
+
return sendJson(req, res, 200, out)
|
|
2749
|
+
}
|
|
2750
|
+
if (req.method === 'POST') {
|
|
2751
|
+
let body
|
|
2752
|
+
try {
|
|
2753
|
+
body = await readBody(req)
|
|
2754
|
+
} catch {
|
|
2755
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
2756
|
+
}
|
|
2757
|
+
try {
|
|
2758
|
+
// apiKey 为空串 = 清除本地覆盖;未传(undefined)= 保持不变
|
|
2759
|
+
// 设置变更(换网关 / 换模型 / 换密钥 / 动视觉开关)→ 视觉探测结论作废,下轮重探
|
|
2760
|
+
visionProbeCache.clear()
|
|
2761
|
+
writeAiConfigLocal(root, {
|
|
2762
|
+
baseUrl: body.baseUrl,
|
|
2763
|
+
apiKey: body.apiKey === '' ? '' : body.apiKey,
|
|
2764
|
+
model: body.model,
|
|
2765
|
+
allowShell: body.allowShell === undefined ? undefined : Boolean(body.allowShell),
|
|
2766
|
+
maxTokens: body.maxTokens === undefined ? undefined : body.maxTokens,
|
|
2767
|
+
thinking: body.thinking === undefined ? undefined : Boolean(body.thinking),
|
|
2768
|
+
vision: body.vision === undefined ? undefined : Boolean(body.vision),
|
|
2769
|
+
autoApprove: body.autoApprove === undefined ? undefined : normalizeAutoApprove(body.autoApprove),
|
|
2770
|
+
maxSteps: body.maxSteps === undefined ? undefined : body.maxSteps,
|
|
2771
|
+
})
|
|
2772
|
+
const cfg = readAiConfig(root)
|
|
2773
|
+
return sendJson(req, res, 200, {
|
|
2774
|
+
ok: true,
|
|
2775
|
+
baseUrl: cfg.baseUrl,
|
|
2776
|
+
model: cfg.model,
|
|
2777
|
+
hasKey: Boolean(cfg.apiKey),
|
|
2778
|
+
apiKeyMasked: maskKey(cfg.apiKey),
|
|
2779
|
+
shellEnabled: Boolean(cfg.shellAllowed),
|
|
2780
|
+
autoApprove: cfg.autoApprove,
|
|
2781
|
+
maxTokens: effectiveMaxTokens(cfg),
|
|
2782
|
+
maxTokensAuto: !(cfg.maxTokens > 0),
|
|
2783
|
+
maxTokensCap: capCache.get(maxTokensSig(cfg))?.cap || 0,
|
|
2784
|
+
maxTokensWindow: windowCache.get(maxTokensSig(cfg))?.window || 0,
|
|
2785
|
+
thinking: cfg.thinking !== false,
|
|
2786
|
+
vision: cfg.vision === true,
|
|
2787
|
+
maxSteps: cfg.maxSteps,
|
|
2788
|
+
turnTimeoutMs: cfg.turnTimeoutMs,
|
|
2789
|
+
})
|
|
2790
|
+
} catch (e) {
|
|
2791
|
+
return sendJson(req, res, 500, { error: 'write-failed', message: String((e && e.message) || e) })
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// ---------- 单次输出上限探测(设置面板「探测」按钮)----------
|
|
2798
|
+
if (pathname === '/__ai/probe-maxtokens') {
|
|
2799
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2800
|
+
const cfg = readAiConfig(root)
|
|
2801
|
+
if (!cfg.baseUrl || !cfg.apiKey || !cfg.model) {
|
|
2802
|
+
return sendJson(req, res, 400, { error: 'missing-config', message: '请先填写 Base URL / API Key / 模型名' })
|
|
2803
|
+
}
|
|
2804
|
+
capCache.delete(maxTokensSig(cfg))
|
|
2805
|
+
windowCache.delete(maxTokensSig(cfg))
|
|
2806
|
+
maxTokensMetaMiss.delete(maxTokensSig(cfg))
|
|
2807
|
+
const probed = await probeMaxTokensSupport(cfg)
|
|
2808
|
+
// maxTokens = 留空时后端真正会用的值(封顶后);window = 上下文窗口(仅元数据);cap = 接受的单次输出上限(试探 / 减半)
|
|
2809
|
+
return sendJson(req, res, 200, {
|
|
2810
|
+
ok: true,
|
|
2811
|
+
maxTokens: autoMaxTokens(probed.cap, probed.window),
|
|
2812
|
+
window: probed.window,
|
|
2813
|
+
cap: probed.cap,
|
|
2814
|
+
ceiling: AUTO_MAX_TOKENS_CEILING,
|
|
2815
|
+
source: probed.source || 'fallback',
|
|
2816
|
+
})
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// ---------- 连通性自测(设置面板「测试连接」)----------
|
|
2820
|
+
if (pathname === '/__ai/ping') {
|
|
2821
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2822
|
+
let body
|
|
2823
|
+
try {
|
|
2824
|
+
body = await readBody(req, 64 * 1024)
|
|
2825
|
+
} catch {
|
|
2826
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
2827
|
+
}
|
|
2828
|
+
const cfg = readAiConfig(root)
|
|
2829
|
+
const baseUrl = String(body.baseUrl || cfg.baseUrl || '')
|
|
2830
|
+
const model = String(body.model || cfg.model || '')
|
|
2831
|
+
// 前端不持有明文 key:留空即用服务端已配置值
|
|
2832
|
+
const apiKey = String(body.apiKey || cfg.apiKey || '')
|
|
2833
|
+
if (!baseUrl || !apiKey || !model) {
|
|
2834
|
+
return sendJson(req, res, 400, { error: 'missing-config', message: '请先填写 Base URL / API Key / 模型名' })
|
|
2835
|
+
}
|
|
2836
|
+
const started = Date.now()
|
|
2837
|
+
const ctl = new AbortController()
|
|
2838
|
+
const timer = setTimeout(() => ctl.abort(), 20000)
|
|
2839
|
+
try {
|
|
2840
|
+
const resp = await fetch(baseUrl.replace(/\/$/, '') + '/chat/completions', {
|
|
2841
|
+
method: 'POST',
|
|
2842
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
2843
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 8, stream: false }),
|
|
2844
|
+
signal: ctl.signal,
|
|
2845
|
+
})
|
|
2846
|
+
const latencyMs = Date.now() - started
|
|
2847
|
+
if (!resp.ok) {
|
|
2848
|
+
const text = await resp.text().catch(() => '')
|
|
2849
|
+
return sendJson(req, res, 200, { ok: false, status: resp.status, latencyMs, message: text.slice(0, 300) })
|
|
2850
|
+
}
|
|
2851
|
+
const json = await resp.json().catch(() => ({}))
|
|
2852
|
+
return sendJson(req, res, 200, {
|
|
2853
|
+
ok: true,
|
|
2854
|
+
latencyMs,
|
|
2855
|
+
model: json.model || model,
|
|
2856
|
+
reply: String(json.choices?.[0]?.message?.content || '').slice(0, 80),
|
|
2857
|
+
})
|
|
2858
|
+
} catch (e) {
|
|
2859
|
+
return sendJson(req, res, 200, { ok: false, latencyMs: Date.now() - started, message: String((e && e.message) || e).slice(0, 300) })
|
|
2860
|
+
} finally {
|
|
2861
|
+
clearTimeout(timer)
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// ---------- 对话记录 ----------
|
|
2866
|
+
if (pathname === '/__ai/history') {
|
|
2867
|
+
const cardId = query.get('cardId') || ''
|
|
2868
|
+
if (req.method === 'GET') {
|
|
2869
|
+
if (query.get('list') === '1') return sendJson(req, res, 200, { items: listHistories(chatDir) })
|
|
2870
|
+
if (!safeCardId(cardId)) return sendJson(req, res, 400, { error: 'missing-card-id' })
|
|
2871
|
+
return sendJson(req, res, 200, readHistory(chatDir, cardId))
|
|
2872
|
+
}
|
|
2873
|
+
if (req.method === 'POST') {
|
|
2874
|
+
let body
|
|
2875
|
+
try {
|
|
2876
|
+
body = await readBody(req)
|
|
2877
|
+
} catch {
|
|
2878
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
2879
|
+
}
|
|
2880
|
+
const id = body.cardId || cardId
|
|
2881
|
+
if (!safeCardId(id)) return sendJson(req, res, 400, { error: 'missing-card-id' })
|
|
2882
|
+
try {
|
|
2883
|
+
return sendJson(req, res, 200, writeHistory(chatDir, id, body.messages))
|
|
2884
|
+
} catch (e) {
|
|
2885
|
+
return sendJson(req, res, 500, { error: 'write-failed', message: String((e && e.message) || e) })
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
if (req.method === 'DELETE') {
|
|
2889
|
+
let cardIds = [cardId]
|
|
2890
|
+
if (req.method === 'DELETE' && !cardId) {
|
|
2891
|
+
try {
|
|
2892
|
+
const body = await readBody(req, 1024 * 1024)
|
|
2893
|
+
if (Array.isArray(body.cardIds)) cardIds = body.cardIds
|
|
2894
|
+
} catch {
|
|
2895
|
+
/* 无 body 时按单卡处理 */
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
const failed = []
|
|
2899
|
+
for (const id of cardIds) {
|
|
2900
|
+
const r = deleteHistory(chatDir, id)
|
|
2901
|
+
if (!r.ok) failed.push({ cardId: id, error: r.error })
|
|
2902
|
+
}
|
|
2903
|
+
return sendJson(req, res, failed.length ? 207 : 200, { ok: !failed.length, failed })
|
|
2904
|
+
}
|
|
2905
|
+
return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// ---------- 本地文件挂载 ----------
|
|
2909
|
+
if (pathname === '/__ai/fs/pick') {
|
|
2910
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2911
|
+
let body
|
|
2912
|
+
try {
|
|
2913
|
+
body = await readBody(req, 64 * 1024)
|
|
2914
|
+
} catch {
|
|
2915
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
2916
|
+
}
|
|
2917
|
+
try {
|
|
2918
|
+
const picked = await pickPaths({ kind: body.kind || 'file', multiple: body.multiple !== false })
|
|
2919
|
+
const items = (picked.paths || []).map((p) => describePath(p)).filter((i) => i.exists)
|
|
2920
|
+
return sendJson(req, res, 200, { cancelled: Boolean(picked.cancelled), items })
|
|
2921
|
+
} catch (e) {
|
|
2922
|
+
const code = e?.code === 'unsupported' ? 501 : 500
|
|
2923
|
+
return sendJson(req, res, code, { error: 'pick-failed', message: String((e && e.message) || e) })
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
if (pathname === '/__ai/fs/stat') {
|
|
2928
|
+
if (req.method !== 'GET') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2929
|
+
const target = query.get('path') || ''
|
|
2930
|
+
if (!target) return sendJson(req, res, 400, { error: 'missing-path' })
|
|
2931
|
+
const info = describePath(target)
|
|
2932
|
+
if (!info.exists) return sendJson(req, res, 404, { error: 'not-found', path: info.path })
|
|
2933
|
+
return sendJson(req, res, 200, info)
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (pathname === '/__ai/fs/raw') {
|
|
2937
|
+
if (req.method !== 'GET') {
|
|
2938
|
+
res.statusCode = 405
|
|
2939
|
+
return res.end()
|
|
2940
|
+
}
|
|
2941
|
+
const target = query.get('path') || ''
|
|
2942
|
+
const abs = path.resolve(expandHome(target))
|
|
2943
|
+
const ext = path.extname(abs).toLowerCase()
|
|
2944
|
+
if (!target || !IMAGE_EXT.has(ext)) {
|
|
2945
|
+
res.statusCode = 400
|
|
2946
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
2947
|
+
return res.end('unsupported-media')
|
|
2948
|
+
}
|
|
2949
|
+
let st
|
|
2950
|
+
try {
|
|
2951
|
+
st = fs.statSync(abs)
|
|
2952
|
+
} catch {
|
|
2953
|
+
res.statusCode = 404
|
|
2954
|
+
return res.end('not-found')
|
|
2955
|
+
}
|
|
2956
|
+
if (!st.isFile() || st.size > RAW_MAX_BYTES) {
|
|
2957
|
+
res.statusCode = 413
|
|
2958
|
+
return res.end('too-large')
|
|
2959
|
+
}
|
|
2960
|
+
res.statusCode = 200
|
|
2961
|
+
res.setHeader('Content-Type', IMAGE_MIME[ext] || 'application/octet-stream')
|
|
2962
|
+
res.setHeader('Content-Length', String(st.size))
|
|
2963
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
2964
|
+
const allow = trustedOrigin(req)
|
|
2965
|
+
if (allow) res.setHeader('Access-Control-Allow-Origin', allow)
|
|
2966
|
+
fs.createReadStream(abs).pipe(res)
|
|
2967
|
+
return
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
if (pathname === '/__ai/fs/stash') {
|
|
2971
|
+
// 剪贴板粘贴的截图没有本机路径:落到 .cache/ai-attachments/ 后按「本地文件」同等对待
|
|
2972
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
2973
|
+
let body
|
|
2974
|
+
try {
|
|
2975
|
+
body = await readBody(req, 24 * 1024 * 1024)
|
|
2976
|
+
} catch {
|
|
2977
|
+
return sendJson(req, res, 413, { error: 'too-large' })
|
|
2978
|
+
}
|
|
2979
|
+
const dataUrl = String(body.dataUrl || '')
|
|
2980
|
+
const m = dataUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i)
|
|
2981
|
+
if (!m) return sendJson(req, res, 400, { error: 'invalid-image' })
|
|
2982
|
+
const ext = (m[1].split('/')[1] || 'png').replace('svg+xml', 'svg').toLowerCase()
|
|
2983
|
+
if (!IMAGE_EXT.has(`.${ext}`)) return sendJson(req, res, 400, { error: 'unsupported-media' })
|
|
2984
|
+
try {
|
|
2985
|
+
const buf = Buffer.from(m[2], 'base64')
|
|
2986
|
+
if (!buf.length) return sendJson(req, res, 400, { error: 'empty-image' })
|
|
2987
|
+
if (buf.length > RAW_MAX_BYTES) return sendJson(req, res, 413, { error: 'too-large' })
|
|
2988
|
+
const dir = path.join(root, '.cache', 'ai-attachments')
|
|
2989
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
2990
|
+
const file = path.join(dir, `paste-${Date.now()}.${ext}`)
|
|
2991
|
+
fs.writeFileSync(file, buf)
|
|
2992
|
+
return sendJson(req, res, 200, describePath(file))
|
|
2993
|
+
} catch (e) {
|
|
2994
|
+
return sendJson(req, res, 500, { error: 'stash-failed', message: String((e && e.message) || e) })
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
if (pathname === '/__ai/fs/locate') {
|
|
2999
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
3000
|
+
let body
|
|
3001
|
+
try {
|
|
3002
|
+
body = await readBody(req, 256 * 1024)
|
|
3003
|
+
} catch {
|
|
3004
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
3005
|
+
}
|
|
3006
|
+
const found = await locateDropped({
|
|
3007
|
+
name: body.name,
|
|
3008
|
+
relPath: body.relPath || '',
|
|
3009
|
+
size: Number.isFinite(Number(body.size)) ? Number(body.size) : -1,
|
|
3010
|
+
})
|
|
3011
|
+
if (!found) return sendJson(req, res, 404, { error: 'not-located' })
|
|
3012
|
+
const info = describePath(found)
|
|
3013
|
+
return sendJson(req, res, 200, info)
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
// ---------- 对话内审批回落 ----------
|
|
3017
|
+
if (pathname === '/__ai/approve') {
|
|
3018
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
3019
|
+
let body
|
|
3020
|
+
try {
|
|
3021
|
+
body = await readBody(req, 64 * 1024)
|
|
3022
|
+
} catch {
|
|
3023
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
3024
|
+
}
|
|
3025
|
+
const settled = settleApproval(body.approvalId, body.ok !== false, body.scope === 'turn' ? 'turn' : 'once')
|
|
3026
|
+
return sendJson(req, res, settled ? 200 : 404, { ok: settled, error: settled ? undefined : 'approval-not-found' })
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// ---------- 流式对话 ----------
|
|
3030
|
+
if (pathname !== '/__ai/chat') return sendJson(req, res, 404, { error: 'unknown-endpoint' })
|
|
3031
|
+
if (req.method !== 'POST') return sendJson(req, res, 405, { error: 'method-not-allowed' })
|
|
3032
|
+
|
|
3033
|
+
let body
|
|
3034
|
+
try {
|
|
3035
|
+
body = await readBody(req)
|
|
3036
|
+
} catch {
|
|
3037
|
+
return sendJson(req, res, 400, { error: 'invalid-body' })
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
const { messages = [], config = {}, cardContext, attachments = [] } = body
|
|
3041
|
+
const defaults = readAiConfig(root)
|
|
3042
|
+
const baseUrl = config.baseUrl || defaults.baseUrl
|
|
3043
|
+
const apiKey = config.apiKey || defaults.apiKey
|
|
3044
|
+
const model = config.model || defaults.model
|
|
3045
|
+
// 单次输出上限:推理模型的 reasoning 也算 completion,8192 实测不够建一张卡(思考 2 万字就把
|
|
3046
|
+
// 额度吃光,工具参数还没开始吐就被截断)。留空 = 自动:min(网关窗口, 32768);窗口只读免费的
|
|
3047
|
+
// /v1/models 元数据(最多等 2s,超时先用默认值开跑,结果留给后续轮次),读不到就用默认 24576;
|
|
3048
|
+
// 真被网关 400 拒绝还有减半重试兜底并回写窗口认知 —— 普通用户全程不用管这一项。
|
|
3049
|
+
const maxTokensAuto = !(Number(config.maxTokens) > 0) && !(defaults.maxTokens > 0)
|
|
3050
|
+
// 窗口认知与 maxTokens 是否「自动」无关:/v1/models 元数据免费、带缓存、最多等 2s,始终取一次——
|
|
3051
|
+
// 单轮上下文预算与输入框圆环的分母都靠它;手填了 max_tokens 也不该失去窗口认知
|
|
3052
|
+
const probed =
|
|
3053
|
+
(await settleWithin(probeMaxTokensSupport({ baseUrl, apiKey, model }, { binary: false }), MAXTOKENS_PROBE_WAIT_MS)) || null
|
|
3054
|
+
const finalConfig = {
|
|
3055
|
+
baseUrl,
|
|
3056
|
+
apiKey,
|
|
3057
|
+
model,
|
|
3058
|
+
maxTokens: maxTokensAuto
|
|
3059
|
+
? autoMaxTokens(probed?.cap || 0, probed?.window || 0)
|
|
3060
|
+
: Number(config.maxTokens) || defaults.maxTokens,
|
|
3061
|
+
// 单轮步数硬顶(0 = 不限):本轮请求 > 设置/环境变量;此前这里合并完却在循环里读原始 config,
|
|
3062
|
+
// 导致 AI_MAX_STEPS 形同虚设,现已统一走 finalConfig
|
|
3063
|
+
maxSteps: Number(config.maxSteps) > 0 ? Number(config.maxSteps) : defaults.maxSteps,
|
|
3064
|
+
// 单轮墙钟上限(毫秒):不限步数后的失控兜底
|
|
3065
|
+
turnTimeoutMs: Number(config.turnTimeoutMs) > 0 ? Number(config.turnTimeoutMs) : defaults.turnTimeoutMs,
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// 手填值按「网关接受的单次输出上限」主动夹取:与其等网关 400 再减半(白白失败一次请求),
|
|
3069
|
+
// 不如开跑前就夹好并在对话里说明;上限未知(没试探 / 没减半过)时仍保留减半兜底
|
|
3070
|
+
let clampNotice = ''
|
|
3071
|
+
const knownCap = capCache.get(maxTokensSig(finalConfig))?.cap || 0
|
|
3072
|
+
if (!maxTokensAuto && knownCap > 0 && finalConfig.maxTokens > knownCap) {
|
|
3073
|
+
clampNotice = `手填单次输出上限 ${finalConfig.maxTokens} 超过网关接受上限 ${knownCap}:本轮已夹取为 ${knownCap}(设置里留空即走自动)`
|
|
3074
|
+
finalConfig.maxTokens = knownCap
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
if (!finalConfig.baseUrl || !finalConfig.apiKey || !finalConfig.model) {
|
|
3078
|
+
return sendJson(req, res, 400, {
|
|
3079
|
+
error: 'missing-config',
|
|
3080
|
+
message: '未配置 AI 模型:请在设置面板填写 Base URL / API Key / 模型名(保存在 .env.cb.local)',
|
|
3081
|
+
})
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// 多模态视觉:开关开启 → 先探测网关是否真支持图像(带缓存);不支持自动降级并在对话里提示一次
|
|
3085
|
+
const visionWanted = config.vision === undefined ? defaults.vision === true : Boolean(config.vision)
|
|
3086
|
+
const visionProbe = visionWanted ? await probeVisionSupport(finalConfig) : { ok: false, reason: '' }
|
|
3087
|
+
const visionOn = visionProbe.ok
|
|
3088
|
+
|
|
3089
|
+
let tools, dispatch, systemPrompt
|
|
3090
|
+
try {
|
|
3091
|
+
;({ tools, dispatch } = await loadTools())
|
|
3092
|
+
systemPrompt = buildSystemPrompt({
|
|
3093
|
+
shellAllowed: Boolean(defaults.shellAllowed),
|
|
3094
|
+
vision: visionOn,
|
|
3095
|
+
autoApprove: defaults.autoApprove,
|
|
3096
|
+
})
|
|
3097
|
+
} catch (e) {
|
|
3098
|
+
return sendJson(req, res, 500, { error: 'tools-load-failed', message: String((e && e.message) || e) })
|
|
3099
|
+
}
|
|
3100
|
+
// skill_exec 常驻注册:它只能跑 skills/*/scripts/ 下的脚本(路径形状硬校验、参数不经 shell),
|
|
3101
|
+
// 是「设计稿 → sketch-spec-to-card 前处理 → dsl-card-generator 生成」链路唯一的执行手段;
|
|
3102
|
+
// 默认策略下免确认,一轮建卡十几次脚本调用才不会被打断成手工点「允许」
|
|
3103
|
+
tools = [
|
|
3104
|
+
...tools,
|
|
3105
|
+
SKILL_READ_TOOL,
|
|
3106
|
+
CARD_GREP_TOOL,
|
|
3107
|
+
CARD_PATCH_TOOL,
|
|
3108
|
+
FS_READ_TOOL,
|
|
3109
|
+
FS_LIST_TOOL,
|
|
3110
|
+
FS_WRITE_TOOL,
|
|
3111
|
+
FS_COPY_TOOL,
|
|
3112
|
+
skillExecTool(autoApproves(defaults.autoApprove, 'skill_exec')),
|
|
3113
|
+
]
|
|
3114
|
+
// shell 是高危能力:默认不注册;开启后是否逐次确认看策略(默认 all 之外仍逐次问)
|
|
3115
|
+
if (defaults.shellAllowed) tools = [...tools, shellTool(autoApproves(defaults.autoApprove, 'shell_exec'))]
|
|
3116
|
+
/** 本轮已获「不再询问」授权的工具名(scope=turn):管线连跑多步时不必逐步点确认 */
|
|
3117
|
+
const approvedTools = new Set()
|
|
3118
|
+
|
|
3119
|
+
let contextPrompt = systemPrompt
|
|
3120
|
+
// 只注入「当前卡片 ID」:此前额外塞 8KB schema 摘要,与模型自己 card_read 的全文重复,
|
|
3121
|
+
// 实测推理模型会在思考里反复背诵这份摘要而空转不收敛
|
|
3122
|
+
if (cardContext?.id) {
|
|
3123
|
+
contextPrompt += `\n\n## 当前卡片上下文\n\n用户正在编辑卡片 ID: ${cardContext.id};现状以 card_read / card_node_models 的结果为准,不要凭记忆猜测。`
|
|
3124
|
+
}
|
|
3125
|
+
/** 本轮是否「设计稿建卡」流程(由附件路由判定得出,用于思考熔断催促时附加 IR 路线指引) */
|
|
3126
|
+
let designFlow = false
|
|
3127
|
+
// 用户挂载的目录(或文件所在目录)纳入 fs_read / fs_list 白名单:挂载即授权只读
|
|
3128
|
+
const mountedRoots = attachments
|
|
3129
|
+
.map((a) => (a.isDir ? String(a.path || '') : path.dirname(String(a.path || ''))))
|
|
3130
|
+
.filter(Boolean)
|
|
3131
|
+
if (attachments.length) {
|
|
3132
|
+
// 目录浅层清单 + 建卡输入路由(Workflow 0)在服务端先跑掉再进上下文:
|
|
3133
|
+
// 实测让模型自己摸索时,12 步工具额度里 4 步浪费在「fs_read 一个目录」上,最后超限零产出
|
|
3134
|
+
const brief = await describeAttachments(root, attachments, dispatch, mountedRoots)
|
|
3135
|
+
designFlow = brief.designFlow
|
|
3136
|
+
contextPrompt += `\n\n## 用户挂载的本地文件(仅本机路径,未上传;需要内容时用工具自行读取)\n\n${brief.text}`
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
res.statusCode = 200
|
|
3140
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
3141
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
3142
|
+
res.setHeader('Connection', 'keep-alive')
|
|
3143
|
+
res.setHeader('X-Accel-Buffering', 'no')
|
|
3144
|
+
const allow = trustedOrigin(req)
|
|
3145
|
+
if (allow) res.setHeader('Access-Control-Allow-Origin', allow)
|
|
3146
|
+
res.flushHeaders?.()
|
|
3147
|
+
|
|
3148
|
+
// 前端「停止」= abort fetch = 连接关闭:立刻中止 LLM 请求与工具循环
|
|
3149
|
+
const abortCtl = new AbortController()
|
|
3150
|
+
let aborted = false
|
|
3151
|
+
const onClose = () => {
|
|
3152
|
+
if (aborted) return
|
|
3153
|
+
aborted = true
|
|
3154
|
+
abortCtl.abort()
|
|
3155
|
+
}
|
|
3156
|
+
req.on('close', onClose)
|
|
3157
|
+
req.on('aborted', onClose)
|
|
3158
|
+
res.on('close', onClose)
|
|
3159
|
+
|
|
3160
|
+
// 上下文预算:多轮对话 + 大工具结果超限时,把早期轮次折叠成摘要(按轮折叠,
|
|
3161
|
+
// 保证 assistant.tool_calls 与 tool 结果成对保留),并给前端一条 notice 说明
|
|
3162
|
+
const contextBudget = Number(config.contextTokens) > 0 ? Number(config.contextTokens) : DEFAULT_CONTEXT_TOKENS
|
|
3163
|
+
const compacted = compactContext(messages, contextBudget)
|
|
3164
|
+
if (compacted.droppedTurns) {
|
|
3165
|
+
sseWrite(res, {
|
|
3166
|
+
type: 'notice',
|
|
3167
|
+
message: `对话较长:早期 ${compacted.droppedTurns} 轮已压缩为摘要(最近轮次保留原文),需要细节时 AI 会用工具重读`,
|
|
3168
|
+
})
|
|
3169
|
+
}
|
|
3170
|
+
if (visionWanted && !visionOn) {
|
|
3171
|
+
sseWrite(res, {
|
|
3172
|
+
type: 'notice',
|
|
3173
|
+
message: `已开启多模态视觉,但本轮用不了图像,降级为数值闭环(pixelDiff.match)。原因:${visionProbe.reason || '探测失败'}`,
|
|
3174
|
+
})
|
|
3175
|
+
}
|
|
3176
|
+
const fullMessages = [{ role: 'system', content: contextPrompt }, ...toOpenAiMessages(compacted.messages)]
|
|
3177
|
+
// 视觉注入点①:用户挂载的图片随本轮 user 消息进上下文(落盘历史不含 base64,重开不重复注入)
|
|
3178
|
+
if (visionOn && attachments.length) {
|
|
3179
|
+
const parts = []
|
|
3180
|
+
for (const a of attachments) {
|
|
3181
|
+
if (a.isDir) continue
|
|
3182
|
+
const ap = String(a.path || '')
|
|
3183
|
+
if (!IMAGE_EXT.has(path.extname(ap).toLowerCase())) continue
|
|
3184
|
+
const part = await fileToVisionPart(path.isAbsolute(ap) ? ap : path.join(root, ap))
|
|
3185
|
+
if (part) parts.push({ caption: `用户挂载图片:${a.name || path.basename(ap)}`, part })
|
|
3186
|
+
if (parts.length >= VISION_INJECT_MAX) break
|
|
3187
|
+
}
|
|
3188
|
+
if (parts.length) {
|
|
3189
|
+
for (let i = fullMessages.length - 1; i >= 0; i--) {
|
|
3190
|
+
if (fullMessages[i].role === 'user') {
|
|
3191
|
+
const text = typeof fullMessages[i].content === 'string' ? fullMessages[i].content : JSON.stringify(fullMessages[i].content ?? '')
|
|
3192
|
+
fullMessages[i] = {
|
|
3193
|
+
role: 'user',
|
|
3194
|
+
content: [{ type: 'text', text }, ...parts.flatMap((v) => [{ type: 'text', text: `[图] ${v.caption}` }, v.part])],
|
|
3195
|
+
}
|
|
3196
|
+
break
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// 单轮预算:步数默认不限(设计稿建卡是多阶段管线,列画板 → 前处理 → cluster → spec_tree →
|
|
3203
|
+
// scaffold → 填组件 → iterate × N → fidelity,几十次调用很正常),失控交给三道熔断;
|
|
3204
|
+
// 只有用户显式配了 AI_MAX_STEPS / 面板「单轮步数上限」> 0 才恢复旧硬顶
|
|
3205
|
+
const HARD_STEPS = Number(finalConfig.maxSteps) > 0 ? Number(finalConfig.maxSteps) : 0
|
|
3206
|
+
const turnTimeoutMs = Number(finalConfig.turnTimeoutMs) > 0 ? Number(finalConfig.turnTimeoutMs) : DEFAULT_TURN_TIMEOUT_MS
|
|
3207
|
+
// 单轮上下文预算:探测到网关窗口就按 70% 收紧(给输出 + 思考留余量),否则用默认值
|
|
3208
|
+
const knownWindow = probed?.window || windowCache.get(maxTokensSig(finalConfig))?.window || 0
|
|
3209
|
+
const turnContextBudget =
|
|
3210
|
+
Number(config.turnContextTokens) > 0
|
|
3211
|
+
? Number(config.turnContextTokens)
|
|
3212
|
+
: knownWindow > 0
|
|
3213
|
+
? Math.max(8000, Math.min(DEFAULT_TURN_CONTEXT_TOKENS, Math.floor(knownWindow * 0.7)))
|
|
3214
|
+
: DEFAULT_TURN_CONTEXT_TOKENS
|
|
3215
|
+
// 单轮「纯思考」预算:推理网关空转时(只吐 reasoning、迟迟不产出)中止上游并催促重试。
|
|
3216
|
+
// 90s 对设计稿建卡太紧——实测前处理管线跑完、正要建卡时连续思考 90s+ 就被熔断,整轮白跑;
|
|
3217
|
+
// 调到 240s(仍可 config.reasoningTimeoutMs 覆盖),另用 stallTimeoutMs 兜「网关彻底静默」。
|
|
3218
|
+
const reasoningTimeoutMs = Number(config.reasoningTimeoutMs) > 0 ? Number(config.reasoningTimeoutMs) : 240000
|
|
3219
|
+
// 网关静默判定:一个增量都不来才算挂起(与「正在思考」区分开),避免大上下文首 token 慢被误杀
|
|
3220
|
+
const stallTimeoutMs = Number(config.stallTimeoutMs) > 0 ? Number(config.stallTimeoutMs) : 120000
|
|
3221
|
+
let step = 0
|
|
3222
|
+
/** 本轮开始时刻:墙钟熔断的基准 */
|
|
3223
|
+
const turnStartedAt = Date.now()
|
|
3224
|
+
/** 预算耗尽原因(null = 模型自己收尾):'steps'(用户设了硬顶)/ 'timeout'(墙钟)/ 'loop'(原地打转) */
|
|
3225
|
+
let budgetReason = null
|
|
3226
|
+
/** 上一轮工具调用指纹与「连续相同」计数:死循环熔断的依据 */
|
|
3227
|
+
let lastRoundSig = ''
|
|
3228
|
+
let repeatRounds = 0
|
|
3229
|
+
/** 熔断时展示给用户的调用名(比整串指纹可读) */
|
|
3230
|
+
let loopToolNames = ''
|
|
3231
|
+
/** 墙钟用到 80% 时的一次性收敛提醒(不打断流程) */
|
|
3232
|
+
let timeNudged = false
|
|
3233
|
+
/** 单轮上下文裁剪是否已提示过(每轮都可能裁,提示只发一次免得刷屏) */
|
|
3234
|
+
let trimNoticed = false
|
|
3235
|
+
/** 单轮深度压缩是否已提示过(同 trimNoticed,免刷屏) */
|
|
3236
|
+
let deepNoticed = false
|
|
3237
|
+
/** 网关报上下文溢出后的「压缩 + 重试」次数:最多 2 次,仍超才报错收尾 */
|
|
3238
|
+
let contextRetries = 0
|
|
3239
|
+
/** 网关静默空回复(无正文 / 无工具 / 无 finish 标记)的重试次数:最多 1 次 */
|
|
3240
|
+
let emptyRetries = 0
|
|
3241
|
+
/** 熔断收尾的人话原因:随 turn-ended 事件落进对话记录,重开面板仍看得见「这轮为什么停」 */
|
|
3242
|
+
let budgetWhy = ''
|
|
3243
|
+
let currentMessages = fullMessages
|
|
3244
|
+
/** 本轮工具产出的待注入图像(截图 / 并排对比图):下一次 callLLM 前打包成一条 user 消息 */
|
|
3245
|
+
const visionQueue = []
|
|
3246
|
+
/** 思考熔断后是否已催促重试过一次:重试仍超时才报错,给推理网关一次收敛机会 */
|
|
3247
|
+
let thinkingNudged = false
|
|
3248
|
+
/** 重试轮关闭深度思考:实测推理模型会在思考里逐字拼完整 IR / schema,两轮 240s 都产不出工具调用 */
|
|
3249
|
+
let retryNoThinking = false
|
|
3250
|
+
/** 深度思考总开关(设置为关时每轮都走 NO_THINKING_BODY) */
|
|
3251
|
+
const thinkingEnabled = config.thinking === undefined ? defaults.thinking !== false : Boolean(config.thinking)
|
|
3252
|
+
|
|
3253
|
+
try {
|
|
3254
|
+
while (HARD_STEPS === 0 || step < HARD_STEPS) {
|
|
3255
|
+
if (aborted) break
|
|
3256
|
+
step++
|
|
3257
|
+
|
|
3258
|
+
// 熔断②墙钟:不限步数后,唯一能兜住「无限跑下去」的闸门(到点收尾汇报,上下文不丢)
|
|
3259
|
+
const elapsedMs = Date.now() - turnStartedAt
|
|
3260
|
+
if (elapsedMs > turnTimeoutMs) {
|
|
3261
|
+
budgetReason = 'timeout'
|
|
3262
|
+
break
|
|
3263
|
+
}
|
|
3264
|
+
if (!timeNudged && elapsedMs > turnTimeoutMs * 0.8) {
|
|
3265
|
+
timeNudged = true
|
|
3266
|
+
sseWrite(res, {
|
|
3267
|
+
type: 'notice',
|
|
3268
|
+
message: `本轮已跑 ${fmtDuration(elapsedMs)}(墙钟上限 ${fmtDuration(turnTimeoutMs)}):已要求模型优先落盘 / 给结论`,
|
|
3269
|
+
})
|
|
3270
|
+
currentMessages.push({
|
|
3271
|
+
role: 'user',
|
|
3272
|
+
content: `[系统提醒] 本轮墙钟预算只剩 20%(上限 ${fmtDuration(turnTimeoutMs)}):立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料、不要开新战线。`,
|
|
3273
|
+
})
|
|
3274
|
+
}
|
|
3275
|
+
// 熔断③软提醒:每 SOFT_NUDGE_EVERY 步催一次收敛,但**不打断**(步数多不等于空转)
|
|
3276
|
+
if (SOFT_NUDGE_EVERY > 0 && step % SOFT_NUDGE_EVERY === 0) {
|
|
3277
|
+
sseWrite(res, {
|
|
3278
|
+
type: 'notice',
|
|
3279
|
+
message: `本轮已用 ${step} 步工具调用(步数不设硬顶):提醒模型收敛,别在同类信息上重复取数`,
|
|
3280
|
+
})
|
|
3281
|
+
currentMessages.push({
|
|
3282
|
+
role: 'user',
|
|
3283
|
+
content: `[系统提醒] 本轮已进行 ${step} 步工具调用(不设硬顶,但别空转):自查是否已拿到足够证据——能落盘就 card_write / card_patch / iterate.mjs 落盘并推进到下一门禁;同类信息只读一次,不要重复读同一份资料、不要重跑同一条命令。`,
|
|
3284
|
+
})
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
// 熔断④上下文:步数不限后工具结果会一路累积,撑爆网关窗口 = 整轮 400 报废。
|
|
3288
|
+
// 每轮发请求前先按预算折叠早期工具结果 / 注入图像(最近 TRIM_KEEP_RECENT 条保持原文)
|
|
3289
|
+
const turnTrim = trimTurnContext(currentMessages, { budgetTokens: turnContextBudget })
|
|
3290
|
+
// 输入框圆环的数据源:本轮**实际送进网关**的上下文量(裁剪后),有窗口认知时前端按窗口画环
|
|
3291
|
+
sseWrite(res, { type: 'context-usage', used: turnTrim.tokens, total: turnContextBudget, window: knownWindow })
|
|
3292
|
+
if (clampNotice) sseWrite(res, { type: 'notice', message: clampNotice })
|
|
3293
|
+
if (turnTrim.trimmed) {
|
|
3294
|
+
currentMessages = turnTrim.messages
|
|
3295
|
+
if (!trimNoticed) {
|
|
3296
|
+
trimNoticed = true
|
|
3297
|
+
sseWrite(res, {
|
|
3298
|
+
type: 'notice',
|
|
3299
|
+
message: `单轮上下文接近预算(约 ${turnTrim.before} token,上限 ${turnContextBudget}):已折叠 ${turnTrim.trimmed} 条早期工具结果 / 图像,最近 ${TRIM_KEEP_RECENT} 条保持原文`,
|
|
3300
|
+
})
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
// 深度压缩:折叠工具结果后仍超预算 ⇒ 整组丢弃最早的工具调用并留摘要。
|
|
3304
|
+
// 任务消息与最近工作记忆保留原文,压缩完循环照常继续(用户要求:主动压缩且不断任务)
|
|
3305
|
+
const estNow = currentMessages.reduce((a, m) => a + openAiMsgTokens(m), 0)
|
|
3306
|
+
if (estNow > turnContextBudget) {
|
|
3307
|
+
const deep = deepCompactTurn(currentMessages, turnContextBudget)
|
|
3308
|
+
if (deep.droppedGroups) {
|
|
3309
|
+
currentMessages = deep.messages
|
|
3310
|
+
if (!deepNoticed) {
|
|
3311
|
+
deepNoticed = true
|
|
3312
|
+
sseWrite(res, {
|
|
3313
|
+
type: 'notice',
|
|
3314
|
+
message: `单轮上下文约 ${deep.before} token 超预算 ${turnContextBudget}:已深度压缩最早 ${deep.droppedGroups} 组工具调用为摘要(任务与最近 ${DEEP_KEEP_RECENT} 条保持原文),任务继续`,
|
|
3315
|
+
})
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
let assistantContent = ''
|
|
3321
|
+
let assistantToolCalls = null
|
|
3322
|
+
let roundProgressAt = Date.now()
|
|
3323
|
+
let lastDeltaAt = Date.now()
|
|
3324
|
+
let roundTimedOut = false
|
|
3325
|
+
let roundStalled = false
|
|
3326
|
+
let finishReason = ''
|
|
3327
|
+
|
|
3328
|
+
// 轮级 AbortController:思考熔断 / 静默看门狗只杀当前轮;客户端断开经 AbortSignal.any 一并生效
|
|
3329
|
+
const roundCtl = new AbortController()
|
|
3330
|
+
// 巡检间隔跟随阈值缩放(500ms~5s):既不误杀首 token 慢的大上下文请求,也能尽快发现真静默
|
|
3331
|
+
const watchdog = setInterval(() => {
|
|
3332
|
+
if (aborted || roundTimedOut || roundStalled) return
|
|
3333
|
+
if (Date.now() - lastDeltaAt > stallTimeoutMs) {
|
|
3334
|
+
roundStalled = true
|
|
3335
|
+
roundCtl.abort()
|
|
3336
|
+
}
|
|
3337
|
+
}, Math.min(5000, Math.max(500, Math.floor(stallTimeoutMs / 4))))
|
|
3338
|
+
const gen = callLLM({
|
|
3339
|
+
baseUrl: finalConfig.baseUrl,
|
|
3340
|
+
apiKey: finalConfig.apiKey,
|
|
3341
|
+
model: finalConfig.model,
|
|
3342
|
+
messages: currentMessages,
|
|
3343
|
+
tools,
|
|
3344
|
+
maxTokens: finalConfig.maxTokens,
|
|
3345
|
+
// 用户关了深度思考 ⇒ 每轮都关;否则正常带思考,只有熔断重试那一轮强制关(见下)
|
|
3346
|
+
extraBody: retryNoThinking || !thinkingEnabled ? NO_THINKING_BODY : undefined,
|
|
3347
|
+
signal: AbortSignal.any([abortCtl.signal, roundCtl.signal]),
|
|
3348
|
+
})
|
|
3349
|
+
|
|
3350
|
+
// 看门狗必须在任何退出路径上都清掉:残留 setInterval 会让进程(与测试)无法退出
|
|
3351
|
+
try {
|
|
3352
|
+
for await (const evt of gen) {
|
|
3353
|
+
if (aborted) break
|
|
3354
|
+
lastDeltaAt = Date.now()
|
|
3355
|
+
switch (evt.type) {
|
|
3356
|
+
case 'reasoning-delta':
|
|
3357
|
+
if (Date.now() - roundProgressAt > reasoningTimeoutMs) {
|
|
3358
|
+
roundTimedOut = true
|
|
3359
|
+
roundCtl.abort()
|
|
3360
|
+
break
|
|
3361
|
+
}
|
|
3362
|
+
sseWrite(res, { type: 'reasoning-delta', text: evt.text })
|
|
3363
|
+
break
|
|
3364
|
+
case 'text-delta':
|
|
3365
|
+
roundProgressAt = Date.now()
|
|
3366
|
+
assistantContent += evt.text
|
|
3367
|
+
sseWrite(res, { type: 'text-delta', text: evt.text })
|
|
3368
|
+
break
|
|
3369
|
+
case 'tool-call-start':
|
|
3370
|
+
roundProgressAt = Date.now()
|
|
3371
|
+
sseWrite(res, {
|
|
3372
|
+
type: 'tool-call-start',
|
|
3373
|
+
toolName: evt.toolName,
|
|
3374
|
+
toolCallId: evt.toolCallId || '',
|
|
3375
|
+
index: evt.index,
|
|
3376
|
+
})
|
|
3377
|
+
break
|
|
3378
|
+
case 'finish':
|
|
3379
|
+
if (evt.reason) finishReason = evt.reason
|
|
3380
|
+
break
|
|
3381
|
+
case 'notice':
|
|
3382
|
+
sseWrite(res, { type: 'notice', message: evt.text })
|
|
3383
|
+
break
|
|
3384
|
+
case 'tool-calls':
|
|
3385
|
+
roundProgressAt = Date.now()
|
|
3386
|
+
assistantToolCalls = evt.toolCalls
|
|
3387
|
+
for (const tc of evt.toolCalls) {
|
|
3388
|
+
sseWrite(res, {
|
|
3389
|
+
type: 'tool-call',
|
|
3390
|
+
toolName: tc.function.name,
|
|
3391
|
+
args: safeParseArgs(tc.function.arguments),
|
|
3392
|
+
toolCallId: tc.id,
|
|
3393
|
+
})
|
|
3394
|
+
}
|
|
3395
|
+
break
|
|
3396
|
+
}
|
|
3397
|
+
if (roundTimedOut || roundStalled) break
|
|
3398
|
+
}
|
|
3399
|
+
} catch (e) {
|
|
3400
|
+
// 熔断 / 看门狗主动 abort 时,上游生成器会抛 AbortError:这不是「用户中止」,
|
|
3401
|
+
// 吞掉后交给下面的 roundStalled / roundTimedOut 分支给出准确原因
|
|
3402
|
+
if (roundStalled || roundTimedOut) {
|
|
3403
|
+
/* 吞掉,走下面分支 */
|
|
3404
|
+
} else if (
|
|
3405
|
+
contextRetries < 2 &&
|
|
3406
|
+
(e?.code === 'context-overflow' ||
|
|
3407
|
+
(/LLM 400/.test(String(e && e.message)) && /context|length|token/i.test(String(e && e.message))))
|
|
3408
|
+
) {
|
|
3409
|
+
// 网关报上下文溢出:callLLM 已按详情分类(减半救不了的才带 context-overflow 标记抛出),
|
|
3410
|
+
// 说明是**输入**超窗。压缩后重试同一步(此刻尚未追加 assistant 消息,无悬空 tool_calls),任务不中断
|
|
3411
|
+
contextRetries++
|
|
3412
|
+
const tight = Math.max(4000, Math.floor(turnContextBudget * 0.6))
|
|
3413
|
+
const deep = deepCompactTurn(currentMessages, tight, Math.max(4, DEEP_KEEP_RECENT - 2), true)
|
|
3414
|
+
currentMessages = deep.droppedGroups
|
|
3415
|
+
? deep.messages
|
|
3416
|
+
: trimTurnContext(currentMessages, { budgetTokens: tight, keepRecent: 4, stubChars: 200 }).messages
|
|
3417
|
+
sseWrite(res, {
|
|
3418
|
+
type: 'notice',
|
|
3419
|
+
message: `网关报上下文溢出(HTTP 400):已${deep.droppedGroups ? `深度压缩 ${deep.droppedGroups} 组早期工具调用` : '加强折叠早期工具结果'}后自动重试本步,任务不中断`,
|
|
3420
|
+
})
|
|
3421
|
+
continue
|
|
3422
|
+
} else {
|
|
3423
|
+
throw e
|
|
3424
|
+
}
|
|
3425
|
+
} finally {
|
|
3426
|
+
clearInterval(watchdog)
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
if (finishReason === 'length') {
|
|
3430
|
+
// 输出被截断:本轮的正文 / 工具参数都不可信,别拿去执行(半截 JSON 会得到莫名其妙的报错)
|
|
3431
|
+
sseWrite(res, {
|
|
3432
|
+
type: 'error',
|
|
3433
|
+
message: `本轮输出被单次输出上限(max_tokens=${finalConfig.maxTokens})截断,结果不可用。请在设置里调高「单次输出上限」,或要求 AI 走紧凑 IR 路线(fs_write 写 .cache/ai-scratch/*.ir.json → expand_ir.mjs 展开),不要一次性输出整份 schema`,
|
|
3434
|
+
})
|
|
3435
|
+
break
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
if (roundStalled) {
|
|
3439
|
+
sseWrite(res, {
|
|
3440
|
+
type: 'error',
|
|
3441
|
+
message: `上游 ${Math.round(stallTimeoutMs / 1000)} 秒没有返回任何数据(网关静默),已中止本轮:请重试或检查模型服务`,
|
|
3442
|
+
})
|
|
3443
|
+
break
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
if (roundTimedOut) {
|
|
3447
|
+
if (!thinkingNudged) {
|
|
3448
|
+
// 第一次熔断:不报错,插入催促 + **关掉深度思考**再给一轮机会。
|
|
3449
|
+
// 只催促不关思考实测无效:模型继续用思考逐字草拟 schema,第二轮同样超时,整轮 10 分钟零产出。
|
|
3450
|
+
thinkingNudged = true
|
|
3451
|
+
retryNoThinking = true
|
|
3452
|
+
sseWrite(res, {
|
|
3453
|
+
type: 'notice',
|
|
3454
|
+
message: `本轮思考超过 ${Math.round(reasoningTimeoutMs / 1000)} 秒仍无产出:已关闭深度思考并催促模型直接落盘,重试一次`,
|
|
3455
|
+
})
|
|
3456
|
+
currentMessages = [
|
|
3457
|
+
...currentMessages,
|
|
3458
|
+
{
|
|
3459
|
+
role: 'user',
|
|
3460
|
+
content: `[系统催促] 思考预算已用完:立即停止分析,本轮**必须**以一次工具调用收尾(fs_write 写 IR / card_write / card_patch / card_scaffold),或直接用正文回复结论;不要再读取任何资料、不要复述卡片数据、不要在脑子里逐字草拟 JSON。${designFlow ? '建卡走 IR 路线:fs_write 把紧凑 IR 写到 .cache/ai-scratch/<id>.ir.json,再用 skill_exec 跑 expand_ir.mjs 展开,一次调用就能落盘整卡;落盘后仍须跑完 check_fidelity + iterate.mjs 像素自循环(数值指标,1-2 轮即可)才允许收尾。' : ''}`,
|
|
3461
|
+
},
|
|
3462
|
+
]
|
|
3463
|
+
continue
|
|
3464
|
+
}
|
|
3465
|
+
sseWrite(res, {
|
|
3466
|
+
type: 'error',
|
|
3467
|
+
message: `两轮思考均超过 ${Math.round(reasoningTimeoutMs / 1000)} 秒仍无产出,已停止。可重试、简化需求或拆成更小步骤`,
|
|
3468
|
+
})
|
|
3469
|
+
break
|
|
3470
|
+
}
|
|
3471
|
+
if (aborted) break
|
|
3472
|
+
// 网关静默空回复:无正文、无工具调用、连 finish 标记都没有——多是上游连接中途被切 / 空流。
|
|
3473
|
+
// 静默 break 会让前端只看到一句「已终止生成」而不知原因(体感就是「卡住后突然死掉」):
|
|
3474
|
+
// 先明示并自动重试一次;重试仍空才报错收尾
|
|
3475
|
+
if (!assistantToolCalls?.length && !assistantContent && !finishReason && !roundStalled && !roundTimedOut) {
|
|
3476
|
+
if (emptyRetries < 1) {
|
|
3477
|
+
emptyRetries++
|
|
3478
|
+
sseWrite(res, {
|
|
3479
|
+
type: 'notice',
|
|
3480
|
+
message: '网关返回空响应(无正文、无结束标记,连接可能被切断):自动重试本步一次',
|
|
3481
|
+
})
|
|
3482
|
+
continue
|
|
3483
|
+
}
|
|
3484
|
+
sseWrite(res, {
|
|
3485
|
+
type: 'error',
|
|
3486
|
+
message: '网关连续两次返回空响应(无正文、无结束标记):本轮已停止。可重试,或检查模型服务 / 网络',
|
|
3487
|
+
})
|
|
3488
|
+
break
|
|
3489
|
+
}
|
|
3490
|
+
if (!assistantToolCalls?.length) break
|
|
3491
|
+
|
|
3492
|
+
// 熔断①原地打转:同工具 + 同参数连续重复(实测最烧钱的失控形态,如反复 fs_read 同一份 spec.json、
|
|
3493
|
+
// 反复跑同一条 iterate --set)。第 2 轮先警告,第 3 轮不再执行、直接收尾汇报。
|
|
3494
|
+
const roundSig = toolRoundSignature(assistantToolCalls)
|
|
3495
|
+
if (roundSig === lastRoundSig) repeatRounds++
|
|
3496
|
+
else {
|
|
3497
|
+
lastRoundSig = roundSig
|
|
3498
|
+
repeatRounds = 1
|
|
3499
|
+
}
|
|
3500
|
+
loopToolNames = [...new Set(assistantToolCalls.map((tc) => tc?.function?.name || '?'))].join(' + ')
|
|
3501
|
+
if (repeatRounds >= LOOP_BREAK_REPEATS) {
|
|
3502
|
+
budgetReason = 'loop'
|
|
3503
|
+
// 协议要求每个 tool_call 都有对应的 tool 结果:回填「已跳过」再收尾,别留下悬空 tool_calls
|
|
3504
|
+
currentMessages = [
|
|
3505
|
+
...currentMessages,
|
|
3506
|
+
{ role: 'assistant', content: assistantContent || null, tool_calls: assistantToolCalls },
|
|
3507
|
+
]
|
|
3508
|
+
for (const tc of assistantToolCalls) {
|
|
3509
|
+
sseWrite(res, {
|
|
3510
|
+
type: 'tool-result',
|
|
3511
|
+
toolName: tc.function.name,
|
|
3512
|
+
toolCallId: tc.id,
|
|
3513
|
+
error: `已跳过:连续 ${repeatRounds} 轮完全相同的调用(同工具同参数),触发死循环熔断`,
|
|
3514
|
+
})
|
|
3515
|
+
currentMessages.push({
|
|
3516
|
+
role: 'tool',
|
|
3517
|
+
tool_call_id: tc.id,
|
|
3518
|
+
content: `Error: 已跳过——这是连续第 ${repeatRounds} 次完全相同的调用(同工具 + 同参数),说明当前思路拿不到新信息,本轮到此为止。`,
|
|
3519
|
+
})
|
|
3520
|
+
}
|
|
3521
|
+
break
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
currentMessages = [
|
|
3525
|
+
...currentMessages,
|
|
3526
|
+
{ role: 'assistant', content: assistantContent || null, tool_calls: assistantToolCalls },
|
|
3527
|
+
]
|
|
3528
|
+
|
|
3529
|
+
for (const tc of assistantToolCalls) {
|
|
3530
|
+
if (aborted) break
|
|
3531
|
+
const toolName = tc.function.name
|
|
3532
|
+
const parsedArgs = safeParseArgs(tc.function.arguments)
|
|
3533
|
+
try {
|
|
3534
|
+
let result
|
|
3535
|
+
switch (toolName) {
|
|
3536
|
+
case 'skill_read':
|
|
3537
|
+
result = skillRead(root, parsedArgs)
|
|
3538
|
+
break
|
|
3539
|
+
case 'card_grep':
|
|
3540
|
+
result = cardGrep(root, parsedArgs)
|
|
3541
|
+
break
|
|
3542
|
+
case 'card_patch':
|
|
3543
|
+
result = await cardPatch(root, parsedArgs, dispatch)
|
|
3544
|
+
break
|
|
3545
|
+
case 'fs_read':
|
|
3546
|
+
result = fsRead(root, parsedArgs, mountedRoots)
|
|
3547
|
+
break
|
|
3548
|
+
case 'fs_list':
|
|
3549
|
+
result = fsList(root, parsedArgs, mountedRoots)
|
|
3550
|
+
break
|
|
3551
|
+
case 'fs_write':
|
|
3552
|
+
result = fsWrite(root, parsedArgs)
|
|
3553
|
+
break
|
|
3554
|
+
case 'fs_copy':
|
|
3555
|
+
result = fsCopy(root, parsedArgs, mountedRoots)
|
|
3556
|
+
break
|
|
3557
|
+
case 'skill_exec':
|
|
3558
|
+
result = await runSkillExec(root, parsedArgs, res, approvedTools)
|
|
3559
|
+
break
|
|
3560
|
+
case 'shell_exec':
|
|
3561
|
+
result = await runShellExec(root, parsedArgs, res, approvedTools)
|
|
3562
|
+
break
|
|
3563
|
+
default:
|
|
3564
|
+
result = await dispatch(toolName, parsedArgs)
|
|
3565
|
+
}
|
|
3566
|
+
const { text, image } = splitToolResult(result)
|
|
3567
|
+
sseWrite(res, {
|
|
3568
|
+
type: 'tool-result',
|
|
3569
|
+
toolName,
|
|
3570
|
+
toolCallId: tc.id,
|
|
3571
|
+
result: text.slice(0, 4000),
|
|
3572
|
+
truncated: text.length > 4000,
|
|
3573
|
+
image,
|
|
3574
|
+
})
|
|
3575
|
+
currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: text.slice(0, 12000) })
|
|
3576
|
+
// 视觉注入点②:工具产出的图像先入队,循环末尾打包注入(文本模型下 image 恒为 undefined,零开销)
|
|
3577
|
+
if (visionOn && image) {
|
|
3578
|
+
visionQueue.push({ caption: `${toolName} 产出的渲染截图`, part: dataUrlPart(Buffer.from(image.base64, 'base64'), image.mime) })
|
|
3579
|
+
}
|
|
3580
|
+
if (visionOn && toolName === 'skill_exec') {
|
|
3581
|
+
const dm = text.match(/"diffImage"\s*:\s*"([^"]+)"/)
|
|
3582
|
+
if (dm) {
|
|
3583
|
+
const part = await fileToVisionPart(path.isAbsolute(dm[1]) ? dm[1] : path.join(root, dm[1]))
|
|
3584
|
+
if (part) visionQueue.push({ caption: '设计稿 vs 渲染并排对比(左=设计,右=渲染)', part })
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
} catch (e) {
|
|
3588
|
+
const errMsg = String(e?.message || e)
|
|
3589
|
+
sseWrite(res, { type: 'tool-result', toolName, toolCallId: tc.id, error: errMsg })
|
|
3590
|
+
currentMessages.push({ role: 'tool', tool_call_id: tc.id, content: `Error: ${errMsg}` })
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
// 视觉注入点②收尾:把本轮工具产出的图像打包成一条 user 消息回喂(数值指标仍是收敛判据)
|
|
3595
|
+
if (visionOn && visionQueue.length) {
|
|
3596
|
+
const batch = visionQueue.splice(0, VISION_INJECT_MAX)
|
|
3597
|
+
currentMessages.push({
|
|
3598
|
+
role: 'user',
|
|
3599
|
+
content: [
|
|
3600
|
+
{
|
|
3601
|
+
type: 'text',
|
|
3602
|
+
text: '[视觉] 以下图像为上面工具调用的渲染结果 / 对比图,可直接读图定位差异;收敛判据仍是 pixelDiff.match 与 check_fidelity。',
|
|
3603
|
+
},
|
|
3604
|
+
...batch.flatMap((v) => [{ type: 'text', text: v.caption }, v.part]),
|
|
3605
|
+
],
|
|
3606
|
+
})
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
// 连续第 2 轮相同调用:先给一次明确的「换证据链」警告(第 3 轮才熔断),多数模型这一句就能纠偏
|
|
3610
|
+
if (!aborted && repeatRounds >= 2) {
|
|
3611
|
+
sseWrite(res, {
|
|
3612
|
+
type: 'notice',
|
|
3613
|
+
message: `连续第 ${repeatRounds} 轮完全相同的工具调用(${loopToolNames}):已要求换证据链,再重复将熔断收尾`,
|
|
3614
|
+
})
|
|
3615
|
+
currentMessages.push({
|
|
3616
|
+
role: 'user',
|
|
3617
|
+
content: `[系统提醒] 你已连续 ${repeatRounds} 轮发起**完全相同**的工具调用(${loopToolNames},参数一字不差),结果不会变。立刻换证据链:改参数 / 换工具(如 diff_bands.mjs 取逐带 dy、references/generation_pitfalls.md 查静默丢弃清单),或直接落盘 card_write / card_patch。再重复一次本轮就会被熔断收尾。`,
|
|
3618
|
+
})
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// 用户设了硬顶且额度快用尽:提前催收敛,别等超限才发现整轮没产出
|
|
3622
|
+
if (!aborted && HARD_STEPS > 0 && step === HARD_STEPS - 2) {
|
|
3623
|
+
sseWrite(res, { type: 'notice', message: `本轮工具额度只剩 2 步:已要求模型优先落盘 / 给结论` })
|
|
3624
|
+
currentMessages.push({
|
|
3625
|
+
role: 'user',
|
|
3626
|
+
content: `[系统提醒] 本轮工具调用额度只剩 2 步:立即收敛——优先 card_write / card_patch 落盘或给出正文结论与下一步,不要再读资料。`,
|
|
3627
|
+
})
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
// while 条件自然退出 = 用户自设的步数硬顶到顶(不限步数时不会走到这里)
|
|
3632
|
+
if (!budgetReason && !aborted && HARD_STEPS > 0 && step >= HARD_STEPS) budgetReason = 'steps'
|
|
3633
|
+
|
|
3634
|
+
if (aborted) {
|
|
3635
|
+
sseWrite(res, { type: 'aborted' })
|
|
3636
|
+
} else if (budgetReason) {
|
|
3637
|
+
// 预算耗尽不再「报错 + 零正文」:不带工具再问一次,逼出进度汇总与下一步
|
|
3638
|
+
const why =
|
|
3639
|
+
budgetReason === 'loop'
|
|
3640
|
+
? `检测到连续 ${LOOP_BREAK_REPEATS} 轮完全相同的工具调用(${loopToolNames}),判定为原地打转`
|
|
3641
|
+
: budgetReason === 'timeout'
|
|
3642
|
+
? `本轮时长已达墙钟上限 ${fmtDuration(turnTimeoutMs)}(共用 ${step} 步工具调用)`
|
|
3643
|
+
: `已达单轮工具调用上限(${HARD_STEPS} 步)`
|
|
3644
|
+
budgetWhy = why
|
|
3645
|
+
sseWrite(res, {
|
|
3646
|
+
type: 'notice',
|
|
3647
|
+
message: `${why}:正在汇总进度与下一步,回复「继续」可接着做`,
|
|
3648
|
+
})
|
|
3649
|
+
try {
|
|
3650
|
+
const wrap = callLLM({
|
|
3651
|
+
baseUrl: finalConfig.baseUrl,
|
|
3652
|
+
apiKey: finalConfig.apiKey,
|
|
3653
|
+
model: finalConfig.model,
|
|
3654
|
+
messages: [
|
|
3655
|
+
...currentMessages,
|
|
3656
|
+
{
|
|
3657
|
+
role: 'user',
|
|
3658
|
+
content: `[系统提醒] ${why},本轮工具调用已被停止,不要再调用任何工具。用 6 行以内中文汇报:①已经做完什么(含跑过的脚本与产物路径)②卡在哪${
|
|
3659
|
+
budgetReason === 'loop'
|
|
3660
|
+
? '(为什么重复同一条调用拿不到新信息、下一步该换哪条证据链)'
|
|
3661
|
+
: ''
|
|
3662
|
+
} ③用户下一步该说什么(例如「继续」${budgetReason === 'loop' ? ',或直接给出新思路' : ''})。`,
|
|
3663
|
+
},
|
|
3664
|
+
],
|
|
3665
|
+
maxTokens: 2048,
|
|
3666
|
+
extraBody: NO_THINKING_BODY,
|
|
3667
|
+
signal: abortCtl.signal,
|
|
3668
|
+
})
|
|
3669
|
+
for await (const evt of wrap) {
|
|
3670
|
+
if (aborted) break
|
|
3671
|
+
if (evt.type === 'text-delta') sseWrite(res, { type: 'text-delta', text: evt.text })
|
|
3672
|
+
else if (evt.type === 'reasoning-delta') sseWrite(res, { type: 'reasoning-delta', text: evt.text })
|
|
3673
|
+
}
|
|
3674
|
+
} catch (e) {
|
|
3675
|
+
sseWrite(res, {
|
|
3676
|
+
type: 'error',
|
|
3677
|
+
message: `${why},收尾汇总也失败:${String((e && e.message) || e)}`,
|
|
3678
|
+
})
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
// 熔断 / 硬顶收尾都补一条持久事件:notice 横幅 6 秒就没了,对话记录里要留得住「为什么停」
|
|
3682
|
+
if (!aborted && budgetWhy) sseWrite(res, { type: 'turn-ended', reason: budgetReason, message: budgetWhy })
|
|
3683
|
+
sseWrite(res, { type: 'done' })
|
|
3684
|
+
} catch (e) {
|
|
3685
|
+
if (!aborted) {
|
|
3686
|
+
console.error('[ai-chat] failed:', e)
|
|
3687
|
+
const msg = String((e && e.message) || e)
|
|
3688
|
+
sseWrite(res, { type: 'error', message: /aborted/i.test(msg) ? '请求已中止' : msg })
|
|
3689
|
+
}
|
|
3690
|
+
sseWrite(res, { type: 'done' })
|
|
3691
|
+
} finally {
|
|
3692
|
+
req.off('close', onClose)
|
|
3693
|
+
req.off('aborted', onClose)
|
|
3694
|
+
res.off('close', onClose)
|
|
3695
|
+
if (!res.writableEnded) res.end()
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
// 单测入口(tools/tests/ai-chat-shell-gate.test.mjs):网关与压缩是纯函数,直接断言
|
|
3701
|
+
export {
|
|
3702
|
+
assertShellAllowlist,
|
|
3703
|
+
segmentCommandWord,
|
|
3704
|
+
shellSubCommands,
|
|
3705
|
+
trimTurnContext,
|
|
3706
|
+
deepCompactTurn,
|
|
3707
|
+
compactContext,
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
export default createAiChatMiddleware
|