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 CHANGED
@@ -619,6 +619,47 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
619
619
  );
620
620
  }
621
621
 
622
+ /**
623
+ * 会话行尾悬停按钮(`sidebar.workspaces.session.row.action`):
624
+ * 点一下就把它绑定的 deck / 设计在 Deckline 打开——比"先点行开对话、再点 ⋯ 菜单"少两步。
625
+ * 宿主会拦掉点击不让它触发行选中,所以这里也显式 stopPropagation。
626
+ */
627
+ function OpenDecklineRowAction({ sessionId }) {
628
+ const [busy, setBusy] = React.useState(false);
629
+ const onOpen = (event) => {
630
+ event?.preventDefault?.();
631
+ event?.stopPropagation?.();
632
+ if (busy) return;
633
+ setBusy(true);
634
+ void openSessionInWorkbench(sessionId).finally(() => setBusy(false));
635
+ };
636
+ return h(
637
+ "button",
638
+ {
639
+ type: "button",
640
+ title: "在 Deckline 打开",
641
+ "aria-label": "在 Deckline 打开",
642
+ "data-deck-studio-row-open": "",
643
+ disabled: busy,
644
+ onClick: onOpen,
645
+ style: {
646
+ display: "inline-flex",
647
+ alignItems: "center",
648
+ justifyContent: "center",
649
+ width: 22,
650
+ height: 22,
651
+ padding: 0,
652
+ border: 0,
653
+ background: "transparent",
654
+ color: "var(--dsw-alias-label-secondary, var(--dsw-alias-label-primary))",
655
+ borderRadius: "var(--dsw-radius-sm)",
656
+ cursor: busy ? "default" : "pointer",
657
+ },
658
+ },
659
+ h(DeckGlyph, { size: 14 }),
660
+ );
661
+ }
662
+
622
663
  const currentSessionId = () => {
623
664
  try {
624
665
  const raw = window.localStorage.getItem("dsh.sessions.current");
@@ -673,14 +714,23 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
673
714
 
674
715
  /* 工作台 iframe → 宿主:把一条完整 prompt 发进当前会话的原生 Chat。 */
675
716
  ctx.effect(() => {
676
- const sendToChat = async (text) => {
677
- const sessionId = currentSessionId();
678
- if (!sessionId) throw new Error("no current session");
679
- return sendToSession(sessionId, text);
717
+ /**
718
+ * F-211:优先用工作台指定的会话。
719
+ *
720
+ * 以前只按 `dsh.sessions.current` 发——那是宿主主区(or web iframe)的当前会话。
721
+ * 桌面 Dock 模式(F-202B / ADR-0027)下,工作台左栏显示的是 `dsh-deck-studio:chat-session`
722
+ * 报上来的 deck 绑定会话,两者经常不是同一个:指令落进隔壁会话,界面却提示已发送。
723
+ * 2026-11-07 真机:指令进了未绑定的 session-a274e455,绑定的 session-e2eda8c9 什么都没收到。
724
+ */
725
+ const sendToChat = async (text, sessionId) => {
726
+ const target = String(sessionId ?? "").trim() || currentSessionId();
727
+ if (!target) throw new Error("no current session");
728
+ return sendToSession(target, text);
680
729
  };
681
730
  const onMessage = (event) => {
682
731
  if (event?.data?.type !== "dsh-deck-studio:prompt") return;
683
732
  const text = String(event.data.text ?? "");
733
+ const sessionId = String(event.data.sessionId ?? "").trim();
684
734
  if (!text.trim()) return;
685
735
  const reply = (payload) => {
686
736
  try {
@@ -689,8 +739,8 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
689
739
  /* 跨窗口失效就忽略 */
690
740
  }
691
741
  };
692
- sendToChat(text)
693
- .then((sessionId) => reply({ ok: true, sessionId }))
742
+ sendToChat(text, sessionId)
743
+ .then((sentTo) => reply({ ok: true, sessionId: sentTo }))
694
744
  .catch((error) => {
695
745
  ctx.logger?.warn?.("deck-studio: prompt bridge failed", error);
696
746
  reply({ ok: false, reason: String(error?.message ?? error) });
@@ -732,10 +782,53 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
732
782
  .filter((item) => normalize(path) === normalize(item.path) || normalize(path).startsWith(`${normalize(item.path)}/`))
733
783
  .sort((a, b) => b.path.length - a.path.length)[0];
734
784
  const register = async (path) => {
735
- const workspace = await workspaces.create({ path });
736
- if (!workspace?.workspaceId) throw new Error("工作区创建未返回 workspaceId");
737
- return workspace.workspaceId;
785
+ // 0.2.28:`workspaces.create()` 的成功返回值是 `{ workspace, created }`(服务端 HTTP 契约就是
786
+ // `workspaceView(...)` 包一层);以前直接读 `.workspaceId`,永远 undefined → 抛错 → 建会话失败。
787
+ // html-deck 那边一直是 `result.workspace ?? result` 再取 `workspaceId ?? id`,所以它没这问题。
788
+ const created = await workspaces.create({ path });
789
+ const row = created?.workspace ?? created;
790
+ const workspaceId = row?.workspaceId ?? row?.id;
791
+ if (!workspaceId) throw new Error("工作区创建未返回 workspaceId(返回:" + JSON.stringify(created)?.slice(0, 200) + ")");
792
+ return workspaceId;
793
+ };
794
+ const title = String(data.title ?? "").trim().slice(0, 60);
795
+ const desiredName = title ? `Deckline · ${title}` : "";
796
+ /**
797
+ * 给「deck 自己的工作区」命名(F-210):侧栏要能一眼看出这是哪本 deck。
798
+ * 重名 / rename 失败只影响侧栏文案,绝不能让建会话失败(html-deck 同款容错)。
799
+ */
800
+ const nameDeckWorkspace = async (workspaceId) => {
801
+ if (!desiredName || typeof workspaces.rename !== "function") return workspaceId;
802
+ try {
803
+ const snapshot = workspaces.list?.getSnapshot?.();
804
+ const rows = snapshot?.phase === "ready" ? snapshot.items ?? [] : items;
805
+ const taken = rows.some((item) => String(item.name ?? "") === desiredName && (item.workspaceId ?? item.id) !== workspaceId);
806
+ if (!taken) await workspaces.rename(workspaceId, desiredName);
807
+ } catch {
808
+ /* 名字冲突 / 权限问题:会话照建 */
809
+ }
810
+ return workspaceId;
738
811
  };
812
+ /**
813
+ * 0.2.16 / F-210:**deck 自己的项目目录排第一**。
814
+ * 以前它排在 cwd / sourceRoot 之后,源目录挂在一个更大的工作区下时永远轮不到它——
815
+ * 于是会话落进「sharing」这种大工作区,侧栏永远看不到以 deck 命名的项目。
816
+ */
817
+ const projectPath = String(data.sessionWorkspacePath ?? "").trim();
818
+ if (projectPath) {
819
+ const exact = items.find((item) => normalize(item.path) === normalize(projectPath));
820
+ const exactId = exact?.workspaceId ?? exact?.id;
821
+ if (exactId) return nameDeckWorkspace(exactId);
822
+ try {
823
+ return nameDeckWorkspace(await register(projectPath));
824
+ } catch (error) {
825
+ // 建不了(路径非法 / 落在别的工作区里 / 权限):退回父工作区,至少别让「开对话」失败。
826
+ const parent = containing(projectPath);
827
+ ctx.logger?.warn?.(`deck-studio: 建 deck 工作区失败(${projectPath}):${error?.message ?? error}${parent ? ";退回父工作区" : ""}`);
828
+ if (parent?.workspaceId ?? parent?.id) return parent.workspaceId ?? parent.id;
829
+ throw error;
830
+ }
831
+ }
739
832
  const configured = String(data.workspacePath ?? "").trim();
740
833
  if (configured) return register(configured);
741
834
  const cwd = String(data.cwd ?? "").trim();
@@ -754,28 +847,6 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
754
847
  const sourceRoot = String(data.sourceRoot ?? "").trim();
755
848
  const sourceWorkspace = sourceRoot ? containing(sourceRoot) : null;
756
849
  if (sourceWorkspace) return sourceWorkspace.workspaceId;
757
- /**
758
- * 0.2.16:deck 自己的项目目录(工作台载荷里的 sessionWorkspacePath)。
759
- * 桌面宿主里上面几级会全落空——cwd 是 profile 目录被守卫挡掉、源目录还没登记成工作区、
760
- * 客户端 workspaces 服务也没有 initializeDefault——结果是「从没有会话的 deck」
761
- * 永远建不出会话、也就永远绑不上(点了 + 没反应)。这里按 deck 项目目录建/复用工作区,
762
- * 保证每本 deck 都有落点;工作区名只是侧栏文案,命名失败不影响建会话。
763
- */
764
- const projectPath = String(data.sessionWorkspacePath ?? "").trim();
765
- if (projectPath) {
766
- const existing = containing(projectPath);
767
- if (existing) return existing.workspaceId;
768
- const projectWorkspaceId = await register(projectPath);
769
- const title = String(data.title ?? "").trim().slice(0, 60);
770
- if (title && typeof workspaces.rename === "function") {
771
- try {
772
- await workspaces.rename(projectWorkspaceId, `Deckline · ${title}`);
773
- } catch {
774
- /* 工作区重名等失败只影响侧栏文案,会话照建 */
775
- }
776
- }
777
- return projectWorkspaceId;
778
- }
779
850
  const fallback = await workspaces.initializeDefault?.();
780
851
  if (fallback?.workspaceId) return fallback.workspaceId;
781
852
  throw new Error("无法确定启动工作区,未创建会话;请配置 workspacePath 后重试");
@@ -1033,6 +1104,32 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
1033
1104
 
1034
1105
  // 唯一入口:侧栏面板行「Deckline」(与 plugins / schedules 等插件同一种形态,带图标);
1035
1106
  // → 对话用工作台里的「← 回到对话」。
1107
+ // 会话行「⋯」菜单 + 行尾悬停按钮:**不放进 hasRightbar / selectPanel 分支**。
1108
+ // 这两个 slot 是宿主可选契约(旧 DSH 没有就只记一条警告);但一旦被右栏服务门禁包住,
1109
+ // 桌面宿主没加载 sidebar-right 时菜单会整个消失——用户看到的效果就是「点会话没反应」。
1110
+ try {
1111
+ ctx.effect(
1112
+ () => ctx.slots.inject("sidebar.workspaces.session.menu.item", () =>
1113
+ ctx.slots.register({
1114
+ name: "sidebar.workspaces.session.menu.item",
1115
+ id: "dsh-plugin-deck-studio.open-binding",
1116
+ order: 150,
1117
+ }, OpenDecklineMenuItem)),
1118
+ "deck-studio: session menu entry",
1119
+ );
1120
+ ctx.effect(
1121
+ () => ctx.slots.inject("sidebar.workspaces.session.row.action", () =>
1122
+ ctx.slots.register({
1123
+ name: "sidebar.workspaces.session.row.action",
1124
+ id: "dsh-plugin-deck-studio.open-binding-row",
1125
+ order: 150,
1126
+ }, OpenDecklineRowAction)),
1127
+ "deck-studio: session row action",
1128
+ );
1129
+ } catch (error) {
1130
+ ctx.logger?.warn?.(`deck-studio: session menu slot unavailable: ${error?.message ?? error}`);
1131
+ }
1132
+
1036
1133
  if (hasRightbar) {
1037
1134
  if (layout?.selectPanel) {
1038
1135
  ctx.effect(
@@ -1040,21 +1137,6 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
1040
1137
  ctx.slots.register({ name: "sidebar.panellist", id: WORKBENCH_KEY, order: 46, label: () => "Deckline" }, DeckGlyph)),
1041
1138
  "deck-studio: workbench sidebar entry",
1042
1139
  );
1043
- // 会话行「⋯」菜单:把当前会话绑定的 deck 在工作台里打开(未绑定给可见提示)。
1044
- // 该槽位是宿主可选契约,旧版本没有时只记一条警告,不能影响面板本身注册。
1045
- try {
1046
- ctx.effect(
1047
- () => ctx.slots.inject("sidebar.workspaces.session.menu.item", () =>
1048
- ctx.slots.register({
1049
- name: "sidebar.workspaces.session.menu.item",
1050
- id: "dsh-plugin-deck-studio.open-binding",
1051
- order: 150,
1052
- }, OpenDecklineMenuItem)),
1053
- "deck-studio: session menu entry",
1054
- );
1055
- } catch (error) {
1056
- ctx.logger?.warn?.(`deck-studio: session menu slot unavailable: ${error?.message ?? error}`);
1057
- }
1058
1140
  }
1059
1141
  ctx.logger?.info?.("deck-studio client: workbench-only mode");
1060
1142
  } else {
@@ -186,7 +186,12 @@
186
186
  - 新建 deck:按弹窗填的意图 / 参考资料直接开始,先出图也行。叙事主线写进 intent.md 的「故事线」段
187
187
  (deck_doc_update,docId=intent);台账字段有依据才写,没依据列成问题问用户,不要停留补文档。
188
188
  资料 ≥5 份 / 很长时再用 deck_research_plan 分批,读到可引用事实顺手写进调研台账。
189
- - 加页:deck_script_add_page(可一次多页,只追加到末尾),产出提案,确认后落版;
189
+ - 加页 / 插页:末尾加页用 deck_script_add_page(可一次多页,只追加到末尾);任意位置插页用
190
+ deck_script_insert_page(deckId、at、pages[]、reason?),at = 插到第 N 页之前(1-based,
191
+ at = 当前页数 + 1 等价追加)。两者都只出提案,确认后落一条 revision:>= at 的页号整体后移,
192
+ manifest 的 slide id/order 同步重编号、slides/<pageId>/versions 目录整体 rename——已出的图跟着内容走,
193
+ 不需要重出整份,也不要让用户手动搬页。用户要求插页时不要回答「没有插入工具 / 只能整份重出」;
194
+ at 越界、空 pages、一次插超过 20 页要如实报错(表格惯例的 script 暂不支持中间插页)。
190
195
  新页没有 brief 可以直接出草稿图,再决定要不要补写。
191
196
  - 交付:用户要交付时建议跑一次 deck_review 自查成品问题(P0 优先);导出 / 交接前再补交接文档。
192
197
  出图不需要文档齐全,docs_check 的分数不影响渲染。
package/cordis.patch.yml CHANGED
@@ -172,7 +172,12 @@
172
172
  - 新建 deck:按弹窗填的意图 / 参考资料直接开始,先出图也行。叙事主线写进 intent.md 的「故事线」段
173
173
  (deck_doc_update,docId=intent);台账字段有依据才写,没依据列成问题问用户,不要停留补文档。
174
174
  资料 ≥5 份 / 很长时再用 deck_research_plan 分批,读到可引用事实顺手写进调研台账。
175
- - 加页:deck_script_add_page(可一次多页,只追加到末尾),产出提案,确认后落版;
175
+ - 加页 / 插页:末尾加页用 deck_script_add_page(可一次多页,只追加到末尾);任意位置插页用
176
+ deck_script_insert_page(deckId、at、pages[]、reason?),at = 插到第 N 页之前(1-based,
177
+ at = 当前页数 + 1 等价追加)。两者都只出提案,确认后落一条 revision:>= at 的页号整体后移,
178
+ manifest 的 slide id/order 同步重编号、slides/<pageId>/versions 目录整体 rename——已出的图跟着内容走,
179
+ 不需要重出整份,也不要让用户手动搬页。用户要求插页时不要回答「没有插入工具 / 只能整份重出」;
180
+ at 越界、空 pages、一次插超过 20 页要如实报错(表格惯例的 script 暂不支持中间插页)。
176
181
  新页没有 brief 可以直接出草稿图,再决定要不要补写。
177
182
  - 交付:用户要交付时建议跑一次 deck_review 自查成品问题(P0 优先);导出 / 交接前再补交接文档。
178
183
  出图不需要文档齐全,docs_check 的分数不影响渲染。
package/index.js CHANGED
@@ -36,7 +36,7 @@ import { appendSampleRecord, readSampleRecords, readStyleDefinition, writeStyleD
36
36
  import { resolveMediaFile, scanDesignStyles, scanResources } from "./lib/home-catalog.js";
37
37
  import { defaultSelection, renderModelPayload, resolveSelection } from "./lib/render-nodes.js";
38
38
  import { applyRenderSettings, readRenderSettings, renderSettingsPayload, saveRenderSettings } from "./lib/render-settings.js";
39
- import { buildPrompt, editEndpointFor, selectRenderer } from "./lib/render.js";
39
+ import { buildPrompt, editEndpointFor, selectRenderer, stripVisualBriefScaffolding } from "./lib/render.js";
40
40
  import { PDF_IMPORT_MAX_PAGES, applyImportedTitles, buildImportNote, extractIntentFields, headingsAsTitles, listImportDir, normalizeDpi, rasterizePdfToPng, scriptPagesFromMarkdown, titleFromPdfName } from "./lib/pdf-import.js";
41
41
  import { readDeckBrief } from "./lib/deck-brief.js";
42
42
  import { detectProfileDir, installModeOf, isNewer, latestFromRegistry, repoLinksOf } from "./lib/update-check.js";
@@ -1416,8 +1416,12 @@ export function createService(config, logger, env = process.env) {
1416
1416
  for (const pageId of pageIds.slice(0, 6)) {
1417
1417
  const info = slideRenderInfo(deckId, pageId);
1418
1418
  if (!info) continue;
1419
+ // F-208:源 baseline 图按 slide 的原始源页取(插页重编号后 pageId ≠ 源页号)。
1420
+ const sourceKey = workspace.sourcePageKey(deckId, pageId);
1419
1421
  const sourcePath = info.fromSource
1420
- ? sourceImages[pageId]?.path
1422
+ ? sourceKey
1423
+ ? sourceImages[sourceKey]?.path
1424
+ : null
1421
1425
  : join(workspace.slideDir(deckId, pageId), "versions", info.revision, `image${info.ext}`);
1422
1426
  if (!sourcePath || !existsSync(sourcePath)) continue;
1423
1427
  const ext = String(info.ext ?? "png").replace(/^\./, "");
@@ -1851,7 +1855,45 @@ export function createService(config, logger, env = process.env) {
1851
1855
  function pageForEdit(deckId, pageId) {
1852
1856
  const adapter = adapterOf(deckId);
1853
1857
  if (!adapter?.available()) return null;
1854
- return pageEditView(adapter, pageId);
1858
+ // F-209:插页重编号后「本地页号 ≠ 源页号」(本地 p04 的源是 P02)。逐页微调必须按
1859
+ // slide.sourcePage 读源,否则 Inspector 显示的是源里同号那页(用户看到的「4、5 是以前的 4、5」)。
1860
+ const deck = workspace.manifest(deckId);
1861
+ const normalized = String(pageId ?? "").trim();
1862
+ const pageNo = Number(normalized.replace(/^p/i, ""));
1863
+ const slide = (deck?.slides ?? []).find(
1864
+ (item) => item.id === normalized || (Number.isFinite(pageNo) && pageNo > 0 && item.order === pageNo),
1865
+ );
1866
+ if (!slide) return pageEditView(adapter, pageId);
1867
+ // 本机新建 deck(ADR-0022):源 script.md 是本地页序的镜像,sourcePage 只是 baseline 映射残留,
1868
+ // 用本地页号才对应「源里同一内容」那一节;受控客户源停在原始页序,必须用 sourcePage。
1869
+ const sourcePage = localDeckIds().has(deckId) ? Number(slide.order) : workspace.sourcePageOf(deckId, slide.id);
1870
+ if (sourcePage == null) {
1871
+ const order = Number(slide.order) || null;
1872
+ return {
1873
+ page: order,
1874
+ pageId: slide.id,
1875
+ unsupported: true,
1876
+ reason: `P${String(order ?? 0).padStart(2, "0")} 是插页新增的页,源 script.md 里没有对应分节;逐页微调只能改源里已有的页。要改这页请让 Agent 用 deck_script_propose_edit 提案。`,
1877
+ };
1878
+ }
1879
+ const view = pageEditView(adapter, `p${String(sourcePage).padStart(2, "0")}`);
1880
+ if (!view) return null;
1881
+ // 源里其实没有这一页分节(例如插页新增页被记了 baseline 映射、或源后来少了页):
1882
+ // 别把 pageEditView 的「表格惯例」原因甩给用户,明确说是源里没有这一节。
1883
+ if (view.unsupported) {
1884
+ const rawScript = typeof adapter.readScriptRaw === "function" ? String(adapter.readScriptRaw() ?? "") : "";
1885
+ const sectionExists = new RegExp(`^#{2,3}\\s*P0*${sourcePage}\\b`, "m").test(rawScript);
1886
+ if (!sectionExists) {
1887
+ return {
1888
+ ...view,
1889
+ page: Number(slide.order) || view.page,
1890
+ pageId: slide.id,
1891
+ sourcePage,
1892
+ reason: `源 script.md 里没有 P${String(sourcePage).padStart(2, "0")} 分节(这页是插页新增或源已更新),逐页微调不可用;改稿请让 Agent 用 deck_script_propose_edit 提案。`,
1893
+ };
1894
+ }
1895
+ }
1896
+ return { ...view, page: Number(slide.order) || view.page, pageId: slide.id, sourcePage, sourcePageId: view.pageId };
1855
1897
  }
1856
1898
 
1857
1899
  /**
@@ -1861,7 +1903,7 @@ export function createService(config, logger, env = process.env) {
1861
1903
  function pagePrompt(deckId, pageId) {
1862
1904
  const adapter = adapterOf(deckId);
1863
1905
  if (!adapter?.available()) return null;
1864
- const deck = workspace.manifest(deckId);
1906
+ const deck = workspace.manifest(deckId) ?? workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1865
1907
  const normalized = String(pageId ?? "").trim();
1866
1908
  const slide = (deck?.slides ?? []).find((item) => item.id === normalized || `p${String(item.order).padStart(2, "0")}` === normalized);
1867
1909
  if (!slide) return null;
@@ -1894,30 +1936,53 @@ export function createService(config, logger, env = process.env) {
1894
1936
  function savePage(deckId, pageId, patch) {
1895
1937
  const adapter = adapterOf(deckId);
1896
1938
  if (!adapter?.available()) throw new Error(`deck 不可用:${deckId}`);
1897
- const page = Number(String(pageId).replace(/^p/i, ""));
1939
+ // F-209:写源必须用「原始源页号」而不是本地页号——插页重编号后两者不再相等,
1940
+ // 按本地页号写会把内容写进源里同号的另一页(绑定层事故的根因之一)。
1941
+ const manifestNow = workspace.manifest(deckId) ?? workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1942
+ const normalized = String(pageId ?? "").trim();
1943
+ const pageNo = Number(normalized.replace(/^p/i, ""));
1944
+ const slide = (manifestNow?.slides ?? []).find(
1945
+ (item) => item.id === normalized || (Number.isFinite(pageNo) && pageNo > 0 && item.order === pageNo),
1946
+ );
1947
+ if (!slide) throw new Error(`页不存在:${pageId}`);
1948
+ // 本机 deck 的源是本地页序镜像 → 用本地页号;受控客户源 → 用原始源页(F-209)。
1949
+ const sourcePage = localDeckIds().has(deckId) ? Number(slide.order) : workspace.sourcePageOf(deckId, slide.id);
1950
+ if (sourcePage == null) {
1951
+ throw new Error(`P${String(slide.order).padStart(2, "0")} 是插页新增的页,源 script.md 里没有对应分节,不能直接写回源(会写错页)。要改这页请让 Agent 用 deck_script_propose_edit 提案。`);
1952
+ }
1898
1953
  const result = savePageEdit({
1899
1954
  sourceRoot: adapter.sourceRoot,
1900
- page,
1955
+ page: sourcePage,
1901
1956
  title: patch?.title ?? "",
1902
1957
  subtitle: patch?.subtitle ?? "",
1903
1958
  body: patch?.body ?? "",
1904
1959
  prompt: typeof patch?.prompt === "string" ? patch.prompt : undefined,
1905
1960
  });
1906
1961
  const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief, forceRebuild: true });
1907
- const message = `deck(${deckId}):微调 P${String(page).padStart(2, "0")}${patch?.title ? `「${String(patch.title).slice(0, 30)}」` : ""}`;
1962
+ // 出图 prompt 来自 manifest.visualBrief;Inspector 改了提示词就要同步过去,否则「保存后重出」还是旧 prompt
1963
+ // (forceRebuild 会保留本地 visualBrief,源 prompts 的改动进不来)。
1964
+ if (typeof patch?.prompt === "string") {
1965
+ const target = (deck?.slides ?? []).find((item) => item.id === slide.id);
1966
+ if (target) {
1967
+ target.visualBrief = stripVisualBriefScaffolding(patch.prompt);
1968
+ workspace.saveManifest(deck);
1969
+ }
1970
+ }
1971
+ const message = `deck(${deckId}):微调 P${String(slide.order).padStart(2, "0")}${patch?.title ? `「${String(patch.title).slice(0, 30)}」` : ""}`;
1908
1972
  const git = commitFiles({ dir: adapter.sourceRoot, files: result.files, message });
1909
1973
  store.recordEvent({
1910
1974
  type: "deck.script.save",
1911
1975
  deckId,
1912
- slideIds: [result.pageId],
1976
+ slideIds: [slide.id],
1913
1977
  actor: "user",
1914
1978
  status: git.ok ? "succeeded" : "saved",
1915
- payload: { page: result.page, files: result.files, commit: git.commit ?? null, gitError: git.error ?? null },
1979
+ payload: { page: Number(slide.order) || result.page, sourcePage, files: result.files, commit: git.commit ?? null, gitError: git.error ?? null },
1916
1980
  });
1917
1981
  return {
1918
1982
  deckId,
1919
- page: result.page,
1920
- pageId: result.pageId,
1983
+ page: Number(slide.order) || result.page,
1984
+ pageId: slide.id,
1985
+ sourcePage,
1921
1986
  files: result.files.map((file) => file.replace(`${adapter.sourceRoot}/`, "")),
1922
1987
  commit: git.commit ?? null,
1923
1988
  gitOk: git.ok,
@@ -2029,7 +2094,9 @@ export function createService(config, logger, env = process.env) {
2029
2094
  const pageId = slide.id;
2030
2095
  const render = slideRenderInfo(deckId, pageId);
2031
2096
  const localFile = render ? assetFile(deckId, pageId, render.revision) : null;
2032
- const sourceFile = adapterOf(deckId)?.sourceImages?.()?.[pageId]?.path ?? null;
2097
+ // F-208:源 baseline 图按原始源页取(插页重编号不影响);插页新增的页没有源图。
2098
+ const sourceKey = workspace.sourcePageKey(deckId, pageId);
2099
+ const sourceFile = (sourceKey ? adapterOf(deckId)?.sourceImages?.()?.[sourceKey]?.path : null) ?? null;
2033
2100
  const file = localFile ?? sourceFile;
2034
2101
  if (!file) continue;
2035
2102
  const ext = extname(file).toLowerCase();
@@ -2339,15 +2406,21 @@ export function createService(config, logger, env = process.env) {
2339
2406
  }
2340
2407
  }
2341
2408
  // 2) 源里已出好的整页图作为 baseline(只读引用,不复制、不占双份空间)
2342
- const source = adapterOf(deckId)?.sourceImages?.()[pageId];
2409
+ // F-208:插过页的 deck 按 slide.sourcePage(原始源页)取;插页重编号后若还按新 pageId 抓源图,
2410
+ // 会把源里同号那页(别人的图)挂到这页上。
2411
+ const sourceKey = workspace.sourcePageKey(deckId, pageId);
2412
+ const source = sourceKey ? adapterOf(deckId)?.sourceImages?.()[sourceKey] : null;
2343
2413
  if (source) {
2344
2414
  return {
2345
2415
  revision: "source",
2346
2416
  ext: source.ext,
2347
- url: `${ROUTES.asset}?deckId=${encodeURIComponent(deckId)}&pageId=${encodeURIComponent(pageId)}&rev=source`,
2417
+ // F-209:URL 带上解析后的源页(&sourcePage=p02)。插页前后 rev=source 的 URL 原本完全一样,
2418
+ // 旧实例/旧 payload/旧缓存出的图和新的无法区分;带上源页后一眼能看出「这页取的是哪张源图」。
2419
+ url: `${ROUTES.asset}?deckId=${encodeURIComponent(deckId)}&pageId=${encodeURIComponent(pageId)}&rev=source&sourcePage=${encodeURIComponent(sourceKey ?? "")}`,
2348
2420
  sizeBytes: source.sizeBytes,
2349
2421
  renderedAt: statSync(source.path).mtime.toISOString(),
2350
2422
  fromSource: true,
2423
+ sourcePage: sourceKey ?? null,
2351
2424
  };
2352
2425
  }
2353
2426
  return null;
@@ -2389,7 +2462,8 @@ export function createService(config, logger, env = process.env) {
2389
2462
  /** 资产读取:只允许 versions/vNNN/image.<ext> 或源 baseline(rev=source),防目录穿越。 */
2390
2463
  function assetFile(deckId, pageId, revision) {
2391
2464
  if (revision === "source") {
2392
- const image = adapterOf(deckId)?.sourceImages?.()[pageId];
2465
+ const sourceKey = workspace.sourcePageKey(deckId, pageId);
2466
+ const image = sourceKey ? adapterOf(deckId)?.sourceImages?.()[sourceKey] : null;
2393
2467
  return image && existsSync(image.path) ? image.path : null;
2394
2468
  }
2395
2469
  if (!/^v\d+$/.test(revision ?? "")) return null;
@@ -2480,6 +2554,9 @@ export function createService(config, logger, env = process.env) {
2480
2554
  // ensureDeck 幂等:manifest 缺失时初始化;源变了且本地还没改过时按源刷新派生字段。
2481
2555
  deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
2482
2556
  } catch (error) {
2557
+ // F-209:manifest 损坏时不能吞掉错误退回「空 deck」(看起来像 0 页 / 像源 deck 丢了)。
2558
+ // 明确抛给调用方(工作台报错、Agent 工具报错),让人先备份/修复 deck.json。
2559
+ if (/manifest 损坏/.test(String(error?.message ?? error))) throw error;
2483
2560
  logger?.warn?.(`${name}: ensureDeck(${activeId}) 失败:${error?.message ?? error}`);
2484
2561
  deck = workspace.manifest(activeId);
2485
2562
  }
@@ -2513,6 +2590,8 @@ export function createService(config, logger, env = process.env) {
2513
2590
  visualBrief: slide.visualBrief ?? "",
2514
2591
  // F-114:这条画面要求写于哪个关联风格(换风格时 UI 标「旧风格 brief」)。
2515
2592
  visualBriefDesign: slide.visualBriefDesign ?? null,
2593
+ // F-209:把「这页对应源 deck 的哪一页」暴露给工作台,插页重编号后排查错位不用猜。
2594
+ sourcePage: workspace.sourcePageOf(activeId, slide.id),
2516
2595
  render: slideRenderInfo(activeId, slide.id),
2517
2596
  })),
2518
2597
  appendix: deck?.appendix ?? { count: view?.appendixCount ?? 0, rows: view?.appendixRows ?? [] },
package/lib/deck-docs.js CHANGED
@@ -27,6 +27,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
27
27
  import { join } from "node:path";
28
28
 
29
29
  import { parseFrontmatter } from "./source-adapter.js";
30
+ import { slideSourcePage } from "./deck-workspace.js";
30
31
 
31
32
  /* ------------------------------------------------------------------ *
32
33
  * 文档规格
@@ -585,19 +586,23 @@ export function resolveDeckDocs({ adapter, deckDir, render = {}, revisions = [],
585
586
  // F-110:画面要求(visualBrief)住在工作副本 manifest 里,不是源 prompts/Pxx.txt。
586
587
  // 以前只数 prompts/ 文件,新建 deck 永远显示「提示词覆盖 0/10」,把 Agent 和人都误导成「不能出图」。
587
588
  const manifest = readManifestJson(deckDir);
588
- const briefOf = (page) => {
589
- const pageId = `p${String(page.page).padStart(2, "0")}`;
590
- const slide = (manifest?.slides ?? []).find((item) => item.id === pageId);
591
- return String(slide?.visualBrief ?? "").trim();
592
- };
593
- const covered = pages.filter((page) => {
594
- const pageId = `p${String(page.page).padStart(2, "0")}`;
595
- const brief = briefOf(page);
596
- const fieldPrompt = String(page.fields?.["提示词"] ?? "").trim();
597
- return (brief && !isPlaceholder(brief)) || promptIds.includes(pageId) || (fieldPrompt && !isPlaceholder(fieldPrompt));
598
- });
589
+ const manifestSlides = Array.isArray(manifest?.slides) ? manifest.slides : [];
590
+ // F-209:页数 / 画面要求覆盖率按 manifest 算(manifest 才有插页/追加后的完整页序)。
591
+ const covered = manifestSlides.length
592
+ ? manifestSlides.filter((slide) => {
593
+ const brief = String(slide.visualBrief ?? "").trim();
594
+ const sourcePage = slideSourcePage(slide);
595
+ const sourcePrompt = sourcePage != null ? prompts[`p${String(sourcePage).padStart(2, "0")}`] : "";
596
+ return (brief && !isPlaceholder(brief)) || (sourcePrompt && !isPlaceholder(sourcePrompt));
597
+ })
598
+ : pages.filter((page) => {
599
+ const pageId = `p${String(page.page).padStart(2, "0")}`;
600
+ const brief = String(manifest?.slides?.find((item) => item.id === pageId)?.visualBrief ?? "").trim();
601
+ const fieldPrompt = String(page.fields?.["提示词"] ?? "").trim();
602
+ return (brief && !isPlaceholder(brief)) || promptIds.includes(pageId) || (fieldPrompt && !isPlaceholder(fieldPrompt));
603
+ });
599
604
  doc.facts = {
600
- pageCount: pages.length,
605
+ pageCount: manifestSlides.length || pages.length,
601
606
  promptCount: promptIds.length,
602
607
  promptCoverage: covered.length,
603
608
  // 故事线概览并入脚本页:分段表从 script 的 `## 段落 / 页码 / 任务` 解析,零副本。
@@ -867,24 +872,49 @@ export function readDeckDoc({ adapter, deckDir, docId, file = "", render = {}, r
867
872
  parsed.fields = doc.facts.fields;
868
873
  parsed.derived = doc.facts.derived;
869
874
  } else if (docId === "script") {
870
- parsed.pages = pages.map((page) => {
871
- const pageId = `p${String(page.page).padStart(2, "0")}`;
875
+ // F-209:页列表以 **manifest** 为准。插页/追加后的本地页在源 script 里可能没有对应分节
876
+ // (或源还停在原始页序);按源 storyPages 枚举会缺页、串位(实测 25 页 deck 只显示 21 张卡)。
877
+ const manifestSlides = Array.isArray(readManifestJson(deckDir)?.slides) ? readManifestJson(deckDir).slides : [];
878
+ const sourceRowByPage = new Map(pages.map((page) => [page.page, page]));
879
+ const fromManifest = (slide) => {
880
+ const sourcePage = slideSourcePage(slide);
881
+ const row = sourcePage != null ? sourceRowByPage.get(sourcePage) : null;
882
+ const pageNo = Number(slide.order) || Number(String(slide.id ?? "").replace(/^p/i, "")) || 0;
883
+ const pageId = `p${String(pageNo).padStart(2, "0")}`;
872
884
  return {
873
885
  id: pageId,
874
- page: page.page,
875
- title: page.title,
876
- subtitle: page.question,
877
- claim: page.claim,
878
- // F-108:正文既可能是 **正文** 字段行,也可能是标题行以下的散段;两处都取,
879
- // 否则脚本页永远显示「(这页没有正文)」,用户在 UI 里看不到 Agent 写的内容。
880
- content: [page.fields?.["正文"], page.content]
886
+ page: pageNo,
887
+ title: slide.title ?? row?.title ?? "",
888
+ subtitle: slide.question ?? row?.question ?? "",
889
+ claim: slide.claim ?? row?.claim ?? "",
890
+ // F-108:正文既可能是 manifest.body,也可能是源里 **正文** 字段 / 标题行以下散段。
891
+ content: [slide.body, row?.fields?.["正文"], row?.content]
881
892
  .map((part) => String(part ?? "").trim())
882
893
  .filter(Boolean)
883
894
  .join("\n\n"),
884
- prompt: prompts[pageId] || page.fields?.["提示词"] || "",
885
- source: page.source,
895
+ // 画面要求住在 manifest.visualBrief(F-110);源 prompts 只作兜底(按原始源页取)。
896
+ prompt: String(slide.visualBrief ?? "").trim() || (sourcePage != null ? prompts[`p${String(sourcePage).padStart(2, "0")}`] : "") || row?.fields?.["提示词"] || "",
897
+ source: slide.source ?? row?.source ?? "manifest",
886
898
  };
887
- });
899
+ };
900
+ parsed.pages = manifestSlides.length
901
+ ? manifestSlides.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map(fromManifest)
902
+ : pages.map((page) => {
903
+ const pageId = `p${String(page.page).padStart(2, "0")}`;
904
+ return {
905
+ id: pageId,
906
+ page: page.page,
907
+ title: page.title,
908
+ subtitle: page.question,
909
+ claim: page.claim,
910
+ content: [page.fields?.["正文"], page.content]
911
+ .map((part) => String(part ?? "").trim())
912
+ .filter(Boolean)
913
+ .join("\n\n"),
914
+ prompt: prompts[pageId] || page.fields?.["提示词"] || "",
915
+ source: page.source,
916
+ };
917
+ });
888
918
  parsed.segments = doc.facts.segments ?? [];
889
919
  } else if (docId === "design") {
890
920
  parsed.rules = doc.facts.rules;