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.
- package/README.md +8 -8
- package/dist/cli.js +21 -8
- package/dist/init.js +3 -3
- package/dist/preview-editor/assets/codeBridgeHeartbeat.worker-Zb_A5RUd.js +1 -0
- package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
- package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/skills-upgrade.js +64 -25
- package/dist/standalone.js +49 -0
- package/dist/vendored/card-editor/components/MultiColumn.vue +19 -10
- package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
- package/dist/vendored/mcp/client.mjs +22 -3
- package/dist/vendored/server/core/ai-chat.js +92 -12
- package/dist/vendored/server/core/cardExport.js +831 -0
- package/dist/vendored/server/core/cardHistoryRemote.js +203 -0
- package/dist/vendored/server/core/cardImportMeta.js +6 -1
- package/dist/vendored/server/core/cardPublishVersions.js +91 -0
- package/dist/vendored/server/core/cardSave.js +143 -6
- package/dist/vendored/server/core/cardSync.js +467 -95
- package/dist/vendored/server/core/codeBridge.js +4 -2
- package/dist/vendored/server/core/index.js +16 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +657 -15
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +79 -8
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +25 -1
- package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_uqjKqI.css +0 -1
- 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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/dist/skills-upgrade.js
CHANGED
|
@@ -7,18 +7,22 @@
|
|
|
7
7
|
* 就是为这个场景而生,且**只动 skills/**,绝不碰 .env.cb / package.json / README.md
|
|
8
8
|
* (那是 `init --force` 的危险面,可能冲掉端口 / FinMall 配置 / AI 网关占位)。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* 行为模型(upgrade = 显式要把新版带进来,「有差异 = 应当覆盖」,不做好坏判定):
|
|
11
11
|
* ADDED 源有、目标无 → 复制(安全)
|
|
12
|
-
* SAME
|
|
13
|
-
* MODIFIED 两边都有、内容不同 →
|
|
14
|
-
* ORPHAN 目标有、源无 →
|
|
12
|
+
* SAME 两边一致 → 跳过
|
|
13
|
+
* MODIFIED 两边都有、内容不同 → 默认覆盖(这正是升级的目的,会丢弃本地副本的改动)
|
|
14
|
+
* ORPHAN 目标有、源无 → 默认保留(可能是私有 / 上游已下架)
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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]
|
|
128
|
+
* @param {boolean} [opts.force] 跳过确认直接覆盖本地已修改的 shipped skill(非交互必备)
|
|
103
129
|
* @param {boolean} [opts.dryRun] 只报告不落盘
|
|
104
130
|
* @param {boolean} [opts.prune] 额外删除包内已不存在的技能文件(默认保留)
|
|
105
|
-
* @param {boolean} [opts.
|
|
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
|
-
|
|
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
|
|
150
|
-
const toCopy = [...diff.added, ...(
|
|
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
|
-
` 新增 ${
|
|
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
|
}
|
package/dist/standalone.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
官方 component-meta 里 MultiColumn 没有 column 属性(properties: []),
|
|
5
|
+
真实运行时语义 = **直接子节点数即列数,单行均分**(N 个子节点 → N 等宽列)。
|
|
6
|
+
预览器此前自造 `column=2` 默认值,导致 4 子节点被排成 2×2(overseaTransfer
|
|
7
|
+
步骤条 row_hf 实测踩坑:内容高度翻倍后被 AbsoluteLayout 裁切,圆圈/标签错位)。
|
|
7
8
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3543
|
+
done(resolve, data ? JSON.parse(data) : {})
|
|
3538
3544
|
} catch (e) {
|
|
3539
|
-
reject
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5439
|
-
|
|
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
|
|