dsh-plugin-deck-studio 0.2.27 → 0.2.28

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.
@@ -8,16 +8,48 @@
8
8
  * @module dsh-plugin-deck-studio/deck-workspace
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { createHash } from "node:crypto";
14
14
  import { createLocalSourceAdapter } from "./source-adapter.js";
15
15
  import { nowIso, makeId } from "./store.js";
16
16
  import { lineDiff } from "./diff.js";
17
+ import { stripVisualBriefScaffolding } from "./render.js";
17
18
 
18
19
  /** script 表格行里可编辑字段 → 单元格下标。 */
19
20
  export const SCRIPT_FIELD_CELL = { title: 1, question: 2, claim: 3 };
20
21
 
22
+ /** `p07` → 7;非法返回 null。 */
23
+ export function pageNumberFromId(pageId) {
24
+ const page = Number(String(pageId ?? "").replace(/^p/i, ""));
25
+ return Number.isFinite(page) && page > 0 ? page : null;
26
+ }
27
+
28
+ /** 页号 → `p07`。 */
29
+ export function pageIdFromNumber(page) {
30
+ return `p${String(Number(page)).padStart(2, "0")}`;
31
+ }
32
+
33
+ /**
34
+ * slide 对应的**原始源页号**(F-208 插页后 manifest 会记 `sourcePage`)。
35
+ * 源 baseline 图(`outputs/full/final/P0N.png`)按原始源页映射:插页改了页号,
36
+ * 但这页的源图还是原来那张,不能跟着改号。
37
+ * - 老 manifest 没有该字段 → 按 order(插页前的行为和现在一致)。
38
+ * - 显式 `sourcePage: null` → 这页是插页新增的,源里没有 baseline 图。
39
+ */
40
+ export function slideSourcePage(slide) {
41
+ if (!slide || typeof slide !== "object") return null;
42
+ if (Object.prototype.hasOwnProperty.call(slide, "sourcePage")) {
43
+ const raw = slide.sourcePage;
44
+ // 显式 null / undefined 都表示「源里没有这页」(插页新增);不能走 Number(null)=0。
45
+ if (raw == null || raw === "") return null;
46
+ const page = Number(raw);
47
+ return Number.isFinite(page) && page > 0 ? page : null;
48
+ }
49
+ const order = Number(slide.order);
50
+ return Number.isFinite(order) && order > 0 ? order : null;
51
+ }
52
+
21
53
  /** 新值里的竖线与换行会破坏表格结构,替换为全角安全字符。 */
22
54
  export function sanitizeCell(value) {
23
55
  return String(value ?? "").replace(/\|/g, "/").replace(/\r?\n/g, " ").trim();
@@ -165,7 +197,12 @@ export function createDeckWorkspace(config) {
165
197
  function saveManifest(deck) {
166
198
  const dir = deckDir(deck.id);
167
199
  mkdirSync(dir, { recursive: true });
168
- writeFileSync(join(dir, "deck.json"), `${JSON.stringify(deck, null, 2)}\n`, "utf8");
200
+ // F-209:多实例共享同一个 .deck 时,半截 deck.json 会被 manifest() 当成「没有 manifest」,
201
+ // ensureDeck 于是按源静默重建、把插页/追加的本地页丢掉。写入走 tmp + rename(同目录原子替换)。
202
+ const file = join(dir, "deck.json");
203
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
204
+ writeFileSync(tmp, `${JSON.stringify(deck, null, 2)}\n`, "utf8");
205
+ renameSync(tmp, file);
169
206
  }
170
207
 
171
208
  /** 从源初始化 manifest + script 工作副本(幂等:已存在不覆盖)。 */
@@ -188,7 +225,9 @@ export function createDeckWorkspace(config) {
188
225
  body: String(row.fields?.["正文"] ?? "").trim(),
189
226
  asset: yamlPages[row.page - 1] ?? null,
190
227
  // 源里已写好的 image prompt(prompts/P01.txt)直接作为 visualBrief 起点。
191
- visualBrief: prompts[pageId] ?? "",
228
+ // F-209:源 prompt 是完整独立 prompt,含 `### P02 · 标题` 这类分页标题;原样拼进出图 prompt
229
+ // 会被模型当图上文字印出来(实测页顶出现「##P2」),导入时先做最小清洗。
230
+ visualBrief: stripVisualBriefScaffolding(prompts[pageId] ?? ""),
192
231
  };
193
232
  });
194
233
  }
@@ -208,58 +247,45 @@ export function createDeckWorkspace(config) {
208
247
  return pages;
209
248
  }
210
249
 
250
+ /** 一页的分节 script 文本块(追加 / 插页共用)。 */
251
+ function scriptPageBlock(spec) {
252
+ const pageNo = spec.page;
253
+ const title = spec.title || `待补章节 ${pageNo}`;
254
+ return [
255
+ `### P${String(pageNo).padStart(2, "0")} · ${title}`,
256
+ `**标题**:${title}`,
257
+ `**副标题**:${spec.question || "这一页回答什么(待补)"}`,
258
+ `**主张**:${spec.claim || "(待补:这页要证明的判断)"}`,
259
+ `**正文**:${spec.body || "(待补:3–5 句正文)"}`,
260
+ `**提示词**:${spec.visualBrief || "(待补:visualBrief)"}`,
261
+ ].join("\n");
262
+ }
263
+
211
264
  /**
212
- * 追加页到 script 工作副本(F-99)。只追加到最后一个正文页之后(附录前):
213
- * 页号 = 当前最大页号 + 1..N,不重排既有页——重排会让 slides/<pageId>/versions、
214
- * asset URL、proposal.pageId 跟着错配(那需要迁目录,另有 ADR 再谈)。
215
- * 同时把 frontmatter 的 `pages:` 计数改成新页数,保证元数据与正文一致。
265
+ * 规格化新页(追加 / 插页共用)。页号**永远按当前位置重算**,不信调用方存的旧页号:
266
+ * 提案存放期间如果又落了别的 revision(比如先插页、再确认之前提的追加页),旧页号会和现页撞车。
216
267
  */
217
- function appendScriptPages(scriptText, pages) {
218
- const text = String(scriptText ?? "");
219
- const lines = text.split("\n");
220
- const existing = scriptPageNumbers(text);
221
- const startPage = (existing.length ? Math.max(...existing) : 0) + 1;
222
- const specs = (Array.isArray(pages) ? pages : []).map((page, offset) => ({
223
- page: Number(page?.page) > 0 ? Number(page.page) : startPage + offset,
268
+ function normalizeNewPageSpecs(pages, startPage) {
269
+ return (Array.isArray(pages) ? pages : []).map((page, offset) => ({
270
+ page: startPage + offset,
224
271
  title: String(page?.title ?? "").trim(),
225
272
  question: String(page?.question ?? "").trim(),
226
273
  claim: String(page?.claim ?? "").trim(),
227
274
  body: String(page?.body ?? "").trim(),
228
275
  visualBrief: String(page?.visualBrief ?? "").trim(),
229
276
  }));
230
- if (!specs.length) throw new Error("appendScriptPages:没有要追加的页");
231
- let lastPageLine = -1;
232
- lines.forEach((line, index) => {
233
- if (SCRIPT_PAGE_HEAD.test(line)) lastPageLine = index;
234
- });
235
- // 最后一个正文页之后若出现一级/二级小节(如 `## 附录`),插到它前面。
236
- let insertAt = lines.length;
237
- if (lastPageLine >= 0) {
238
- for (let index = lastPageLine + 1; index < lines.length; index += 1) {
239
- if (/^#{1,2}\s/.test(lines[index])) {
240
- insertAt = index;
241
- break;
242
- }
243
- }
244
- }
277
+ }
278
+
279
+ /** 在 `insertAt` 行之前塞入新页块,并修掉前后多余空行(追加 / 插页共用)。 */
280
+ function splicePageBlocks(lines, insertAt, specs) {
245
281
  const head = lines.slice(0, insertAt);
246
282
  while (head.length && head[head.length - 1].trim() === "") head.pop();
247
283
  const tail = lines.slice(insertAt);
248
284
  while (tail.length && tail[0].trim() === "") tail.shift();
249
285
  const nextLines = [...head];
250
286
  for (const spec of specs) {
251
- const pageNo = spec.page;
252
- const title = spec.title || `待补章节 ${pageNo}`;
253
- const block = [
254
- `### P${String(pageNo).padStart(2, "0")} · ${title}`,
255
- `**标题**:${title}`,
256
- `**副标题**:${spec.question || "这一页回答什么(待补)"}`,
257
- `**主张**:${spec.claim || "(待补:这页要证明的判断)"}`,
258
- `**正文**:${spec.body || "(待补:3–5 句正文)"}`,
259
- `**提示词**:${spec.visualBrief || "(待补:visualBrief)"}`,
260
- ].join("\n");
261
287
  if (nextLines.length) nextLines.push("");
262
- nextLines.push(block);
288
+ nextLines.push(scriptPageBlock(spec));
263
289
  }
264
290
  if (tail.length) {
265
291
  nextLines.push("");
@@ -268,7 +294,143 @@ export function createDeckWorkspace(config) {
268
294
  let next = nextLines.join("\n");
269
295
  const pageCount = scriptPageNumbers(next).length;
270
296
  next = next.replace(/^pages:\s*\d+\s*$/m, `pages: ${pageCount}`);
271
- return { text: next, pageCount, startPage, pages: specs };
297
+ return { text: next, pageCount };
298
+ }
299
+
300
+ /** 追加位置:最后一个正文页之后(`## 附录` 之类的一级/二级小节之前)。 */
301
+ function appendInsertLine(lines) {
302
+ let lastPageLine = -1;
303
+ lines.forEach((line, index) => {
304
+ if (SCRIPT_PAGE_HEAD.test(line)) lastPageLine = index;
305
+ });
306
+ let insertAt = lines.length;
307
+ if (lastPageLine >= 0) {
308
+ for (let index = lastPageLine + 1; index < lines.length; index += 1) {
309
+ if (/^#{1,2}\s/.test(lines[index])) {
310
+ insertAt = index;
311
+ break;
312
+ }
313
+ }
314
+ }
315
+ return insertAt;
316
+ }
317
+
318
+ /**
319
+ * 追加页到 script 工作副本(F-99)。只追加到最后一个正文页之后(附录前),不重排既有页。
320
+ * 同时把 frontmatter 的 `pages:` 计数改成新页数,保证元数据与正文一致。
321
+ */
322
+ function appendScriptPages(scriptText, pages) {
323
+ const text = String(scriptText ?? "");
324
+ const lines = text.split("\n");
325
+ const existing = scriptPageNumbers(text);
326
+ const startPage = (existing.length ? Math.max(...existing) : 0) + 1;
327
+ const specs = normalizeNewPageSpecs(pages, startPage);
328
+ if (!specs.length) throw new Error("appendScriptPages:没有要追加的页");
329
+ const spliced = splicePageBlocks(lines, appendInsertLine(lines), specs);
330
+ return { ...spliced, startPage, pages: specs };
331
+ }
332
+
333
+ /**
334
+ * 插页到 script 工作副本(F-208)。`at` = 插到第 N 页之前(1-based;at = 最大页号+1 等价追加)。
335
+ *
336
+ * 与追加的差别:插页会让 >= at 的页号整体后移 `pages.length` 页——脚本标题行、manifest 的
337
+ * slide id/order、以及 `.deck/.../slides/<pageId>/versions` 目录都必须一起迁(见
338
+ * `shiftSlideDirs` 与 proposal-decision 的 `syncInsertPageManifest`)。**已出的图跟着内容走**,
339
+ * 不需要重出整份(ADR-0028)。
340
+ *
341
+ * 纯函数:只算新文本,不写盘;调用方负责 revision / manifest / 目录迁移。
342
+ */
343
+ function insertScriptPages(scriptText, { at, pages } = {}) {
344
+ const text = String(scriptText ?? "");
345
+ if (!text.trim()) throw new Error("插页失败:script 工作副本为空");
346
+ const lines = text.split("\n");
347
+ const existing = scriptPageNumbers(text);
348
+ if (!existing.length) {
349
+ throw new Error("插页只支持分节惯例的 script(`### Pxx · 标题`);这份 deck 是表格惯例,暂不支持在中间插页——可以用 deck_script_add_page 追加,或先确认是否要改成分节惯例。");
350
+ }
351
+ const maxPage = Math.max(...existing);
352
+ const atPage = Number(at);
353
+ if (!Number.isInteger(atPage) || atPage < 1) {
354
+ throw new Error(`插页位置无效:at=${at}(要插到第 N 页之前,N 从 1 开始)`);
355
+ }
356
+ if (atPage > maxPage + 1) {
357
+ throw new Error(`插页位置超界:当前 ${maxPage} 页,at 最大是 ${maxPage + 1}(= 追加到末尾,也可以直接用 deck_script_add_page)`);
358
+ }
359
+ const specs = normalizeNewPageSpecs(pages, atPage);
360
+ if (!specs.length) throw new Error("insertScriptPages:没有要插入的页");
361
+ const shift = specs.length;
362
+
363
+ // 插入行 = 第一个页号 >= at 的标题行;at = max+1 时退回「末尾 / 附录前」的锚点。
364
+ let insertAt = -1;
365
+ for (let index = 0; index < lines.length; index += 1) {
366
+ const match = lines[index].match(SCRIPT_PAGE_HEAD);
367
+ if (match && Number(match[1]) >= atPage) {
368
+ insertAt = index;
369
+ break;
370
+ }
371
+ }
372
+ if (insertAt < 0) insertAt = appendInsertLine(lines);
373
+
374
+ // 后移:插入点之后、页号 >= at 的标题行 +shift(保留 `###` 层级与标题,只改页号)。
375
+ const renumbered = lines.map((line, index) => {
376
+ if (index < insertAt) return line;
377
+ const match = line.match(SCRIPT_PAGE_HEAD);
378
+ if (!match) return line;
379
+ const page = Number(match[1]);
380
+ if (!(page >= atPage)) return line;
381
+ return line.replace(/^(#{2,3}\s*P)0*(\d+)/, (match, prefix) => `${prefix}${String(page + shift).padStart(2, "0")}`);
382
+ });
383
+
384
+ const spliced = splicePageBlocks(renumbered, insertAt, specs);
385
+ return {
386
+ ...spliced,
387
+ at: atPage,
388
+ shift,
389
+ pages: specs,
390
+ // 被后移的旧页:旧页号 → 新页号(manifest / 目录 / 提案按这个映射迁)
391
+ renumberedPages: existing.filter((page) => page >= atPage).map((page) => ({ from: page, to: page + shift })),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * 插页时把 `slides/<pageId>/versions` 目录整体后移(F-208)。
397
+ * 从大到小 rename,避免目标目录已存在互相覆盖;中途失败回滚已迁的目录,保持工作区干净。
398
+ * 返回 [{ fromId, toId }]。
399
+ */
400
+ function shiftSlideDirs(deckId, { fromPage, shift } = {}) {
401
+ const from = Number(fromPage);
402
+ const step = Number(shift);
403
+ if (!Number.isInteger(from) || from < 1) throw new Error(`shiftSlideDirs:fromPage 无效(${fromPage})`);
404
+ if (!Number.isInteger(step) || step < 1) throw new Error(`shiftSlideDirs:shift 无效(${shift})`);
405
+ const slidesDir = join(deckDir(deckId), "slides");
406
+ if (!existsSync(slidesDir)) return [];
407
+ const entries = readdirSync(slidesDir, { withFileTypes: true })
408
+ .filter((entry) => entry.isDirectory() && /^p\d+$/.test(entry.name))
409
+ .map((entry) => ({ id: entry.name, page: Number(entry.name.slice(1)) }))
410
+ .filter((entry) => entry.page >= from)
411
+ .sort((left, right) => right.page - left.page);
412
+ const moved = [];
413
+ try {
414
+ for (const entry of entries) {
415
+ const toId = `p${String(entry.page + step).padStart(2, "0")}`;
416
+ const target = join(slidesDir, toId);
417
+ if (existsSync(target)) {
418
+ throw new Error(`插页迁移冲突:${target} 已存在(目录与 manifest 不一致;先核对 slides/ 与 deck.json,或跑 deck_script_repair 体检)`);
419
+ }
420
+ renameSync(join(slidesDir, entry.id), target);
421
+ moved.push({ fromId: entry.id, toId });
422
+ }
423
+ } catch (error) {
424
+ for (const item of [...moved].reverse()) {
425
+ try {
426
+ renameSync(join(slidesDir, item.toId), join(slidesDir, item.fromId));
427
+ } catch {
428
+ /* 回滚尽力而为;原始错误更重要 */
429
+ }
430
+ }
431
+ throw error;
432
+ }
433
+ return moved;
272
434
  }
273
435
 
274
436
  /**
@@ -299,6 +461,12 @@ export function createDeckWorkspace(config) {
299
461
  function ensureDeck(adapter, { styleBrief, forceRebuild = false } = {}) {
300
462
  let deck = manifest(adapter.id);
301
463
  const scriptFile = join(deckDir(adapter.id), "script", "current.md");
464
+ const manifestFile = join(deckDir(adapter.id), "deck.json");
465
+ // F-209:deck.json 存在但 JSON 坏了 ≠ 新 deck。以前会当 null → 按源重建 manifest,
466
+ // 插页/追加的本地页静默消失(script 还是 25 页)。宁可报错让人先备份/修复。
467
+ if (!deck && existsSync(manifestFile)) {
468
+ throw new Error(`manifest 损坏,拒绝按源重建(会丢插页/追加页):${manifestFile};先备份并修复 deck.json(或从 script/revisions 重建),再打开这本 deck。`);
469
+ }
302
470
  if (!deck) {
303
471
  const view = adapter.overview();
304
472
  deck = {
@@ -322,29 +490,66 @@ export function createDeckWorkspace(config) {
322
490
  const view = adapter.overview();
323
491
  const rebuilt = slidesFromSource(adapter);
324
492
  const previous = Array.isArray(deck.slides) ? deck.slides : [];
325
- const byId = new Map(previous.map((slide) => [slide.id, slide]));
326
- // 源里有的页:字段以源为准,但保留本地已填的 visualBrief(manifest-only,源里没有)。
327
- const merged = rebuilt.map((slide) => {
328
- const local = byId.get(slide.id);
329
- if (!local || !String(local.visualBrief ?? "").trim()) return slide;
493
+ // F-208:插过页之后 manifest 的 id/order 已经重编号,和源页号不再一一对应。
494
+ // 对齐键取决于源是不是本地脚本的镜像:
495
+ // - 本机 deck 确认后会把 script 回写源(ADR-0022),源的「第 N 页」= 本地第 N 页(含插页)
496
+ // → 按 id 对齐;用 sourcePage 反而会把插入页当成「源里没有的页」重复插一遍。
497
+ // - 受控客户源永远停在原始页序 → 按 slide.sourcePage(原始源页)对齐,避免 Inspector 保存
498
+ // (forceRebuild)把后移过的页弄丢或重复。
499
+ // 判据是「源的页号语义 == 本地页号语义」,不是「文本一模一样」:
500
+ // Inspector 保存(savePage)只写源 script.md、不写本地 current.md(有 revision 时源不覆盖本地),
501
+ // 两份文本会立刻不相等——但页号集合仍一致,仍该按 id 对齐。受控源插页后源仍是原始页数,集合不同,
502
+ // 才按 slide.sourcePage 对齐。所以只比页号序列。
503
+ const localScriptText = readFileSafe(scriptFile);
504
+ const sourceScriptText = typeof adapter.readScriptRaw === "function" ? adapter.readScriptRaw() : null;
505
+ const pageNumbersOfText = (text) => [...String(text ?? "").matchAll(/^#{2,3}\s*P(\d+)/gm)].map((match) => Number(match[1]));
506
+ const localPageNumbers = pageNumbersOfText(localScriptText);
507
+ const sourcePageNumbers = pageNumbersOfText(sourceScriptText);
508
+ const sourceMirrorsLocal = localPageNumbers.length > 0
509
+ && localPageNumbers.length === sourcePageNumbers.length
510
+ && localPageNumbers.every((page, index) => page === sourcePageNumbers[index]);
511
+ const alignPageOf = (slide) => (sourceMirrorsLocal ? pageNumberFromId(slide.id) : slideSourcePage(slide));
512
+ const byAlignedPage = new Map();
513
+ for (const slide of previous) {
514
+ const sourcePage = alignPageOf(slide);
515
+ if (sourcePage != null && !byAlignedPage.has(sourcePage)) byAlignedPage.set(sourcePage, slide);
516
+ }
517
+ // 源里有的页:字段以源为准,但保留本地 id/order、原始源页标记与 local-only 的 visualBrief。
518
+ const merged = rebuilt.map((row) => {
519
+ const local = byAlignedPage.get(row.order);
520
+ if (!local) return row;
330
521
  // F-121:重建 manifest 时 visualBrief 是「本地才有的字段」,必须连写作风格一起保住;
331
522
  // 只留 visualBrief 会把 provenance 冲掉,脚本页就全变成「brief 风格未记录」。
332
- return {
333
- ...slide,
334
- visualBrief: local.visualBrief,
335
- ...(local.visualBriefDesign ? { visualBriefDesign: local.visualBriefDesign } : {}),
336
- };
523
+ const next = { ...row, id: local.id, order: local.order };
524
+ if (Object.prototype.hasOwnProperty.call(local, "sourcePage")) next.sourcePage = local.sourcePage;
525
+ if (String(local.visualBrief ?? "").trim()) {
526
+ next.visualBrief = local.visualBrief;
527
+ if (local.visualBriefDesign) next.visualBriefDesign = local.visualBriefDesign;
528
+ }
529
+ return next;
337
530
  });
338
- // 源里没有的页 = 本地追加页(F-99 deck_script_add_page)。重建 manifest 时不能丢,
531
+ // 源里没有的页 = 本地追加 / 插入页(F-99 / F-208)。重建 manifest 时不能丢,
339
532
  // 否则 Inspector 一保存(forceRebuild)就把 6→10 页打回 6 页。
340
- const extras = previous.filter((slide) => !rebuilt.some((candidate) => candidate.id === slide.id));
533
+ const extras = previous.filter((slide) => {
534
+ const sourcePage = alignPageOf(slide);
535
+ return sourcePage == null || !rebuilt.some((row) => row.order === sourcePage);
536
+ });
537
+ // 兜底:源与本地都改过时可能撞出重复 id,宁可保守去重(保留先出现的那条),也不能一本 deck 两份 p07。
538
+ const seenIds = new Set();
539
+ const slides = [...merged, ...extras]
540
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
541
+ .filter((slide) => {
542
+ if (!slide?.id || seenIds.has(slide.id)) return false;
543
+ seenIds.add(slide.id);
544
+ return true;
545
+ });
341
546
  deck = {
342
547
  ...deck,
343
548
  title: view.title,
344
549
  status: view.status || deck.status,
345
550
  sourceSignature: signature,
346
551
  buildWarnings: view.warnings,
347
- slides: [...merged, ...extras].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
552
+ slides,
348
553
  appendix: { count: view.appendixCount ?? 0, rows: view.appendixRows ?? [] },
349
554
  };
350
555
  saveManifest(deck);
@@ -368,13 +573,23 @@ export function createDeckWorkspace(config) {
368
573
  const rows = adapter.storyPages();
369
574
  for (const slide of current.slides) {
370
575
  if (String(slide.body ?? "").trim()) continue;
371
- const row = rows.find((item) => `p${String(item.page).padStart(2, "0")}` === slide.id);
576
+ const sourcePage = slideSourcePage(slide);
577
+ const row = rows.find((item) => item.page === sourcePage);
372
578
  const body = String(row?.fields?.["正文"] ?? "").trim();
373
579
  if (body) {
374
580
  slide.body = body;
375
581
  changed = true;
376
582
  }
377
583
  }
584
+ // F-209:已落盘的旧 manifest 里 visualBrief 可能还是「源 prompt 原样」(含 `### P02` 分页标题)。
585
+ // 只删结构标题行、别的一字不动;改一次就够(下次比较不再变化)。
586
+ for (const slide of current.slides) {
587
+ const cleaned = stripVisualBriefScaffolding(slide.visualBrief);
588
+ if (cleaned !== String(slide.visualBrief ?? "")) {
589
+ slide.visualBrief = cleaned;
590
+ changed = true;
591
+ }
592
+ }
378
593
  if (changed) saveManifest(current);
379
594
  }
380
595
  return manifest(adapter.id);
@@ -483,7 +698,27 @@ export function createDeckWorkspace(config) {
483
698
  ensureDeck,
484
699
  scriptCurrent,
485
700
  appendScriptPages,
701
+ insertScriptPages,
702
+ shiftSlideDirs,
486
703
  scriptPageNumbers,
704
+ /** 某页对应的原始源页号(源 baseline 图映射;页不存在返回 null)(F-208)。 */
705
+ sourcePageOf(deckId, pageId) {
706
+ const deck = manifest(deckId);
707
+ const slide = deck?.slides?.find((candidate) => candidate.id === pageId);
708
+ return slide ? slideSourcePage(slide) : null;
709
+ },
710
+ /**
711
+ * 源 baseline 图的 key(`p03`):按 slide 的原始源页取,插页重编号不会挂错图。
712
+ * 页不在 manifest 时按传入 id 原样返回(导航/预览会直接拿源页号查);
713
+ * 返回 null = 这页是插页新增的,源里没有 baseline 图。
714
+ */
715
+ sourcePageKey(deckId, pageId) {
716
+ const deck = manifest(deckId);
717
+ const slide = deck?.slides?.find((candidate) => candidate.id === pageId);
718
+ if (!slide) return pageId;
719
+ const page = slideSourcePage(slide);
720
+ return page == null ? null : pageIdFromNumber(page);
721
+ },
487
722
  saveScriptRevision,
488
723
  listScriptRevisions,
489
724
  saveProposal,
package/lib/faq.js CHANGED
@@ -188,10 +188,13 @@ export const FAQ_ENTRIES = [
188
188
  {
189
189
  id: "add-page",
190
190
  title: "页数不够怎么加页",
191
- keywords: ["加页", "增加页面", "页数", "扩页", "多一页"],
191
+ keywords: ["加页", "插页", "插入页面", "增加页面", "页数", "扩页", "多一页"],
192
192
  answer:
193
- "工具栏「+ 添加页面」或让 Agent 加页(deck_script_add_page,可一次多页,只追加到末尾,产出提案,确认后落版)。\n" +
194
- "新页没有画面要求也能先出草稿图,再决定要不要补写。",
193
+ "工具栏「+ 添加页面」或让 Agent 加页 / 插页:末尾追加用 deck_script_add_page(可一次多页),任意位置插入用 " +
194
+ "deck_script_insert_page(at = 插到第 N 页之前,1-based;当前页数+1 等价追加)。都只出提案,确认后落一条 revision;" +
195
+ ">= at 的页号整体后移、已出的图跟着内容走,不需要重出整份。\n" +
196
+ "插页/追加都只新增,不会替换或移动已有页;如果稿子里已经有同标题的页,提案会明确警告「会出现两套内容」。\n" +
197
+ "新页没有画面要求也能先出草稿图,再决定要不要补写;删页暂不支持。",
195
198
  },
196
199
  {
197
200
  id: "delete-deck",