dsh-plugin-deck-studio 0.0.0-stage → 0.2.2

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/client.js +500 -0
  4. package/cordis.example.yml +163 -0
  5. package/cordis.patch.yml +147 -0
  6. package/index.js +2340 -0
  7. package/lib/config.js +204 -0
  8. package/lib/content-density.js +98 -0
  9. package/lib/deck-brief.js +87 -0
  10. package/lib/deck-docs.js +1095 -0
  11. package/lib/deck-rules.js +124 -0
  12. package/lib/deck-templates.js +149 -0
  13. package/lib/deck-workspace.js +480 -0
  14. package/lib/design-style.js +157 -0
  15. package/lib/designs.js +175 -0
  16. package/lib/diff.js +88 -0
  17. package/lib/exporter.js +336 -0
  18. package/lib/faq.js +300 -0
  19. package/lib/git-commit.js +87 -0
  20. package/lib/home-catalog.js +249 -0
  21. package/lib/png.js +275 -0
  22. package/lib/proposal-decision.js +204 -0
  23. package/lib/render-nodes.js +171 -0
  24. package/lib/render-settings.js +213 -0
  25. package/lib/render.js +287 -0
  26. package/lib/review-personas.js +258 -0
  27. package/lib/script-style.js +55 -0
  28. package/lib/script-writer.js +165 -0
  29. package/lib/source-adapter.js +327 -0
  30. package/lib/store.js +212 -0
  31. package/lib/tools.js +2594 -0
  32. package/lib/watermark.js +137 -0
  33. package/package.json +66 -4
  34. package/seeds/resources/deckline-logo.png +0 -0
  35. package/seeds/resources/deckline-logo.svg +12 -0
  36. package/seeds/resources/deckline-mark.png +0 -0
  37. package/seeds/resources/index.json +19 -0
  38. package/seeds/samples/BIZ-01.jpg +0 -0
  39. package/seeds/samples/BIZ-02.jpg +0 -0
  40. package/seeds/samples/BIZ-03.jpg +0 -0
  41. package/seeds/samples/EDT-01.jpg +0 -0
  42. package/seeds/samples/EDT-02.jpg +0 -0
  43. package/seeds/samples/EDT-03.jpg +0 -0
  44. package/seeds/samples/FYI-01.jpg +0 -0
  45. package/seeds/samples/FYI-02.jpg +0 -0
  46. package/seeds/samples/FYI-03.jpg +0 -0
  47. package/seeds/samples/GRD-01.jpg +0 -0
  48. package/seeds/samples/GRD-02.jpg +0 -0
  49. package/seeds/samples/GRD-03.jpg +0 -0
  50. package/seeds/samples/JPN-01.jpg +0 -0
  51. package/seeds/samples/JPN-02.jpg +0 -0
  52. package/seeds/samples/JPN-03.jpg +0 -0
  53. package/seeds/samples/SKT-01.jpg +0 -0
  54. package/seeds/samples/SKT-02.jpg +0 -0
  55. package/seeds/samples/SKT-03.jpg +0 -0
  56. package/seeds/samples/SWS-01.jpg +0 -0
  57. package/seeds/samples/SWS-02.jpg +0 -0
  58. package/seeds/samples/SWS-03.jpg +0 -0
  59. package/seeds/samples/TEC-01.jpg +0 -0
  60. package/seeds/samples/TEC-02.jpg +0 -0
  61. package/seeds/samples/TEC-03.jpg +0 -0
  62. package/seeds/styles/BIZ.json +22 -0
  63. package/seeds/styles/EDT.json +22 -0
  64. package/seeds/styles/FYI.json +22 -0
  65. package/seeds/styles/GRD.json +22 -0
  66. package/seeds/styles/JPN.json +22 -0
  67. package/seeds/styles/SKT.json +22 -0
  68. package/seeds/styles/SWS.json +22 -0
  69. package/seeds/styles/TEC.json +22 -0
  70. package/ui/logo.svg +20 -0
  71. package/ui/workbench.css +687 -0
  72. package/ui/workbench.html +4554 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * deck 工作区:`.deck/decks/<deckId>/` 的 manifest、script 工作副本、提案与 revision。
3
+ *
4
+ * 纪律(ADR-0006 D4/D5):
5
+ * - manifest / 工作副本第一次使用时从受控源初始化,之后只改 `.deck/`,不回写源;
6
+ * - script revision 是 copy-on-write 全文快照(revisions/vNNN.md),current.md 始终等于最新;
7
+ * - 提案(proposals/*.json)是 waiting-user 的事实载体,确认 / 驳回都留痕。
8
+ * @module dsh-plugin-deck-studio/deck-workspace
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { createHash } from "node:crypto";
14
+ import { createLocalSourceAdapter } from "./source-adapter.js";
15
+ import { nowIso, makeId } from "./store.js";
16
+ import { lineDiff } from "./diff.js";
17
+
18
+ /** script 表格行里可编辑字段 → 单元格下标。 */
19
+ export const SCRIPT_FIELD_CELL = { title: 1, question: 2, claim: 3 };
20
+
21
+ /** 新值里的竖线与换行会破坏表格结构,替换为全角安全字符。 */
22
+ export function sanitizeCell(value) {
23
+ return String(value ?? "").replace(/\|/g, "/").replace(/\r?\n/g, " ").trim();
24
+ }
25
+
26
+ /**
27
+ * 在 script 文本中找到页号对应的表格行;返回 {line, index} 或 null。
28
+ * 页号跨表唯一(正文 1..36,附录 37..55);重复取第一条并让调用方警告。
29
+ */
30
+ export function findScriptRow(scriptText, page) {
31
+ const lines = scriptText.split("\n");
32
+ const pattern = new RegExp(`^\\|\\s*${page}\\s*\\|`);
33
+ for (let i = 0; i < lines.length; i += 1) {
34
+ if (pattern.test(lines[i])) return { line: lines[i], index: i };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** 替换一行的指定单元格;cells 数量不足时补空串。 */
40
+ export function replaceCell(rowLine, cellIndex, newValue) {
41
+ const cells = rowLine.replace(/^\|/, "").replace(/\|\s*$/, "").split("|").map((cell) => cell.trim());
42
+ while (cells.length <= cellIndex) cells.push("");
43
+ cells[cellIndex] = sanitizeCell(newValue);
44
+ return `| ${cells.join(" | ")} |`;
45
+ }
46
+
47
+ /**
48
+ * 分节惯例(`### P04 · 标题` + `**字段**:值`)里,字段 → 源文件里的字段名(按优先级)。
49
+ * 表格惯例走 SCRIPT_FIELD_CELL;分节惯例走这张表(F-75 评估发现:新建 deck 是分节惯例,
50
+ * 之前 propose_edit 只认表格行 → 对新建 deck 直接报「找不到页的表格行」)。
51
+ */
52
+ export const SECTION_FIELD_KEYS = {
53
+ title: ["标题"],
54
+ question: ["副标题", "页面标签"],
55
+ claim: ["主张", "为什么值得 AI 做", "为什么值得 Agent 做", "讲述意图"],
56
+ // F-109:正文独立成字段。以前 claim 把「正文」当别名,提案会整段覆盖正文,
57
+ // 现在 body 走自己的字段,claim 不再误伤正文。
58
+ body: ["正文"],
59
+ };
60
+
61
+ /** 标题行(`### P04 · …`)与该页结束行(下一个 heading 之前)。 */
62
+ function sectionRange(lines, page) {
63
+ const headRe = new RegExp(`^#{2,3}\\s*P0*${page}\\s*[·.\\-–—:]?`);
64
+ const start = lines.findIndex((line) => headRe.test(line.trim()));
65
+ if (start < 0) return null;
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i += 1) {
68
+ if (/^#{1,4}\s/.test(lines[i])) {
69
+ end = i;
70
+ break;
71
+ }
72
+ }
73
+ return { start, end };
74
+ }
75
+
76
+ /**
77
+ * 找到某页在 script 里的行区间(`### Pxx` 分节惯例);表格惯例没有分节时返回 null。
78
+ * F-108:提案的 before 行必须在「这一页」里找——新 deck 每页都有同样的占位行,
79
+ * 全篇 indexOf 会把 P01 的标题改到 P02 上。
80
+ */
81
+ export function scriptSectionRange(scriptText, page) {
82
+ const pageNo = Number(page);
83
+ if (!Number.isFinite(pageNo) || pageNo <= 0) return null;
84
+ return sectionRange(String(scriptText ?? "").split("\n"), pageNo);
85
+ }
86
+
87
+ /** 分节惯例:替换 / 插入某页的字段行。 */
88
+ function applySectionFieldEdit(scriptText, page, field, newValue) {
89
+ const keys = SECTION_FIELD_KEYS[field];
90
+ if (!keys) return null;
91
+ const lines = scriptText.split("\n");
92
+ const range = sectionRange(lines, page);
93
+ if (!range) return null;
94
+ const value = sanitizeCell(newValue);
95
+ const pattern = new RegExp(`^(\\s*)\\*\\*(${keys.map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\*\\*\\s*[::]\\s*(.*)$`);
96
+ for (let i = range.start + 1; i < range.end; i += 1) {
97
+ const match = pattern.exec(lines[i]);
98
+ if (!match) continue;
99
+ const oldRow = lines[i];
100
+ lines[i] = `${match[1]}**${match[2]}**:${value}`;
101
+ return { text: lines.join("\n"), oldRow, newRow: lines[i] };
102
+ }
103
+ // 这页还没有该字段(老 deck 没有 `**主张**`)→ 插在标题行之后,保持其它字段原样。
104
+ const newRow = `**${keys[0]}**:${value}`;
105
+ lines.splice(range.start + 1, 0, newRow);
106
+ return { text: lines.join("\n"), oldRow: "", newRow };
107
+ }
108
+
109
+ /** 对 script 文本应用一个字段修改;返回 {text, oldRow, newRow} 或 null(找不到页)。 */
110
+ export function applyScriptFieldEdit(scriptText, page, field, newValue) {
111
+ const cellIndex = SCRIPT_FIELD_CELL[field];
112
+ if (Number.isFinite(cellIndex)) {
113
+ const row = findScriptRow(scriptText, page);
114
+ if (!row) return applySectionFieldEdit(scriptText, page, field, newValue);
115
+ const newRow = replaceCell(row.line, cellIndex, newValue);
116
+ const lines = scriptText.split("\n");
117
+ const oldRow = lines[row.index];
118
+ lines[row.index] = newRow;
119
+ return { text: lines.join("\n"), oldRow, newRow };
120
+ }
121
+ return applySectionFieldEdit(scriptText, page, field, newValue);
122
+ }
123
+
124
+ /**
125
+ * @param {{dataRoot: string}} config - mergeConfig 的产物(用到 dataRoot)。
126
+ */
127
+ export function createDeckWorkspace(config) {
128
+ const decksDir = join(config.dataRoot, "decks");
129
+
130
+ function deckDir(deckId) {
131
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(deckId)) throw new Error(`unsafe deckId: ${deckId}`);
132
+ return join(decksDir, deckId);
133
+ }
134
+
135
+ function slideDir(deckId, pageId) {
136
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(pageId)) throw new Error(`unsafe pageId: ${pageId}`);
137
+ return join(deckDir(deckId), "slides", pageId);
138
+ }
139
+
140
+ function readFileSafe(file) {
141
+ try {
142
+ return existsSync(file) ? readFileSync(file, "utf8") : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /** manifest(无则 null)。 */
149
+ function manifest(deckId) {
150
+ const raw = readFileSafe(join(deckDir(deckId), "deck.json"));
151
+ if (!raw) return null;
152
+ try {
153
+ return JSON.parse(raw);
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function saveManifest(deck) {
160
+ const dir = deckDir(deck.id);
161
+ mkdirSync(dir, { recursive: true });
162
+ writeFileSync(join(dir, "deck.json"), `${JSON.stringify(deck, null, 2)}\n`, "utf8");
163
+ }
164
+
165
+ /** 从源初始化 manifest + script 工作副本(幂等:已存在不覆盖)。 */
166
+ /** 从受控源构建 slides(唯一入口:初始化和「源变了」重建都走这里)。 */
167
+ function slidesFromSource(adapter) {
168
+ const yamlPages = adapter.yamlPages();
169
+ const prompts = typeof adapter.prompts === "function" ? adapter.prompts() : {};
170
+ return adapter.storyPages().map((row) => {
171
+ const pageId = `p${String(row.page).padStart(2, "0")}`;
172
+ return {
173
+ id: pageId,
174
+ order: row.page,
175
+ type: row.page === 1 ? "cover" : "story",
176
+ title: row.title,
177
+ question: row.question,
178
+ claim: row.claim,
179
+ source: row.source,
180
+ // F-130:正文也进 manifest(以前的 manifest 只有 title/question/claim,脚本页只能从源文档拿
181
+ // 正文,密度检查 / 工作台看到的「已落版正文」和源快照会打架)。
182
+ body: String(row.fields?.["正文"] ?? "").trim(),
183
+ asset: yamlPages[row.page - 1] ?? null,
184
+ // 源里已写好的 image prompt(prompts/P01.txt)直接作为 visualBrief 起点。
185
+ visualBrief: prompts[pageId] ?? "",
186
+ };
187
+ });
188
+ }
189
+
190
+ /** 分节惯例的页头:`### P07 · 标题`(表格惯例另由 SCRIPT_FIELD_CELL 处理)。 */
191
+ const SCRIPT_PAGE_HEAD = /^#{2,3}\s*P(\d+)\s*(?:[·.‐-―::-]\s*)?(.*)$/;
192
+
193
+ /** script 文本里的页号(按出现顺序去重)。 */
194
+ function scriptPageNumbers(scriptText) {
195
+ const pages = [];
196
+ for (const line of String(scriptText ?? "").split("\n")) {
197
+ const match = line.match(SCRIPT_PAGE_HEAD);
198
+ if (!match) continue;
199
+ const page = Number(match[1]);
200
+ if (Number.isInteger(page) && page > 0 && !pages.includes(page)) pages.push(page);
201
+ }
202
+ return pages;
203
+ }
204
+
205
+ /**
206
+ * 追加页到 script 工作副本(F-99)。只追加到最后一个正文页之后(附录前):
207
+ * 页号 = 当前最大页号 + 1..N,不重排既有页——重排会让 slides/<pageId>/versions、
208
+ * asset URL、proposal.pageId 跟着错配(那需要迁目录,另有 ADR 再谈)。
209
+ * 同时把 frontmatter 的 `pages:` 计数改成新页数,保证元数据与正文一致。
210
+ */
211
+ function appendScriptPages(scriptText, pages) {
212
+ const text = String(scriptText ?? "");
213
+ const lines = text.split("\n");
214
+ const existing = scriptPageNumbers(text);
215
+ const startPage = (existing.length ? Math.max(...existing) : 0) + 1;
216
+ const specs = (Array.isArray(pages) ? pages : []).map((page, offset) => ({
217
+ page: Number(page?.page) > 0 ? Number(page.page) : startPage + offset,
218
+ title: String(page?.title ?? "").trim(),
219
+ question: String(page?.question ?? "").trim(),
220
+ claim: String(page?.claim ?? "").trim(),
221
+ body: String(page?.body ?? "").trim(),
222
+ visualBrief: String(page?.visualBrief ?? "").trim(),
223
+ }));
224
+ if (!specs.length) throw new Error("appendScriptPages:没有要追加的页");
225
+ let lastPageLine = -1;
226
+ lines.forEach((line, index) => {
227
+ if (SCRIPT_PAGE_HEAD.test(line)) lastPageLine = index;
228
+ });
229
+ // 最后一个正文页之后若出现一级/二级小节(如 `## 附录`),插到它前面。
230
+ let insertAt = lines.length;
231
+ if (lastPageLine >= 0) {
232
+ for (let index = lastPageLine + 1; index < lines.length; index += 1) {
233
+ if (/^#{1,2}\s/.test(lines[index])) {
234
+ insertAt = index;
235
+ break;
236
+ }
237
+ }
238
+ }
239
+ const head = lines.slice(0, insertAt);
240
+ while (head.length && head[head.length - 1].trim() === "") head.pop();
241
+ const tail = lines.slice(insertAt);
242
+ while (tail.length && tail[0].trim() === "") tail.shift();
243
+ const nextLines = [...head];
244
+ for (const spec of specs) {
245
+ const pageNo = spec.page;
246
+ const title = spec.title || `待补章节 ${pageNo}`;
247
+ const block = [
248
+ `### P${String(pageNo).padStart(2, "0")} · ${title}`,
249
+ `**标题**:${title}`,
250
+ `**副标题**:${spec.question || "这一页回答什么(待补)"}`,
251
+ `**主张**:${spec.claim || "(待补:这页要证明的判断)"}`,
252
+ `**正文**:${spec.body || "(待补:3–5 句正文)"}`,
253
+ `**提示词**:${spec.visualBrief || "(待补:visualBrief)"}`,
254
+ ].join("\n");
255
+ if (nextLines.length) nextLines.push("");
256
+ nextLines.push(block);
257
+ }
258
+ if (tail.length) {
259
+ nextLines.push("");
260
+ nextLines.push(...tail);
261
+ }
262
+ let next = nextLines.join("\n");
263
+ const pageCount = scriptPageNumbers(next).length;
264
+ next = next.replace(/^pages:\s*\d+\s*$/m, `pages: ${pageCount}`);
265
+ return { text: next, pageCount, startPage, pages: specs };
266
+ }
267
+
268
+ /**
269
+ * 源内容指纹:脚本正文 + 解析结果 + 源 prompt 数量。
270
+ * 用于判断「源更新了,但本地还没开始改(无 script revision)」时自动重建 manifest,
271
+ * 避免 adapter 解析规则升级后旧 deck.json 里的空字段一直残留。
272
+ */
273
+ function sourceSignature(adapter) {
274
+ const view = adapter.overview();
275
+ const rows = adapter.storyPages();
276
+ const prompts = typeof adapter.prompts === "function" ? adapter.prompts() : {};
277
+ const payload = JSON.stringify({
278
+ title: view.title,
279
+ status: view.status,
280
+ warnings: view.warnings,
281
+ pages: rows.map((row) => [row.page, row.title, row.question, row.claim, row.source]),
282
+ prompts: Object.keys(prompts).length,
283
+ });
284
+ return createHash("sha1").update(payload).digest("hex").slice(0, 16);
285
+ }
286
+
287
+ /** 本地是否已有 script revision(有 = 人已经改过,源刷新不得覆盖)。 */
288
+ function hasLocalScriptEdits(deckId) {
289
+ const dir = join(deckDir(deckId), "script", "revisions");
290
+ return existsSync(dir) && readdirSync(dir).some((name) => /^v\d+\.md$/.test(name));
291
+ }
292
+
293
+ function ensureDeck(adapter, { styleBrief, forceRebuild = false } = {}) {
294
+ let deck = manifest(adapter.id);
295
+ const scriptFile = join(deckDir(adapter.id), "script", "current.md");
296
+ if (!deck) {
297
+ const view = adapter.overview();
298
+ deck = {
299
+ schemaVersion: 1,
300
+ id: adapter.id,
301
+ title: view.title,
302
+ status: view.status || "draft",
303
+ renderer: "image",
304
+ styleId: "deckline-teal-paper-v1",
305
+ source: { root: adapter.sourceRoot, readonly: true, kind: "local-client-deck" },
306
+ sourceSignature: sourceSignature(adapter),
307
+ buildWarnings: view.warnings,
308
+ slides: slidesFromSource(adapter),
309
+ // 附录在源里是区间登记(如 A3–A10),不是逐页——摘要进 manifest,不造假逐页。
310
+ appendix: { count: view.appendixCount ?? 0, rows: view.appendixRows ?? [] },
311
+ };
312
+ saveManifest(deck);
313
+ } else if (forceRebuild || !hasLocalScriptEdits(adapter.id)) {
314
+ const signature = sourceSignature(adapter);
315
+ if (deck.sourceSignature !== signature) {
316
+ const view = adapter.overview();
317
+ const rebuilt = slidesFromSource(adapter);
318
+ const previous = Array.isArray(deck.slides) ? deck.slides : [];
319
+ const byId = new Map(previous.map((slide) => [slide.id, slide]));
320
+ // 源里有的页:字段以源为准,但保留本地已填的 visualBrief(manifest-only,源里没有)。
321
+ const merged = rebuilt.map((slide) => {
322
+ const local = byId.get(slide.id);
323
+ if (!local || !String(local.visualBrief ?? "").trim()) return slide;
324
+ // F-121:重建 manifest 时 visualBrief 是「本地才有的字段」,必须连写作风格一起保住;
325
+ // 只留 visualBrief 会把 provenance 冲掉,脚本页就全变成「brief 风格未记录」。
326
+ return {
327
+ ...slide,
328
+ visualBrief: local.visualBrief,
329
+ ...(local.visualBriefDesign ? { visualBriefDesign: local.visualBriefDesign } : {}),
330
+ };
331
+ });
332
+ // 源里没有的页 = 本地追加页(F-99 deck_script_add_page)。重建 manifest 时不能丢,
333
+ // 否则 Inspector 一保存(forceRebuild)就把 6→10 页打回 6 页。
334
+ const extras = previous.filter((slide) => !rebuilt.some((candidate) => candidate.id === slide.id));
335
+ deck = {
336
+ ...deck,
337
+ title: view.title,
338
+ status: view.status || deck.status,
339
+ sourceSignature: signature,
340
+ buildWarnings: view.warnings,
341
+ slides: [...merged, ...extras].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
342
+ appendix: { count: view.appendixCount ?? 0, rows: view.appendixRows ?? [] },
343
+ };
344
+ saveManifest(deck);
345
+ // 有本地 revision 时 current.md 是事实源,不能被源覆盖(否则追加页会丢)。
346
+ const raw = hasLocalScriptEdits(adapter.id) ? null : adapter.readScriptRaw();
347
+ if (raw != null) writeFileSync(scriptFile, raw, "utf8");
348
+ }
349
+ }
350
+ if (!existsSync(scriptFile)) {
351
+ const raw = adapter.readScriptRaw();
352
+ if (raw != null) {
353
+ mkdirSync(join(deckDir(adapter.id), "script"), { recursive: true });
354
+ writeFileSync(scriptFile, raw, "utf8");
355
+ }
356
+ }
357
+ // F-130:老 manifest 里没有 body 字段(正文只存在源 script 里),补一次;
358
+ // 否则脚本页的密度达标标记和「按当前密度检查」都会把正文当 0 字。
359
+ const current = manifest(adapter.id);
360
+ if (current && Array.isArray(current.slides)) {
361
+ let changed = false;
362
+ const rows = adapter.storyPages();
363
+ for (const slide of current.slides) {
364
+ if (String(slide.body ?? "").trim()) continue;
365
+ const row = rows.find((item) => `p${String(item.page).padStart(2, "0")}` === slide.id);
366
+ const body = String(row?.fields?.["正文"] ?? "").trim();
367
+ if (body) {
368
+ slide.body = body;
369
+ changed = true;
370
+ }
371
+ }
372
+ if (changed) saveManifest(current);
373
+ }
374
+ return manifest(adapter.id);
375
+ }
376
+
377
+ /** script 工作副本(未初始化返回 null)。 */
378
+ function scriptCurrent(deckId) {
379
+ return readFileSafe(join(deckDir(deckId), "script", "current.md"));
380
+ }
381
+
382
+ /** 全文 revision(copy-on-write):写 revisions/vNNN.md 并把 current.md 推进到它。 */
383
+ function saveScriptRevision(deckId, text, note) {
384
+ const dir = deckDir(deckId);
385
+ const versionsDir = join(dir, "script", "revisions");
386
+ mkdirSync(versionsDir, { recursive: true });
387
+ const count = readdirSync(versionsDir).filter((name) => /^v\d+\.md$/.test(name)).length;
388
+ const revision = `v${String(count + 1).padStart(3, "0")}`;
389
+ const file = join(versionsDir, `${revision}.md`);
390
+ writeFileSync(file, text, "utf8");
391
+ writeFileSync(join(dir, "script", "current.md"), text, "utf8");
392
+ if (note) writeFileSync(join(versionsDir, `${revision}.note.txt`), `${note}\n`, "utf8");
393
+ return { revision, path: file };
394
+ }
395
+
396
+ /** 提案。 */
397
+ function saveProposal(proposal) {
398
+ const dir = join(deckDir(proposal.deckId), "proposals");
399
+ mkdirSync(dir, { recursive: true });
400
+ writeFileSync(join(dir, `${proposal.proposalId}.json`), `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
401
+ }
402
+
403
+ function proposal(proposalId) {
404
+ if (!/^prop_[a-zA-Z0-9]+$/.test(proposalId)) throw new Error(`unsafe proposalId: ${proposalId}`);
405
+ for (const deckId of listDeckIds()) {
406
+ const raw = readFileSafe(join(deckDir(deckId), "proposals", `${proposalId}.json`));
407
+ if (raw) {
408
+ try {
409
+ return JSON.parse(raw);
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+ }
415
+ return null;
416
+ }
417
+
418
+ /** 更新并回写提案(状态迁移由调用方负责)。 */
419
+ function updateProposal(proposal, patch) {
420
+ const next = { ...proposal, ...patch, proposalId: proposal.proposalId };
421
+ saveProposal(next);
422
+ return next;
423
+ }
424
+
425
+ function listProposals(deckId, status = null) {
426
+ const dir = join(deckDir(deckId), "proposals");
427
+ if (!existsSync(dir)) return [];
428
+ return readdirSync(dir)
429
+ .filter((name) => name.endsWith(".json"))
430
+ .map((name) => {
431
+ try {
432
+ return JSON.parse(readFileSync(join(dir, name), "utf8"));
433
+ } catch {
434
+ return null;
435
+ }
436
+ })
437
+ .filter(Boolean)
438
+ .filter((item) => !status || item.status === status)
439
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
440
+ }
441
+
442
+ function listDeckIds() {
443
+ if (!existsSync(decksDir)) return [];
444
+ return readdirSync(decksDir, { withFileTypes: true })
445
+ .filter((entry) => entry.isDirectory() && existsSync(join(decksDir, entry.name, "deck.json")))
446
+ .map((entry) => entry.name)
447
+ .sort();
448
+ }
449
+
450
+ return {
451
+ get dataRoot() {
452
+ return config.dataRoot;
453
+ },
454
+ deckDir,
455
+ slideDir,
456
+ manifest,
457
+ saveManifest,
458
+ ensureDeck,
459
+ scriptCurrent,
460
+ appendScriptPages,
461
+ scriptPageNumbers,
462
+ saveScriptRevision,
463
+ saveProposal,
464
+ proposal,
465
+ updateProposal,
466
+ listProposals,
467
+ listDeckIds,
468
+ };
469
+ }
470
+
471
+ /** 新提案 id。 */
472
+ export function newProposalId() {
473
+ return makeId("prop");
474
+ }
475
+
476
+ /** 提案时间戳。 */
477
+ export const proposalNow = nowIso;
478
+
479
+ /** 便捷:生成 script 行级 diff。 */
480
+ export const scriptDiff = (oldText, newText) => lineDiff(oldText, newText);
@@ -0,0 +1,157 @@
1
+ /**
2
+ * design(风格)的提示词定义与样张记录(F-72)。
3
+ *
4
+ * 背景:预设风格包 `seeds/styles/*.json` 里本来带一段 `styleBrief`(精确的
5
+ * 画面提示词),但建 design 时被丢掉了;`deck_design_sample` 又只拿
6
+ * description + 新 brief 现拼 prompt,于是「重新生成样张」看起来像把风格提示词
7
+ * 重造了一遍,也和已有样张不一致。
8
+ *
9
+ * 本模块把「风格定义文件」当成唯一事实来源:
10
+ * - `designs/<ABBR>/style.json`:description / constraints / reference / styleBrief;
11
+ * - `designs/<ABBR>/style.md`:人读版;
12
+ * - `designs/<ABBR>/samples.jsonl`:每张样张实际用的 brief + prompt + 参数。
13
+ *
14
+ * 纯文件读写,无业务状态;供服务端(index.js)和工具(tools.js)共用。
15
+ * @module dsh-plugin-deck-studio/design-style
16
+ */
17
+
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ /** `designs/<ABBR>/` 目录。 */
22
+ export function styleDirOf(sourceRoot, abbr) {
23
+ return join(sourceRoot, "designs", String(abbr ?? "").trim().toUpperCase());
24
+ }
25
+
26
+ function readJson(file) {
27
+ try {
28
+ return JSON.parse(readFileSync(file, "utf8"));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 读风格定义:style.json 优先,style.md 兜底,registry 字段再兜底。
36
+ * @param {string} sourceRoot - 画布 deck 的源目录。
37
+ * @param {string} abbr - 风格缩写(目录名)。
38
+ * @param {Record<string, unknown>} [fallback] - registry 里的 design 字段。
39
+ */
40
+ export function readStyleDefinition(sourceRoot, abbr, fallback = {}) {
41
+ const dir = styleDirOf(sourceRoot, abbr);
42
+ const jsonPath = join(dir, "style.json");
43
+ const mdPath = join(dir, "style.md");
44
+ const json = readJson(jsonPath) ?? {};
45
+ let styleMd = "";
46
+ try {
47
+ if (existsSync(mdPath)) styleMd = readFileSync(mdPath, "utf8");
48
+ } catch {
49
+ styleMd = "";
50
+ }
51
+ const text = (value) => String(value ?? "").trim();
52
+ return {
53
+ dir,
54
+ jsonPath,
55
+ mdPath,
56
+ exists: existsSync(jsonPath) || Boolean(styleMd),
57
+ abbr: text(json.abbr || fallback.abbr || abbr).toUpperCase(),
58
+ name: text(json.name || fallback.name),
59
+ description: text(json.description || fallback.description),
60
+ constraints: text(json.constraints || fallback.constraints),
61
+ reference: text(json.reference || fallback.reference),
62
+ // 兼容手动写的风格文件:prompt / stylePrompt 都视作提示词本体。
63
+ styleBrief: text(json.styleBrief || json.stylePrompt || json.prompt),
64
+ styleMd,
65
+ };
66
+ }
67
+
68
+ /** 风格定义 → style.md 文本。 */
69
+ export function styleMarkdown(definition = {}) {
70
+ const name = String(definition.name ?? "").trim();
71
+ const abbr = String(definition.abbr ?? "").trim().toUpperCase();
72
+ const lines = [`# ${name}(${abbr})`, "", String(definition.description ?? "").trim()];
73
+ if (definition.styleBrief) lines.push("", "## 风格提示词(styleBrief)", "", String(definition.styleBrief).trim());
74
+ if (definition.constraints) lines.push("", "## 不要什么", "", String(definition.constraints).trim());
75
+ if (definition.reference) lines.push("", "## 参考", "", String(definition.reference).trim());
76
+ return `${lines.join("\n")}\n`;
77
+ }
78
+
79
+ /** 风格定义 → style.json 对象(保留已有字段,patch 覆盖)。 */
80
+ export function styleJson(definition = {}, base = {}) {
81
+ const pick = (value) => (typeof value === "string" ? value.trim() : "");
82
+ return {
83
+ ...base,
84
+ abbr: pick(definition.abbr || base.abbr).toUpperCase(),
85
+ name: pick(definition.name || base.name),
86
+ description: pick(definition.description ?? base.description),
87
+ constraints: pick(definition.constraints ?? base.constraints),
88
+ reference: pick(definition.reference ?? base.reference),
89
+ styleBrief: pick(definition.styleBrief ?? base.styleBrief),
90
+ ...(base.designId ? { designId: base.designId } : {}),
91
+ ...(base.createdAt ? { createdAt: base.createdAt } : {}),
92
+ updatedAt: new Date().toISOString(),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * 写回 style.json / style.md(幂等;用于创建、样式更新、老 design 迁移)。
98
+ * @returns {{ dir: string, jsonPath: string, mdPath: string, definition: object }}
99
+ */
100
+ export function writeStyleDefinition(sourceRoot, definition) {
101
+ const dir = styleDirOf(sourceRoot, definition.abbr);
102
+ mkdirSync(dir, { recursive: true });
103
+ const jsonPath = join(dir, "style.json");
104
+ const mdPath = join(dir, "style.md");
105
+ const base = readJson(jsonPath) ?? {};
106
+ const next = styleJson(definition, base);
107
+ writeFileSync(jsonPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
108
+ writeFileSync(mdPath, styleMarkdown(next), "utf8");
109
+ return { dir, jsonPath, mdPath, definition: next };
110
+ }
111
+
112
+ /**
113
+ * 组合样张 prompt:已有风格提示词在前,新 brief 只作为「这张要试的画面」。
114
+ * 关键约束(F-72):
115
+ * - 新样张不重写风格提示词;没有 styleBrief 时才退回 description;
116
+ * - `reference` 只给人看,不进出图 prompt(常含机构/客户品牌名,image adapter
117
+ * 的内容过滤会把这类 prompt 挂到超时——2026-10-20 BIZ 会话实测 3 次 180s 超时)。
118
+ */
119
+ export function composeSamplePrompt(definition, brief) {
120
+ const styleLine = `# 风格:${definition.name || definition.abbr}(${definition.abbr})`;
121
+ const stylePrompt = definition.styleBrief || definition.description || "";
122
+ const description = definition.description && definition.description !== stylePrompt
123
+ ? `# 画面语言\n${definition.description}`
124
+ : "";
125
+ return [
126
+ styleLine,
127
+ stylePrompt,
128
+ description,
129
+ definition.constraints ? `# 不要\n${definition.constraints}` : "",
130
+ `# 这张要试的画面(沿用上面的风格提示词,只补这一张的新内容)\n${String(brief ?? "").trim()}`,
131
+ ].filter(Boolean).join("\n\n");
132
+ }
133
+
134
+ /** 读 samples.jsonl(坏行忽略)。 */
135
+ export function readSampleRecords(styleDir) {
136
+ const file = join(styleDir, "samples.jsonl");
137
+ if (!existsSync(file)) return [];
138
+ const out = [];
139
+ for (const line of readFileSync(file, "utf8").split("\n")) {
140
+ const row = line.trim();
141
+ if (!row) continue;
142
+ try {
143
+ out.push(JSON.parse(row));
144
+ } catch {
145
+ /* 坏行忽略 */
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /** 追加一张样张的 prompt 记录(不覆盖旧记录)。 */
152
+ export function appendSampleRecord(styleDir, record = {}) {
153
+ mkdirSync(styleDir, { recursive: true });
154
+ const row = { ...record, createdAt: record.createdAt || new Date().toISOString() };
155
+ appendFileSync(join(styleDir, "samples.jsonl"), `${JSON.stringify(row)}\n`, "utf8");
156
+ return row;
157
+ }