cardbuddy 0.13.3 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -7
- package/dist/init.js +15 -4
- package/dist/portGuard.js +2 -2
- package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
- package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +14 -6
- package/dist/vendored/card-editor/api.js +6 -6
- package/dist/vendored/mcp/client.mjs +53 -3
- package/dist/vendored/mcp/tools.mjs +17 -1
- package/dist/vendored/server/core/ai-chat.js +1443 -271
- package/dist/vendored/server/core/cardFiles.js +271 -39
- package/dist/vendored/server/core/cardImportMeta.js +16 -0
- package/dist/vendored/server/core/chatHistory.js +122 -0
- package/dist/vendored/server/core/index.js +10 -3
- package/dist/vendored/server/core/turnJournal.js +386 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
- package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
- package/dist/vendored/tools/finmall-env.mjs +6 -6
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
- package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合日志(turn journal):把一轮 AI 对话的完整 SSE 事件流按 seq 追加落盘。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它(P2 的地基):
|
|
5
|
+
* ① 断线续跑 —— 客户端断开后回合在服务端继续跑(detached),事件全在日志里,
|
|
6
|
+
* 重连 / 刷新 / 换标签页都能按 seq 回放 + 续订,几十分钟的建卡不再「连不上就等于没了」。
|
|
7
|
+
* ② 服务端权威历史 —— 回合结束时把日志 reduce 成一条 assistant 消息写进 chatHistory,
|
|
8
|
+
* 浏览器侧的 POST 退化为合并校对:客户端死掉(崩溃 / 关标签页)也不再丢轨迹。
|
|
9
|
+
* ③ 事后可查 —— .cache/ai-turns/<cardId>.jsonl 就是这轮发生过什么的完整证据链,
|
|
10
|
+
* 排查「模型为什么说改好了其实没改」时比对话记录更细(含每次重试与折叠)。
|
|
11
|
+
*
|
|
12
|
+
* 形态:一张卡一个 jsonl(跨回合追加,行内带 turnId),行 = SSE 事件 + {seq, ts}。
|
|
13
|
+
* 写入走 250ms 合批 appendFile:text-delta 一秒能来几十条,逐条同步写盘会把事件循环打碎。
|
|
14
|
+
* 截图 base64 不入日志(与对话记录同取向:只留 mime 标记),否则一轮几张图就几 MB。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { safeCardId } from './chatHistory.js'
|
|
19
|
+
|
|
20
|
+
/** 单个日志文件的上限:超了轮转成 .1.jsonl(只留一代旧档),避免 .cache 无限涨 */
|
|
21
|
+
const JOURNAL_MAX_BYTES = 4 * 1024 * 1024
|
|
22
|
+
/** 合批写盘间隔:再短 IO 次数翻倍,再长断电解剖时丢的窗口变大 */
|
|
23
|
+
const FLUSH_INTERVAL_MS = 250
|
|
24
|
+
|
|
25
|
+
/** 回合日志目录(.cache/ 不入库):与 ai-chats 同级,删卡时一并清理 */
|
|
26
|
+
export function turnsDir(root) {
|
|
27
|
+
return path.join(root, '.cache', 'ai-turns')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function journalFile(root, cardId) {
|
|
31
|
+
const id = safeCardId(cardId)
|
|
32
|
+
return id ? path.join(turnsDir(root), `${id}.jsonl`) : ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 内存里的活跃回合:cardId -> TurnJournal。reattach / stop / 收尾写历史都从这里找 */
|
|
36
|
+
const liveJournals = new Map()
|
|
37
|
+
|
|
38
|
+
export function getLiveJournal(cardId) {
|
|
39
|
+
return liveJournals.get(String(cardId || '')) || null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 读一份日志的行(服务重启后的回放 / 事后排查用)。
|
|
44
|
+
* 坏行(写到一半断电)直接跳过:日志是证据不是状态,容错比报错有用。
|
|
45
|
+
*/
|
|
46
|
+
export function readJournalLines(root, cardId) {
|
|
47
|
+
const file = journalFile(root, cardId)
|
|
48
|
+
if (!file || !fs.existsSync(file)) return []
|
|
49
|
+
const out = []
|
|
50
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
51
|
+
const t = line.trim()
|
|
52
|
+
if (!t) continue
|
|
53
|
+
try {
|
|
54
|
+
out.push(JSON.parse(t))
|
|
55
|
+
} catch {
|
|
56
|
+
/* 半截行跳过 */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 最近回合清单(面板「上一轮跑到哪了」与测试断言用):扫日志里的 turn-start / turn-end 配对。
|
|
64
|
+
* @returns {Array<{turnId:string, startedAt:number, endedAt:number, status:string, steps:number}>}
|
|
65
|
+
*/
|
|
66
|
+
export function listTurns(root, cardId, limit = 10) {
|
|
67
|
+
const lines = readJournalLines(root, cardId)
|
|
68
|
+
const byId = new Map()
|
|
69
|
+
for (const l of lines) {
|
|
70
|
+
if (l.type === 'turn-start') {
|
|
71
|
+
byId.set(l.turnId, { turnId: l.turnId, startedAt: l.ts || 0, endedAt: 0, status: 'running', steps: 0 })
|
|
72
|
+
} else if (l.type === 'turn-end' && byId.has(l.turnId)) {
|
|
73
|
+
const t = byId.get(l.turnId)
|
|
74
|
+
t.endedAt = l.ts || 0
|
|
75
|
+
t.status = l.status || 'done'
|
|
76
|
+
} else if (l.type === 'tool-result' && byId.has(l.turnId)) {
|
|
77
|
+
byId.get(l.turnId).steps++
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...byId.values()].slice(-limit)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class TurnJournal {
|
|
84
|
+
/**
|
|
85
|
+
* @param {{ root:string, cardId:string, turnId:string, startedAt:number, meta?:object }} opts
|
|
86
|
+
*/
|
|
87
|
+
constructor({ root, cardId, turnId, startedAt, meta = {} }) {
|
|
88
|
+
this.root = root
|
|
89
|
+
this.cardId = String(cardId || '')
|
|
90
|
+
this.turnId = turnId
|
|
91
|
+
this.startedAt = startedAt
|
|
92
|
+
this.meta = meta
|
|
93
|
+
this.seq = 0
|
|
94
|
+
/** 本回合全部行(回放 / reduce 的数据源;detached 期间也继续长) */
|
|
95
|
+
this.lines = []
|
|
96
|
+
/** 活着的续订连接(reattach 的 SSE res) */
|
|
97
|
+
this.subscribers = new Set()
|
|
98
|
+
this.status = 'running'
|
|
99
|
+
this.detached = false
|
|
100
|
+
this.closed = false
|
|
101
|
+
this.file = journalFile(root, cardId)
|
|
102
|
+
this._pending = ''
|
|
103
|
+
this._flushTimer = 0
|
|
104
|
+
this._writing = null
|
|
105
|
+
if (this.file) {
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true })
|
|
108
|
+
if (fs.existsSync(this.file) && fs.statSync(this.file).size > JOURNAL_MAX_BYTES) {
|
|
109
|
+
const old = `${this.file}.1`
|
|
110
|
+
try {
|
|
111
|
+
if (fs.existsSync(old)) fs.unlinkSync(old)
|
|
112
|
+
fs.renameSync(this.file, old)
|
|
113
|
+
} catch {
|
|
114
|
+
/* 轮转失败不致命:继续往原文件追加 */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
this.file = ''
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
liveJournals.set(this.cardId, this)
|
|
122
|
+
this.append({ type: 'turn-start', turnId: this.turnId, cardId: this.cardId, startedAt: this.startedAt, ...this.meta })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 记一条事件并返回带上 seq 的行(sseWrite 会把 seq 一并发给客户端,重连时按它对账)。
|
|
127
|
+
* 截图 base64 在这里被摘掉:日志要能长期留着,base64 一张图几百 KB 留不起。
|
|
128
|
+
*/
|
|
129
|
+
append(evt) {
|
|
130
|
+
if (this.closed) return null
|
|
131
|
+
this.seq += 1
|
|
132
|
+
let payload = evt
|
|
133
|
+
if (evt && evt.image && typeof evt.image === 'object') {
|
|
134
|
+
payload = { ...evt, image: { mime: evt.image.mime || 'image/png', omitted: true } }
|
|
135
|
+
}
|
|
136
|
+
const line = { seq: this.seq, ts: Date.now(), turnId: this.turnId, ...payload }
|
|
137
|
+
this.lines.push(line)
|
|
138
|
+
this._pending += `${JSON.stringify(line)}\n`
|
|
139
|
+
if (!this._flushTimer) this._flushTimer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS)
|
|
140
|
+
for (const res of this.subscribers) {
|
|
141
|
+
try {
|
|
142
|
+
if (!res.writableEnded && !res.destroyed) res.write(`data: ${JSON.stringify(line)}\n\n`)
|
|
143
|
+
} catch {
|
|
144
|
+
this.subscribers.delete(res)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return line
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 合批落盘;并发调用串成链,避免两个 appendFile 交错写坏行 */
|
|
151
|
+
flush() {
|
|
152
|
+
if (this._flushTimer) {
|
|
153
|
+
clearTimeout(this._flushTimer)
|
|
154
|
+
this._flushTimer = 0
|
|
155
|
+
}
|
|
156
|
+
if (!this.file || !this._pending) return Promise.resolve()
|
|
157
|
+
const chunk = this._pending
|
|
158
|
+
this._pending = ''
|
|
159
|
+
const job = (this._writing || Promise.resolve())
|
|
160
|
+
.then(() => {
|
|
161
|
+
try {
|
|
162
|
+
fs.appendFileSync(this.file, chunk, 'utf8')
|
|
163
|
+
} catch {
|
|
164
|
+
/* 写盘失败(磁盘满 / 目录被删):内存里仍在,续跑不受影响 */
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
this._writing = job
|
|
168
|
+
return job
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** seq 之后的行(回放用;afterSeq=0 即全量) */
|
|
172
|
+
replay(afterSeq = 0) {
|
|
173
|
+
const n = Number(afterSeq) || 0
|
|
174
|
+
return n > 0 ? this.lines.filter((l) => l.seq > n) : this.lines.slice()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
subscribe(res) {
|
|
178
|
+
this.subscribers.add(res)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
unsubscribe(res) {
|
|
182
|
+
this.subscribers.delete(res)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 客户端断开:回合继续跑,只打个标记(日志与收尾写历史都不受影响) */
|
|
186
|
+
markDetached() {
|
|
187
|
+
if (this.detached || this.closed) return
|
|
188
|
+
this.detached = true
|
|
189
|
+
this.append({ type: 'client-detached', turnId: this.turnId })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 回合收尾:落 turn-end、flush、通知续订者结束、退出活跃表 */
|
|
193
|
+
async close(status = 'done', extra = {}) {
|
|
194
|
+
if (this.closed) return
|
|
195
|
+
this.status = status
|
|
196
|
+
this.append({ type: 'turn-end', turnId: this.turnId, status, ...extra })
|
|
197
|
+
// 置 closed 必须晚于 turn-end:append 对已关闭日志直接丢弃,顺序反了这行永远写不进去
|
|
198
|
+
this.closed = true
|
|
199
|
+
await this.flush()
|
|
200
|
+
for (const res of this.subscribers) {
|
|
201
|
+
try {
|
|
202
|
+
if (!res.writableEnded && !res.destroyed) res.end()
|
|
203
|
+
} catch {
|
|
204
|
+
/* 连接早已不在 */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
this.subscribers.clear()
|
|
208
|
+
if (liveJournals.get(this.cardId) === this) liveJournals.delete(this.cardId)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 把事件流 reduce 成一条 assistant 消息(服务端权威历史的唯一来源)。
|
|
213
|
+
* 口径与前端 ChatPanel.onEvent 对齐:同一份事件,两边还原出同一条消息,
|
|
214
|
+
* 合并时才能按 turnId 认亲而不是出现「同一轮两条回答」。
|
|
215
|
+
*/
|
|
216
|
+
reduce() {
|
|
217
|
+
const msg = {
|
|
218
|
+
id: `srv-${this.turnId}`,
|
|
219
|
+
role: 'assistant',
|
|
220
|
+
content: '',
|
|
221
|
+
reasoning: '',
|
|
222
|
+
reasoningMs: 0,
|
|
223
|
+
toolCalls: [],
|
|
224
|
+
events: [],
|
|
225
|
+
error: '',
|
|
226
|
+
errorCode: '',
|
|
227
|
+
status: 'done',
|
|
228
|
+
turnId: this.turnId,
|
|
229
|
+
serverAuthored: true,
|
|
230
|
+
ts: this.startedAt,
|
|
231
|
+
elapsedMs: 0,
|
|
232
|
+
}
|
|
233
|
+
let thinkStartedAt = 0
|
|
234
|
+
let lastTs = this.startedAt
|
|
235
|
+
const rowOf = (id, name) => {
|
|
236
|
+
let row = msg.toolCalls.find((t) => t.toolCallId === id)
|
|
237
|
+
if (!row) {
|
|
238
|
+
row = { toolCallId: id, toolName: name || '', args: {}, result: undefined, error: '', ms: 0, startedAt: 0 }
|
|
239
|
+
msg.toolCalls.push(row)
|
|
240
|
+
}
|
|
241
|
+
return row
|
|
242
|
+
}
|
|
243
|
+
for (const l of this.lines) {
|
|
244
|
+
lastTs = l.ts || lastTs
|
|
245
|
+
switch (l.type) {
|
|
246
|
+
case 'reasoning-delta':
|
|
247
|
+
if (!thinkStartedAt) thinkStartedAt = l.ts
|
|
248
|
+
msg.reasoning += l.text || ''
|
|
249
|
+
break
|
|
250
|
+
case 'text-delta':
|
|
251
|
+
if (thinkStartedAt && !msg.reasoningMs) msg.reasoningMs = Math.max(0, l.ts - thinkStartedAt)
|
|
252
|
+
msg.content += l.text || ''
|
|
253
|
+
break
|
|
254
|
+
case 'tool-call-start': {
|
|
255
|
+
const row = rowOf(l.toolCallId, l.toolName)
|
|
256
|
+
row.toolName = l.toolName || row.toolName
|
|
257
|
+
row.startedAt = row.startedAt || l.ts
|
|
258
|
+
row.pending = true
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
case 'tool-call': {
|
|
262
|
+
const row = rowOf(l.toolCallId, l.toolName)
|
|
263
|
+
row.toolName = l.toolName || row.toolName
|
|
264
|
+
row.args = l.args || {}
|
|
265
|
+
row.pending = false
|
|
266
|
+
row.startedAt = row.startedAt || l.ts
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
case 'tool-result': {
|
|
270
|
+
const row = rowOf(l.toolCallId, l.toolName)
|
|
271
|
+
row.result = l.result ?? ''
|
|
272
|
+
row.truncated = Boolean(l.truncated)
|
|
273
|
+
if (l.error) row.error = String(l.error)
|
|
274
|
+
if (l.image) row.image = l.image
|
|
275
|
+
row.startedAt = row.startedAt || l.ts
|
|
276
|
+
row.ms = Math.max(0, l.ts - row.startedAt)
|
|
277
|
+
row.pending = false
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
case 'approval-request': {
|
|
281
|
+
const row = msg.toolCalls.find((t) => t.approvalId === l.approvalId) || msg.toolCalls[msg.toolCalls.length - 1]
|
|
282
|
+
if (row && row.result === undefined) {
|
|
283
|
+
row.approvalId = l.approvalId
|
|
284
|
+
if (l.command) row.command = l.command
|
|
285
|
+
row.needsApproval = true
|
|
286
|
+
}
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
case 'approval-decided': {
|
|
290
|
+
const row = msg.toolCalls.find((t) => t.approvalId === l.approvalId)
|
|
291
|
+
if (row) {
|
|
292
|
+
row.needsApproval = false
|
|
293
|
+
row.approvalNote = l.ok
|
|
294
|
+
? l.scope === 'turn' ? '本回合已授权,不再询问' : '已确认执行'
|
|
295
|
+
: l.reason ? `已拒绝执行(${l.reason})` : '已拒绝执行'
|
|
296
|
+
row.autoApproved = false
|
|
297
|
+
}
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
case 'approval-auto': {
|
|
301
|
+
const row = msg.toolCalls[msg.toolCalls.length - 1]
|
|
302
|
+
if (row) {
|
|
303
|
+
row.autoApproved = true
|
|
304
|
+
row.approvalNote = '免确认策略命中,已自动执行'
|
|
305
|
+
if (l.command) row.command = l.command
|
|
306
|
+
}
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
case 'notice':
|
|
310
|
+
msg.events.push({ ts: l.ts, level: l.level || 'warn', code: l.code || '', message: l.message || '' })
|
|
311
|
+
break
|
|
312
|
+
case 'round-retry':
|
|
313
|
+
msg.events.push({ ts: l.ts, level: 'warn', code: l.code || 'round-retry', message: l.message || `本步自动重试(第 ${l.attempt}/${l.attempts} 次)` })
|
|
314
|
+
break
|
|
315
|
+
case 'turn-end':
|
|
316
|
+
// 日志收尾行(close 写的):状态以它为准。中止可能走「上游抛错 → 外层 catch」这条路,
|
|
317
|
+
// 那条路上不会再发 aborted 事件,只看事件流会把「用户停了」误记成「跑完了」。
|
|
318
|
+
// 注意与下面的 'turn-ended'(后端预算/熔断的收尾事件)不是同一个东西。
|
|
319
|
+
if (l.status === 'aborted' || l.status === 'error') msg.status = l.status
|
|
320
|
+
if (l.error && !msg.error) msg.error = String(l.error)
|
|
321
|
+
if (l.detached) msg.detached = true
|
|
322
|
+
// 本回合累计用量 / 前缀缓存命中:随权威历史落盘,刷新后摘要条上的命中率不丢
|
|
323
|
+
if (l.usage) msg.usage = l.usage
|
|
324
|
+
break
|
|
325
|
+
case 'turn-ended':
|
|
326
|
+
msg.budget = { reason: l.reason || '', message: l.message || '' }
|
|
327
|
+
break
|
|
328
|
+
case 'error':
|
|
329
|
+
msg.error = l.message || ''
|
|
330
|
+
msg.errorCode = l.code || ''
|
|
331
|
+
msg.status = 'error'
|
|
332
|
+
break
|
|
333
|
+
case 'aborted':
|
|
334
|
+
msg.status = 'aborted'
|
|
335
|
+
break
|
|
336
|
+
default:
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (msg.reasoning && !msg.reasoningMs && thinkStartedAt) msg.reasoningMs = Math.max(0, lastTs - thinkStartedAt)
|
|
341
|
+
// 悬空调用(轮次被中断 / 报错时没等到结果)钉成终止态:与前端 revive 的口径一致
|
|
342
|
+
if (msg.status !== 'done') {
|
|
343
|
+
for (const tc of msg.toolCalls) {
|
|
344
|
+
if (!tc.error && tc.result === undefined) tc.error = '已终止:该调用未拿到结果(轮次被中断)'
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
msg.elapsedMs = Math.max(0, lastTs - this.startedAt)
|
|
348
|
+
return msg
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 开一份回合日志并登记进活跃表(同卡单飞锁保证同一时刻只有一份)。
|
|
354
|
+
* @returns {TurnJournal|null} 无 cardId 时返回 null(无卡回合不落日志,也无从续跑)
|
|
355
|
+
*/
|
|
356
|
+
export function openJournal({ root, cardId, turnId, startedAt, meta }) {
|
|
357
|
+
if (!cardId) return null
|
|
358
|
+
return new TurnJournal({ root, cardId, turnId, startedAt, meta })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 删卡联动:把这张卡的回合日志(含轮转旧档)一并清掉,.cache 里不留孤儿文件。
|
|
363
|
+
* 若此刻仍有活跃回合(删卡与建卡并发),先摘出活跃表并停止写盘 —— 卡片都没了,
|
|
364
|
+
* 续跑与权威历史都失去落点,留着反而会在下轮误判「上一轮还在跑」。
|
|
365
|
+
*/
|
|
366
|
+
export function deleteJournal(root, cardId) {
|
|
367
|
+
const file = journalFile(root, cardId)
|
|
368
|
+
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
369
|
+
const live = getLiveJournal(cardId)
|
|
370
|
+
if (live && live.file === file) {
|
|
371
|
+
live.closed = true
|
|
372
|
+
live.file = ''
|
|
373
|
+
if (live._flushTimer) {
|
|
374
|
+
clearTimeout(live._flushTimer)
|
|
375
|
+
live._flushTimer = 0
|
|
376
|
+
}
|
|
377
|
+
live._pending = ''
|
|
378
|
+
if (liveJournals.get(live.cardId) === live) liveJournals.delete(live.cardId)
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
for (const f of [file, file + '.1']) if (fs.existsSync(f)) fs.unlinkSync(f)
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
384
|
+
}
|
|
385
|
+
return { ok: true }
|
|
386
|
+
}
|