cardbuddy 0.17.0 → 0.19.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.
@@ -127,6 +127,25 @@ export function recordExport(dir, verified) {
127
127
  return next
128
128
  }
129
129
 
130
+ /**
131
+ * 记录一次远端最新版本号(「卡片来源」tab 展示用,并由发布/拉取/拉取全部后持久化)。
132
+ * 与 recordExport 正交:recordExport 记录本地保存读回校验,本函数记录线上当前最高版本(IDC10004 maxVersion)。
133
+ * 无侧车(本地新建卡)时静默跳过。
134
+ * @param {string} dir 卡片目录
135
+ * @param {string|number|null} version 远端最新版本号;null 表示清除
136
+ */
137
+ export function recordRemoteVersion(dir, version) {
138
+ const meta = readImportMeta(dir)
139
+ if (!meta) return null
140
+ const next = {
141
+ ...meta,
142
+ remoteVersion: version == null ? null : String(version),
143
+ remoteVersionAt: new Date().toISOString(),
144
+ }
145
+ writeImportMeta(dir, next)
146
+ return next
147
+ }
148
+
130
149
  /**
131
150
  * dev server 中间件:GET /__import-meta/:id 暴露导入来源元数据(供 UI「来源」tab / AI skill 读取)。
132
151
  * 纯只读、no-store。
@@ -0,0 +1,100 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { assertRemoteSource } from './cardSidecar.js'
4
+ import { checkPushAllowed } from './cardListCache.js'
5
+ import { log } from './logger.js'
6
+ import { resolveSkillPathOrProject } from './skillPaths.js'
7
+
8
+ /**
9
+ * finmall-card-sync 的批量同步脚本(发布复用),相对 skills/
10
+ * 与 cardSync.js 同款定位:发布只用到其中的 publishCard。
11
+ */
12
+ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
13
+
14
+ /**
15
+ * 顶栏「远端 → 卡片发布」的服务端处理:取远端最大版本号 +1 后发布上线(IDC10004 → IDC10016)。
16
+ * 复用 finmall-card-sync 的 publishCard(与「同步」「保存远端」「预览」共用同一后台 FinMall 会话)。
17
+ *
18
+ * 安全闸(与 /__preview 一致):
19
+ * - assertRemoteSource:本地自建、无 .import-meta.json 侧车的卡一律禁止发布,
20
+ * 避免把测试数据误发布到 FinMall 服务器。
21
+ * - checkPushAllowed:卡片须存在于远端列表缓存(全量同步时刷新);缓存为空(尚未同步)则放行。
22
+ *
23
+ * @param {{ root: string }} opts
24
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
25
+ */
26
+ export function createPublishMiddleware({ root }) {
27
+ return function publishMiddleware(req, res, next) {
28
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
29
+ if (pathname !== '/__publish') return next()
30
+ if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
31
+
32
+ let body = ''
33
+ req.setEncoding('utf8')
34
+ req.on('data', (c) => {
35
+ body += c
36
+ if (body.length > 4 * 1024 * 1024) req.destroy()
37
+ })
38
+ req.on('end', async () => {
39
+ let payload
40
+ try {
41
+ payload = JSON.parse(body || '{}')
42
+ } catch (e) {
43
+ res.statusCode = 400
44
+ res.setHeader('Content-Type', 'application/json')
45
+ return res.end(JSON.stringify({ error: 'invalid json', message: String(e.message) }))
46
+ }
47
+ if (!payload.schema || !payload.schema.pageId) {
48
+ res.statusCode = 400
49
+ res.setHeader('Content-Type', 'application/json')
50
+ return res.end(JSON.stringify({ error: 'missing schema.pageId', message: '需传入当前卡片 schema(含 pageId)' }))
51
+ }
52
+ // 远端来源硬闸:本地自建卡禁止发布(与 /__preview 同源)。
53
+ {
54
+ const src = assertRemoteSource(root, { pageId: payload.schema.pageId })
55
+ if (!src.ok) {
56
+ res.statusCode = 409
57
+ res.setHeader('Content-Type', 'application/json')
58
+ return res.end(JSON.stringify({ ok: false, error: 'local_card_no_source', message: src.reason }))
59
+ }
60
+ }
61
+ // 存在性闸门:发布前确认目标卡片在远端卡片列表缓存中(为空说明尚未同步,放行)。
62
+ {
63
+ const allow = checkPushAllowed(payload.schema.pageId)
64
+ if (!allow.allowed) {
65
+ res.statusCode = 409
66
+ res.setHeader('Content-Type', 'application/json')
67
+ return res.end(JSON.stringify({ ok: false, error: 'card_not_in_list', message: allow.reason }))
68
+ }
69
+ }
70
+ try {
71
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
72
+ const { publishCard } = await import(pathToFileURL(skillPath).href)
73
+ const result = await publishCard(payload.schema)
74
+ log.ok('卡片发布完成', { cardId: payload.schema.pageId, nextVersion: result.nextVersion })
75
+ res.statusCode = 200
76
+ res.setHeader('Content-Type', 'application/json')
77
+ return res.end(JSON.stringify({ ok: true, maxVersion: result.maxVersion, nextVersion: result.nextVersion }))
78
+ } catch (e) {
79
+ log.error('卡片发布失败', e)
80
+ // 区分「凭据缺失」与「真正的发布错误」:前者让前端弹登录表单,而非裸抛 ENOENT。
81
+ const isNoCred =
82
+ e &&
83
+ /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
84
+ res.statusCode = isNoCred ? 200 : 500
85
+ res.setHeader('Content-Type', 'application/json')
86
+ return res.end(
87
+ JSON.stringify(
88
+ isNoCred
89
+ ? {
90
+ error: 'need_credentials',
91
+ needCredentials: true,
92
+ message: String(e && e.message),
93
+ }
94
+ : { error: 'publish failed', message: String(e && e.message) },
95
+ ),
96
+ )
97
+ }
98
+ })
99
+ }
100
+ }
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
6
6
 
7
- import { recordExport } from './cardImportMeta.js'
7
+ import { recordExport, markDirty } from './cardImportMeta.js'
8
8
  import { checkPushAllowed } from './cardListCache.js'
9
9
  import { assertRemoteSource, findCardDir } from './cardSidecar.js'
10
10
  import { log } from './logger.js'
@@ -107,8 +107,13 @@ export function createSaveMiddleware({ root }) {
107
107
  if (root) {
108
108
  try {
109
109
  const dir = findCardDir(root, { pageId: schema.pageId })
110
- if (dir) await recordExport(dir, verified)
111
- else console.warn(`[__save] 未找到 pageId=${schema.pageId} 对应的本地卡片目录,跳过导出元数据`)
110
+ if (dir) {
111
+ await recordExport(dir, verified)
112
+ // 推送成功即与远端对齐:清除「偏离导入镜像」标记(来源 tab 据此提示)。
113
+ // 注:本地磁盘(含编辑)仍可能 ≠ 当初导入镜像,此处语义转为「已与远端同步」,
114
+ // 符合用户预期——推送到远端后不应再被标为「偏离」。
115
+ await markDirty(dir, false)
116
+ } else console.warn(`[__save] 未找到 pageId=${schema.pageId} 对应的本地卡片目录,跳过导出元数据`)
112
117
  } catch (e) {
113
118
  log.warn('写导出元数据失败(不影响保存结果)', { error: e && e.message })
114
119
  }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { findCardDir } from './cardSidecar.js'
4
+ import { readImportMeta, recordRemoteVersion } from './cardImportMeta.js'
5
+ import { log } from './logger.js'
6
+ import { resolveSkillPathOrProject } from './skillPaths.js'
7
+
8
+ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
9
+
10
+ /**
11
+ * 调试面板「卡片来源」tab 的远端最新版本持久化(POST,fetch + persist):POST /__card-version/:id
12
+ *
13
+ * 语义:
14
+ * - body 带 { version }:直接把该版本号写入 .import-meta.json(如发布后已知 nextVersion),不再远端查询。
15
+ * - 否则:实时查询 IDC10004 的 maxVersion(线上当前最高版本),写入 .import-meta.json 后返回。
16
+ *
17
+ * 触发时机由前端控制,不再在打开来源 tab 时自动查询(避免每次切 tab 都拉起后台 Chrome):
18
+ * - 卡片发布(doPublish 成功,带 nextVersion)
19
+ * - 拉取 / 拉取全部(runSync 成功,实时查询)
20
+ * - 用户在来源 tab 点「刷新」(主动触发,实时查询)
21
+ *
22
+ * 安全闸(与 /__publish 同源):仅「已同步导入卡」(有 .import-meta.json 侧车且含 cardId)可写;
23
+ * 本地自建卡 / 未同步卡返回 409 local_card_no_source。
24
+ *
25
+ * @param {{ root: string }} opts
26
+ * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
27
+ */
28
+ export function createCardVersionMiddleware({ root }) {
29
+ return function cardVersionMiddleware(req, res, next) {
30
+ const pathname = decodeURIComponent((req.url || '').split('?')[0])
31
+ const m = pathname.match(/^\/__card-version\/([^/]+)$/)
32
+ if (!m) return next()
33
+ if ((req.method || 'GET').toUpperCase() !== 'POST') return next()
34
+
35
+ const id = decodeURIComponent(m[1])
36
+ const dir = findCardDir(root, { code: id })
37
+ const meta = dir ? readImportMeta(dir) : null
38
+ if (!meta || !meta.cardId) {
39
+ res.statusCode = 409
40
+ res.setHeader('Content-Type', 'application/json')
41
+ return res.end(
42
+ JSON.stringify({
43
+ ok: false,
44
+ error: 'local_card_no_source',
45
+ message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法记录远端版本。',
46
+ }),
47
+ )
48
+ }
49
+
50
+ // 解析请求体(允许空 / { version })
51
+ let body = ''
52
+ req.setEncoding('utf8')
53
+ req.on('data', (c) => {
54
+ body += c
55
+ if (body.length > 64 * 1024) req.destroy()
56
+ })
57
+ req.on('end', async () => {
58
+ let payload = {}
59
+ try {
60
+ payload = JSON.parse(body || '{}')
61
+ } catch {
62
+ /* 空 / 非法体当作无 version */
63
+ }
64
+ try {
65
+ let maxVersion = payload.version != null ? String(payload.version) : null
66
+ if (maxVersion == null) {
67
+ // 未带版本号:实时查询 IDC10004 的 maxVersion
68
+ const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
69
+ const { fetchCardLatestVersion } = await import(pathToFileURL(skillPath).href)
70
+ const r = await fetchCardLatestVersion(meta.cardId)
71
+ maxVersion = r.maxVersion
72
+ }
73
+ recordRemoteVersion(dir, maxVersion)
74
+ log.ok('记录远端最新版本', { cardId: meta.cardId, maxVersion })
75
+ res.statusCode = 200
76
+ res.setHeader('Content-Type', 'application/json')
77
+ res.end(JSON.stringify({ ok: true, maxVersion }))
78
+ } catch (e) {
79
+ // 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
80
+ const isNoCred = e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
81
+ res.statusCode = isNoCred ? 200 : 500
82
+ res.setHeader('Content-Type', 'application/json')
83
+ res.end(
84
+ JSON.stringify(
85
+ isNoCred
86
+ ? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
87
+ : { error: 'version persist failed', message: String(e && e.message) },
88
+ ),
89
+ )
90
+ }
91
+ })
92
+ }
93
+ }
@@ -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' }
@@ -11,6 +11,8 @@
11
11
  * 接收 viteServer 参数透传;无 SSR 需求的场景可传 null(这两个端点会返回明确错误)。
12
12
  */
13
13
  import { createSyncMiddleware, createPreviewMiddleware, createLoginCredentialsMiddleware } from './cardSync.js'
14
+ import { createPublishMiddleware } from './cardPublish.js'
15
+ import { createCardVersionMiddleware } from './cardVersion.js'
14
16
  import { createSliceUploadMiddleware } from './sliceUpload.js'
15
17
  import { createCardFilesMiddleware, refreshCardList } from './cardFiles.js'
16
18
  import { createSaveMiddleware } from './cardSave.js'
@@ -42,6 +44,8 @@ export {
42
44
  createSyncMiddleware,
43
45
  createPreviewMiddleware,
44
46
  createLoginCredentialsMiddleware,
47
+ createPublishMiddleware,
48
+ createCardVersionMiddleware,
45
49
  createSliceUploadMiddleware,
46
50
  createCardFilesMiddleware,
47
51
  createSaveMiddleware,
@@ -88,6 +92,12 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
88
92
  app.use(createSyncMiddleware({ root }))
89
93
  app.use(createPreviewMiddleware({ root }))
90
94
  app.use(createLoginCredentialsMiddleware({ root }))
95
+ // 卡片发布(顶栏「远端 → 卡片发布」):取远端最大版本号 +1 后发布上线(IDC10004 → IDC10016),
96
+ // 复用 finmall-card-sync 的 publishCard,与同步/保存/预览共用同一后台 FinMall 会话。
97
+ app.use(createPublishMiddleware({ root }))
98
+ // 卡片来源 tab 的「远端最新版本」查询(只读):GET /__card-version/:id 取 IDC10004 的 maxVersion,
99
+ // 复用 finmall-card-sync 的 fetchCardLatestVersion,与发布/同步/保存共用同一后台 FinMall 会话。
100
+ app.use(createCardVersionMiddleware({ root }))
91
101
  // 切图上传(顶栏「远端 → 切图上传」):把当前卡引用的 src/slices 切图传到 FinMall 图片库,
92
102
  // 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
93
103
  // 仅在用户点击时触发,没有任何自动上传路径。
@@ -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
- console.log(JSON.stringify(result, null, 2))
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
@@ -403,6 +403,107 @@ export async function previewCardRemote(schema, { skeletonScreen = '', publish =
403
403
  }
404
404
  }
405
405
 
406
+ /**
407
+ * 远端发布:取当前卡片远端最大版本号 +1,发布上线为新版本。
408
+ * 链路(对齐 ubomp 发布流程,仅两步):
409
+ * 1) IDC10004.do 取当前卡片最大版本号 maxVersion;
410
+ * 2) IDC10016.do 以 maxVersion+1 作为新版本号发布(发布的是远端「当前已保存」的卡片内容——
411
+ * 故发布前应先用「推送当前卡」把本地内容保存到远端,否则会发布陈旧内容)。
412
+ * 会话由 ensureFinMallSession 自动保证;与 saveCardToRemote / syncCards / previewCardRemote
413
+ * 共用同一套已登录后台 Chrome,命中会话/权限失效(BLEC0006 等)时自动清 session 重登重试一次。
414
+ *
415
+ * @param {object} schema 当前卡片 schema(需含 pageId)
416
+ * @returns {Promise<{maxVersion:string, nextVersion:string, response:object}>}
417
+ */
418
+ export async function publishCard(schema) {
419
+ if (!schema || !schema.pageId) {
420
+ throw new Error('schema 缺少 pageId,无法确定要发布的卡片(IDC10016.do 需要 cardId)。')
421
+ }
422
+ const appId = finmallConfig.appId
423
+ const workspaceId = finmallConfig.workspaceId
424
+ const cardId = String(schema.pageId)
425
+ const session = await ensureFinMallSession()
426
+ const browser = session.browser
427
+ try {
428
+ const context = browser.contexts()[0]
429
+ const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
430
+ if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
431
+ await page.bringToFront()
432
+
433
+ // 1) 取当前最大版本号
434
+ const maxResp = await finmallPostWithHeal(
435
+ page,
436
+ `/ubomp-web/IDC10004.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
437
+ { cardId, pageNo: 1, pageSize: 1, sassAppId: appId, sassWorkspaceId: workspaceId },
438
+ 'IDC10004.do',
439
+ )
440
+ const maxVersion = String(maxResp && maxResp.maxVersion != null ? maxResp.maxVersion : '0')
441
+ const maxNum = Number(maxVersion)
442
+ const nextVersion = String(Number.isFinite(maxNum) ? maxNum + 1 : 1)
443
+ log.info('发布前取最大版本', { cardId, maxVersion, nextVersion })
444
+
445
+ // 2) 以 maxVersion+1 发布(发布类型/平台等参数对齐 ubomp 发布弹窗的默认值)
446
+ const response = await finmallPostWithHeal(
447
+ page,
448
+ `/ubomp-web/IDC10016.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
449
+ {
450
+ cardId,
451
+ version: nextVersion,
452
+ platformStatus: JSON.stringify(['iOS', 'Android', 'HarmonyOS']),
453
+ publishType: '2',
454
+ publishMode: '1',
455
+ whitelistIds: '',
456
+ realGreyEndtime: '',
457
+ greyNum: '1',
458
+ memo: '',
459
+ greyConfigInfo: '[]',
460
+ sassAppId: appId,
461
+ sassWorkspaceId: workspaceId,
462
+ },
463
+ 'IDC10016.do',
464
+ )
465
+ log.ok('卡片发布完成', { cardId, nextVersion })
466
+ return { maxVersion, nextVersion, response }
467
+ } finally {
468
+ try { await browser.disconnect() } catch { /* ignore */ }
469
+ }
470
+ }
471
+
472
+ /**
473
+ * 取远端「当前卡片最新版本号」:IDC10004 返回 maxVersion(该卡线上已发布的最高版本)。
474
+ * 供 UI「卡片来源」tab 展示「远端最新版本」,让用户一眼看到线上当前版本。
475
+ * 只读、不发布;与 publishCard 共用同一后台 FinMall 会话(ensureFinMallSession + finmallPostWithHeal)。
476
+ * @param {string|number} cardId
477
+ * @returns {Promise<{ maxVersion: string }>}
478
+ */
479
+ export async function fetchCardLatestVersion(cardId) {
480
+ if (cardId == null) {
481
+ throw new Error('缺少 cardId,无法查询远端版本(IDC10004.do 需要 cardId)。')
482
+ }
483
+ const appId = finmallConfig.appId
484
+ const workspaceId = finmallConfig.workspaceId
485
+ const id = String(cardId)
486
+ const session = await ensureFinMallSession()
487
+ const browser = session.browser
488
+ try {
489
+ const context = browser.contexts()[0]
490
+ const page = context?.pages().find((p) => p.url().includes('finmall.com')) || context?.pages()[0]
491
+ if (!page) throw new Error('未能在后台 Chrome 中找到 FinMall 页面,请先运行 finmall-session-navigator 登录。')
492
+ await page.bringToFront()
493
+ const maxResp = await finmallPostWithHeal(
494
+ page,
495
+ `/ubomp-web/IDC10004.do?sassAppId=${appId}&sassWorkspaceId=${workspaceId}`,
496
+ { cardId: id, pageNo: 1, pageSize: 1, sassAppId: appId, sassWorkspaceId: workspaceId },
497
+ 'IDC10004.do',
498
+ )
499
+ const maxVersion = String(maxResp && maxResp.maxVersion != null ? maxResp.maxVersion : '0')
500
+ log.info('查询远端最新版本', { cardId: id, maxVersion })
501
+ return { maxVersion }
502
+ } finally {
503
+ try { await browser.disconnect() } catch { /* ignore */ }
504
+ }
505
+ }
506
+
406
507
  // —— 保存落库校验辅助 ——
407
508
 
408
509
  /** 稳定序列化:对象键排序、带 id/key/className/name 的数组元素按该键排序,避免服务端字段重排导致指纹误判。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.17.0",
3
+ "version": "0.19.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": {