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.
@@ -12,7 +12,7 @@
12
12
  import { existsSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
- import { SCRIPT_FIELD_CELL, proposalNow, scriptSectionRange } from "./deck-workspace.js";
15
+ import { SCRIPT_FIELD_CELL, pageIdFromNumber, pageNumberFromId, proposalNow, scriptSectionRange } from "./deck-workspace.js";
16
16
  import { commitFiles } from "./git-commit.js";
17
17
  import { isFirstDraft, writeAutoland } from "./first-draft.js";
18
18
 
@@ -28,7 +28,7 @@ export function createScriptToSourceSync({ adapters = [], isLocalDeck = null } =
28
28
  if (!sourceRoot || !existsSync(sourceRoot)) return null;
29
29
  const file = join(sourceRoot, "script.md");
30
30
  writeFileSync(file, scriptText.endsWith("\n") ? scriptText : `${scriptText}\n`, "utf8");
31
- const label = proposal?.kind === "script-add-page" ? "追加页" : "改稿";
31
+ const label = proposal?.kind === "script-add-page" ? "追加页" : proposal?.kind === "script-insert-page" ? "插页" : "改稿";
32
32
  const git = commitFiles({ dir: sourceRoot, files: [file], message: `deck(${deckId}):${label}回写 script` });
33
33
  return { path: file, ok: git?.ok === true, commit: git?.commit ?? null, error: git?.ok ? null : git?.error ?? "git 未提交" };
34
34
  };
@@ -118,6 +118,108 @@ export function syncAddPageManifest(workspace, proposal, appliedPages) {
118
118
  workspace.saveManifest(deck);
119
119
  }
120
120
 
121
+ /**
122
+ * 插页提案 → manifest 重编号 + 新页入表 + `slides/<pageId>/versions` 目录后移(F-208)。
123
+ *
124
+ * 顺序很重要:**先迁目录、再写 manifest**,所以下面先调 `workspace.shiftSlideDirs`
125
+ * (它内部失败会回滚已 rename 的目录),manifest 写盘在最后。这样任何一步抛错,
126
+ * 都不会出现「manifest 指向一个不存在的版本目录」的半截状态。
127
+ *
128
+ * slide 上补 `sourcePage`(原始源页号):源 baseline 图按它取,插页改号不会让源图错位;
129
+ * 插页新增的页 `sourcePage: null`(源里没有 baseline 图,别去源里抓一张别人的图)。
130
+ */
131
+ export function syncInsertPageManifest(workspace, proposal, applied) {
132
+ const deck = workspace.manifest(proposal.deckId);
133
+ if (!deck) throw new Error(`manifest 缺失:${proposal.deckId}`);
134
+ const slides = Array.isArray(deck.slides) ? deck.slides : [];
135
+ const atPage = Number(applied.at);
136
+ const shift = Number(applied.shift) || (applied.pages?.length ?? 0);
137
+ if (!Number.isInteger(atPage) || atPage < 1 || !Number.isInteger(shift) || shift < 1) {
138
+ throw new Error(`插页参数无效:at=${applied.at} shift=${applied.shift}`);
139
+ }
140
+ const oldCover = slides.find((slide) => Number(slide.order) === 1 && slide.type === "cover") ?? null;
141
+
142
+ // 1) 目录跟着内容后移(.deck/.../slides/p03 → p05 …)
143
+ const moved = workspace.shiftSlideDirs(deck.id, { fromPage: atPage, shift });
144
+
145
+ // 2) manifest 重编号:id 与 order 一起改,并把「原始源页」记到 sourcePage(只在第一次改号时记)。
146
+ const renumbered = slides.map((slide) => {
147
+ const order = Number(slide.order);
148
+ if (!(order >= atPage)) return slide;
149
+ const next = { ...slide, order: order + shift, id: pageIdFromNumber(order + shift) };
150
+ if (!Object.prototype.hasOwnProperty.call(slide, "sourcePage")) next.sourcePage = order;
151
+ return next;
152
+ });
153
+
154
+ // 3) 新页入表:插到第 1 页之前时,封面身份交给新页(老封面降级为 story,保持「一本只有一个封面」)。
155
+ // 占位判断用**重编号之后**的 id:新页要占的号原本属于被后移的页,那不是冲突。
156
+ const occupiedIds = new Set(renumbered.map((slide) => slide.id));
157
+ const inserted = (applied.pages ?? []).map((spec, index) => {
158
+ const pageId = pageIdFromNumber(spec.page);
159
+ if (occupiedIds.has(pageId)) {
160
+ throw new Error(`manifest 已有 ${pageId},拒绝重复插入(manifest 与脚本页号不一致;先核对 deck.json 或跑 deck_script_repair 体检)`);
161
+ }
162
+ const takesCover = atPage === 1 && index === 0 && oldCover != null;
163
+ return {
164
+ id: pageId,
165
+ order: spec.page,
166
+ type: takesCover ? "cover" : "story",
167
+ title: spec.title,
168
+ question: spec.question,
169
+ claim: spec.claim,
170
+ source: "script.md",
171
+ asset: null,
172
+ visualBrief: spec.visualBrief,
173
+ // 源里没有这一页 → 没有 baseline 图;显式 null 防止回退到同号源页的图。
174
+ sourcePage: null,
175
+ inserted: true,
176
+ };
177
+ });
178
+ deck.slides = [...renumbered, ...inserted].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
179
+ if (atPage === 1 && oldCover != null) {
180
+ // 老封面整体后移到 1+shift,降级成 story(新第一页接管封面)。
181
+ const demoted = deck.slides.find((slide) => Number(slide.order) === 1 + shift && slide.type === "cover");
182
+ if (demoted) demoted.type = "story";
183
+ }
184
+ workspace.saveManifest(deck);
185
+ return { moved, fromPage: atPage, shift, insertedIds: inserted.map((slide) => slide.id) };
186
+ }
187
+
188
+ /**
189
+ * 插页确认后,把落在被后移页上的**待确认提案**改到新页号(F-208)。
190
+ * 不改就全落错页:提案里存的是旧 page/pageId,而工作副本已经重编号。
191
+ * 追加页 / 插页提案带自己的页号语义,不动(它们在落版时按当前脚本重算)。
192
+ */
193
+ export function remapProposalsAfterInsert(workspace, deckId, { fromPage, shift, skipIds = [] }) {
194
+ const remapped = [];
195
+ for (const proposal of workspace.listProposals(deckId, "waiting-user")) {
196
+ if (skipIds.includes(proposal.proposalId)) continue;
197
+ if (proposal.kind === "script-add-page" || proposal.kind === "script-insert-page") continue;
198
+ const oldPage = Number(proposal.page);
199
+ const oldPageIds = Array.isArray(proposal.pageIds) ? proposal.pageIds : [];
200
+ const pageShifted = Number.isFinite(oldPage) && oldPage >= fromPage;
201
+ const idsShifted = oldPageIds.some((id) => {
202
+ const page = pageNumberFromId(id);
203
+ return page != null && page >= fromPage;
204
+ });
205
+ if (!pageShifted && !idsShifted) continue;
206
+ const patch = { renumberedAfterInsert: { from: oldPage, shift } };
207
+ if (pageShifted) {
208
+ patch.page = oldPage + shift;
209
+ patch.pageId = pageIdFromNumber(oldPage + shift);
210
+ }
211
+ if (oldPageIds.length) {
212
+ patch.pageIds = oldPageIds.map((id) => {
213
+ const page = pageNumberFromId(id);
214
+ return page != null && page >= fromPage ? pageIdFromNumber(page + shift) : id;
215
+ });
216
+ }
217
+ workspace.updateProposal(proposal, patch);
218
+ remapped.push({ proposalId: proposal.proposalId, from: proposal.pageId, to: patch.pageId ?? proposal.pageId });
219
+ }
220
+ return remapped;
221
+ }
222
+
121
223
  /**
122
224
  * 批量落版(F-176 / 智力升级 I-18):把一批 script 提案合成**一条** revision。
123
225
  * 首稿自动落版(F-173)与用户「接受并更新」勾选批次共用这一段,保证两条路径的写入语义一致。
@@ -129,10 +231,14 @@ function landProposalBatch({ store, workspace, proposals, decidedBy, quote = "",
129
231
  for (const proposal of list) {
130
232
  if (proposal.deckId !== deckId) throw new Error("批量落版只处理同一个 deck 的提案");
131
233
  if (proposal.status !== "waiting-user") throw new Error(`提案 ${proposal.proposalId} 状态为 ${proposal.status},只有 waiting-user 可落版`);
132
- if (proposal.kind !== "script" && proposal.kind !== "script-add-page" && proposal.kind !== "manifest") {
133
- throw new Error(`批量落版不支持 ${proposal.kind} 类提案(只支持 script 正文 / 追加页 / visualBrief)`);
234
+ if (proposal.kind !== "script" && proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page" && proposal.kind !== "manifest") {
235
+ throw new Error(`批量落版不支持 ${proposal.kind} 类提案(只支持 script 正文 / 追加页 / 插页 / visualBrief)`);
134
236
  }
135
237
  }
238
+ // F-208:插页会让后面的页号整体后移;和别的提案混批时,那些提案里的 page 已经对不上工作副本。
239
+ if (list.some((proposal) => proposal.kind === "script-insert-page") && list.length > 1) {
240
+ throw new Error("插页提案要单独确认:它会让后面的页号整体后移,和别的提案混批会落到错页。请先单独确认插页提案,再处理其它提案。");
241
+ }
136
242
  const current = workspace.scriptCurrent(deckId);
137
243
  if (current == null) throw new Error(`script 工作副本缺失:${deckId}`);
138
244
  let text = current;
@@ -150,6 +256,15 @@ function landProposalBatch({ store, workspace, proposals, decidedBy, quote = "",
150
256
  manifestTouched = true;
151
257
  continue;
152
258
  }
259
+ if (proposal.kind === "script-insert-page") {
260
+ const applied = workspace.insertScriptPages(text, { at: proposal.at, pages: proposal.pages ?? [] });
261
+ if (!applied.pages.length) throw new Error(`提案 ${proposal.proposalId} 没有可插入的页`);
262
+ text = applied.text;
263
+ syncInsertPageManifest(workspace, proposal, applied);
264
+ remapProposalsAfterInsert(workspace, deckId, { fromPage: applied.at, shift: applied.shift, skipIds: [proposal.proposalId] });
265
+ proposal.pageCountAfter = applied.pageCount;
266
+ continue;
267
+ }
153
268
  if (proposal.kind === "script-add-page") {
154
269
  const applied = workspace.appendScriptPages(text, proposal.pages ?? []);
155
270
  if (!applied.pages.length) throw new Error(`提案 ${proposal.proposalId} 没有可追加的页`);
@@ -274,8 +389,8 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
274
389
  // 判定放在这里(而不是 Agent 侧):Agent 说错话也落不了有历史版本的稿子。
275
390
  const wantAutoland = autoland != null;
276
391
  if (wantAutoland) {
277
- if (proposal.kind !== "script" && proposal.kind !== "script-add-page") {
278
- throw new Error("自动落版只用于 script 内容(正文 / 追加页);visualBrief 这类仍走提案确认。");
392
+ if (proposal.kind !== "script" && proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page") {
393
+ throw new Error("自动落版只用于 script 内容(正文 / 追加页 / 插页);visualBrief 这类仍走提案确认。");
279
394
  }
280
395
  if (!isFirstDraft(workspace, proposal.deckId)) {
281
396
  throw new Error(`首稿自动落版被拒:${proposal.deckId} 已有已落版 revision。自动落版只用于第一次落库,这次请等用户确认后再落。`);
@@ -312,6 +427,22 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
312
427
  syncAddPageManifest(workspace, proposal, applied.pages);
313
428
  proposal.pageCountAfter = applied.pageCount;
314
429
  appliedScriptText = applied.text;
430
+ } else if (proposal.kind === "script-insert-page") {
431
+ // F-208:插页 = 重编号 + 目录迁移。和追加页一样基于**当前**工作副本重算,避免提案后被别的
432
+ // revision 改过而覆盖;manifest / 目录 / 待确认提案的页号一起迁,已出的图跟着内容走。
433
+ const current = workspace.scriptCurrent(proposal.deckId);
434
+ if (current == null) throw new Error(`script 工作副本缺失:${proposal.deckId}`);
435
+ const applied = workspace.insertScriptPages(current, { at: proposal.at, pages: proposal.pages ?? [] });
436
+ if (!applied.pages.length) throw new Error(`提案 ${proposal.proposalId} 没有可插入的页`);
437
+ const synced = syncInsertPageManifest(workspace, proposal, applied);
438
+ revisionInfo = workspace.saveScriptRevision(
439
+ proposal.deckId,
440
+ applied.text,
441
+ `insert ${applied.pages.length} page(s) before P${String(applied.at).padStart(2, "0")}; ${synced.moved.length} page dir(s) shifted by ${applied.shift} by user confirmation (${proposal.proposalId})`,
442
+ );
443
+ remapProposalsAfterInsert(workspace, proposal.deckId, { fromPage: applied.at, shift: applied.shift, skipIds: [proposal.proposalId] });
444
+ proposal.pageCountAfter = applied.pageCount;
445
+ appliedScriptText = applied.text;
315
446
  } else {
316
447
  const deck = workspace.manifest(proposal.deckId);
317
448
  const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
@@ -357,7 +488,7 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
357
488
  slideIds: affectedSlideIds,
358
489
  actor: autolandRecord ? "agent" : "user",
359
490
  status: "succeeded",
360
- payload: { proposalId: proposal.proposalId, outcome: "applied", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, revisionPath: revisionInfo?.path ?? null, ...(autolandRecord ? { autoland: true, selfReview: autolandRecord.selfReview, draftRound: autolandRecord.draftRound } : {}), ...(sourceSync ? { sourceSync } : {}), ...(proposal.kind === "script-add-page" ? { field: "add-page", pages: proposal.pageIds, pageCountAfter: proposal.pageCountAfter ?? null } : {}) },
491
+ payload: { proposalId: proposal.proposalId, outcome: "applied", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, revisionPath: revisionInfo?.path ?? null, ...(autolandRecord ? { autoland: true, selfReview: autolandRecord.selfReview, draftRound: autolandRecord.draftRound } : {}), ...(sourceSync ? { sourceSync } : {}), ...(proposal.kind === "script-add-page" ? { field: "add-page", pages: proposal.pageIds, pageCountAfter: proposal.pageCountAfter ?? null } : {}), ...(proposal.kind === "script-insert-page" ? { field: "insert-page", pages: proposal.pageIds, at: proposal.at ?? null, pageCountAfter: proposal.pageCountAfter ?? null } : {}) },
361
492
  });
362
493
 
363
494
  return {
package/lib/render.js CHANGED
@@ -79,6 +79,87 @@ export function onImageText(slide) {
79
79
  };
80
80
  }
81
81
 
82
+ /**
83
+ * 源 deck 完整 prompt 的脚手架清洗(F-209)。
84
+ * 源 `prompts/Pnn.txt` 是「STYLE + LAYOUT AND EXACT TEXT(含标题/正文)+ LAYOUT VISUALS + BOTTOM TAKEAWAY」
85
+ * 的完整独立 prompt(导入时原样进了 manifest.visualBrief)。原样拼进出图 prompt 时:
86
+ * - `### P02 · 标题` 会被模型当图上文字印出来(事故:页顶出现 `##P2`,p05 还出现 `P03 · …` 徽章);
87
+ * - `STYLE:` / `LAYOUT AND EXACT TEXT:` / `**标题**:` 这些脚手架也可能被换个形式画到图上。
88
+ * 只删脚手架,**内容一字不动**(受控源 deck 的 manifest.body 常为空,图上正文来自这些内容段,
89
+ * 删内容会丢整页正文)。未识别成完整源 prompt 的文本(用户自己写的画面要求、单行 STYLE)原样返回:
90
+ * 幂等、零误伤。实测一本 21 页受控源 deck 的 p04:1005 → 295 字符(出图 prompt 1517 → 829)。
91
+ * @param {string} value
92
+ * @returns {string}
93
+ */
94
+ export function stripVisualBriefScaffolding(value) {
95
+ const text = String(value ?? "").replace(/\r\n/g, "\n");
96
+ if (!text.trim()) return "";
97
+ const lines = text.split("\n");
98
+ // 只有「像完整源 prompt」时才洗;单行 STYLE / 用户自写画面要求一律不动(smoke 契约)。
99
+ const looksLikeSourcePrompt = lines.some((line) => {
100
+ const t = line.trim();
101
+ return /^LAYOUT AND EXACT TEXT\s*[::]?\s*$/i.test(t)
102
+ || /^PAGE CONTENT\b/i.test(t)
103
+ || /^LAYOUT VISUALS\b/i.test(t)
104
+ || /^BOTTOM TAKEAWAY\s*[::]/i.test(t)
105
+ || /^LAYOUT GUARD\s*[::]/i.test(t)
106
+ || /^#{1,6}\s*P\d+\b/.test(t)
107
+ || /^TITLE, the largest\b/i.test(t);
108
+ });
109
+ if (!looksLikeSourcePrompt) return text.trim();
110
+
111
+ const MD_PAGE_HEAD = /^\s{0,3}#{1,6}\s*P\d+\b.*$/;
112
+ const STYLE_START = /^STYLE\s*[::]/i;
113
+ const EXACT_HEAD = /^LAYOUT AND EXACT TEXT\s*[::]?\s*$/i;
114
+ const ENGLISH_FIELD_HEADS = [/^PAGE CONTENT\b/i, /^LAYOUT VISUALS\b/i, /^LAYOUT GUARD\b/i];
115
+ const TAKEAWAY_HEAD = /^(?:BOTTOM TAKEAWAY|页脚收口)\s*[::]\s*(.*)$/i;
116
+ const FIELD = /^\*\*([^*]+)\*\*\s*[::]\s*(.*)$/;
117
+ const DROP_FIELDS = new Set(["标题", "副标题", "讲述意图", "页面内容", "正文", "画面说明"]);
118
+ const EN_TITLE_LINE = /^(TITLE|SUBTITLE)\b[^::]*[::]\s*「?(.*?)」?\s*$/i;
119
+ const EN_TAKEAWAY = /^BOTTOM TAKEAWAY\s*[::]\s*(.*)$/i;
120
+
121
+ const kept = [];
122
+ let inStyle = false;
123
+ for (const rawLine of lines) {
124
+ const line = rawLine.replace(/\s+$/, "");
125
+ const t = line.trim();
126
+ if (!t) {
127
+ inStyle = false;
128
+ kept.push("");
129
+ continue;
130
+ }
131
+ if (STYLE_START.test(t)) {
132
+ inStyle = true;
133
+ continue;
134
+ }
135
+ if (inStyle) continue;
136
+ if (MD_PAGE_HEAD.test(t)) continue;
137
+ if (EXACT_HEAD.test(t)) continue;
138
+ if (ENGLISH_FIELD_HEADS.some((rx) => rx.test(t))) continue;
139
+ if (EN_TITLE_LINE.test(t)) continue;
140
+ const enTake = EN_TAKEAWAY.exec(t);
141
+ if (enTake) {
142
+ kept.push(`页脚通栏一句:「${enTake[1].trim()}」`);
143
+ continue;
144
+ }
145
+ const field = FIELD.exec(t);
146
+ if (field) {
147
+ const name = field[1].trim();
148
+ if (DROP_FIELDS.has(name)) continue;
149
+ const value = field[2].trim();
150
+ kept.push(value ? `${name}:${value}` : `${name}:`);
151
+ continue;
152
+ }
153
+ const take = TAKEAWAY_HEAD.exec(t);
154
+ if (take) {
155
+ kept.push(`页脚通栏一句:「${take[1].trim()}」`);
156
+ continue;
157
+ }
158
+ kept.push(line);
159
+ }
160
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
161
+ }
162
+
82
163
  const ON_IMAGE_HEAD = "【这一页必须在画面上印出的文字:逐字照排,不要改写、翻译、缩写或省略】";
83
164
  /** 反装饰:图例 / 清单面板 / 英文角标这类「看起来专业但跟本页无关」的元素,模型最容易凭空造。 */
84
165
  const CANVAS_DISCIPLINE =
@@ -112,11 +193,12 @@ export function buildPrompt(slide, styleBrief, context = {}) {
112
193
  const brief = context.brief ?? {};
113
194
  const list = onImageText(slide);
114
195
  const isCover = slide?.type === "cover" || Number(slide?.order) === 1;
196
+ const visualBrief = stripVisualBriefScaffolding(slide?.visualBrief);
115
197
  if (isCover) {
116
198
  const parts = [styleBrief, "封面页(16:9):只放标题信息", ...onImageLines({ ...list, bullets: [] })];
117
199
  parts.push(...coverPromptLines(brief));
118
200
  parts.push("版式纪律:只放主标题 / 副标题 / 作者 / 日期;正文不超过一行;不要目录、要点清单、数据图表、大段文字;留白为主。");
119
- if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
201
+ if (visualBrief) parts.push(`画面要求:${visualBrief}`);
120
202
  parts.push(CANVAS_DISCIPLINE);
121
203
  parts.push("文字优先:上面【图上文字】的每一行都必须出现在画面里;画面要求与它冲突时,以上面为准。");
122
204
  return parts.filter(Boolean).join("\n");
@@ -124,7 +206,7 @@ export function buildPrompt(slide, styleBrief, context = {}) {
124
206
  const parts = [styleBrief, ...onImageLines(list)];
125
207
  const body = String(slide?.body ?? "").trim();
126
208
  if (body) parts.push(`讲述用正文(不要整段排进画面,只提炼成上面的要点;用来把握语气与例子):${body.slice(0, 300)}`);
127
- if (slide?.visualBrief) parts.push(`画面要求:${slide.visualBrief}`);
209
+ if (visualBrief) parts.push(`画面要求:${visualBrief}`);
128
210
  parts.push(CANVAS_DISCIPLINE);
129
211
  parts.push("文字优先:上面【图上文字】的主标题 / 副标题 / 主张必须完整印出(可换行、可分层,但不得省略、不得改写成别的说法);画面要求若写「只出现 XX」,XX 之外仍要保留这三项与要点短语。");
130
212
  parts.push(...briefPromptLines(brief));
package/lib/tools.js CHANGED
@@ -57,6 +57,28 @@ function sessionIdOf(exec) {
57
57
  return exec?.agent?.id ?? null;
58
58
  }
59
59
 
60
+ /**
61
+ * F-209:新页与稿子里已有页「同标题」时给出明确提醒。
62
+ * 插页/追加都只新增、不替换——用户拿「追加」当「搬位置」时会出现两套内容(
63
+ * 事故:先追加 P22/P23,再插页同一内容到 P02/P03,deck 里就多出一套)。
64
+ * 只做提醒,不阻断(有时确实要两页同标题的不同版本);文案要让 Agent 先说清楚、别默认重复。
65
+ */
66
+ function duplicateContentNotes(deck, specs) {
67
+ const normalize = (value) => String(value ?? "").replace(/[\s,。!?、;:"'“”‘’()()]/g, "").trim();
68
+ const existing = (deck?.slides ?? [])
69
+ .map((slide) => ({ id: slide.id, order: Number(slide.order) || null, title: normalize(slide.title), rawTitle: String(slide.title ?? "").trim() }))
70
+ .filter((slide) => slide.title);
71
+ const notes = [];
72
+ for (const spec of specs ?? []) {
73
+ const title = normalize(spec?.title);
74
+ if (!title) continue;
75
+ const hit = existing.find((slide) => slide.title === title);
76
+ if (hit) notes.push(`P${String(hit.order ?? 0).padStart(2, "0")}「${hit.rawTitle}」`);
77
+ }
78
+ return notes;
79
+ }
80
+
81
+
60
82
  /**
61
83
  * Agent 可读的参考资料白名单(F-71)。
62
84
  *
@@ -994,6 +1016,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
994
1016
  const preview = workspace.appendScriptPages(current, specs);
995
1017
  const pageIds = specs.map((spec) => `p${String(spec.page).padStart(2, "0")}`);
996
1018
  const diff = scriptDiff(current, preview.text);
1019
+ const duplicateNotes = duplicateContentNotes(deck, specs);
997
1020
  const proposal = {
998
1021
  proposalId: newProposalId(),
999
1022
  deckId: deck.id,
@@ -1014,6 +1037,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1014
1037
  status: "waiting-user",
1015
1038
  createdAt: proposalNow(),
1016
1039
  sessionId: sessionIdOf(exec),
1040
+ ...(duplicateNotes.length ? { duplicatePages: duplicateNotes } : {}),
1017
1041
  };
1018
1042
  workspace.saveProposal(proposal);
1019
1043
 
@@ -1052,12 +1076,151 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1052
1076
  diff,
1053
1077
  text:
1054
1078
  `追加页提案 ${proposal.proposalId} 已进入 waiting-user:${deck.slides.length} 页 → ${preview.pageCount} 页` +
1055
- `(新增 ${pageIds.join(" / ")},只追加到末尾,既有页不动)。\n\n${diff || "(无文本差异)"}\n` +
1079
+ `(新增 ${pageIds.join(" / ")},只追加到末尾,既有页不动)。\n` +
1080
+ `${duplicateNotes.length ? `\n⚠ 稿子里已经有同标题的页:${duplicateNotes.join(" / ")}。追加只新增、不替换也不移动——确认后会出现两套内容。如果目的是把已有页搬到末尾,请先跟用户说明当前还不能移动/删页,不要直接追加副本。\n` : ""}` +
1081
+ `\n${diff || "(无文本差异)"}\n` +
1056
1082
  `请在 Chat 里确认;确认后才写 script revision + manifest。`,
1057
1083
  };
1058
1084
  }),
1059
1085
  });
1060
1086
 
1087
+ // ── 3.6 deck_script_insert_page ─────────────────────────────────────────────
1088
+ tools.push({
1089
+ name: "deck_script_insert_page",
1090
+ description:
1091
+ "在任意位置插入页(F-208:用户要「插到第 N 页之后 / 放在 P02 和 P03 之间」时用它)。" +
1092
+ "`at` = 插到第 N 页之前(1-based;at = 当前页数+1 等价追加到末尾,那种情况优先用 deck_script_add_page)。" +
1093
+ "生成 `### Pxx` 分节 + diff 并进入 waiting-user,用户确认后再用 deck_revision_confirm 落版。" +
1094
+ "确认后 >= at 的页号整体后移 k 页,manifest 的 slide id/order 与 slides/<pageId>/versions 目录一起迁移——" +
1095
+ "**已出的图跟着各自内容走,不需要整份重出**,绝对不要建议用户整份重出图。" +
1096
+ "一次最多插 20 页。",
1097
+ parameters: {
1098
+ type: "object",
1099
+ properties: {
1100
+ deckId: { type: "string" },
1101
+ at: { type: "number", description: "插到第 N 页之前(1-based)。P02 之后插入 → at=3;P01 之前(新开场)→ at=1" },
1102
+ pages: {
1103
+ type: "array",
1104
+ description: "要插入的页(按顺序;必填,最多 20 页)",
1105
+ items: {
1106
+ type: "object",
1107
+ properties: {
1108
+ title: { type: "string" },
1109
+ question: { type: "string" },
1110
+ claim: { type: "string" },
1111
+ body: { type: "string" },
1112
+ visualBrief: { type: "string" },
1113
+ },
1114
+ additionalProperties: false,
1115
+ },
1116
+ },
1117
+ reason: { type: "string", description: "为什么插在这里(写进 trace)" },
1118
+ },
1119
+ required: ["deckId", "at", "pages"],
1120
+ additionalProperties: false,
1121
+ },
1122
+ output: { schema: { type: "object", properties: { proposalId: { type: "string" }, status: { type: "string" }, pageCount: { type: "number" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1123
+ execute: withToolEvent(store, "deck_script_insert_page", async (args, exec) => {
1124
+ const adapter = adapterFor(args.deckId);
1125
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1126
+ const current = workspace.scriptCurrent(deck.id);
1127
+ if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
1128
+ const explicit = Array.isArray(args.pages) ? args.pages.filter(Boolean) : [];
1129
+ if (!explicit.length) throw new Error("插页需要 pages(至少一页:title / question / claim / body / visualBrief)");
1130
+ if (explicit.length > 20) throw new Error("一次最多插入 20 页(更多请拆成两次提案)");
1131
+ const preview = workspace.insertScriptPages(current, {
1132
+ at: args.at,
1133
+ pages: explicit.map((page) => ({
1134
+ title: page?.title,
1135
+ question: page?.question,
1136
+ claim: page?.claim,
1137
+ body: page?.body,
1138
+ visualBrief: page?.visualBrief,
1139
+ })),
1140
+ });
1141
+ const pageIds = preview.pages.map((spec) => `p${String(spec.page).padStart(2, "0")}`);
1142
+ const diff = scriptDiff(current, preview.text);
1143
+ const renumberLabel = preview.renumberedPages.length
1144
+ ? `原 ${preview.renumberedPages.map((item) => `P${String(item.from).padStart(2, "0")}`).join(" / ")} 后移为 ${preview.renumberedPages.map((item) => `P${String(item.to).padStart(2, "0")}`).join(" / ")}`
1145
+ : "没有页需要后移(插在末尾)";
1146
+ // 插过页的 deck 里已经出过图的页,确认后会在 manifest 记上原始源页,源 baseline 图不错位。
1147
+ const duplicateNotes = duplicateContentNotes(deck, preview.pages);
1148
+ const pending = workspace.listProposals(deck.id, "waiting-user");
1149
+ const pendingShifted = pending.filter((proposal) => Number(proposal.page) >= preview.at && proposal.kind !== "script-add-page" && proposal.kind !== "script-insert-page");
1150
+ const proposal = {
1151
+ proposalId: newProposalId(),
1152
+ deckId: deck.id,
1153
+ pageId: pageIds[0],
1154
+ page: preview.pages[0].page,
1155
+ pageIds,
1156
+ kind: "script-insert-page",
1157
+ field: "insert-page",
1158
+ at: preview.at,
1159
+ shift: preview.shift,
1160
+ before: "",
1161
+ after: preview.text,
1162
+ // 和追加页一致:插页没有「单字段纯值」,value 留空。
1163
+ value: "",
1164
+ pages: preview.pages,
1165
+ renumberedPages: preview.renumberedPages,
1166
+ pageCountBefore: deck.slides.length,
1167
+ pageCountAfter: preview.pageCount,
1168
+ diff,
1169
+ reason: String(args.reason ?? ""),
1170
+ status: "waiting-user",
1171
+ createdAt: proposalNow(),
1172
+ sessionId: sessionIdOf(exec),
1173
+ ...(duplicateNotes.length ? { duplicatePages: duplicateNotes } : {}),
1174
+ };
1175
+ workspace.saveProposal(proposal);
1176
+
1177
+ const task = store.taskCreate({
1178
+ kind: "edit",
1179
+ deckId: deck.id,
1180
+ slideIds: pageIds,
1181
+ actor: "agent",
1182
+ sessionId: proposal.sessionId,
1183
+ inputRefs: [`script:${deck.id}#insert-page@P${String(preview.at).padStart(2, "0")}`],
1184
+ });
1185
+ store.taskUpdate(task.taskId, { status: "running", actor: "agent" });
1186
+ store.taskUpdate(task.taskId, {
1187
+ status: "waiting-user",
1188
+ actor: "agent",
1189
+ inputRefs: [`script:${deck.id}#insert-page@P${String(preview.at).padStart(2, "0")}`, `proposal:${proposal.proposalId}`],
1190
+ });
1191
+ store.recordEvent({
1192
+ type: "deck.edit",
1193
+ sessionId: proposal.sessionId,
1194
+ taskId: task.taskId,
1195
+ deckId: deck.id,
1196
+ slideIds: pageIds,
1197
+ actor: "agent",
1198
+ status: "waiting-user",
1199
+ payload: { proposalId: proposal.proposalId, field: "insert-page", at: preview.at, shift: preview.shift, reason: proposal.reason, diff, pages: preview.pages.map((spec) => spec.page) },
1200
+ });
1201
+
1202
+ return {
1203
+ proposalId: proposal.proposalId,
1204
+ taskId: task.taskId,
1205
+ deckId: deck.id,
1206
+ slideIds: pageIds,
1207
+ status: "waiting-user",
1208
+ pageCount: preview.pageCount,
1209
+ at: preview.at,
1210
+ shift: preview.shift,
1211
+ diff,
1212
+ text:
1213
+ `插入页提案 ${proposal.proposalId} 已进入 waiting-user:${deck.slides.length} 页 → ${preview.pageCount} 页` +
1214
+ `(插到 P${String(preview.at).padStart(2, "0")} 之前,新增 ${pageIds.join(" / ")};${renumberLabel})。\n` +
1215
+ `${duplicateNotes.length ? `\n⚠ 稿子里已经有同标题的页:${duplicateNotes.join(" / ")}。插页是新增副本、不会替换或移动它们——确认后会出现两套内容。如果目的是把已有页搬到这里,请先跟用户说明当前还不能移动/删页,不要直接插重复页。\n` : ""}` +
1216
+ `确认后会迁移 slides/<pageId>/versions 目录——**已出的图跟着各自内容走,不需要重出整份**。` +
1217
+ `${pendingShifted.length ? `\n另有 ${pendingShifted.length} 条待确认提案落在后移页上(${pendingShifted.map((item) => item.pageId).join(" / ")}),确认时会自动改到新页号。` : ""}` +
1218
+ `\n\n${diff || "(无文本差异)"}\n` +
1219
+ `请在 Chat 里确认;确认后才写 script revision + manifest + 目录迁移。`,
1220
+ };
1221
+ }),
1222
+ });
1223
+
1061
1224
  // ── 4. deck_revision_confirm ───────────────────────────────────────────────
1062
1225
  tools.push({
1063
1226
  name: "deck_revision_confirm",
@@ -1065,7 +1228,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1065
1228
  "对 waiting-user 的提案执行用户裁决。confirmed=true 表示用户已确认(Chat 明确同意或工作台点了确认);" +
1066
1229
  "confirmationQuote 引用用户原话最好,没带也能落版(会标 quoteMissing 留痕)——不要为了凑原话反复问用户;" +
1067
1230
  "写入 script revision(copy-on-write,不覆盖旧版)并同步 manifest。" +
1068
- "确认支持脚本字段(含 body 正文)与 visualBrief / 追加页;confirmed=false 记为用户驳回。",
1231
+ "确认支持脚本字段(含 body 正文)与 visualBrief / 追加页 / 插页;confirmed=false 记为用户驳回。",
1069
1232
  parameters: {
1070
1233
  type: "object",
1071
1234
  properties: {
@@ -3401,7 +3564,9 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
3401
3564
  }
3402
3565
  }
3403
3566
  if (!sourceFile) {
3404
- const baseline = adapter.sourceImages?.()[slide.id];
3567
+ // F-208:插过页的 deck 按原始源页取 baseline(sourcePage),否则会盖到源里同号那页的图上。
3568
+ const sourceKey = workspace.sourcePageKey(deckId, slide.id);
3569
+ const baseline = sourceKey ? adapter.sourceImages?.()[sourceKey] : null;
3405
3570
  if (baseline?.ext === ".png") {
3406
3571
  sourceFile = baseline.path;
3407
3572
  sourceRevision = "source";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
5
5
  "icon": "assets/icon.svg",
6
6
  "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",