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 +128 -46
- package/cordis.example.yml +6 -1
- package/cordis.patch.yml +6 -1
- package/index.js +124 -16
- package/lib/data-dir.js +9 -0
- 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/seeds/docs/install.md +7 -0
- package/ui/workbench.html +175 -39
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;
|
|
@@ -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
|
-
|
|
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";
|