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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { markDirty, recordEdit, readImportMeta } from './cardImportMeta.js'
|
|
3
|
+
import { markDirty, recordEdit, readImportMeta, setImportMetaCardCode } from './cardImportMeta.js'
|
|
4
|
+
import { deleteChatHistory, chatHistoryDir, safeCardId } from './chatHistory.js'
|
|
5
|
+
// 回合日志(.cache/ai-turns/<id>.jsonl)与对话记录同生命周期:删卡时一并清掉,
|
|
6
|
+
// 否则「上一轮跑到哪了」会指向一张已经不存在的卡。
|
|
7
|
+
import { deleteJournal, turnsDir } from './turnJournal.js'
|
|
4
8
|
import { log } from './logger.js'
|
|
5
9
|
import { buildDataFromSchema } from './buildDataFromSchema.js'
|
|
6
10
|
|
|
@@ -19,46 +23,84 @@ import { buildDataFromSchema } from './buildDataFromSchema.js'
|
|
|
19
23
|
/** 扫描 src/cards/* 返回 [{id,name,pageId,mtime,imported}](导出供 MCP 复用);
|
|
20
24
|
* mtime 为 schema.json / data.json 中较新的修改时间(毫秒),供前端按修改时间排序;
|
|
21
25
|
* imported = 目录下存在 .import-meta.json 侧车(经远端同步导入而来),供 UI 打「导入卡」标记 */
|
|
26
|
+
/**
|
|
27
|
+
* 扫描单张卡片目录,抽取列表项元信息 {id,name,pageId,mtime,imported}。
|
|
28
|
+
* 抽出以便 listCards(全量)与 readCardListItem(单卡)复用同一套口径,避免漂移。
|
|
29
|
+
* 目录不存在 / 非目录返回 null(调用方据此跳过)。
|
|
30
|
+
*/
|
|
31
|
+
function scanCardMeta(cardsDir, id) {
|
|
32
|
+
const dir = path.join(cardsDir, id)
|
|
33
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null
|
|
34
|
+
let name = id
|
|
35
|
+
let pageId = null
|
|
36
|
+
let mtime = 0
|
|
37
|
+
try {
|
|
38
|
+
const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'))
|
|
39
|
+
if (schema && schema.name) name = schema.name
|
|
40
|
+
if (schema && schema.pageId != null) pageId = schema.pageId
|
|
41
|
+
} catch {
|
|
42
|
+
/* 读取失败则退用目录名 / pageId=null,不阻断列表 */
|
|
43
|
+
}
|
|
44
|
+
// 卡片修改时间:取 schema.json 的单文件 mtime(毫秒)。data.json 是 schema 的
|
|
45
|
+
// 单向派生产物,不应参与排序,否则它每次被重算都会让卡片「看起来更新」。
|
|
46
|
+
try {
|
|
47
|
+
mtime = fs.statSync(path.join(dir, 'schema.json')).mtimeMs
|
|
48
|
+
} catch {
|
|
49
|
+
/* 取不到修改时间则记为 0,不参与新→旧排序时排在最前 */
|
|
50
|
+
}
|
|
51
|
+
// 优先采用侧车 .import-meta.json 记录的本地编辑时间(editedAt 更准确反映「编辑」而非文件拷贝),
|
|
52
|
+
// 无侧车 / 无 editedAt 时退用上面的文件 mtime。
|
|
53
|
+
// 导入卡标记:存在 .import-meta.json 侧车即为「经远端同步导入」的卡,
|
|
54
|
+
// UI 据此在下拉项前置绿色圆点 / 网格格子上角打「导入」标签。
|
|
55
|
+
let imported = false
|
|
56
|
+
try {
|
|
57
|
+
const meta = readImportMeta(dir)
|
|
58
|
+
imported = !!meta
|
|
59
|
+
if (meta && meta.editedAt) {
|
|
60
|
+
const t = Date.parse(meta.editedAt)
|
|
61
|
+
if (!Number.isNaN(t)) mtime = t
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* 解析失败则保持文件 mtime / imported=false */
|
|
65
|
+
}
|
|
66
|
+
return { id, name, pageId, mtime, imported }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 进程内卡片列表快照缓存:dev 服务不再每次 GET /__cards 都全量 readdir + 逐张 JSON.parse。
|
|
71
|
+
* 快照仅在低频时机刷新:watcher 检测到 src/cards 下 .json 变更 / 本地 CRUD(新建·删除·改名)成功。
|
|
72
|
+
* 日常列表读取直接命中缓存,把 70+ 张卡片的磁盘扫描降到接近零(修复 A)。
|
|
73
|
+
* @type {Map<string, Array<{id:string,name:string,pageId:?string,mtime:number,imported:boolean}>>}
|
|
74
|
+
*/
|
|
75
|
+
const cardListCache = new Map()
|
|
76
|
+
|
|
77
|
+
/** 强制重扫并缓存列表(供 watcher / CRUD 调用) */
|
|
78
|
+
export function refreshCardList(root) {
|
|
79
|
+
const list = listCards(root)
|
|
80
|
+
cardListCache.set(root, list)
|
|
81
|
+
return list
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读取列表:命中进程内快照则直接返回,否则惰性全扫一次并缓存 */
|
|
85
|
+
export function getCardList(root) {
|
|
86
|
+
const hit = cardListCache.get(root)
|
|
87
|
+
if (hit) return hit
|
|
88
|
+
return refreshCardList(root)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 读单卡列表项(供前端 onCardsChanged 增量更新,O(1) 只读该卡 schema.json,不扫全目录) */
|
|
92
|
+
export function readCardListItem(root, id) {
|
|
93
|
+
return scanCardMeta(path.join(root, 'src', 'cards'), id)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 扫描 src/cards/* 返回 [{id,name,pageId,mtime,imported}](导出供 MCP 复用) */
|
|
22
97
|
export function listCards(root) {
|
|
23
98
|
const cardsDir = path.join(root, 'src', 'cards')
|
|
24
99
|
const list = []
|
|
25
100
|
if (fs.existsSync(cardsDir)) {
|
|
26
101
|
for (const id of fs.readdirSync(cardsDir)) {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
let name = id
|
|
30
|
-
let pageId = null
|
|
31
|
-
let mtime = 0
|
|
32
|
-
try {
|
|
33
|
-
const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'))
|
|
34
|
-
if (schema && schema.name) name = schema.name
|
|
35
|
-
if (schema && schema.pageId != null) pageId = schema.pageId
|
|
36
|
-
} catch {
|
|
37
|
-
/* 读取失败则退用目录名 / pageId=null,不阻断列表 */
|
|
38
|
-
}
|
|
39
|
-
// 卡片修改时间:取 schema.json 的单文件 mtime(毫秒)。data.json 是 schema 的
|
|
40
|
-
// 单向派生产物,不应参与排序,否则它每次被重算都会让卡片「看起来更新」。
|
|
41
|
-
try {
|
|
42
|
-
mtime = fs.statSync(path.join(dir, 'schema.json')).mtimeMs
|
|
43
|
-
} catch {
|
|
44
|
-
/* 取不到修改时间则记为 0,不参与新→旧排序时排在最前 */
|
|
45
|
-
}
|
|
46
|
-
// 优先采用侧车 .import-meta.json 记录的本地编辑时间(editedAt 更准确反映「编辑」而非文件拷贝),
|
|
47
|
-
// 无侧车 / 无 editedAt 时退用上面的文件 mtime。
|
|
48
|
-
// 导入卡标记:存在 .import-meta.json 侧车即为「经远端同步导入」的卡,
|
|
49
|
-
// UI 据此在下拉项前置绿色圆点 / 网格格子上角打「导入」标签。
|
|
50
|
-
let imported = false
|
|
51
|
-
try {
|
|
52
|
-
const meta = readImportMeta(dir)
|
|
53
|
-
imported = !!meta
|
|
54
|
-
if (meta && meta.editedAt) {
|
|
55
|
-
const t = Date.parse(meta.editedAt)
|
|
56
|
-
if (!Number.isNaN(t)) mtime = t
|
|
57
|
-
}
|
|
58
|
-
} catch {
|
|
59
|
-
/* 解析失败则保持文件 mtime / imported=false */
|
|
60
|
-
}
|
|
61
|
-
list.push({ id, name, pageId, mtime, imported })
|
|
102
|
+
const item = scanCardMeta(cardsDir, id)
|
|
103
|
+
if (item) list.push(item)
|
|
62
104
|
}
|
|
63
105
|
}
|
|
64
106
|
list.sort((a, b) => a.id.localeCompare(b.id))
|
|
@@ -123,11 +165,125 @@ export function writeCardFiles(dir, payload) {
|
|
|
123
165
|
return written
|
|
124
166
|
}
|
|
125
167
|
|
|
168
|
+
/** 卡片封面图支持扩展名(与 card-image 服务一致) */
|
|
169
|
+
const COVER_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif']
|
|
170
|
+
|
|
126
171
|
/**
|
|
127
|
-
*
|
|
172
|
+
* 把卡片目录内「与旧 id 同名」的封面图改名为「与新 id 同名」。
|
|
173
|
+
* card-image 服务按 base===id 取封面,故目录改名后封面文件名必须同步跟随,否则网格回退到实时截图。
|
|
174
|
+
*/
|
|
175
|
+
function renameCoverImage(dir, oldId, newId) {
|
|
176
|
+
try {
|
|
177
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
178
|
+
const dot = entry.lastIndexOf('.')
|
|
179
|
+
if (dot < 0) continue
|
|
180
|
+
const base = entry.slice(0, dot)
|
|
181
|
+
const ext = entry.slice(dot).toLowerCase()
|
|
182
|
+
if (base === oldId && COVER_EXTS.includes(ext)) {
|
|
183
|
+
try {
|
|
184
|
+
fs.renameSync(path.join(dir, entry), path.join(dir, newId + ext))
|
|
185
|
+
} catch {
|
|
186
|
+
/* 单图改名失败不阻断重命名主体 */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* 读目录失败忽略:封面缺失不致命 */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 把该卡的对话记录 / 回合日志缓存一并改名跟随(避免重命名后丢历史 / 留孤儿缓存) */
|
|
196
|
+
function moveCardCache(root, oldId, newId) {
|
|
197
|
+
const move = (dir, suffix) => {
|
|
198
|
+
if (!fs.existsSync(dir)) return
|
|
199
|
+
const from = path.join(dir, `${safeCardId(oldId)}${suffix}`)
|
|
200
|
+
const to = path.join(dir, `${safeCardId(newId)}${suffix}`)
|
|
201
|
+
if (from === to || !fs.existsSync(from)) return
|
|
202
|
+
try {
|
|
203
|
+
fs.renameSync(from, to)
|
|
204
|
+
} catch {
|
|
205
|
+
/* 目标已存在 / 并发占用则忽略,不阻断重命名 */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
move(chatHistoryDir(root), '.json')
|
|
209
|
+
move(turnsDir(root), '.jsonl')
|
|
210
|
+
move(turnsDir(root), '.jsonl.1')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 重命名卡片:改卡片目录名(src/cards/<oldId> → <newId>)+ 写回 schema.name/desc,
|
|
215
|
+
* 并把该卡的封面图 / 对话记录 / 回合日志缓存一并改名跟随(重命名后历史不丢、不留孤儿缓存)。
|
|
216
|
+
* - id 未传 / 等于旧 id:只改描述(schema.name/desc),不移动目录。
|
|
217
|
+
* - id 不同:先做目录撞名兜底,再原子 rename 目录。
|
|
218
|
+
* 导出供 MCP 降级模式复用。
|
|
219
|
+
* @throws {{ code?: string, message: string }} NOT_FOUND / INVALID_ID / INVALID_DESC / CONFLICT
|
|
220
|
+
* @returns {{ ok: true, id: string, oldId: string, renamed: boolean }}
|
|
221
|
+
*/
|
|
222
|
+
export function renameCard(root, oldId, payload = {}) {
|
|
223
|
+
const newId = String(payload.id || '').trim()
|
|
224
|
+
const desc = payload.desc == null ? '' : String(payload.desc).trim()
|
|
225
|
+
const cardsRoot = path.join(root, 'src', 'cards')
|
|
226
|
+
const oldDir = path.join(cardsRoot, oldId)
|
|
227
|
+
if (!fs.existsSync(oldDir) || !fs.statSync(oldDir).isDirectory()) {
|
|
228
|
+
const e = new Error(`卡片不存在:src/cards/${oldId}`)
|
|
229
|
+
e.code = 'NOT_FOUND'
|
|
230
|
+
throw e
|
|
231
|
+
}
|
|
232
|
+
const renameFolder = !!newId && newId !== oldId
|
|
233
|
+
if (renameFolder && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(newId)) {
|
|
234
|
+
const e = new Error('卡片 ID 只能以字母/数字开头,含字母/数字/_/-(≤64 字符)')
|
|
235
|
+
e.code = 'INVALID_ID'
|
|
236
|
+
throw e
|
|
237
|
+
}
|
|
238
|
+
if (desc.length > 200) {
|
|
239
|
+
const e = new Error('卡片描述过长(≤200 字)')
|
|
240
|
+
e.code = 'INVALID_DESC'
|
|
241
|
+
throw e
|
|
242
|
+
}
|
|
243
|
+
const finalId = renameFolder ? newId : oldId
|
|
244
|
+
const finalDir = renameFolder ? path.join(cardsRoot, newId) : oldDir
|
|
245
|
+
if (renameFolder && fs.existsSync(finalDir)) {
|
|
246
|
+
const e = new Error(`卡片 ID 已存在:src/cards/${newId} 已在本地,换一个 ID`)
|
|
247
|
+
e.code = 'CONFLICT'
|
|
248
|
+
throw e
|
|
249
|
+
}
|
|
250
|
+
if (renameFolder) {
|
|
251
|
+
fs.renameSync(oldDir, finalDir)
|
|
252
|
+
renameCoverImage(finalDir, oldId, newId)
|
|
253
|
+
moveCardCache(root, oldId, newId)
|
|
254
|
+
// 侧车 cardCode(=目录名)必须跟着改:远端身份是 schema.pageId,不随本地改名变化,
|
|
255
|
+
// 但 cardCode 停留旧值会让「来源」tab / 推送闸门 / card-save 落点判定指向不存在的目录。
|
|
256
|
+
try {
|
|
257
|
+
setImportMetaCardCode(finalDir, newId)
|
|
258
|
+
} catch {
|
|
259
|
+
/* 侧车写入失败不阻断重命名主体(本地新建卡本来就没有侧车) */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// 写回 schema.name/desc(与新建空白卡同构:name 回落 id);data 由 schema 单向重算
|
|
263
|
+
const schema = JSON.parse(fs.readFileSync(path.join(finalDir, 'schema.json'), 'utf8'))
|
|
264
|
+
schema.name = desc || finalId
|
|
265
|
+
schema.desc = desc
|
|
266
|
+
writeJsonFile(finalDir, 'schema.json', schema)
|
|
267
|
+
writeJsonFile(finalDir, 'data.json', buildDataFromSchema(schema))
|
|
268
|
+
if (renameFolder) {
|
|
269
|
+
try {
|
|
270
|
+
recordEdit(finalDir)
|
|
271
|
+
} catch {
|
|
272
|
+
/* 无侧车时忽略 */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
log.ok(renameFolder ? '重命名卡片' : '更新卡片描述', `${oldId} → ${finalId}`)
|
|
276
|
+
return { ok: true, id: finalId, oldId, renamed: renameFolder }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {{ root: string, notify?: ((data: object, event?: string) => void) | null }} opts
|
|
281
|
+
* - notify:SSE 推送入口(cardbuddy 的 /__events hub)。重命名成功后据此推一条
|
|
282
|
+
* `cards-changed { type: 'rename', oldId, id }`,让前端能把选中态 / 会话状态一并迁移到新 id
|
|
283
|
+
* (chokidar 的 unlink(旧) + add(新) 在 120ms 防抖里会被压成一条,认不出「这是改名」)。
|
|
128
284
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
129
285
|
*/
|
|
130
|
-
export function createCardFilesMiddleware({ root }) {
|
|
286
|
+
export function createCardFilesMiddleware({ root, notify = null }) {
|
|
131
287
|
return function cardFilesMiddleware(req, res, next) {
|
|
132
288
|
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
133
289
|
const method = (req.method || 'GET').toUpperCase()
|
|
@@ -207,6 +363,30 @@ export function createCardFilesMiddleware({ root }) {
|
|
|
207
363
|
return
|
|
208
364
|
}
|
|
209
365
|
|
|
366
|
+
// 单卡列表项(O(1):只读该卡 schema.json,供前端增量更新,避免为「改一张卡」全量重扫 70+ 张)
|
|
367
|
+
const metaMatch = pathname.match(/^\/__card\/([^/]+)\/meta$/)
|
|
368
|
+
if (metaMatch && method === 'GET') {
|
|
369
|
+
const id = decodeURIComponent(metaMatch[1])
|
|
370
|
+
const cardsRoot = path.join(root, 'src', 'cards')
|
|
371
|
+
const dir = path.join(cardsRoot, id)
|
|
372
|
+
const rel = path.relative(cardsRoot, dir)
|
|
373
|
+
if (rel.startsWith('..') || path.isAbsolute(rel) || !rel) {
|
|
374
|
+
res.statusCode = 404
|
|
375
|
+
res.setHeader('Content-Type', 'application/json')
|
|
376
|
+
return res.end(JSON.stringify({ error: 'card not found', id }))
|
|
377
|
+
}
|
|
378
|
+
const item = readCardListItem(root, id)
|
|
379
|
+
if (!item) {
|
|
380
|
+
res.statusCode = 404
|
|
381
|
+
res.setHeader('Content-Type', 'application/json')
|
|
382
|
+
return res.end(JSON.stringify({ error: 'card not found', id }))
|
|
383
|
+
}
|
|
384
|
+
res.statusCode = 200
|
|
385
|
+
res.setHeader('Content-Type', 'application/json')
|
|
386
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
387
|
+
return res.end(JSON.stringify(item))
|
|
388
|
+
}
|
|
389
|
+
|
|
210
390
|
const m = pathname.match(/^\/__card\/([^/]+)$/)
|
|
211
391
|
if (!m) return next()
|
|
212
392
|
const id = decodeURIComponent(m[1])
|
|
@@ -270,6 +450,13 @@ export function createCardFilesMiddleware({ root }) {
|
|
|
270
450
|
// 删除本地卡片文件夹(网格预览格子右下角删除钮触发,前端有警告确认兜底)
|
|
271
451
|
try {
|
|
272
452
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
453
|
+
// 卡片没了,.cache/ai-chats/<id>.json 就是孤儿(还会让「最近对话」列出已删除的卡),
|
|
454
|
+
// 在这里联动清掉。best-effort:删卡已成功,缓存清理失败只记日志,不把响应翻成 500。
|
|
455
|
+
const gone = deleteChatHistory(root, id)
|
|
456
|
+
if (!gone.ok) log.warn('清理对话缓存失败', `${id}: ${gone.error}`)
|
|
457
|
+
const turns = deleteJournal(root, id)
|
|
458
|
+
if (!turns.ok) log.warn('清理回合日志失败', `${id}: ${turns.error}`)
|
|
459
|
+
try { refreshCardList(root) } catch { /* 快照刷新失败不阻断删除 */ }
|
|
273
460
|
log.ok('删除卡片', id)
|
|
274
461
|
res.statusCode = 200
|
|
275
462
|
res.setHeader('Content-Type', 'application/json')
|
|
@@ -278,9 +465,54 @@ export function createCardFilesMiddleware({ root }) {
|
|
|
278
465
|
log.error('删除卡片失败', e)
|
|
279
466
|
res.statusCode = 500
|
|
280
467
|
res.setHeader('Content-Type', 'application/json')
|
|
281
|
-
|
|
468
|
+
return res.end(JSON.stringify({ error: 'delete failed', message: String(e.message) }))
|
|
282
469
|
}
|
|
283
470
|
}
|
|
471
|
+
// 重命名卡片(目录改名 + 描述写回):PATCH /__card/<oldId>,body { id?, desc? }
|
|
472
|
+
if (method === 'PATCH') {
|
|
473
|
+
let body = ''
|
|
474
|
+
req.on('data', (c) => {
|
|
475
|
+
body += c
|
|
476
|
+
if (body.length > 64 * 1024) req.destroy()
|
|
477
|
+
})
|
|
478
|
+
req.on('end', () => {
|
|
479
|
+
const send = (code, payload) => {
|
|
480
|
+
res.statusCode = code
|
|
481
|
+
res.setHeader('Content-Type', 'application/json')
|
|
482
|
+
res.end(JSON.stringify(payload))
|
|
483
|
+
}
|
|
484
|
+
let payload
|
|
485
|
+
try {
|
|
486
|
+
payload = JSON.parse(body || '{}')
|
|
487
|
+
} catch (e) {
|
|
488
|
+
return send(400, { error: 'invalid json', message: String(e.message) })
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const result = renameCard(root, id, payload)
|
|
492
|
+
try { refreshCardList(root) } catch { /* 快照刷新失败不阻断改名 */ }
|
|
493
|
+
// 改名广播:必须带上 oldId→id 的映射(file 字段仅供前端日志/正则解析),
|
|
494
|
+
// 前端据此把 selectedId 与对话面板的卡上下文一起迁移,否则会停在已消失的旧目录上。
|
|
495
|
+
if (result.renamed && notify) {
|
|
496
|
+
try {
|
|
497
|
+
notify({ file: `src/cards/${result.id}/schema.json`, type: 'rename', oldId: result.oldId, id: result.id }, 'cards-changed')
|
|
498
|
+
} catch { /* 推送失败不影响改名结果 */ }
|
|
499
|
+
}
|
|
500
|
+
return send(200, result)
|
|
501
|
+
} catch (e) {
|
|
502
|
+
const code =
|
|
503
|
+
e.code === 'NOT_FOUND'
|
|
504
|
+
? 404
|
|
505
|
+
: e.code === 'CONFLICT'
|
|
506
|
+
? 409
|
|
507
|
+
: e.code === 'INVALID_ID' || e.code === 'INVALID_DESC'
|
|
508
|
+
? 400
|
|
509
|
+
: 500
|
|
510
|
+
log.error('重命名卡片失败', e)
|
|
511
|
+
return send(code, { error: e.code ? String(e.code).toLowerCase() : 'rename failed', message: String(e.message) })
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
return
|
|
515
|
+
}
|
|
284
516
|
return next()
|
|
285
517
|
}
|
|
286
518
|
}
|
|
@@ -89,6 +89,22 @@ export function recordEdit(dir) {
|
|
|
89
89
|
return next
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 卡片目录被重命名时同步侧车里的 cardCode(= 目录名)。
|
|
94
|
+
*
|
|
95
|
+
* 为什么必须跟随:cardCode 是侧车对「卡片落在哪个目录」的自我声明,
|
|
96
|
+
* 远端身份是 cardId(→ schema.pageId),**不**随本地改名变化;但 cardCode 一旦与实际
|
|
97
|
+
* 目录名不符,「来源」tab、推送闸门与 card-save 的落点判定都会指向一个不存在的目录。
|
|
98
|
+
* 只在侧车存在时改写(本地新建卡无侧车,目录名即身份,无需同步)。
|
|
99
|
+
*/
|
|
100
|
+
export function setImportMetaCardCode(dir, cardCode) {
|
|
101
|
+
const meta = readImportMeta(dir)
|
|
102
|
+
if (!meta) return null
|
|
103
|
+
const next = { ...meta, cardCode: cardCode || '' }
|
|
104
|
+
writeImportMeta(dir, next)
|
|
105
|
+
return next
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
/**
|
|
93
109
|
* 记录一次远端保存(/__save)的读回校验结果。
|
|
94
110
|
* @param {string} dir
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话记录落盘(.cache/ai-chats/<cardId>.json)的单一实现。
|
|
3
|
+
*
|
|
4
|
+
* 从 ai-chat.js 抽成中立模块的原因:删卡(cardFiles.js 的 DELETE /__card/<id>)也要顺手
|
|
5
|
+
* 清掉对应缓存对话,否则卡片删了孤儿 json 还躺在 .cache 里越积越多,「最近对话」还会列出
|
|
6
|
+
* 已不存在的卡。目录约定与文件名消毒只能有一处,ai-chat 与 cardFiles 共用本模块,避免漂移。
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** 单卡最多保留的消息条数:写盘时截尾,防止长对话把 .cache 撑爆 */
|
|
12
|
+
const MAX_HISTORY_MESSAGES = 200
|
|
13
|
+
|
|
14
|
+
/** 对话记录目录(.cache/ 不入库):随 root 走,测试可用临时根隔离 */
|
|
15
|
+
export function chatHistoryDir(root) {
|
|
16
|
+
return path.join(root, '.cache', 'ai-chats')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function safeCardId(cardId) {
|
|
20
|
+
const id = String(cardId || '').trim()
|
|
21
|
+
if (!id) return ''
|
|
22
|
+
// 只允许文件名安全字符,杜绝 ../ 路径穿越
|
|
23
|
+
return id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function historyFile(chatDir, cardId) {
|
|
27
|
+
const id = safeCardId(cardId)
|
|
28
|
+
return id ? path.join(chatDir, `${id}.json`) : ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readHistory(chatDir, cardId) {
|
|
32
|
+
const file = historyFile(chatDir, cardId)
|
|
33
|
+
if (!file || !fs.existsSync(file)) return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
36
|
+
return {
|
|
37
|
+
cardId: String(raw.cardId || cardId || ''),
|
|
38
|
+
messages: Array.isArray(raw.messages) ? raw.messages : [],
|
|
39
|
+
updatedAt: Number(raw.updatedAt || 0),
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
return { cardId: String(cardId || ''), messages: [], updatedAt: 0 }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 服务端权威历史合并(P2-13):回合结束时把 turnJournal.reduce() 的 assistant 消息写进记录。
|
|
48
|
+
* 同 turnId 的旧副本(客户端回合中的中途落盘)先剔掉再写服务端这份,避免「同一轮两条回答」;
|
|
49
|
+
* 其余消息原样保留并按 ts 稳定排序 —— 用户消息仍由浏览器负责,服务端只权威 assistant 一侧。
|
|
50
|
+
* @param {object} msg journal.reduce() 的产物(带 turnId / serverAuthored 标记)
|
|
51
|
+
*/
|
|
52
|
+
export function mergeServerTurn(chatDir, cardId, msg) {
|
|
53
|
+
if (!msg || msg.role !== 'assistant' || !msg.turnId) return { ok: false, error: 'bad-message' }
|
|
54
|
+
const cur = readHistory(chatDir, cardId)
|
|
55
|
+
const kept = (Array.isArray(cur.messages) ? cur.messages : []).filter((m) => !(m && m.turnId === msg.turnId))
|
|
56
|
+
kept.push(msg)
|
|
57
|
+
kept.sort((a, b) => (Number(a.ts) || 0) - (Number(b.ts) || 0))
|
|
58
|
+
return writeHistory(chatDir, cardId, kept)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeHistory(chatDir, cardId, messages) {
|
|
62
|
+
const file = historyFile(chatDir, cardId)
|
|
63
|
+
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
64
|
+
fs.mkdirSync(chatDir, { recursive: true })
|
|
65
|
+
const list = Array.isArray(messages) ? messages.slice(-MAX_HISTORY_MESSAGES) : []
|
|
66
|
+
const payload = { cardId: safeCardId(cardId), messages: list, updatedAt: Date.now() }
|
|
67
|
+
const tmp = `${file}.tmp`
|
|
68
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
|
|
69
|
+
fs.renameSync(tmp, file)
|
|
70
|
+
return { ok: true, count: list.length, updatedAt: payload.updatedAt }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function deleteHistory(chatDir, cardId) {
|
|
74
|
+
const file = historyFile(chatDir, cardId)
|
|
75
|
+
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
76
|
+
try {
|
|
77
|
+
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
80
|
+
}
|
|
81
|
+
return { ok: true }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 历史卡片清单(面板「最近对话」用,按更新时间倒序) */
|
|
85
|
+
export function listHistories(chatDir, limit = 30) {
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(chatDir)) return []
|
|
88
|
+
return fs
|
|
89
|
+
.readdirSync(chatDir)
|
|
90
|
+
.filter((f) => f.endsWith('.json'))
|
|
91
|
+
.map((f) => {
|
|
92
|
+
try {
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(path.join(chatDir, f), 'utf8'))
|
|
94
|
+
const messages = Array.isArray(raw.messages) ? raw.messages : []
|
|
95
|
+
return {
|
|
96
|
+
cardId: String(raw.cardId || f.replace(/\.json$/, '')),
|
|
97
|
+
updatedAt: Number(raw.updatedAt || 0),
|
|
98
|
+
count: messages.length,
|
|
99
|
+
preview: String(messages.find((m) => m.role === 'user')?.content || '').slice(0, 60),
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
107
|
+
.slice(0, limit)
|
|
108
|
+
} catch {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 删卡联动入口:清掉该卡的缓存对话文件。
|
|
115
|
+
* 调用方(cardFiles 的 DELETE /__card/<id>)按 best-effort 处理返回值:
|
|
116
|
+
* 删卡本身已成功时,缓存清理失败只记日志,不把响应翻成 500。
|
|
117
|
+
* @param {string} root 工程根
|
|
118
|
+
* @param {string} cardId
|
|
119
|
+
*/
|
|
120
|
+
export function deleteChatHistory(root, cardId) {
|
|
121
|
+
return deleteHistory(chatHistoryDir(root), cardId)
|
|
122
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createSyncMiddleware, createPreviewMiddleware, createLoginCredentialsMiddleware } from './cardSync.js'
|
|
14
14
|
import { createSliceUploadMiddleware } from './sliceUpload.js'
|
|
15
|
-
import { createCardFilesMiddleware } from './cardFiles.js'
|
|
15
|
+
import { createCardFilesMiddleware, refreshCardList } from './cardFiles.js'
|
|
16
16
|
import { createSaveMiddleware } from './cardSave.js'
|
|
17
17
|
import { createImportMetaMiddleware } from './cardImportMeta.js'
|
|
18
18
|
import { createHistoryMiddleware } from './cardHistory.js'
|
|
@@ -57,6 +57,12 @@ export {
|
|
|
57
57
|
createCardImageMiddleware,
|
|
58
58
|
createOpenFolderMiddleware,
|
|
59
59
|
createCodeBridgeMiddleware,
|
|
60
|
+
// refreshCardList:standalone / --preview 服务在文件变动后重建卡片列表快照要用它。
|
|
61
|
+
// 必须经本入口导出,**不要**让包外调用方直接 import './cardFiles.js' —— 发布态
|
|
62
|
+
// dist/core.js 被构造成桥到包内快照(./vendored/server/core/index.js),而写死的仓库相对路径
|
|
63
|
+
// 在安装态会解析到包外(0.14.0 的 standalone.js 就因此 ERR_MODULE_NOT_FOUND)。
|
|
64
|
+
// build.mjs 门禁 D 会静态拦住这类「dist 里解析不到的相对引用」。
|
|
65
|
+
refreshCardList,
|
|
60
66
|
createLogMiddleware,
|
|
61
67
|
installLogMirror,
|
|
62
68
|
subscribeLog,
|
|
@@ -72,7 +78,8 @@ export {
|
|
|
72
78
|
* @param {{ use: (mw: Function) => void }} app
|
|
73
79
|
* @param {{ root: string, viteServer?: import('vite').ViteDevServer | null, upstream?: string, notify?: (data: object, event?: string) => void, watchFile?: (file: string, cb: () => void) => { close: () => void }, allowDispatch?: boolean }} opts
|
|
74
80
|
* - upstream:FinMall 远端站点基址(来自 FINMALL_BASE_URL),资源代理回源用;空串表示不回源。
|
|
75
|
-
* - notify:SSE 推送入口(cardbuddy 的 /__events hub
|
|
81
|
+
* - notify:SSE 推送入口(cardbuddy 的 /__events hub);卡片改名的 card-renamed 广播、代码桥
|
|
82
|
+
* bridge 事件都经它推送,缺省时静默降级。
|
|
76
83
|
* - watchFile:代码桥单文件监听能力注入(chokidar 版);缺省代码桥用 fs.watch 兜底。
|
|
77
84
|
* - allowDispatch:AI 的 card_patch 是否走 card_write 管线(历史快照 + 校验)。仓库内运行由
|
|
78
85
|
* 「root === 仓库根」自动成立;cardbuddy 发布态代码快照在包内,需宿主显式开启。
|
|
@@ -85,7 +92,7 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
|
|
|
85
92
|
// 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
|
|
86
93
|
// 仅在用户点击时触发,没有任何自动上传路径。
|
|
87
94
|
app.use(createSliceUploadMiddleware({ root }))
|
|
88
|
-
app.use(createCardFilesMiddleware({ root }))
|
|
95
|
+
app.use(createCardFilesMiddleware({ root, notify }))
|
|
89
96
|
app.use(createSaveMiddleware({ root }))
|
|
90
97
|
app.use(createHistoryMiddleware({ root }))
|
|
91
98
|
app.use(createValidateMiddleware({ root }))
|