cardbuddy 0.17.0 → 0.18.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/dist/preview-editor/assets/index-DPK_SisH.css +1 -0
- package/dist/preview-editor/assets/{index-DLQkCPLT.js → index-kaTaTm71.js} +89 -83
- package/dist/preview-editor/index.html +2 -2
- package/dist/vendored/server/core/ai-chat.js +923 -138
- package/dist/vendored/server/core/chatHistory.js +40 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +140 -3
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CXDf5tVg.css +0 -1
|
@@ -58,6 +58,46 @@ export function mergeServerTurn(chatDir, cardId, msg) {
|
|
|
58
58
|
return writeHistory(chatDir, cardId, kept)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 浏览器 POST 整表时的「合并校对」:服务端已按 turnId 写过权威消息,这里只补浏览器独有的东西。
|
|
63
|
+
*
|
|
64
|
+
* 铁律:**已收尾的回合,服务端那份永远赢** —— 不是「客户端自认不是权威才丢」,而是
|
|
65
|
+
* 「只要盘上已有该 turnId 的权威消息,客户端同 turnId 的任何副本一律丢弃」。
|
|
66
|
+
*
|
|
67
|
+
* 为什么不能用 `!m.serverAuthored` 当条件(2026-10-09 实测修掉的缺陷):
|
|
68
|
+
* 客户端 `revive()` 会把 `serverAuthored: true` 还原到内存消息上,`serialize()` 又原样写回。
|
|
69
|
+
* 于是浏览器 POST 上来的副本**也带着 serverAuthored=true**,旧条件
|
|
70
|
+
* `srvTurns.has(turnId) && !m.serverAuthored` 永远为假 → 副本被 `put()` 按 id 覆盖权威消息。
|
|
71
|
+
* 而 `serialize()` 是**有损白名单**(刻意剥掉 base64 截图 / 流式态),于是这次覆盖必然丢字段。
|
|
72
|
+
* 现场:greetingRecommend 第一回合 usage(缓存命中率)与 17 条 toolCall 的 truncated 全部消失,
|
|
73
|
+
* 摘要条退化成「未提供」、工具行不再显示截断提示。**服务端日志里数据是全的** ——
|
|
74
|
+
* 说明不是采集问题,是落盘被覆盖。
|
|
75
|
+
*
|
|
76
|
+
* @param {any[]} stored 已落盘的消息(服务端权威侧)
|
|
77
|
+
* @param {any[]} incoming 浏览器 POST 上来的整表
|
|
78
|
+
* @returns {any[]} 合并结果,按 ts 稳定排序
|
|
79
|
+
*/
|
|
80
|
+
export function mergeIncomingMessages(stored, incoming) {
|
|
81
|
+
const storedMsgs = Array.isArray(stored) ? stored : []
|
|
82
|
+
// 盘上已收尾的回合:这些 turnId 的任何浏览器副本都不许覆盖权威消息
|
|
83
|
+
const settled = new Set(storedMsgs.filter((m) => m && m.serverAuthored && m.turnId).map((m) => m.turnId))
|
|
84
|
+
const byId = new Map()
|
|
85
|
+
const anon = []
|
|
86
|
+
const put = (m) => {
|
|
87
|
+
if (!m || typeof m !== 'object') return
|
|
88
|
+
if (m.id) byId.set(m.id, m)
|
|
89
|
+
else anon.push(m)
|
|
90
|
+
}
|
|
91
|
+
for (const m of storedMsgs) put(m)
|
|
92
|
+
for (const m of Array.isArray(incoming) ? incoming : []) {
|
|
93
|
+
if (!m || typeof m !== 'object') continue
|
|
94
|
+
// 已收尾回合的客户端副本:无论它自称什么,一律丢掉(上面已说明为什么不能信它的标记)
|
|
95
|
+
if (m.role === 'assistant' && m.turnId && settled.has(m.turnId)) continue
|
|
96
|
+
put(m)
|
|
97
|
+
}
|
|
98
|
+
return [...byId.values(), ...anon].sort((x, y) => (Number(x.ts) || 0) - (Number(y.ts) || 0))
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
export function writeHistory(chatDir, cardId, messages) {
|
|
62
102
|
const file = historyFile(chatDir, cardId)
|
|
63
103
|
if (!file) return { ok: false, error: 'missing-card-id' }
|
|
@@ -1164,6 +1164,140 @@ export function persistReport(result, { sp, dp, irPath, reset = false, design =
|
|
|
1164
1164
|
}
|
|
1165
1165
|
}
|
|
1166
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* 报告摘要:给 stdout(= 模型上下文)用,全文已在 persistReport 里落盘。
|
|
1169
|
+
*
|
|
1170
|
+
* 为什么必须摘要(2026-10-09 实测):原版 `JSON.stringify(result, null, 2)` 把整份报告灌进上下文,
|
|
1171
|
+
* 单轮 6~15KB,而模型每轮的决策依据只有「gate 过了没有 / match 多少 / 差在哪个节点」。
|
|
1172
|
+
* 体积大头全在它不需要的东西上:
|
|
1173
|
+
* - `contributors.elements[]`:每个热区逐节点度量(rect/h/hSibling/designH/flags),
|
|
1174
|
+
* 而提示词自己写着「一轮只改 contributors[0].elements[0]」—— 后面的热区本轮根本不看;
|
|
1175
|
+
* - `elements[]`(--elements 的逐元素实测)、`fidelity.missing[]`/`invented[]` 的全文清单:
|
|
1176
|
+
* 门禁只判 `.length === 0`,逐条文案对「该不该改样式」没有增量信息;
|
|
1177
|
+
* - `geometry.collapsed[]`/`overWrapped[]` 全文、`lint.warningDetails`、`preflight.details`。
|
|
1178
|
+
* 15 轮迭代 = 9~22 万字符常驻,且每轮都重发(宿主把历史工具结果原样带回)⇒ 上下文斜率被它单独拉高。
|
|
1179
|
+
*
|
|
1180
|
+
* 摘要必须保留的字段(S5 全部决策都基于这些,删一条就会「照着不存在的结论调像素」):
|
|
1181
|
+
* ok / gate(含 checks.failed.verdict.stage)/ pixelDiff.match / pixelDiff.size.heightDelta
|
|
1182
|
+
* / best / stalled / rounds / conclusion / contributors[0] / structuralGaps
|
|
1183
|
+
* / contentOffset1x+hint / widthMismatch / geometry.counts / 各 errors 计数
|
|
1184
|
+
* / readiness / diffImage / renderImage / report(全文路径,供 --detail 或 fs_read 取全文)
|
|
1185
|
+
*
|
|
1186
|
+
* 削峰的边界(刻意保留的「第一条 + 计数」):
|
|
1187
|
+
* - contributors 只留 [0],其余热区只报个数;
|
|
1188
|
+
* - missing / invented / collapsed 等清单只留**前 3 条文本 + 总数**——
|
|
1189
|
+
* 前几条往往就是漏得最刺眼的(最长文案 / 最显眼的标题),模型据此能定位;要看全量有 report 路径。
|
|
1190
|
+
*/
|
|
1191
|
+
export function summarizeReport(result) {
|
|
1192
|
+
if (!result || typeof result !== 'object') return result
|
|
1193
|
+
// 幂等:已封装的摘要原样放过。宿主侧会对旧版未封装的全量报告做兜底裁剪,
|
|
1194
|
+
// 二次裁剪会把 {total,sample} 当数组处理、把内容清空 —— 这是「封装过就别再动」的硬约束。
|
|
1195
|
+
if (result.summary === true) return result
|
|
1196
|
+
const r = result
|
|
1197
|
+
const pd = r.pixelDiff
|
|
1198
|
+
/**
|
|
1199
|
+
* 清单类字段:留前 3 条 + 总数(不是只留计数 —— 模型要靠具体文案判断漏了什么)。
|
|
1200
|
+
* 容忍两种输入:数组(全量)与 {total,sample}(已封装)—— 后者原样返回。
|
|
1201
|
+
*/
|
|
1202
|
+
const list = (arr, map = (x) => x) => {
|
|
1203
|
+
if (arr && !Array.isArray(arr) && typeof arr === 'object') return arr.total != null || arr.sample ? arr : undefined
|
|
1204
|
+
const a = Array.isArray(arr) ? arr : []
|
|
1205
|
+
if (!a.length) return undefined
|
|
1206
|
+
return { total: a.length, sample: a.slice(0, 3).map(map), more: a.length > 3 ? `…另 ${a.length - 3} 条(见 report 全文)` : undefined }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const out = {
|
|
1210
|
+
ok: r.ok,
|
|
1211
|
+
cardId: r.cardId,
|
|
1212
|
+
// 全文落盘路径放第一行:模型要看被削掉的内容时有确定入口,不必重跑 --detail
|
|
1213
|
+
report: r.report,
|
|
1214
|
+
conclusion: r.conclusion,
|
|
1215
|
+
rounds: r.rounds,
|
|
1216
|
+
best: r.best,
|
|
1217
|
+
stalled: r.stalled,
|
|
1218
|
+
gate: r.gate,
|
|
1219
|
+
iterationReset: r.iterationReset,
|
|
1220
|
+
appliedSets: r.appliedSets,
|
|
1221
|
+
validate: r.validate
|
|
1222
|
+
? { errors: r.validate.errors, warnings: r.validate.warnings, envError: r.validate.envError, details: r.validate.details }
|
|
1223
|
+
: undefined,
|
|
1224
|
+
lint: r.lint
|
|
1225
|
+
? {
|
|
1226
|
+
errors: r.lint.errors,
|
|
1227
|
+
warnings: r.lint.warnings,
|
|
1228
|
+
infos: r.lint.infos,
|
|
1229
|
+
details: r.lint.details,
|
|
1230
|
+
warningDetails: r.lint.warningDetails,
|
|
1231
|
+
}
|
|
1232
|
+
: undefined,
|
|
1233
|
+
preflight: r.preflight
|
|
1234
|
+
? { errors: r.preflight.errors, details: list(r.preflight.details, (d) => (typeof d === 'string' ? d : d?.message || JSON.stringify(d))) }
|
|
1235
|
+
: undefined,
|
|
1236
|
+
readiness: r.readiness,
|
|
1237
|
+
widthMismatch: r.widthMismatch,
|
|
1238
|
+
contentOffset1x: r.contentOffset1x,
|
|
1239
|
+
contentOffsetHint: r.contentOffsetHint,
|
|
1240
|
+
// 像素主体:只留决策要用的三件(match / 尺寸差 / 热区),inkBox / diffPixels / totalPixels 是中间量
|
|
1241
|
+
pixelDiff: pd
|
|
1242
|
+
? {
|
|
1243
|
+
match: pd.match,
|
|
1244
|
+
diffRatio: pd.diffRatio,
|
|
1245
|
+
size: pd.size,
|
|
1246
|
+
contentOffset: pd.contentOffset,
|
|
1247
|
+
structuralGaps: pd.structuralGaps,
|
|
1248
|
+
// 热区本身已在 contributors 里归因过,这里只留坐标供「还有哪些区没归因」的自查
|
|
1249
|
+
hotspots: Array.isArray(pd.hotspots) ? pd.hotspots.slice(0, 3) : pd.hotspots,
|
|
1250
|
+
hotspotsTotal: Array.isArray(pd.hotspots) ? pd.hotspots.length : pd.hotspotsTotal,
|
|
1251
|
+
}
|
|
1252
|
+
: undefined,
|
|
1253
|
+
structuralGaps: r.structuralGaps,
|
|
1254
|
+
// 分项归因:只留首条热区(提示词钦定「一轮只改 contributors[0].elements[0]」),其余只报个数
|
|
1255
|
+
contributors: Array.isArray(r.contributors) ? r.contributors.slice(0, 1) : r.contributors,
|
|
1256
|
+
contributorsTotal: Array.isArray(r.contributors) ? r.contributors.length : r.contributorsTotal,
|
|
1257
|
+
contributorsNote: r.contributorsNote,
|
|
1258
|
+
geometry: r.geometry
|
|
1259
|
+
? {
|
|
1260
|
+
counts: r.geometry.counts,
|
|
1261
|
+
collapsed: list(r.geometry.collapsed),
|
|
1262
|
+
outOfCard: list(r.geometry.outOfCard),
|
|
1263
|
+
overWrapped: r.geometry.counts?.overWrapped != null ? undefined : list(r.geometry.overWrapped),
|
|
1264
|
+
}
|
|
1265
|
+
: undefined,
|
|
1266
|
+
geometryNote: r.geometryNote,
|
|
1267
|
+
fidelity: r.fidelity
|
|
1268
|
+
? {
|
|
1269
|
+
total: r.fidelity.total,
|
|
1270
|
+
matched: r.fidelity.matched,
|
|
1271
|
+
missing: list(r.fidelity.missing),
|
|
1272
|
+
invented: list(r.fidelity.invented),
|
|
1273
|
+
slices: r.fidelity.slices,
|
|
1274
|
+
symbols: r.fidelity.symbols,
|
|
1275
|
+
shapes: r.fidelity.shapes,
|
|
1276
|
+
}
|
|
1277
|
+
: undefined,
|
|
1278
|
+
render: r.render,
|
|
1279
|
+
designImage: r.designImage,
|
|
1280
|
+
diffImage: r.diffImage,
|
|
1281
|
+
renderImage: r.renderImage,
|
|
1282
|
+
diffImageNote: r.diffImageNote,
|
|
1283
|
+
elapsedMs: r.elapsedMs,
|
|
1284
|
+
}
|
|
1285
|
+
if (r.error || r.message) {
|
|
1286
|
+
out.error = r.error
|
|
1287
|
+
out.message = r.message
|
|
1288
|
+
}
|
|
1289
|
+
// 被削掉的字段名写清楚:模型必须知道「摘要 ≠ 全量」,否则会把「没提到」误读成「不存在」
|
|
1290
|
+
// fs_read 现在支持 offset 续读(尾巴会给下一段起点),所以「读全文」的正解是照着 offset 读到底,
|
|
1291
|
+
// 而不是一遍 fs_read —— 一遍只拿得到一屏。
|
|
1292
|
+
out.summaryNote =
|
|
1293
|
+
'本报告为摘要(contributors 只留首条热区、要素/几何清单只留前 3 条 + 总数、逐元素度量已省略);' +
|
|
1294
|
+
'需要全量明细用 fs_read 读 report 路径(长报告会带下一段 offset,照抄续读),或重跑加 --detail。'
|
|
1295
|
+
// 幂等标记:宿主侧(ai-chat.js)会对未封装的旧版全量报告做一次兜底裁剪,
|
|
1296
|
+
// 见标记后它会原样放过 —— 否则二次裁剪会把已封装的 {total,sample} 当数组处理、把内容清空。
|
|
1297
|
+
out.summary = true
|
|
1298
|
+
return out
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1167
1301
|
/* ── 核心管线 ──────────────────────────────────────────────── */
|
|
1168
1302
|
async function runPipeline({ ir: irPath, out: outDir, design: designPath, width, session, alwaysDiffImage, withElements, sets, specPath, skipPreflight, slicesDir: slicesDirArg, reset: resetFlag, noReadiness: noReadinessArg }) {
|
|
1169
1303
|
const t0 = performance.now()
|
|
@@ -1476,7 +1610,7 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
1476
1610
|
|
|
1477
1611
|
/* ── CLI ───────────────────────────────────────────────────── */
|
|
1478
1612
|
function parseArgs(argv) {
|
|
1479
|
-
const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '', reset: false, noReadiness: false }
|
|
1613
|
+
const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '', reset: false, noReadiness: false, detail: false }
|
|
1480
1614
|
for (let i = 0; i < argv.length; i++) {
|
|
1481
1615
|
const a = argv[i]
|
|
1482
1616
|
if (a === '--ir') out.ir = argv[++i] || ''
|
|
@@ -1488,6 +1622,8 @@ function parseArgs(argv) {
|
|
|
1488
1622
|
else if (a === '--slices-dir') out.slicesDir = argv[++i] || ''
|
|
1489
1623
|
else if (a === '--id') out.id = argv[++i] || ''
|
|
1490
1624
|
else if (a === '--gate') out.gate = true
|
|
1625
|
+
// stdout 默认只回摘要(省上下文);--detail 才回全量报告(模型真要看逐元素度量时才加)
|
|
1626
|
+
else if (a === '--detail') out.detail = true
|
|
1491
1627
|
else if (a === '--no-preflight') out.skipPreflight = true
|
|
1492
1628
|
// 逃生门:跳过 S4.9 内容就绪门禁(debug 用;正常流程**不该**用它)
|
|
1493
1629
|
else if (a === '--no-readiness') out.noReadiness = true
|
|
@@ -1625,7 +1761,7 @@ async function main() {
|
|
|
1625
1761
|
}
|
|
1626
1762
|
|
|
1627
1763
|
if (!args.ir || !args.out) {
|
|
1628
|
-
console.error('用法: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343]')
|
|
1764
|
+
console.error('用法: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343] [--detail]')
|
|
1629
1765
|
process.exit(1)
|
|
1630
1766
|
}
|
|
1631
1767
|
|
|
@@ -1644,7 +1780,8 @@ async function main() {
|
|
|
1644
1780
|
noReadiness: args.noReadiness,
|
|
1645
1781
|
})
|
|
1646
1782
|
|
|
1647
|
-
|
|
1783
|
+
// 默认摘要:stdout = 模型上下文,全量报告已落盘在 report 字段指向的文件(见 summarizeReport 头注)
|
|
1784
|
+
console.log(JSON.stringify(args.detail ? result : summarizeReport(result)))
|
|
1648
1785
|
if (!result.ok) process.exit(1)
|
|
1649
1786
|
// 门禁未达标不算脚本失败(报告照常回喂),但退出码要让调用方一眼看出没收敛
|
|
1650
1787
|
if (result.gate && result.gate.passed === false) process.exitCode = 2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cardbuddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|