dsh-plugin-deck-studio 0.2.25 → 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;
@@ -2458,7 +2532,12 @@ export function createService(config, logger, env = process.env) {
2458
2532
  appendixCount: adapterOf(deck.deckId)?.available() ? adapterOf(deck.deckId).overview().appendixCount ?? 0 : workspace.manifest(deck.deckId)?.appendix?.count ?? 0,
2459
2533
  appendixRows: workspace.manifest(deck.deckId)?.appendix?.rows ?? [],
2460
2534
  }));
2461
- const activeId = (requestedDeckId && decks.some((deck) => deck.deckId === requestedDeckId) ? requestedDeckId : null) ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
2535
+ // F-205:显式点名要打开某本 deck、但这次快照里还没有它(刚导入 / 刚创建,列表还没跟上)时,
2536
+ // 不要静默退回第一本——前端拿到 requestedMissing 会立刻重试,避免「导入成功却打开别的 deck」。
2537
+ const requestedMissing = Boolean(requestedDeckId) && !decks.some((deck) => deck.deckId === requestedDeckId);
2538
+ const activeId = requestedMissing
2539
+ ? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null
2540
+ : requestedDeckId ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
2462
2541
  // P0:进 deck 时先体检(规格 §2「进入某本 deck 时,每次,读 manifest 之前」)。
2463
2542
  // mismatch / partial 时不 ensureDeck:那会凭空建出空的 decks/<id>/,旧图也找不回(禁止项)。
2464
2543
  const dataDirInfo = dataDirStateOf({ config, store, workspace, adapters, env }, activeId);
@@ -2475,6 +2554,9 @@ export function createService(config, logger, env = process.env) {
2475
2554
  // ensureDeck 幂等:manifest 缺失时初始化;源变了且本地还没改过时按源刷新派生字段。
2476
2555
  deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
2477
2556
  } catch (error) {
2557
+ // F-209:manifest 损坏时不能吞掉错误退回「空 deck」(看起来像 0 页 / 像源 deck 丢了)。
2558
+ // 明确抛给调用方(工作台报错、Agent 工具报错),让人先备份/修复 deck.json。
2559
+ if (/manifest 损坏/.test(String(error?.message ?? error))) throw error;
2478
2560
  logger?.warn?.(`${name}: ensureDeck(${activeId}) 失败:${error?.message ?? error}`);
2479
2561
  deck = workspace.manifest(activeId);
2480
2562
  }
@@ -2508,6 +2590,8 @@ export function createService(config, logger, env = process.env) {
2508
2590
  visualBrief: slide.visualBrief ?? "",
2509
2591
  // F-114:这条画面要求写于哪个关联风格(换风格时 UI 标「旧风格 brief」)。
2510
2592
  visualBriefDesign: slide.visualBriefDesign ?? null,
2593
+ // F-209:把「这页对应源 deck 的哪一页」暴露给工作台,插页重编号后排查错位不用猜。
2594
+ sourcePage: workspace.sourcePageOf(activeId, slide.id),
2511
2595
  render: slideRenderInfo(activeId, slide.id),
2512
2596
  })),
2513
2597
  appendix: deck?.appendix ?? { count: view?.appendixCount ?? 0, rows: view?.appendixRows ?? [] },
@@ -2575,6 +2659,9 @@ export function createService(config, logger, env = process.env) {
2575
2659
  renderSettings: renderSettings(),
2576
2660
  decks,
2577
2661
  active,
2662
+ // F-205:这次请求点名的 deck 是否存在(true = 列表还没跟上,前端重试即可)。
2663
+ requestedDeckId: requestedDeckId ?? null,
2664
+ requestedMissing,
2578
2665
  designProjects: designProjects(),
2579
2666
  bindings: listBindings(),
2580
2667
  proposals: base.pendingProposals,
@@ -2677,6 +2764,25 @@ function noteDataDirTransitions(serviceLike, report, trigger = "poll") {
2677
2764
  /**
2678
2765
  * 数据目录载荷:黄条 / 设置页 / 自检共用(不重算解析,只合并最新体检)。
2679
2766
  */
2767
+ /**
2768
+ * 「指针覆盖了 profile 配置」的事实(没有冲突返回 null)。
2769
+ * 这是本次事故的根因:web profile 没配 dataRoot、desktop 配了仓库 .deck,而指针全机共享——
2770
+ * 谁写的指针谁说了算,界面上却只显示「当前 = 指针」,看不出 profile 里还写着另一个目录。
2771
+ */
2772
+ function pointerConflictOf(resolved = {}) {
2773
+ const profileDataRoot = String(resolved.profileDataRoot ?? "").trim();
2774
+ const fact = resolved.dataRoot ?? {};
2775
+ if (!profileDataRoot || fact.source !== "pointer") return null;
2776
+ if (String(fact.value ?? "") === profileDataRoot) return null;
2777
+ return {
2778
+ profileDataRoot,
2779
+ pointerDataRoot: String(fact.value ?? ""),
2780
+ pointerFile: String(resolved.pointerFile?.path ?? ""),
2781
+ setBy: String(resolved.pointerFile?.setBy ?? ""),
2782
+ updatedAt: String(resolved.pointerFile?.updatedAt ?? ""),
2783
+ };
2784
+ }
2785
+
2680
2786
  function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImages = false } = {}) {
2681
2787
  const { config } = serviceLike;
2682
2788
  const resolved = config.dataDir ?? {};
@@ -2693,6 +2799,8 @@ function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImag
2693
2799
  firstRun: resolved.firstRun === true,
2694
2800
  processCwd: resolved.processCwd ?? process.cwd(),
2695
2801
  sourceLabel: resolved.sourceLabel ?? "",
2802
+ // 指针赢过 profile 时的「为什么」:profile 配的是哪个目录、指针文件在哪。前端只在有冲突时多画一行。
2803
+ pointerConflict: pointerConflictOf(resolved),
2696
2804
  banner: dataDirBannerFacts({ report, deckId, envOverride: resolved.envOverride?.active === true, dataRoot: config.dataRoot }),
2697
2805
  decks: report.decks,
2698
2806
  candidates: report.candidates,
package/lib/data-dir.js CHANGED
@@ -232,6 +232,8 @@ function decksDirEmpty(dataRoot) {
232
232
  /** 冲突 warn 只打一次:5s 轮询与多次重建服务都不重复刷屏。 */
233
233
  let warnedDataRootConflict = false;
234
234
  let warnedProjectsRootConflict = false;
235
+ /** 指针 vs profile 的冲突按「指针→profile」这一对去重:同一处冲突不刷屏,换了目录再提醒一次。 */
236
+ const warnedPointerProfilePairs = new Set();
235
237
 
236
238
  export function resolveDataDir({ env = process.env, profileDataRoot = "", profileProjectsRoot = "", logger = null } = {}) {
237
239
  const envDataRoot = normalizeDirPath(env[DATA_DIR_ENV] ?? "", env);
@@ -253,6 +255,13 @@ export function resolveDataDir({ env = process.env, profileDataRoot = "", profil
253
255
  const pointerDataRoot = pointer.error || !isAbsolute(pointer.dataRoot) ? "" : pointer.dataRoot;
254
256
  const pointerProjectsRoot = pointer.error || !isAbsolute(pointer.projectsRoot) ? "" : pointer.projectsRoot;
255
257
 
258
+ // 指针(用户点「切到这个目录」写下的)优先级高于 profile;两者不一致时以前是**完全静默**的,
259
+ // 表现成「我这本 deck 怎么没了」。留一条 warn,日志里能找到是谁把目录换掉的。
260
+ const pointerProfileKey = pointerDataRoot && profileRoot && pointerDataRoot !== profileRoot ? `${pointerDataRoot}→${profileRoot}` : "";
261
+ if (warn && pointerProfileKey && !warnedPointerProfilePairs.has(pointerProfileKey)) {
262
+ warnedPointerProfilePairs.add(pointerProfileKey);
263
+ warn(`dataRoot: 指针文件(${pointerDataRoot})与 profile 配置(${profileRoot})不一致,已按指针生效;要让 profile 配置生效就删掉指针文件 ${pointer.path}`);
264
+ }
256
265
  const dataRoot = envDataRoot || pointerDataRoot || profileRoot || normalizeDirPath(defaultDataRoot(env), env);
257
266
  const source = envDataRoot ? "env" : pointerDataRoot ? "pointer" : profileRoot ? "profile" : "default";
258
267
  const projectsSource = envProjectsRoot ? "env" : pointerProjectsRoot ? "pointer" : profileProjects ? "profile" : "default";