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.
- package/client.js +128 -46
- package/cordis.example.yml +6 -1
- package/cordis.patch.yml +6 -1
- package/index.js +94 -15
- package/lib/deck-docs.js +54 -24
- package/lib/deck-workspace.js +290 -55
- package/lib/faq.js +6 -3
- package/lib/proposal-decision.js +138 -7
- package/lib/render.js +84 -2
- package/lib/tools.js +168 -3
- package/package.json +1 -1
- package/ui/workbench.html +129 -36
package/lib/deck-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* asset URL、proposal.pageId 跟着错配(那需要迁目录,另有 ADR 再谈)。
|
|
215
|
-
* 同时把 frontmatter 的 `pages:` 计数改成新页数,保证元数据与正文一致。
|
|
265
|
+
* 规格化新页(追加 / 插页共用)。页号**永远按当前位置重算**,不信调用方存的旧页号:
|
|
266
|
+
* 提案存放期间如果又落了别的 revision(比如先插页、再确认之前提的追加页),旧页号会和现页撞车。
|
|
216
267
|
*/
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
// 源里没有的页 =
|
|
531
|
+
// 源里没有的页 = 本地追加 / 插入页(F-99 / F-208)。重建 manifest 时不能丢,
|
|
339
532
|
// 否则 Inspector 一保存(forceRebuild)就把 6→10 页打回 6 页。
|
|
340
|
-
const extras = previous.filter((slide) =>
|
|
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
|
|
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
|
|
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
|
|
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",
|