cardbuddy 0.19.3 → 0.21.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.
Files changed (29) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +21 -8
  3. package/dist/init.js +3 -3
  4. package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
  5. package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
  6. package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
  7. package/dist/preview-editor/index.html +2 -2
  8. package/dist/skills-upgrade.js +64 -25
  9. package/dist/standalone.js +49 -0
  10. package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
  11. package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
  12. package/dist/vendored/mcp/client.mjs +22 -3
  13. package/dist/vendored/server/core/ai-chat.js +92 -12
  14. package/dist/vendored/server/core/cardExport.js +831 -0
  15. package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
  16. package/dist/vendored/server/core/cardImportMeta.js +6 -1
  17. package/dist/vendored/server/core/cardPublishVersions.js +91 -0
  18. package/dist/vendored/server/core/cardSave.js +143 -6
  19. package/dist/vendored/server/core/cardSync.js +467 -95
  20. package/dist/vendored/server/core/codeBridge.js +4 -2
  21. package/dist/vendored/server/core/index.js +16 -0
  22. package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
  23. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
  24. package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
  25. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
  26. package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
  27. package/package.json +1 -1
  28. package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
  29. package/dist/preview-editor/assets/index-DRzBK4qj.js +0 -347
@@ -8,8 +8,8 @@
8
8
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16-Cg6Nrosk.png" />
9
9
  <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32-B_1744sp.png" />
10
10
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-BQcES9QK.png" />
11
- <script type="module" crossorigin src="/assets/index-DRzBK4qj.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-C_uqjKqI.css">
11
+ <script type="module" crossorigin src="/assets/index-CpDojF__.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C8VLzU18.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
@@ -7,18 +7,22 @@
7
7
  * 就是为这个场景而生,且**只动 skills/**,绝不碰 .env.cb / package.json / README.md
8
8
  * (那是 `init --force` 的危险面,可能冲掉端口 / FinMall 配置 / AI 网关占位)。
9
9
  *
10
- * 四类 diff(内容比对,非 mtime):
10
+ * 行为模型(upgrade = 显式要把新版带进来,「有差异 = 应当覆盖」,不做好坏判定):
11
11
  * ADDED 源有、目标无 → 复制(安全)
12
- * SAME 两边内容一致 → 跳过
13
- * MODIFIED 两边都有、内容不同 → 默认跳过 + 黄色警告(覆盖风险:消费者可能改过该 shipped skill)
14
- * ORPHAN 目标有、源无 → 默认保留不删(可能是消费者私有 skill,或上游已下架该 skill)
12
+ * SAME 两边一致 → 跳过
13
+ * MODIFIED 两边都有、内容不同 → 默认覆盖(这正是升级的目的,会丢弃本地副本的改动)
14
+ * ORPHAN 目标有、源无 → 默认保留(可能是私有 / 上游已下架)
15
15
  *
16
- * 标志:--force 覆盖 MODIFIED;--prune 额外删除 ORPHAN(危险,需显式);--dry-run 只报告;
17
- * --strict 存在 MODIFIED 且未 --force 时返回非 0(便于 CI 感知未决覆盖)。
16
+ * 确认门禁:当存在 MODIFIED(将覆盖并丢弃本地改动)时,默认进入交互确认——展示将覆盖的
17
+ * 文件清单,请用户 [y/N] 确认;确认即覆盖,否认即跳过这些 MODIFIED 并继续其余变更。
18
+ * 交互环境外(非 TTY)无法确认时,必须带 --force 才能升级,否则明确拒绝并提示(不再
19
+ * 静默跳过、假装升级成功)。
20
+ * 标志:--force/-y 跳过确认直接覆盖;--prune 额外删除 ORPHAN;--dry-run 只报告不落盘。
18
21
  * 同步后写 skills/.synced-from = 当前装包版本,供下次 drift 检测与启动提示。
19
22
  */
20
23
  import fs from 'node:fs'
21
24
  import path from 'node:path'
25
+ import readline from 'node:readline/promises'
22
26
  import { fileURLToPath } from 'node:url'
23
27
  import chalk from 'chalk'
24
28
  import { loadConfig } from './config.js'
@@ -95,14 +99,36 @@ export function diffSkills(src, dest) {
95
99
  return res
96
100
  }
97
101
 
102
+ /**
103
+ * 确认是否覆盖 MODIFIED 文件。
104
+ * - force/injected confirm:直接给结果(便于非交互 / 测试)
105
+ * - 交互 TTY:readline 逐条列出后请用户 [y/N]
106
+ * - 非 TTY 且无 --force:返回 null(调用方据此明确拒绝,而非静默跳过)
107
+ * @returns {Promise<boolean|null>}
108
+ */
109
+ async function resolveOverwriteConsent({ modified, force, confirm, log, tag }) {
110
+ if (force) return true
111
+ if (typeof confirm === 'function') return Boolean(await confirm(modified))
112
+ if (!process.stdin.isTTY) return null
113
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
114
+ try {
115
+ log(tag + chalk.yellow(`将覆盖 ${modified.length} 个本地已修改的技能文件(你的本地改动将被丢弃):`))
116
+ for (const rel of modified) log(chalk.yellow(' ' + rel))
117
+ const ans = await rl.question(chalk.bold('确认用包内版本覆盖以上文件?[y/N] '))
118
+ return /^(y|yes|是|确认|ok)$/i.test(ans.trim())
119
+ } finally {
120
+ rl.close()
121
+ }
122
+ }
123
+
98
124
  /**
99
125
  * 执行 skills 升级。
100
126
  * @param {object} opts
101
127
  * @param {string} [opts.targetDir='.'] 目标工程根(skills 落在 <targetDir>/skills)
102
- * @param {boolean} [opts.force] 覆盖本地已修改的 shipped skill
128
+ * @param {boolean} [opts.force] 跳过确认直接覆盖本地已修改的 shipped skill(非交互必备)
103
129
  * @param {boolean} [opts.dryRun] 只报告不落盘
104
130
  * @param {boolean} [opts.prune] 额外删除包内已不存在的技能文件(默认保留)
105
- * @param {boolean} [opts.strict] 存在未覆盖 MODIFIED 时返回非 0
131
+ * @param {(modified:string[])=>boolean|Promise<boolean>} [opts.confirm] 注入式确认(测试用,默认交互)
106
132
  * @param {ReturnType<import('./config.js').loadConfig>} [opts.config]
107
133
  * @param {(msg: string) => void} [opts.log]
108
134
  * @returns {Promise<number>} 进程退出码
@@ -112,7 +138,7 @@ export async function runSkillsUpgrade({
112
138
  force = false,
113
139
  dryRun = false,
114
140
  prune = false,
115
- strict = false,
141
+ confirm = null,
116
142
  config,
117
143
  log = (m) => console.log(m),
118
144
  } = {}) {
@@ -141,13 +167,36 @@ export async function runSkillsUpgrade({
141
167
  const version = ownVersion()
142
168
  const diff = diffSkills(src, destSkills)
143
169
 
170
+ if (!diff.added.length && !diff.modified.length && !diff.orphan.length && fs.existsSync(destSkills)) {
171
+ dim(`skills 已是最新(cardbuddy@${version},无新增 / 修改 / 孤儿)`)
172
+ return 0
173
+ }
174
+
175
+ // 确认门禁:存在将被覆盖的 MODIFIED 时,先确认
176
+ let overwriteModified = false
177
+ if (diff.modified.length && !dryRun) {
178
+ const consent = await resolveOverwriteConsent({ modified: diff.modified, force, confirm, log, tag })
179
+ if (consent === null) {
180
+ warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件将被覆盖,但当前非交互环境无法确认。`)
181
+ dim(' 加 --force 跳过确认直接覆盖,或在交互终端运行本命令确认。')
182
+ return 1
183
+ }
184
+ overwriteModified = consent
185
+ if (!overwriteModified) {
186
+ warn(`已跳过 ${diff.modified.length} 个 MODIFIED 文件(未确认覆盖,本地副本保留);其余变更仍会进行。`)
187
+ }
188
+ } else if (diff.modified.length && dryRun) {
189
+ warn(`dry-run:${diff.modified.length} 个已修改的技能文件将被覆盖(确认后)。`)
190
+ }
191
+
144
192
  let copied = 0
145
193
  let bytes = 0
146
194
  let overwritten = 0
195
+ let added = 0
147
196
  let deleted = 0
148
197
 
149
- // ADDED 总是复制;MODIFIED 仅 --force 时复制
150
- const toCopy = [...diff.added, ...(force ? diff.modified : [])]
198
+ // ADDED 总是复制;MODIFIED 仅当用户确认(或 --force)时复制——这正是升级的目的
199
+ const toCopy = [...diff.added, ...(overwriteModified ? diff.modified : [])]
151
200
  for (const rel of toCopy) {
152
201
  const sp = path.join(src, rel)
153
202
  const dp = path.join(destSkills, rel)
@@ -159,15 +208,7 @@ export async function runSkillsUpgrade({
159
208
  bytes += fs.statSync(sp).size
160
209
  copied++
161
210
  if (isModified) overwritten++
162
- }
163
-
164
- // MODIFIED 未 --force:警告(不复制,防丢改动)
165
- if (!force && diff.modified.length) {
166
- warn(`⚠ 检测到 ${diff.modified.length} 个已修改的技能文件(本地副本与包内不一致),默认不覆盖,避免丢失你的改动:`)
167
- for (const rel of diff.modified) say(chalk.yellow(` ${rel}`))
168
- dim(' 如需用包内版本覆盖,加 --force;想先看差异,加 --dry-run 后人工对比。')
169
- } else if (force && diff.modified.length) {
170
- warn(`--force:已用包内版本覆盖 ${diff.modified.length} 个本地修改的技能文件(你的改动将被丢弃)`)
211
+ else added++
171
212
  }
172
213
 
173
214
  // ORPHAN:默认保留;--prune 才删除
@@ -207,14 +248,12 @@ export async function runSkillsUpgrade({
207
248
  say(chalk.dim(` 目标:${targetNote}`))
208
249
  say(
209
250
  chalk.dim(
210
- ` 新增 ${diff.added.length} · 相同 ${diff.same.length} · 覆盖 ${overwritten} · 保留私有 ${diff.orphan.length}` +
251
+ ` 新增 ${added} · 相同 ${diff.same.length} · 覆盖 ${overwritten}` +
252
+ `${overwriteModified ? '' : ` · 跳过修改 ${diff.modified.length - overwritten}`}` +
253
+ ` · 保留私有 ${diff.orphan.length}` +
211
254
  `${prune ? ` · 删除孤儿 ${deleted}` : ''} · 体积 ${fmtBytes(bytes)}`
212
255
  )
213
256
  )
214
257
 
215
- if (strict && diff.modified.length && !force) {
216
- say(chalk.red('(--strict:存在未覆盖的 MODIFIED 文件,返回非 0)'))
217
- return 2
218
- }
219
258
  return 0
220
259
  }
@@ -315,6 +315,49 @@ function createEventsEndpoint() {
315
315
  return { handler, notify }
316
316
  }
317
317
 
318
+ /**
319
+ * 事件循环看门狗:周期性测量循环滞后,超阈值时打日志 + 活动资源清单。
320
+ *
321
+ * 背景:dev server 单进程承载 AI 回合 / 远端同步 / 卡片 CRUD / Vite SSR。任何一个操作堵住
322
+ * 事件循环(同步大计算、挂死的子进程、CPU 被同机 Chrome 抢占),症状都是「端口还在听、
323
+ * 但所有请求都不响应」。没有这个看门狗时这类故障完全无迹可查;有了它,日志里会留下
324
+ * 「滞后多久 + 当时挂着哪些资源(timer / socket / child process)」的直接证据。
325
+ *
326
+ * 只观测、不干预:不做任何自动恢复动作(误杀正在跑的长任务代价更大),把事实留给开发者判断。
327
+ * @param {{ intervalMs?: number, lagWarnMs?: number }} [opts]
328
+ */
329
+ function installEventLoopWatchdog({ intervalMs = 2000, lagWarnMs = 5000 } = {}) {
330
+ let last = Date.now()
331
+ let warned = false
332
+ const timer = setInterval(() => {
333
+ const now = Date.now()
334
+ const lag = now - last - intervalMs
335
+ last = now
336
+ if (lag <= lagWarnMs) {
337
+ warned = false
338
+ return
339
+ }
340
+ // 连续超标只报警一次,恢复后重置,避免把日志刷爆
341
+ if (warned) return
342
+ warned = true
343
+ let resources = ''
344
+ try {
345
+ resources = process.getActiveResourcesInfo().join(', ')
346
+ } catch {
347
+ /* 老版本 Node 没有该 API */
348
+ }
349
+ console.warn(
350
+ srcTag +
351
+ chalk.yellow(
352
+ `⚠ 事件循环滞后 ${(lag / 1000).toFixed(1)}s(阈值 ${lagWarnMs / 1000}s):服务可能暂时无响应。` +
353
+ ` 活动资源:${resources || '(unknown)'}`
354
+ )
355
+ )
356
+ }, intervalMs)
357
+ if (typeof timer.unref === 'function') timer.unref()
358
+ return timer
359
+ }
360
+
318
361
  /**
319
362
  * 监听 src/cards 变更并(防抖)推送。
320
363
  *
@@ -474,6 +517,12 @@ export async function startServer(userConfig = {}) {
474
517
 
475
518
  watchCards(config.root, events.notify)
476
519
 
520
+ // 事件循环看门狗:dev server 是单进程服务一切(AI 回合 / 远端同步 / 卡片 CRUD / Vite SSR)。
521
+ // 任何一个操作把事件循环堵住(同步大计算、挂死的子进程等待、CPU 被同机 Chrome 抢占),
522
+ // 症状都是「端口还在、但所有请求都不响应」——而这种故障此前无任何线索可查。
523
+ // 这里周期性测循环滞后,超阈值就打日志 + 当时的活动资源清单,让下次「僵死」有据可查。
524
+ installEventLoopWatchdog({ intervalMs: 2000, lagWarnMs: 5000 })
525
+
477
526
  const server = tls
478
527
  ? https.createServer(tls, app)
479
528
  : http.createServer(app)
@@ -1,12 +1,13 @@
1
1
  <!--
2
2
  多列布局容器(官方组件 MultiColumn)。
3
3
 
4
- 预览运行时此前未注册该组件,导致画布显示「未知组件」占位(问题 #4)。
5
- 本组件对齐 Row/Column 的容器范式:用 StyleParser 解析样式(rect / flex / padding …),
6
- 渲染默认插槽内容;当 `column` 属性 > 1 时切换为 CSS Grid 多列布局(列数 = column)。
4
+ 官方 component-meta 里 MultiColumn 没有 column 属性(properties: []),
5
+ 真实运行时语义 = **直接子节点数即列数,单行均分**(N 个子节点 → N 等宽列)。
6
+ 预览器此前自造 `column=2` 默认值,导致 4 子节点被排成 2×2(overseaTransfer
7
+ 步骤条 row_hf 实测踩坑:内容高度翻倍后被 AbsoluteLayout 裁切,圆圈/标签错位)。
7
8
 
8
- 说明:当前测试卡的 MultiColumn 实例(comp_3)为「空容器」(无插槽子节点、无 column 属性),
9
- 此处仍提供完整的多列渲染能力,便于后续接入真实带子节点的多列布局卡片。
9
+ 实现:显式传 column > 0 时按指定列数(保留扩展口子);否则按默认插槽的
10
+ 子 vnode 数自动取列数,>1 时切换 CSS Grid 单行均分(flex gap 兼作列间距)。
10
11
  -->
11
12
  <template>
12
13
  <div ref="mc" class="ce_multicolumn" :style="styleObj" @click="$emit('click', $event)">
@@ -15,6 +16,7 @@
15
16
  </template>
16
17
 
17
18
  <script>
19
+ import { Comment, Text } from 'vue'
18
20
  import StyleParser from '../runtime/StyleParser.js'
19
21
  import base from './base.js'
20
22
 
@@ -24,16 +26,23 @@ export default {
24
26
  mixins: [base],
25
27
  props: {
26
28
  styleProps: { type: Object, default: () => ({}) },
27
- column: { type: [Number, String], default: 2 },
29
+ /** 列数。0 = 自动(默认):按子节点数单行均分,对齐官方「无 column 属性」语义 */
30
+ column: { type: [Number, String], default: 0 },
28
31
  },
29
32
  computed: {
33
+ cols() {
34
+ const explicit = parseInt(this.column, 10)
35
+ if (explicit > 0) return explicit
36
+ const vnodes = this.$slots.default ? this.$slots.default() : []
37
+ // 只统计真实子组件 vnode;注释 / 纯文本节点不计入
38
+ return vnodes.filter((v) => v && v.type !== Comment && v.type !== Text).length
39
+ },
30
40
  styleObj() {
31
41
  const s = new StyleParser(this, this.styleProps).parse()
32
- const cols = parseInt(this.column, 10)
33
- if (cols && cols > 1) {
34
- // 多列:用 grid 把子节点按 column 列均分;flex gap 同时充当 grid 行列间距。
42
+ if (this.cols > 1) {
43
+ // 多列:用 grid 把子节点单行均分;flex gap 同时充当 grid 列间距。
35
44
  s.display = 'grid'
36
- s.gridTemplateColumns = `repeat(${cols}, 1fr)`
45
+ s.gridTemplateColumns = `repeat(${this.cols}, 1fr)`
37
46
  }
38
47
  return s
39
48
  },
@@ -111,7 +111,7 @@ export function createCardRuntime(rootScopeRaw, adapter) {
111
111
  },
112
112
  /**
113
113
  * 切换卡片 / 绑定数据整体替换:**原地改写**现有响应式 rootScope,保持引用不变,
114
- * 使 App.vue 的 `:scope="rt.rootScope"` 绑定、所有 `rt.rootScope` 读取方与画布
114
+ * 使 App.vue 的 `:scope="rt.state"` 绑定、所有 `rt.rootScope` 读取方与画布
115
115
  * 实时看到新数据。
116
116
  *
117
117
  * 早期实现把 rootScope 重赋成一个「新 reactive 对象」,导致 RenderTree 的 scope
@@ -29,9 +29,28 @@ if (!process.env.DSL_CARD_PROJECT_ROOT) process.env.DSL_CARD_PROJECT_ROOT = ROOT
29
29
  // 仍可用 CARD_API_BASE 覆盖(如 dev server 跑在非默认端口)。
30
30
  const BASE = (process.env.CARD_API_BASE || devApiBase()).replace(/\/$/, '')
31
31
 
32
+ /**
33
+ * dev server 请求超时。
34
+ *
35
+ * 为什么必须有:AI 回合的工具调度(card_read / render_status / validate / screenshot…)全部
36
+ * 经这里打到 dev server。裸 fetch 没有超时——dev server(Vite SSR / Python 校验子进程)一旦
37
+ * 卡住,工具调用就永久挂起;而回合墙钟只在**每轮开头**检查,挂在工具调用里时永远走不到那里,
38
+ * 结果是该卡的回合锁被无限期占用,之后每条消息都拿到 409「已有一个回合在跑」。
39
+ * 默认 120s 足够覆盖 SSR 渲染 + 8s Python 校验 + 截图,又不会让真挂死无限拖下去。
40
+ */
41
+ const HTTP_TIMEOUT_MS = Number(process.env.CARDBUDDY_MCP_HTTP_TIMEOUT_MS || 120000)
42
+
43
+ /** 带超时的 fetch:超时抛错(含明确文案),由调用方的降级逻辑处理 */
44
+ function httpFetch(p, init = {}) {
45
+ const ctl = new AbortController()
46
+ const timer = setTimeout(() => ctl.abort(), HTTP_TIMEOUT_MS)
47
+ if (typeof timer.unref === 'function') timer.unref()
48
+ return fetch(BASE + p, { ...init, signal: ctl.signal }).finally(() => clearTimeout(timer))
49
+ }
50
+
32
51
  /** 发起 GET,返回 { status, buf, text, json, contentType };任何网络错误向上抛,由调用方决定降级 */
33
52
  async function httpGet(p) {
34
- const res = await fetch(BASE + p, { headers: { Accept: 'application/json, image/png' } })
53
+ const res = await httpFetch(p, { headers: { Accept: 'application/json, image/png' } })
35
54
  const buf = Buffer.from(await res.arrayBuffer())
36
55
  let text = null
37
56
  let json = null
@@ -46,7 +65,7 @@ async function httpGet(p) {
46
65
 
47
66
  /** 发起 PATCH(JSON body)——目前只用于卡片重命名(PATCH /__card/<id>) */
48
67
  async function httpPatch(p, body) {
49
- const res = await fetch(BASE + p, {
68
+ const res = await httpFetch(p, {
50
69
  method: 'PATCH',
51
70
  headers: { 'Content-Type': 'application/json' },
52
71
  body: JSON.stringify(body || {}),
@@ -65,7 +84,7 @@ async function httpPatch(p, body) {
65
84
 
66
85
  /** 发起 POST(JSON body) */
67
86
  async function httpPost(p, body) {
68
- const res = await fetch(BASE + p, {
87
+ const res = await httpFetch(p, {
69
88
  method: 'POST',
70
89
  headers: { 'Content-Type': 'application/json' },
71
90
  body: JSON.stringify(body || {}),
@@ -3524,22 +3524,36 @@ function listCcSwitchProviders() {
3524
3524
  function readBody(req, limitBytes = 8 * 1024 * 1024) {
3525
3525
  return new Promise((resolve, reject) => {
3526
3526
  let data = ''
3527
+ let settled = false
3528
+ const done = (fn, v) => {
3529
+ if (settled) return
3530
+ settled = true
3531
+ fn(v)
3532
+ }
3527
3533
  req.setEncoding('utf8')
3528
3534
  req.on('data', (chunk) => {
3529
3535
  data += chunk
3530
3536
  if (data.length > limitBytes) {
3531
- reject(new Error('body too large'))
3537
+ done(reject, new Error('body too large'))
3532
3538
  req.destroy()
3533
3539
  }
3534
3540
  })
3535
3541
  req.on('end', () => {
3536
3542
  try {
3537
- resolve(data ? JSON.parse(data) : {})
3543
+ done(resolve, data ? JSON.parse(data) : {})
3538
3544
  } catch (e) {
3539
- reject(e)
3545
+ done(reject, e)
3546
+ }
3547
+ })
3548
+ req.on('error', (e) => done(reject, e))
3549
+ // 客户端中断(断网 / 主动取消 / 关标签页)时 req 只发 'aborted'+'close'、不发 'end':
3550
+ // 不收口会让这个 Promise 永远 pending,handler 挂着不返回、连接与回合锁一起泄漏。
3551
+ req.on('aborted', () => done(reject, Object.assign(new Error('request aborted'), { code: 'aborted' })))
3552
+ req.on('close', () => {
3553
+ if (!req.complete) {
3554
+ done(reject, Object.assign(new Error('request aborted'), { code: 'aborted' }))
3540
3555
  }
3541
3556
  })
3542
- req.on('error', reject)
3543
3557
  })
3544
3558
  }
3545
3559
 
@@ -4186,6 +4200,33 @@ function transientRetryDelays() {
4186
4200
 
4187
4201
  const nap = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
4188
4202
 
4203
+ /**
4204
+ * 带硬超时的 fetch:模型网关「接受连接但不回数据」(响应卡死)时,原生 fetch 会一直挂到 OS 层
4205
+ * 超时(分钟级),既拖住当前请求又泄漏 socket。这里统一套一个 AbortController 超时,让所有出向
4206
+ * 模型请求(探测 / 连通性自测 / 流式)都有明确的失败边界,卡死立刻变「错误收敛」而非「服务假死」。
4207
+ * @param {string} url
4208
+ * @param {object} init fetch 选项(signal / method / headers / body 等)
4209
+ * @param {number} [timeoutMs] 超时毫秒;可用 AI_HTTP_TIMEOUT_MS 覆盖(默认 20000)
4210
+ */
4211
+ function fetchWithTimeout(url, init = {}, timeoutMs) {
4212
+ const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : Number(process.env.AI_HTTP_TIMEOUT_MS) || 20000
4213
+ // 已有上层 signal(流式 stall 看门狗 / 用户 stop)时合并,避免互相覆盖
4214
+ const parent = init.signal
4215
+ const ctl = new AbortController()
4216
+ const timer = setTimeout(() => ctl.abort(), ms)
4217
+ if (typeof timer.unref === 'function') timer.unref()
4218
+ const onParentAbort = () => ctl.abort()
4219
+ if (parent && typeof parent.addEventListener === 'function') {
4220
+ if (parent.aborted) ctl.abort()
4221
+ else parent.addEventListener('abort', onParentAbort, { once: true })
4222
+ }
4223
+ const cleanup = () => {
4224
+ clearTimeout(timer)
4225
+ if (parent && typeof parent.removeEventListener === 'function') parent.removeEventListener('abort', onParentAbort)
4226
+ }
4227
+ return fetch(url, { ...init, signal: ctl.signal }).finally(cleanup)
4228
+ }
4229
+
4189
4230
  /** 网关静默后的重试间隔:AI_STALL_RETRY_MS 可覆盖(单测压成 0 提速);次数多于间隔就沿用最后一个 */
4190
4231
  function stallRetryDelays() {
4191
4232
  const list = String(process.env.AI_STALL_RETRY_MS || '2000,5000,10000')
@@ -4268,7 +4309,7 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
4268
4309
  code: 'gateway-retry',
4269
4310
  text: `网关返回 HTTP ${resp.status}(瞬时错误):${wait / 1000} 秒后自动重试本步(第 ${attempt + 1}/${delays.length} 次),任务不中断`,
4270
4311
  })
4271
- await nap(wait)
4312
+ if (!(await napUnlessAborted(wait, signal))) throw new Error('请求已中止')
4272
4313
  } catch (e) {
4273
4314
  if (signal?.aborted) throw e
4274
4315
  lastErr = e
@@ -4279,7 +4320,7 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
4279
4320
  code: 'gateway-retry',
4280
4321
  text: `网关连接失败(${String(e?.message || e).slice(0, 80)}):${wait / 1000} 秒后自动重试本步`,
4281
4322
  })
4282
- await nap(wait)
4323
+ if (!(await napUnlessAborted(wait, signal))) throw new Error('请求已中止')
4283
4324
  }
4284
4325
  }
4285
4326
  const err = new Error(`LLM 网络错误:${String(lastErr?.message || lastErr).slice(0, 200)}`)
@@ -4508,7 +4549,13 @@ async function* callLLM({ baseUrl, apiKey, model, messages, tools, maxTokens = 4
4508
4549
  while (true) {
4509
4550
  if (signal?.aborted) return
4510
4551
  const { done, value } = await reader.read()
4511
- if (done) break
4552
+ // 流 EOF(含网关把错误包成 HTTP 200 + 非 SSE body 返回、且不带 [DONE] 的场景):
4553
+ // 必须连外层 parse 循环一起退出。只 break 内层会让外层 while(true) 立刻重入,
4554
+ // 而已结束流的 read() 立即 resolve {done:true} —— 整个循环退化成纯 microtask 空转,
4555
+ // 事件循环被饿死,8787 对所有请求(含新连接)永久无响应(2026-10-20 实测:
4556
+ // newapi 过期模型返回 200 HTML,服务 100% CPU 卡死,刷新页面也连不上)。
4557
+ // break parse 后走下面的「流末尾残留」兜底解析,行为与 [DONE] 路径一致。
4558
+ if (done) break parse
4512
4559
  // 收到字节就冒一个「连接还活着」事件:轮循环的静默看门狗据此区分「网关彻底不回应」与
4513
4560
  // 「模型正在长思考 / 正在拼超长工具参数,中转网关只在末尾批量 flush」。
4514
4561
  // 此前看门狗只认 reasoning / content / tool_calls 增量:网关缓冲工具参数时连接明明是活的,
@@ -4785,7 +4832,7 @@ async function probeVisionSupport({ baseUrl, apiKey, model }) {
4785
4832
  ],
4786
4833
  })
4787
4834
  const call = async (body) => {
4788
- const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
4835
+ const r = await fetchWithTimeout(url, { method: 'POST', headers, body: JSON.stringify(body) })
4789
4836
  const text = await r.text().catch(() => '')
4790
4837
  let json = null
4791
4838
  try {
@@ -4874,7 +4921,7 @@ async function runMaxTokensProbe({ baseUrl, apiKey, model }, { binary = true } =
4874
4921
  let source = ''
4875
4922
  try {
4876
4923
  if (maxTokensMetaMiss.has(sig)) throw new Error('meta-miss')
4877
- const r = await fetch(`${base}/models`, { headers })
4924
+ const r = await fetchWithTimeout(`${base}/models`, { headers })
4878
4925
  if (r.ok) {
4879
4926
  const j = await r.json().catch(() => null)
4880
4927
  const list = Array.isArray(j && j.data) ? j.data : []
@@ -4899,7 +4946,7 @@ async function runMaxTokensProbe({ baseUrl, apiKey, model }, { binary = true } =
4899
4946
  if (binary && !capCache.has(sig)) {
4900
4947
  for (const cand of MAXTOKENS_CANDIDATES) {
4901
4948
  try {
4902
- const r = await fetch(`${base}/chat/completions`, {
4949
+ const r = await fetchWithTimeout(`${base}/chat/completions`, {
4903
4950
  method: 'POST',
4904
4951
  headers,
4905
4952
  body: JSON.stringify({ model, stream: false, max_tokens: cand, messages: [{ role: 'user', content: 'ping' }] }),
@@ -5435,8 +5482,15 @@ export function createAiChatMiddleware(opts = {}) {
5435
5482
  return true
5436
5483
  }
5437
5484
 
5438
- return async (req, res, next) => {
5439
- const rawUrl = req.url || ''
5485
+ // 边界兜底:本中间件是 async 函数,而宿主(connect)不会 await / catch 异步中间件抛出的错误,
5486
+ // 任意逃逸的 rejection 会变成 unhandledRejection,Node ≥15 默认直接终止进程 —— 一个卡死的模型
5487
+ // 请求就可能让整个 8787 服务「不再响应」。这里把整段逻辑包进 run(),在边界统一 catch:
5488
+ // 既结束响应、释放单飞锁,又不让异常逃逸到进程层。
5489
+ return (req, res, next) => {
5490
+ let _turnKey
5491
+ let _turnLock
5492
+ const run = async () => {
5493
+ const rawUrl = req.url || ''
5440
5494
  const qIdx = rawUrl.indexOf('?')
5441
5495
  const pathname = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl
5442
5496
  if (!pathname.startsWith('/__ai/')) return next()
@@ -6110,6 +6164,9 @@ export function createAiChatMiddleware(opts = {}) {
6110
6164
  }
6111
6165
  const turnLock = { since: Date.now(), turnTimeoutMs: finalConfig.turnTimeoutMs }
6112
6166
  activeTurns.set(turnKey, turnLock)
6167
+ // 暴露给边界 catch:逃逸异常时仍能把这把单飞锁放掉,避免这张卡彻底卡死
6168
+ _turnKey = turnKey
6169
+ _turnLock = turnLock
6113
6170
  /** 释放本回合的单飞锁(幂等:只删自己那把,避免误删接管者的锁) */
6114
6171
  const releaseTurnLock = () => {
6115
6172
  if (activeTurns.get(turnKey) === turnLock) activeTurns.delete(turnKey)
@@ -7224,6 +7281,29 @@ export function createAiChatMiddleware(opts = {}) {
7224
7281
  releaseTurnLock()
7225
7282
  if (!res.writableEnded) res.end()
7226
7283
  }
7284
+ }
7285
+ run()
7286
+ .catch((err) => {
7287
+ try {
7288
+ log.error?.('[ai-chat] 回合未捕获错误(已在中间件边界兜底,不影响 8787 其余请求):', err)
7289
+ } catch {}
7290
+ // 释放单飞锁(与 finally 同款幂等判定),避免这张卡因一次异常永久卡死
7291
+ try {
7292
+ if (_turnKey != null && _turnLock && activeTurns.get(_turnKey) === _turnLock) {
7293
+ activeTurns.delete(_turnKey)
7294
+ }
7295
+ } catch {}
7296
+ // 兜底结束响应:SSE 已开始则直接收尾;尚未发头则回 500,绝不留一个永不结束的连接
7297
+ try {
7298
+ if (!res.headersSent && !res.writableEnded) {
7299
+ res.statusCode = 500
7300
+ res.setHeader('Content-Type', 'application/json')
7301
+ res.end(JSON.stringify({ error: 'internal-error', message: String((err && err.message) || err) }))
7302
+ } else if (!res.writableEnded) {
7303
+ res.end()
7304
+ }
7305
+ } catch {}
7306
+ })
7227
7307
  }
7228
7308
  }
7229
7309