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/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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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((
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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 {
|
package/cordis.example.yml
CHANGED
|
@@ -186,7 +186,12 @@
|
|
|
186
186
|
- 新建 deck:按弹窗填的意图 / 参考资料直接开始,先出图也行。叙事主线写进 intent.md 的「故事线」段
|
|
187
187
|
(deck_doc_update,docId=intent);台账字段有依据才写,没依据列成问题问用户,不要停留补文档。
|
|
188
188
|
资料 ≥5 份 / 很长时再用 deck_research_plan 分批,读到可引用事实顺手写进调研台账。
|
|
189
|
-
-
|
|
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
|
-
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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:
|
|
875
|
-
title:
|
|
876
|
-
subtitle:
|
|
877
|
-
claim:
|
|
878
|
-
// F-108:正文既可能是 **正文**
|
|
879
|
-
|
|
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
|
-
|
|
885
|
-
|
|
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;
|