dsh-plugin-deck-studio 0.0.0-stage → 0.2.2

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/client.js +500 -0
  4. package/cordis.example.yml +163 -0
  5. package/cordis.patch.yml +147 -0
  6. package/index.js +2340 -0
  7. package/lib/config.js +204 -0
  8. package/lib/content-density.js +98 -0
  9. package/lib/deck-brief.js +87 -0
  10. package/lib/deck-docs.js +1095 -0
  11. package/lib/deck-rules.js +124 -0
  12. package/lib/deck-templates.js +149 -0
  13. package/lib/deck-workspace.js +480 -0
  14. package/lib/design-style.js +157 -0
  15. package/lib/designs.js +175 -0
  16. package/lib/diff.js +88 -0
  17. package/lib/exporter.js +336 -0
  18. package/lib/faq.js +300 -0
  19. package/lib/git-commit.js +87 -0
  20. package/lib/home-catalog.js +249 -0
  21. package/lib/png.js +275 -0
  22. package/lib/proposal-decision.js +204 -0
  23. package/lib/render-nodes.js +171 -0
  24. package/lib/render-settings.js +213 -0
  25. package/lib/render.js +287 -0
  26. package/lib/review-personas.js +258 -0
  27. package/lib/script-style.js +55 -0
  28. package/lib/script-writer.js +165 -0
  29. package/lib/source-adapter.js +327 -0
  30. package/lib/store.js +212 -0
  31. package/lib/tools.js +2594 -0
  32. package/lib/watermark.js +137 -0
  33. package/package.json +66 -4
  34. package/seeds/resources/deckline-logo.png +0 -0
  35. package/seeds/resources/deckline-logo.svg +12 -0
  36. package/seeds/resources/deckline-mark.png +0 -0
  37. package/seeds/resources/index.json +19 -0
  38. package/seeds/samples/BIZ-01.jpg +0 -0
  39. package/seeds/samples/BIZ-02.jpg +0 -0
  40. package/seeds/samples/BIZ-03.jpg +0 -0
  41. package/seeds/samples/EDT-01.jpg +0 -0
  42. package/seeds/samples/EDT-02.jpg +0 -0
  43. package/seeds/samples/EDT-03.jpg +0 -0
  44. package/seeds/samples/FYI-01.jpg +0 -0
  45. package/seeds/samples/FYI-02.jpg +0 -0
  46. package/seeds/samples/FYI-03.jpg +0 -0
  47. package/seeds/samples/GRD-01.jpg +0 -0
  48. package/seeds/samples/GRD-02.jpg +0 -0
  49. package/seeds/samples/GRD-03.jpg +0 -0
  50. package/seeds/samples/JPN-01.jpg +0 -0
  51. package/seeds/samples/JPN-02.jpg +0 -0
  52. package/seeds/samples/JPN-03.jpg +0 -0
  53. package/seeds/samples/SKT-01.jpg +0 -0
  54. package/seeds/samples/SKT-02.jpg +0 -0
  55. package/seeds/samples/SKT-03.jpg +0 -0
  56. package/seeds/samples/SWS-01.jpg +0 -0
  57. package/seeds/samples/SWS-02.jpg +0 -0
  58. package/seeds/samples/SWS-03.jpg +0 -0
  59. package/seeds/samples/TEC-01.jpg +0 -0
  60. package/seeds/samples/TEC-02.jpg +0 -0
  61. package/seeds/samples/TEC-03.jpg +0 -0
  62. package/seeds/styles/BIZ.json +22 -0
  63. package/seeds/styles/EDT.json +22 -0
  64. package/seeds/styles/FYI.json +22 -0
  65. package/seeds/styles/GRD.json +22 -0
  66. package/seeds/styles/JPN.json +22 -0
  67. package/seeds/styles/SKT.json +22 -0
  68. package/seeds/styles/SWS.json +22 -0
  69. package/seeds/styles/TEC.json +22 -0
  70. package/ui/logo.svg +20 -0
  71. package/ui/workbench.css +687 -0
  72. package/ui/workbench.html +4554 -0
package/index.js ADDED
@@ -0,0 +1,2340 @@
1
+ /**
2
+ * dsh-plugin-deck-studio —— 宿主半。
3
+ *
4
+ * 两个挂载点(ADR-0006 D2):
5
+ * - profile 根:观测路由 `/api/deck-studio.*`(Connection 鉴权之下)+ 客户端面板数据;
6
+ * - preset 子树(config.tools = true):注册 deck 工具到该 preset 的会话。
7
+ *
8
+ * 只用 node: 内置模块;工具面在 lib/tools.js,trace 落盘在 lib/store.js。
9
+ * @module dsh-plugin-deck-studio
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { PLUGIN_DIR, mergeConfig, repoRootFrom } from "./lib/config.js";
16
+ import { createRuntimeStore } from "./lib/store.js";
17
+ import { createDeckWorkspace, scriptSectionRange } from "./lib/deck-workspace.js";
18
+ import { createLocalSourceAdapter } from "./lib/source-adapter.js";
19
+ import { DOC_FILE_EXTS, readDeckDoc, resolveDeckDocs, resolveDocFile, scaffoldDeckDocs } from "./lib/deck-docs.js";
20
+ import { buildIntentMarkdown, buildMaterialsMarkdown, buildStyleReferenceMarkdown } from "./lib/deck-templates.js";
21
+ import { exportDeck, toJpeg, zip } from "./lib/exporter.js";
22
+ import { commitFiles, findRepo, unstageRemovedPath } from "./lib/git-commit.js";
23
+ import { pageEditView, savePageEdit } from "./lib/script-writer.js";
24
+ import { buildDeckRules, readDeckRules } from "./lib/deck-rules.js";
25
+ import { contentDensityOptions, densitySpec, normalizeContentDensity } from "./lib/content-density.js";
26
+ import { defineDeckTools } from "./lib/tools.js";
27
+ import { faqIndex, searchFaq } from "./lib/faq.js";
28
+ import { createScriptToSourceSync, decideProposal } from "./lib/proposal-decision.js";
29
+ import { createDesignRegistry, normalizeAbbr } from "./lib/designs.js";
30
+ import { readSampleRecords, readStyleDefinition, writeStyleDefinition } from "./lib/design-style.js";
31
+ import { resolveMediaFile, scanDesignStyles, scanResources } from "./lib/home-catalog.js";
32
+ import { defaultSelection, renderModelPayload, resolveSelection } from "./lib/render-nodes.js";
33
+ import { applyRenderSettings, readRenderSettings, renderSettingsPayload, saveRenderSettings } from "./lib/render-settings.js";
34
+ import { editEndpointFor, selectRenderer } from "./lib/render.js";
35
+
36
+ /** 稳定 Cordis 插件名。 */
37
+ export const name = "deck-studio";
38
+
39
+ /** connection:浏览器信任栅栏 + fetch 路由。tools 按需惰性取(见 apply)。 */
40
+ export const inject = ["connection"];
41
+
42
+ export const ROUTES = {
43
+ status: "/api/deck-studio.status",
44
+ state: "/api/deck-studio.state",
45
+ workbench: "/api/deck-studio.workbench",
46
+ app: "/api/deck-studio.app",
47
+ asset: "/api/deck-studio.asset",
48
+ doc: "/api/deck-studio.doc",
49
+ docfile: "/api/deck-studio.docfile",
50
+ export: "/api/deck-studio.export",
51
+ pageEdit: "/api/deck-studio.page-edit",
52
+ pageSave: "/api/deck-studio.page-save",
53
+ media: "/api/deck-studio.media",
54
+ renderModel: "/api/deck-studio.render-model",
55
+ deckCreate: "/api/deck-studio.deck-create",
56
+ deckDensity: "/api/deck-studio.deck-density",
57
+ renderSettings: "/api/deck-studio.render-settings",
58
+ userSettings: "/api/deck-studio.user-settings",
59
+ deckDelete: "/api/deck-studio.deck-delete",
60
+ deckDesign: "/api/deck-studio.deck-design",
61
+ designCreate: "/api/deck-studio.design-create",
62
+ designDelete: "/api/deck-studio.design-delete",
63
+ designSample: "/api/deck-studio.design-sample",
64
+ stylesSeed: "/api/deck-studio.styles-seed",
65
+ sessionBind: "/api/deck-studio.session-bind",
66
+ // F-108:工作台「确认落版 / 驳回」按钮——与 Agent 的 deck_revision_confirm 共用落版逻辑。
67
+ proposalDecide: "/api/deck-studio.proposal-decide",
68
+ sessionBindings: "/api/deck-studio.session-bindings",
69
+ handoff: "/api/deck-studio.handoff",
70
+ sourcefile: "/api/deck-studio.sourcefile",
71
+ // F-140:内置 FAQ(工作台「?帮助」面板与 deck_help 工具同源)
72
+ faq: "/api/deck-studio.faq",
73
+ // F-141:首页「使用手册」——仓库里的 docs/manual.md + 截图,直接在工作台里读
74
+ manual: "/api/deck-studio.manual",
75
+ manualDoc: "/api/deck-studio.manual-doc",
76
+ docsAsset: "/api/deck-studio.docs-asset",
77
+ };
78
+
79
+ /** 出图资产 MIME(image deck 只有图片;svg 仅 fake 测试用)。 */
80
+ const ASSET_MIME = { ".png": "image/png", ".webp": "image/webp", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml" };
81
+ const ASSET_EXTS = [".png", ".webp", ".jpg", ".jpeg", ".svg"];
82
+
83
+ /** 仓库根(资料白名单工具同源:README / docs / samples / plugin 代码都在这下面)。 */
84
+ const REPO_ROOT = repoRootFrom(PLUGIN_DIR);
85
+
86
+ /** 参考资料自由文本解析(F-78):一行一条,也兼容「路径 —— 说明」与缩进续行。 */
87
+ function parseReferenceEntries(raw) {
88
+ const lines = String(raw ?? "").split(/\r?\n/);
89
+ const entries = [];
90
+ for (const line of lines) {
91
+ if (!line.trim()) continue;
92
+ const indent = line.length - line.trimStart().length;
93
+ const body = line.trim().replace(/^(?:[-*+]|\d+[.)、])\s*/, "");
94
+ if (!body) continue;
95
+ const withoutGroup = body.replace(/^【[^】]*】\s*/, "").trim();
96
+ if (/^【[^】]*】$/.test(body) || !withoutGroup) continue;
97
+ const split = withoutGroup.match(/^(.+?)\s*(?:——|–|—|\s\|\s)\s*(.+)$/);
98
+ entries.push({ ref: (split ? split[1] : withoutGroup).trim(), note: split ? split[2].trim() : "", indent });
99
+ }
100
+ return entries.slice(0, 80);
101
+ }
102
+
103
+ /** 这行看起来像「引用」(路径 / 文件名 / 目录 / 链接),还是说明续行。 */
104
+ function looksLikeReference(ref) {
105
+ return /^https?:\/\//i.test(ref)
106
+ || /^[A-Za-z0-9_.\/\-]+\.(md|txt|pdf|docx?|xlsx?|csv|json|ya?ml|jpg|jpeg|png|webp|svg)$/i.test(ref)
107
+ || /^(docs|samples|assets|plugin|scripts|src|tests|appendix|fixtures)\//i.test(ref)
108
+ || /^(README|CHANGELOG|LICENSE)(\.md)?$/i.test(ref)
109
+ || /\/$/.test(ref);
110
+ }
111
+
112
+ /** 参考资料条目 → 支撑材料清单行(文件 / 目录存在性 / 大小 / 标题);不做内容分析。 */
113
+ export function describeReferences(raw, sourceRoot = "") {
114
+ const rows = [];
115
+ for (const { ref, note: rawNote, indent } of parseReferenceEntries(raw)) {
116
+ // 缩进续行(例如路径下面的「定位 / 用户问题 / …」)并到上一条的「已知信息」里。
117
+ if (indent >= 2 && rows.length && !looksLikeReference(ref)) {
118
+ const prev = rows[rows.length - 1];
119
+ prev.note = `${prev.note}${prev.note ? ";" : ""}${rawNote || ref}`.slice(0, 180);
120
+ continue;
121
+ }
122
+ if (/^https?:\/\//i.test(ref)) {
123
+ rows.push({ ref, type: "链接", status: "未抓取", note: rawNote || "创建时登记的链接;需要时由 Agent 读取 / 用户确认" });
124
+ continue;
125
+ }
126
+ const urlHit = ref.match(/https?:\/\/\S+/);
127
+ if (urlHit) {
128
+ rows.push({ ref: urlHit[0], type: "链接", status: "未抓取", note: rawNote || ref.replace(urlHit[0], "").trim() || "创建时登记的链接" });
129
+ continue;
130
+ }
131
+ const candidates = isAbsolute(ref) ? [ref] : [join(sourceRoot, ref), join(REPO_ROOT, ref)];
132
+ const file = candidates.find((candidate) => candidate && existsSync(candidate) && statSync(candidate).isFile());
133
+ const dir = candidates.find((candidate) => candidate && existsSync(candidate) && statSync(candidate).isDirectory());
134
+ if (file) {
135
+ const sizeKb = Math.max(1, Math.round(statSync(file).size / 1024));
136
+ rows.push({ ref, type: "文件", status: "已找到", note: `${sizeKb} KB${rawNote ? ` · ${rawNote}` : ""}` });
137
+ } else if (dir) {
138
+ const count = readdirSync(dir).length;
139
+ rows.push({ ref, type: "目录", status: "已找到", note: `${count} 项${rawNote ? ` · ${rawNote}` : ""}` });
140
+ } else if (looksLikeReference(ref) || /[\\/]|\.(md|txt|pdf|docx?|xlsx?|csv|json|jpg|png)$/i.test(ref)) {
141
+ rows.push({ ref, type: "文件", status: "未找到", note: rawNote || "路径没找到:确认相对仓库根还是 deck 源目录,或改发绝对路径" });
142
+ } else {
143
+ rows.push({ ref, type: "说明", status: "已记录", note: rawNote || (ref.length > 80 ? `${ref.slice(0, 80)}…` : ref) });
144
+ }
145
+ }
146
+ return rows;
147
+ }
148
+
149
+ /** 工作台前端:同一套设计稿页面,由插件在同源伺服(cookie 鉴权之下)。 */
150
+ const UI_DIR = join(PLUGIN_DIR, "ui");
151
+ function workbenchPage() {
152
+ const html = readFileSync(join(UI_DIR, "workbench.html"), "utf8");
153
+ const css = readFileSync(join(UI_DIR, "workbench.css"), "utf8");
154
+ return html.replace("/*__CSS__*/", () => css);
155
+ }
156
+
157
+ /** UI 指纹:HTML+CSS 内容变了前端自动 reload,避免旧页面混用新旧布局。 */
158
+ function uiVersion() {
159
+ const html = readFileSync(join(UI_DIR, "workbench.html"), "utf8");
160
+ const css = readFileSync(join(UI_DIR, "workbench.css"), "utf8");
161
+ return createHash("sha1").update(html).update("\n").update(css).digest("hex").slice(0, 12);
162
+ }
163
+
164
+ /** 受控源目录里已知的工作文件(只读引用,不复制内容)。 */
165
+ function sourceFiles(sourceRoot) {
166
+ if (!sourceRoot) return [];
167
+ // F-136:这 6 项只是「源目录里的文件清单」,不是都必须有。
168
+ // 以前统一标「缺失」,新 deck 一眼 4 个红字,看起来像坏了。
169
+ const known = [
170
+ ["script.md", "逐页故事线源(只读)", { required: true, hint: "出图与脚本页的唯一事实源,必须有" }],
171
+ ["prompts", "每页 image prompt(visualBrief 来源)", { required: false, hint: "没有也不影响出图:visualBrief 写在工作区 manifest 里" }],
172
+ ["build/deck.yaml", "构建清单(页数对账)", { required: false, hint: "只用来源里登记自定义页序 / 页数对账;本地新建 deck 用 manifest 即可" }],
173
+ ["outputs/full/final", "源里已出好的整页图(Deck baseline)", { required: false, hint: "客户端源常带旧版整页图当 baseline;新 deck 还没出图,所以没有" }],
174
+ ["CURRENT.md", "负责人当前状态", { required: false, hint: "交接 / 治理用;新建 deck 不会自动写,需要时让 Agent 起草" }],
175
+ ["feedback.md", "负责人反馈", { required: false, hint: "有真实用户反馈才写;没有就是空,不算缺" }],
176
+ ];
177
+ return known.map(([name, role, meta]) => ({
178
+ name,
179
+ role,
180
+ required: meta.required,
181
+ hint: meta.hint,
182
+ exists: existsSync(join(sourceRoot, name)),
183
+ }));
184
+ }
185
+
186
+ let versionCache = null;
187
+ function pluginVersion() {
188
+ if (versionCache) return versionCache;
189
+ try {
190
+ versionCache = JSON.parse(readFileSync(join(PLUGIN_DIR, "package.json"), "utf8")).version ?? "0.0.0";
191
+ } catch {
192
+ versionCache = "0.0.0";
193
+ }
194
+ return versionCache;
195
+ }
196
+
197
+ function json(payload, status = 200) {
198
+ return Response.json(payload, { status, headers: { "cache-control": "no-store" } });
199
+ }
200
+
201
+ /**
202
+ * 构建运行时服务(路由与工具共用;冒烟测试直接调用本函数喂假 ctx)。
203
+ * @param {object} config - mergeConfig 产物。
204
+ */
205
+ export function createService(config, logger, env = process.env) {
206
+ // F-126:设置页保存的生图配置($dataRoot/render-settings.json)覆盖 profile 默认值,
207
+ // 不用改 patch、不用重启。没有这个文件时保持原配置。
208
+ applyRenderSettings(config, readRenderSettings(config.dataRoot));
209
+ // F-158:本机用户名(首次启动登记,之后问候语 / 新 deck owner 默认用它)。
210
+ const userNameFile = join(config.dataRoot, "user.json");
211
+ function readStoredUserName() {
212
+ try {
213
+ const raw = JSON.parse(readFileSync(userNameFile, "utf8"));
214
+ return typeof raw?.name === "string" ? raw.name.trim().slice(0, 40) : "";
215
+ } catch {
216
+ return "";
217
+ }
218
+ }
219
+ function saveUserName(name) {
220
+ const clean = String(name ?? "").trim().slice(0, 40);
221
+ if (!clean) throw new Error("名字不能为空");
222
+ mkdirSync(config.dataRoot, { recursive: true });
223
+ writeFileSync(userNameFile, `${JSON.stringify({ name: clean }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
224
+ config.userName = clean;
225
+ return clean;
226
+ }
227
+ if (!config.userName) {
228
+ const stored = readStoredUserName();
229
+ if (stored) config.userName = stored;
230
+ }
231
+ const store = createRuntimeStore(config.dataRoot);
232
+ const workspace = createDeckWorkspace(config);
233
+ // 本地新建的 deck 注册表(.deck/decks-registry.json,不进 git):重启后仍然存在。
234
+ const decksRegistryFile = join(config.dataRoot, "decks-registry.json");
235
+ /** design(风格)注册表 + session ↔ deck|design 绑定表(F-43)。 */
236
+ const designRegistry = createDesignRegistry({ dataRoot: config.dataRoot });
237
+ const readLocalDeckEntries = () => {
238
+ try {
239
+ const raw = JSON.parse(readFileSync(decksRegistryFile, "utf8"));
240
+ return Array.isArray(raw?.decks) ? raw.decks : [];
241
+ } catch {
242
+ return [];
243
+ }
244
+ };
245
+ for (const entry of readLocalDeckEntries()) {
246
+ if (!entry?.id || !entry?.sourceRoot) continue;
247
+ if (config.decks.some((deck) => deck.id === entry.id)) continue;
248
+ // F-71:标出「来自注册表」——preset 工具实例靠它区分静态源,运行时删除的 deck 才能被移除。
249
+ config.decks.push({ id: entry.id, label: entry.label || entry.id, sourceRoot: entry.sourceRoot, kind: entry.kind ?? "deck", designId: entry.designId ?? null, fromRegistry: true });
250
+ }
251
+ // F-157 首次启动自举:新装 profile 打开 bootstrap 时,自动登记示例 deck / 拷资源库 / 导入预设风格,
252
+ // 避免「装完四个页面全是空的」。可用 profile patch 里 bootstrap: false 关掉。
253
+ // 示例 deck 必须在 adapters 之前登记;资源和 fashioned 风格放后面(依赖 adapters/workspace)。
254
+ if (config.bootstrap === true) bootstrapSampleDeck();
255
+ const adapters = config.decks.map((deck) => createLocalSourceAdapter(deck));
256
+
257
+ /** deckId → adapter(workbench / 资产路由共用)。 */
258
+ const adapterOf = (deckId) => adapters.find((adapter) => adapter.id === deckId) ?? null;
259
+
260
+ // ── 出图节点 / 模型选择(ADR-0010)────────────────────────────────────────
261
+ /** 当前 deck 的节点选择文件(工作副本;不进 git)。 */
262
+ function renderSelectionFile(deckId) {
263
+ try {
264
+ return join(workspace.deckDir(deckId), "render-selection.json");
265
+ } catch {
266
+ return "";
267
+ }
268
+ }
269
+
270
+ /** 当前生效选择:工作副本里的选择优先,否则用配置 default。 */
271
+ function currentRenderSelection(deckId) {
272
+ const file = renderSelectionFile(deckId);
273
+ if (file && existsSync(file)) {
274
+ try {
275
+ const saved = JSON.parse(readFileSync(file, "utf8"));
276
+ const resolved = resolveSelection(config.render, saved);
277
+ if (resolved) return { nodeId: resolved.nodeId, model: resolved.model };
278
+ } catch {
279
+ /* 坏文件回退配置默认 */
280
+ }
281
+ }
282
+ return defaultSelection(config.render);
283
+ }
284
+
285
+ /** 工作台/设置面板用的节点 payload(只有 keyConfigured,没有 key 值)。 */
286
+ function renderState(deckId) {
287
+ return renderModelPayload(config.render, { env, selection: currentRenderSelection(deckId) });
288
+ }
289
+
290
+ /** 切换当前 deck 的生效模型(校验节点/模型存在;事件留痕)。 */
291
+ function setRenderSelection(deckId, patch) {
292
+ if (!adapterOf(deckId) && !existsSync(workspace.deckDir(deckId))) throw new Error(`未知 deck:${deckId}`);
293
+ const resolved = resolveSelection(config.render, patch);
294
+ if (!resolved) {
295
+ throw new Error(`节点/模型不存在:${patch?.nodeId ?? "?"} / ${patch?.model ?? "?"}(检查 profile 的 render.nodes)`);
296
+ }
297
+ const file = renderSelectionFile(deckId);
298
+ mkdirSync(dirname(file), { recursive: true });
299
+ writeFileSync(file, `${JSON.stringify({ nodeId: resolved.nodeId, model: resolved.model, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
300
+ store.recordEvent({
301
+ type: "deck.render.model.select",
302
+ deckId,
303
+ actor: "user",
304
+ status: "succeeded",
305
+ payload: { nodeId: resolved.nodeId, nodeName: resolved.nodeName, model: resolved.model, label: resolved.label },
306
+ });
307
+ return renderState(deckId);
308
+ }
309
+
310
+ /** 新建 deck 的源目录骨架:默认 6 页占位(标题等对话里补)。 */
311
+ const DEFAULT_NEW_DECK_PAGES = 6;
312
+ const MAX_NEW_DECK_PAGES = 40;
313
+ const NEW_DECK_PAGE_TITLES = [
314
+ "开场:为什么讲这件事",
315
+ "背景:听众现在的工作方式",
316
+ "问题:哪里卡住了",
317
+ "案例:一段真实工作现场",
318
+ "方法:AI 应该进入哪一步",
319
+ "收口:可复用的工作方式",
320
+ ];
321
+
322
+ /**
323
+ * 新建 deck 的页标题(F-99):优先用调用方给的 pageTitles;否则按页数从默认骨架取,
324
+ * 超出 6 页的部分生成占位标题。页数创建时可选、创建后还能在对话里用
325
+ * deck_script_add_page 追加(只追加,不重排)。
326
+ */
327
+ function newDeckPageTitles(payload, blank) {
328
+ if (blank) return [];
329
+ const explicit = Array.isArray(payload.pageTitles)
330
+ ? payload.pageTitles.map((item) => String(item ?? "").trim()).filter(Boolean).slice(0, MAX_NEW_DECK_PAGES)
331
+ : [];
332
+ if (explicit.length) return explicit;
333
+ const requested = Number.parseInt(payload.pages, 10);
334
+ const count = Number.isInteger(requested) && requested > 0
335
+ ? Math.min(requested, MAX_NEW_DECK_PAGES)
336
+ : DEFAULT_NEW_DECK_PAGES;
337
+ const titles = [...NEW_DECK_PAGE_TITLES];
338
+ while (titles.length < count) titles.push(`待补章节 ${titles.length + 1}`);
339
+ return titles.slice(0, count);
340
+ }
341
+
342
+ /** 新建 deck 的目录名(英文 kebab):只允许 [a-z0-9-],空则回退 deckId。 */
343
+ function normalizeDeckSlug(value) {
344
+ const slug = String(value ?? "").trim().toLowerCase();
345
+ if (!slug) return "";
346
+ if (!/^[a-z0-9][a-z0-9-]{1,60}$/.test(slug)) {
347
+ throw new Error("项目目录名只能用小写字母/数字/中划线(例:talk-ai-6-stages)");
348
+ }
349
+ return slug;
350
+ }
351
+
352
+ /**
353
+ * 新建 deck 项目(负责人 2026-10-21 反馈):意图 + 风格 → 源目录 + script 骨架 + 9 份文档骨架,
354
+ * 然后回到对话里补资料/改 script,直到可以出图。不直接出图。
355
+ */
356
+ function createDeck(payload = {}) {
357
+ const title = String(payload.title ?? "").trim();
358
+ const occasion = String(payload.occasion ?? "").trim();
359
+ const success = String(payload.success ?? "").trim();
360
+ const scopeOut = String(payload.scopeOut ?? "").trim();
361
+ if (!title) throw new Error("deck 标题不能为空");
362
+ // 空白画布(design 会话用):0 页,等 Agent 把风格样张沉淀到 designs/<ABBR>/。
363
+ const blank = Boolean(payload.blank);
364
+ const pageTitles = newDeckPageTitles(payload, blank);
365
+ const pad = (n) => String(n).padStart(2, "0");
366
+ const now = new Date();
367
+ const id = `deck-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}-${Math.random().toString(36).slice(2, 6)}`;
368
+ // 新建 deck 的去处(负责人 2026-10-21):配置了 projectsRoot 就落在共享根
369
+ // (`00_company/sharing/<目录名>/`,和已有 deck 源结构一致);不配则沿用旧的
370
+ // 内部 `.deck/projects/<id>/source`。design 空白画布始终走内部数据目录。
371
+ const projectsRoot = blank ? "" : String(config.projectsRoot ?? "").trim();
372
+ const slug = blank ? "" : normalizeDeckSlug(payload.dirName);
373
+ let sourceRoot;
374
+ if (projectsRoot) {
375
+ sourceRoot = join(projectsRoot, slug || id);
376
+ if (existsSync(sourceRoot) && readdirSync(sourceRoot).length > 0) {
377
+ throw new Error(`项目目录已存在且非空:${sourceRoot}(换一个目录名)`);
378
+ }
379
+ } else {
380
+ sourceRoot = join(config.dataRoot, "projects", id, "source");
381
+ }
382
+ mkdirSync(join(sourceRoot, "prompts"), { recursive: true });
383
+ mkdirSync(join(sourceRoot, "build"), { recursive: true });
384
+ mkdirSync(join(sourceRoot, "pages"), { recursive: true });
385
+ mkdirSync(join(sourceRoot, "outputs", "full", "final"), { recursive: true });
386
+
387
+ const pageBlocks = pageTitles.map((pageTitle, index) => {
388
+ const heading = `P${pad(index + 1)} · ${pageTitle}`;
389
+ return `### ${heading}\n**标题**:待定\n**副标题**:这一页回答什么\n**主张**:(待补:这页要证明的判断,一句话)\n**正文**:(待补:3–5 句正文,支撑上面的主张)\n**提示词**:(待补:visualBrief,说明画面要看到什么)\n`;
390
+ }).join("\n");
391
+ const script = [
392
+ "---",
393
+ `title: ${title}`,
394
+ `owner: ${config.userName || ""}`,
395
+ `created: ${now.toISOString()}`,
396
+ "confidential: client",
397
+ "render: image",
398
+ "deck-type: talk",
399
+ `pages: ${pageTitles.length}`,
400
+ "status: draft",
401
+ "---",
402
+ "",
403
+ `# ${title}`,
404
+ "",
405
+ "> 由「新建 Deck」创建:先补意图与资料,再改 script;未确认前不出图。",
406
+ "",
407
+ "## 故事线",
408
+ "",
409
+ blank ? "(空白画布:这个 deck 只作 design 的样张画布,不承载正式 deck 内容。)" : "(待补:分几段、每段回答什么、为什么这个顺序。)",
410
+ "",
411
+ pageBlocks,
412
+ ].join("\n");
413
+ writeFileSync(join(sourceRoot, "script.md"), script, "utf8");
414
+
415
+ // F-123:项目规则随 deck 一起落地(受众 / 禁忌 / 视觉 / 事实来源直接取自创建表单),
416
+ // 治理页不再显示「未配置」再去复制模板;之后用户改 deck.rules.md 即生效。
417
+ if (!blank) {
418
+ writeFileSync(
419
+ join(sourceRoot, "deck.rules.md"),
420
+ buildDeckRules({ audience: payload.audience, scopeOut, style: payload.style, references: payload.references, topic: payload.topic }),
421
+ "utf8",
422
+ );
423
+ }
424
+
425
+ // F-73:按 `**字段**:值` 写结构化字段(治理/「意图」标签页按这个解析),
426
+ // 不再只写自由正文——否则弹窗里填了,打开仍显示「待补 / 缺字段」。
427
+ writeFileSync(
428
+ join(sourceRoot, "intent.md"),
429
+ buildIntentMarkdown({
430
+ title,
431
+ audience: payload.audience,
432
+ topic: payload.topic,
433
+ intent: payload.intent,
434
+ references: payload.references,
435
+ occasion,
436
+ success,
437
+ scopeOut,
438
+ created: now.toISOString(),
439
+ owner: config.userName,
440
+ }),
441
+ "utf8",
442
+ );
443
+
444
+ const styleDescription = String(payload.style?.description ?? "").trim();
445
+ const styleAbbr = String(payload.style?.abbr ?? "").trim();
446
+ // 预设风格(seeds/styles/<ABBR>.json)自带画面语言 / 不要什么 / 参考;
447
+ // 新建时选了 preset 就把这些真实约束落进 Design 文档,治理不当「画面约束不足」。
448
+ let presetStyle = null;
449
+ if (styleAbbr) {
450
+ try {
451
+ presetStyle = JSON.parse(readFileSync(join(SEED_STYLES_DIR, `${styleAbbr.toUpperCase()}.json`), "utf8"));
452
+ } catch {
453
+ presetStyle = null;
454
+ }
455
+ }
456
+ writeFileSync(
457
+ join(sourceRoot, "style-reference.md"),
458
+ buildStyleReferenceMarkdown({ title, styleAbbr, styleDescription, preset: presetStyle, created: now.toISOString() }),
459
+ "utf8",
460
+ );
461
+
462
+ const label = title;
463
+ const deckConfig = { id, label, sourceRoot, kind: payload.kind ?? "deck", designId: payload.designId ?? null };
464
+ config.decks.push(deckConfig);
465
+ const adapter = createLocalSourceAdapter(deckConfig);
466
+ adapters.push(adapter);
467
+ try {
468
+ workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
469
+ // F-124:内容密度(一页写多少字 / 画面要求多细)随 deck 落 manifest,Agent 写 script 前会读到。
470
+ const createdManifest = workspace.manifest(id);
471
+ if (createdManifest) {
472
+ createdManifest.contentDensity = normalizeContentDensity(payload.contentDensity);
473
+ workspace.saveManifest(createdManifest);
474
+ }
475
+ } catch (error) {
476
+ logger?.warn?.(`deck-studio: 新 deck ${id} ensureDeck 失败:${error?.message ?? error}`);
477
+ }
478
+ try {
479
+ deckDocsScaffold(id);
480
+ } catch (error) {
481
+ logger?.warn?.(`deck-studio: 新 deck ${id} 文档骨架失败:${error?.message ?? error}`);
482
+ }
483
+
484
+ // F-78:创建时填的参考资料直接解析进「支撑材料」工作副本,不再留 TODO;
485
+ // Agent 之后读资料,用 deck_doc_update 补「可引用事实 / 支持哪条主张 / 缺口」。
486
+ try {
487
+ const referenceRows = describeReferences(payload.references, sourceRoot);
488
+ const materialsDir = join(workspace.deckDir(id), "docs");
489
+ mkdirSync(materialsDir, { recursive: true });
490
+ writeFileSync(
491
+ join(materialsDir, "materials.md"),
492
+ buildMaterialsMarkdown({ title, raw: String(payload.references ?? "").trim(), rows: referenceRows }),
493
+ "utf8",
494
+ );
495
+ } catch (error) {
496
+ logger?.warn?.(`deck-studio: 新 deck ${id} 支撑材料初始化失败:${error?.message ?? error}`);
497
+ }
498
+
499
+ // 落注册表(重启后仍可用)
500
+ mkdirSync(dirname(decksRegistryFile), { recursive: true });
501
+ const entries = readLocalDeckEntries().filter((entry) => entry?.id !== id);
502
+ entries.push({
503
+ id,
504
+ label,
505
+ sourceRoot,
506
+ dirName: slug || null,
507
+ createdAt: now.toISOString(),
508
+ audience: String(payload.audience ?? "").trim(),
509
+ topic: String(payload.topic ?? "").trim(),
510
+ intent: String(payload.intent ?? "").trim(),
511
+ references: String(payload.references ?? "").trim(),
512
+ occasion,
513
+ success,
514
+ scopeOut,
515
+ style: { abbr: styleAbbr, description: styleDescription },
516
+ kind: payload.kind ?? "deck",
517
+ designId: payload.designId ?? null,
518
+ });
519
+ writeFileSync(decksRegistryFile, `${JSON.stringify({ decks: entries }, null, 2)}\n`, "utf8");
520
+
521
+ // F-73:弹窗选了已登记的预设风格(如 FYI)→ 直接把 deck ↔ design 关联落表,
522
+ // 「Design」标签页不再显示「未关联」,也不需要用户再手动关联一次。
523
+ if (styleAbbr) {
524
+ const matched = designRegistry.findDesignByAbbr(styleAbbr);
525
+ if (matched) {
526
+ try {
527
+ designRegistry.setDeckDesign(id, matched.designId);
528
+ } catch (error) {
529
+ logger?.warn?.(`deck-studio: 新 deck ${id} 关联风格失败:${error?.message ?? error}`);
530
+ }
531
+ }
532
+ }
533
+
534
+ store.recordEvent({
535
+ type: "deck.created",
536
+ deckId: id,
537
+ actor: "user",
538
+ status: "succeeded",
539
+ payload: {
540
+ label,
541
+ sourceRoot,
542
+ kind: payload.kind ?? "deck",
543
+ designId: payload.designId ?? null,
544
+ style: { abbr: styleAbbr, description: styleDescription },
545
+ pages: pageTitles.length,
546
+ note: `初始化:script ${pageTitles.length} 页 + 意图 / 风格 / 参考资料清单 / 文档骨架`,
547
+ },
548
+ });
549
+ return {
550
+ deckId: id,
551
+ label,
552
+ sourceRoot,
553
+ pages: pageTitles.length,
554
+ kind: payload.kind ?? "deck",
555
+ designId: payload.designId ?? null,
556
+ contentDensity: normalizeContentDensity(payload.contentDensity),
557
+ };
558
+ }
559
+
560
+ /**
561
+ * F-124:设置 deck 的内容密度(auto / concise / medium / dense)。
562
+ * 只改 manifest 设置,不改已有内容;Agent 下次读 script / 提案时按新目标字数写。
563
+ */
564
+ function setDeckDensity(deckId, density) {
565
+ const id = String(deckId ?? "").trim();
566
+ const adapter = adapterOf(id);
567
+ if (!adapter) throw new Error(`deck 不可用:${id}`);
568
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
569
+ if (!deck) throw new Error(`manifest 缺失:${id}`);
570
+ const value = normalizeContentDensity(density);
571
+ deck.contentDensity = value;
572
+ workspace.saveManifest(deck);
573
+ store.recordEvent({
574
+ type: "deck.density.set",
575
+ deckId: id,
576
+ actor: "user",
577
+ status: "succeeded",
578
+ payload: { contentDensity: value, label: densitySpec(value).label },
579
+ });
580
+ return { deckId: id, contentDensity: value, label: densitySpec(value).label, spec: densitySpec(value) };
581
+ }
582
+
583
+ /** deck 配置里的 kind(design 画布等;没配就是普通 deck)。 */
584
+ function deckKind(deckId) {
585
+ const entry = config.decks.find((item) => item.id === deckId);
586
+ return { kind: entry?.kind ?? "deck", designId: entry?.designId ?? null };
587
+ }
588
+
589
+ /** 本机「新建 Deck」注册的 deck id 集合(这些可删除;受控源 deck 不可删)。 */
590
+ /** 预设风格包目录(随插件分发;F-56)。 */
591
+ const SEED_STYLES_DIR = join(PLUGIN_DIR, "seeds", "styles");
592
+ const SEED_SAMPLES_DIR = join(PLUGIN_DIR, "seeds", "samples");
593
+
594
+ /** 上传/导入图片的 MIME → 扩展名(只允许图片)。 */
595
+ const SAMPLE_MIME_EXT = { "image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp" };
596
+ const SAMPLE_MAX_BYTES = 12 * 1024 * 1024;
597
+
598
+ /** 某个 design 的画布风格目录:`<canvas sourceRoot>/designs/<ABBR>/`。 */
599
+ function designStyleDir(design) {
600
+ const adapter = adapterOf(design.canvasDeckId);
601
+ if (!adapter) throw new Error(`风格画布不存在:${design.canvasDeckId}`);
602
+ return join(adapter.sourceRoot, "designs", design.abbr);
603
+ }
604
+
605
+ /**
606
+ * 某个 design 的风格定义文件(F-63)。
607
+ * Design 工作区只保留「风格定义 + 样张」两样资料,不再读 deck 的 9 份文档。
608
+ */
609
+ function readDesignStyleFiles(design) {
610
+ let dir = "";
611
+ try {
612
+ dir = designStyleDir(design);
613
+ } catch {
614
+ dir = "";
615
+ }
616
+ const read = (fileName) => {
617
+ if (!dir) return "";
618
+ try {
619
+ const file = join(dir, fileName);
620
+ return existsSync(file) ? readFileSync(file, "utf8") : "";
621
+ } catch {
622
+ return "";
623
+ }
624
+ };
625
+ const markdown = read("style.md");
626
+ let style = null;
627
+ try {
628
+ style = JSON.parse(read("style.json") || "null");
629
+ } catch {
630
+ style = null;
631
+ }
632
+ let files = [];
633
+ if (dir && existsSync(dir)) {
634
+ try {
635
+ files = readdirSync(dir)
636
+ .filter((fileName) => statSync(join(dir, fileName)).isFile())
637
+ .map((fileName) => ({ name: fileName, sizeBytes: statSync(join(dir, fileName)).size }));
638
+ } catch {
639
+ files = [];
640
+ }
641
+ }
642
+ return { dirLabel: `designs/${design.abbr}`, markdown, style, files };
643
+ }
644
+
645
+ /** 给一个 design 加样张(贴图/上传/预设样张共用,F-55/F-56)。 */
646
+ function addDesignSamples(payload = {}) {
647
+ const design = designRegistry.findDesign(String(payload.designId ?? ""));
648
+ if (!design) throw new Error(`未知 design:${payload.designId ?? "?"}`);
649
+ const list = Array.isArray(payload.files) ? payload.files : [];
650
+ if (!list.length) throw new Error("没有收到图片");
651
+ const dir = designStyleDir(design);
652
+ mkdirSync(dir, { recursive: true });
653
+ const added = [];
654
+ for (const file of list) {
655
+ const mime = String(file?.mime ?? "").toLowerCase();
656
+ const ext = SAMPLE_MIME_EXT[mime];
657
+ if (!ext) throw new Error(`只支持 png / jpg / webp 图片(收到 ${mime || "未知类型"})`);
658
+ const raw = String(file?.data ?? "");
659
+ const b64 = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
660
+ const buffer = Buffer.from(b64, "base64");
661
+ if (!buffer.length) throw new Error("图片内容为空");
662
+ if (buffer.length > SAMPLE_MAX_BYTES) throw new Error(`单张图片不能超过 12MB(这张 ${(buffer.length / 1024 / 1024).toFixed(1)}MB)`);
663
+ const base = String(file?.name ?? "sample")
664
+ .replace(/\.[a-z0-9]+$/i, "")
665
+ .replace(/[^\w\u4e00-\u9fa5-]+/g, "-")
666
+ .slice(0, 32) || "sample";
667
+ let fileName = `${base}.${ext}`;
668
+ let n = 1;
669
+ while (existsSync(join(dir, fileName))) fileName = `${base}-${++n}.${ext}`;
670
+ writeFileSync(join(dir, fileName), buffer);
671
+ added.push(fileName);
672
+ }
673
+ store.recordEvent({
674
+ type: "design.sample.add",
675
+ deckId: design.canvasDeckId,
676
+ actor: "user",
677
+ status: "succeeded",
678
+ payload: { designId: design.designId, abbr: design.abbr, samples: added },
679
+ });
680
+ return { designId: design.designId, added, design: designProjectView(design) };
681
+ }
682
+
683
+ /** F-157:把仓库自带的示例 deck 登记成只读源(没有配置任何 decks 时)。 */
684
+ function bootstrapSampleDeck() {
685
+ if (config.decks.length > 0) return;
686
+ const sampleRoot = join(REPO_ROOT, "samples", "deckline-intro");
687
+ if (!existsSync(join(sampleRoot, "script.md"))) return;
688
+ config.decks.push({ id: "sample-deckline-intro", label: "Deckline 示例手册", sourceRoot: sampleRoot, kind: "deck", designId: null, fromBootstrap: true });
689
+ }
690
+
691
+ /** F-157:资源库为空时,把插件自带的品牌素材拷进 `<dataRoot>/resources`。 */
692
+ function bootstrapSharedResources() {
693
+ const seedDir = join(PLUGIN_DIR, "seeds", "resources");
694
+ if (!existsSync(seedDir)) return;
695
+ const target = join(config.dataRoot, "resources");
696
+ try {
697
+ if (existsSync(target) && readdirSync(target).length > 0) return;
698
+ mkdirSync(target, { recursive: true });
699
+ const names = readdirSync(seedDir).filter((name) => name !== "index.json");
700
+ for (const name of names) writeFileSync(join(target, name), readFileSync(join(seedDir, name)));
701
+ if (existsSync(join(seedDir, "index.json"))) {
702
+ writeFileSync(join(target, "index.json"), readFileSync(join(seedDir, "index.json")));
703
+ } else {
704
+ const items = names.filter((name) => /\.(png|jpe?g|webp|svg)$/i.test(name)).map((name) => ({ name, file: name, description: "Deckline 品牌素材(示例)" }));
705
+ writeFileSync(join(target, "index.json"), JSON.stringify({ items }, null, 2));
706
+ }
707
+ } catch (error) {
708
+ logger?.warn?.("deck-studio: 资源库自举失败", error);
709
+ }
710
+ }
711
+
712
+ /** F-157:风格注册表为空时,自动导入插件自带的 8 个预设风格(含样张)。 */
713
+ function bootstrapDesignStyles() {
714
+ try {
715
+ if (designRegistry.listDesigns().length > 0) return;
716
+ seedStyleDesigns();
717
+ } catch (error) {
718
+ logger?.warn?.("deck-studio: 预设风格自举失败", error);
719
+ }
720
+ }
721
+
722
+ /** 把插件自带的预设风格导入当前实例(已存在的 abbr 跳过,F-56)。 */
723
+ function seedStyleDesigns() {
724
+ if (!existsSync(SEED_STYLES_DIR)) throw new Error("插件里没有预设风格包(seeds/styles)");
725
+ const files = readdirSync(SEED_STYLES_DIR).filter((name) => name.endsWith(".json")).sort();
726
+ const seeded = [];
727
+ const skipped = [];
728
+ for (const file of files) {
729
+ let seed;
730
+ try {
731
+ seed = JSON.parse(readFileSync(join(SEED_STYLES_DIR, file), "utf8"));
732
+ } catch {
733
+ continue;
734
+ }
735
+ const abbr = normalizeAbbr(seed?.abbr);
736
+ const seedName = String(seed?.name ?? "").trim();
737
+ const seedDesc = String(seed?.description ?? "").trim();
738
+ if (!abbr || !seedName || !seedDesc) continue;
739
+ if (designRegistry.findDesignByAbbr(abbr)) {
740
+ skipped.push(abbr);
741
+ continue;
742
+ }
743
+ const created = createDesign({
744
+ name: seedName,
745
+ abbr,
746
+ description: seedDesc,
747
+ constraints: String(seed.constraints ?? ""),
748
+ reference: String(seed.reference ?? ""),
749
+ styleBrief: String(seed.styleBrief ?? ""),
750
+ });
751
+ const samples = existsSync(SEED_SAMPLES_DIR)
752
+ ? readdirSync(SEED_SAMPLES_DIR).filter((sample) => sample.toLowerCase().startsWith(`${abbr.toLowerCase()}-`) || sample.toLowerCase().startsWith(`${abbr.toLowerCase()}.`))
753
+ : [];
754
+ const filesPayload = samples.map((sample, index) => {
755
+ const ext = extname(sample).slice(1).toLowerCase();
756
+ const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
757
+ return {
758
+ name: `sample-${String(index + 1).padStart(2, "0")}`,
759
+ mime,
760
+ data: readFileSync(join(SEED_SAMPLES_DIR, sample)).toString("base64"),
761
+ };
762
+ });
763
+ if (filesPayload.length) addDesignSamples({ designId: created.design.designId, files: filesPayload });
764
+ store.recordEvent({
765
+ type: "design.seeded",
766
+ deckId: created.deckId,
767
+ actor: "system",
768
+ status: "succeeded",
769
+ payload: { designId: created.design.designId, abbr, samples: samples.length, seed: file },
770
+ });
771
+ seeded.push({ designId: created.design.designId, abbr, name: seedName, canvasDeckId: created.deckId, samples: samples.length });
772
+ }
773
+ return { seeded, skipped, total: files.length };
774
+ }
775
+
776
+ /**
777
+ * 生图配置状态(F-57):只报「key 是否配置」与节点名,绝不返回 key。
778
+ */
779
+ /** F-126:当前生图设置(设置页读 / 写用;只回 keyConfigured,不回 key)。 */
780
+ function renderSettings() {
781
+ return renderSettingsPayload(config, { env, saved: readRenderSettings(config.dataRoot) });
782
+ }
783
+
784
+ /** 保存设置页填的 endpoint / 模型 / key 来源 / 尺寸 / 模式,立即生效(写本机数据文件)。 */
785
+ function saveRenderConfig(payload = {}) {
786
+ const saved = saveRenderSettings(config.dataRoot, payload);
787
+ applyRenderSettings(config, { values: saved });
788
+ store.recordEvent({
789
+ type: "render.settings.saved",
790
+ deckId: null,
791
+ actor: "user",
792
+ status: "succeeded",
793
+ payload: { mode: saved.mode, endpoint: saved.endpoint, model: saved.model, apiKeyEnv: saved.apiKeyEnv, apiKeyFile: saved.apiKeyFile, size: saved.size, nodes: Array.isArray(saved.nodes) ? saved.nodes.map((node) => ({ id: node.id, name: node.name, endpoint: node.endpoint, models: node.models, keySaved: Boolean(node.apiKey), apiKeyEnv: node.apiKeyEnv, apiKeyFile: node.apiKeyFile })) : undefined },
794
+ });
795
+ return renderSettingsPayload(config, { env, saved: { found: true, values: saved, file: join(config.dataRoot, "render-settings.json") } });
796
+ }
797
+
798
+ function renderStatus() {
799
+ const nodes = Array.isArray(config.render?.nodes) ? config.render.nodes : [];
800
+ const fallback = {
801
+ id: "default",
802
+ name: "默认节点",
803
+ // 旧单节点配置也要把 endpoint 报出来(否则永远显示「缺地址」,F-80 修复)
804
+ endpoint: config.render?.endpoint ?? "",
805
+ apiKeyEnv: config.render?.apiKeyEnv ?? "",
806
+ apiKeyFile: config.render?.apiKeyFile ?? "",
807
+ models: [config.render?.model ?? ""].filter(Boolean),
808
+ };
809
+ const list = (nodes.length ? nodes : [fallback]).map((node) => {
810
+ const apiKeyEnv = String(node?.apiKeyEnv ?? "");
811
+ const apiKeyFile = String(node?.apiKeyFile ?? "");
812
+ let keyConfigured = Boolean(String(node?.apiKey ?? "").trim()) || Boolean(apiKeyEnv && env?.[apiKeyEnv]);
813
+ if (!keyConfigured && apiKeyFile) {
814
+ try {
815
+ keyConfigured = readFileSync(apiKeyFile, "utf8").split("\n").some((line) => new RegExp(`^\\s*${apiKeyEnv}\\s*=`).test(line));
816
+ } catch {
817
+ keyConfigured = false;
818
+ }
819
+ }
820
+ return {
821
+ id: String(node?.id ?? ""),
822
+ name: String(node?.name ?? node?.id ?? ""),
823
+ endpoint: String(node?.endpoint ?? ""),
824
+ models: Array.isArray(node?.models) ? node.models : [],
825
+ keyConfigured,
826
+ keySource: apiKeyEnv ? `env ${apiKeyEnv}` : apiKeyFile ? "apiKeyFile" : "未配置",
827
+ };
828
+ });
829
+ const selection = defaultSelection(config.render);
830
+ // real 模式要同时具备「节点地址 + key」才算就绪(默认不带节点地址,见 lib/config.js)。
831
+ const addressMissing = list.length > 0 && list.every((node) => !node.endpoint);
832
+ const keyMissing = !list.some((node) => node.keyConfigured);
833
+ return {
834
+ mode: config.render?.mode ?? "fake",
835
+ size: config.render?.size ?? "",
836
+ selection,
837
+ nodes: list,
838
+ ready: config.render?.mode === "real" && !addressMissing && !keyMissing,
839
+ hint:
840
+ config.render?.mode === "real"
841
+ ? addressMissing
842
+ ? "生图节点还没配地址:在 profile patch 的 render.nodes[].endpoint(或 env DSH_DECK_IMAGE_ENDPOINT)填 OpenAI 兼容的 /v1/images/generations 地址(见 docs/install.md)"
843
+ : keyMissing
844
+ ? "生图节点还没配 key:在 profile patch 的 render.nodes[].apiKeyEnv / apiKeyFile 配置环境变量或 key 文件(见 docs/install.md)"
845
+ : ""
846
+ : "当前是 fake 模式(占位图,不出真图):把 render.mode 改成 real 并配置节点地址与 key",
847
+ };
848
+ }
849
+
850
+ const localDeckIds = () => new Set(readLocalDeckEntries().map((entry) => entry?.id).filter(Boolean));
851
+
852
+ /** 只删除数据目录 / projectsRoot 下的本机生成目录,绝不碰受控源 deck(F-51)。 */
853
+ function removeLocalDir(dir) {
854
+ const target = String(dir ?? "").trim();
855
+ if (!target) return "";
856
+ const roots = [join(config.dataRoot, "projects"), String(config.projectsRoot ?? "").trim()].filter(Boolean);
857
+ const safe = roots.some((root) => target === root || target.startsWith(`${root}/`));
858
+ if (!safe) return "";
859
+ // F-104:先记下所属仓库(目录删了就找不到),删完把 index 里的暂存条目一起清掉,
860
+ // 避免用户仓库里留下 AD(已 add + 已删)这种半删除状态。
861
+ const repo = findRepo(target);
862
+ rmSync(target, { recursive: true, force: true });
863
+ if (repo) unstageRemovedPath(repo, target);
864
+ return target;
865
+ }
866
+
867
+ /** 某正式 deck 关联的 design(简版,首页卡片 / Design 文档页用)。 */
868
+ function deckDesignBrief(deckId) {
869
+ const association = designRegistry.deckDesignFor(deckId);
870
+ if (!association?.designId) return null;
871
+ const design = designRegistry.findDesign(association.designId);
872
+ if (!design) return null;
873
+ return { designId: design.designId, abbr: design.abbr, name: design.name };
874
+ }
875
+
876
+ /** design 画布自己还没样张时,从受控源 deck 里找同 abbr / 同名称的风格目录借样张(只引用,不复制,F-49)。 */
877
+ function findDeckStyleForDesign(design) {
878
+ const abbrKey = String(design.abbr ?? "").toUpperCase();
879
+ const nameKey = String(design.name ?? "").trim().toLowerCase();
880
+ for (const adapter of adapters) {
881
+ let deckDir = "";
882
+ try {
883
+ deckDir = workspace.deckDir(adapter.id);
884
+ } catch {
885
+ deckDir = "";
886
+ }
887
+ let styles = [];
888
+ try {
889
+ styles = scanDesignStyles({ deckId: adapter.id, sourceRoot: adapter.sourceRoot ?? "", deckDir });
890
+ } catch {
891
+ styles = [];
892
+ }
893
+ for (const style of styles) {
894
+ if (abbrKey && String(style.abbr ?? "").toUpperCase() === abbrKey) return style;
895
+ if (nameKey && String(style.name ?? "").trim().toLowerCase() === nameKey) return style;
896
+ }
897
+ }
898
+ return null;
899
+ }
900
+
901
+ /** 某 design 对应的样张(从它的空白画布 deck 的 designs/<ABBR>/ 扫,不复制)。 */
902
+ function designProjectView(design) {
903
+ const adapter = adapterOf(design.canvasDeckId);
904
+ let deckDir = "";
905
+ try {
906
+ deckDir = workspace.deckDir(design.canvasDeckId);
907
+ } catch {
908
+ deckDir = "";
909
+ }
910
+ let style = null;
911
+ try {
912
+ const styles = scanDesignStyles({ deckId: design.canvasDeckId, sourceRoot: adapter?.sourceRoot ?? "", deckDir });
913
+ const key = String(design.abbr ?? "").toUpperCase();
914
+ style = styles.find((item) => String(item.abbr).toUpperCase() === key) ?? styles[0] ?? null;
915
+ } catch {
916
+ style = null;
917
+ }
918
+ // 画布还没出样张时,借源 deck 里同风格的样张(F-49:Designs 页一张卡同时是会话入口 + 样张)
919
+ let styleSamples = style?.samples ?? [];
920
+ let styleDescription = style?.description ?? "";
921
+ if (!styleSamples.length) {
922
+ const fallback = findDeckStyleForDesign(design);
923
+ if (fallback) {
924
+ styleSamples = fallback.samples ?? [];
925
+ styleDescription = styleDescription || fallback.description || "";
926
+ }
927
+ }
928
+ const binding = designRegistry.bindingFor("design", design.designId);
929
+ const deckEntry = config.decks.find((item) => item.id === design.canvasDeckId);
930
+ // F-72:把 style.json 里的提示词本体带回前端(定义页展示 + 出图复用)。
931
+ let definition = null;
932
+ try {
933
+ definition = readStyleDefinition(adapter?.sourceRoot ?? "", design.abbr, design);
934
+ } catch {
935
+ definition = null;
936
+ }
937
+ return {
938
+ ...design,
939
+ canvasTitle: deckEntry?.label ?? design.canvasDeckId,
940
+ samples: styleSamples,
941
+ description: design.description || styleDescription || "",
942
+ styleBrief: definition?.styleBrief || design.styleBrief || "",
943
+ styleDir: definition?.dir ?? "",
944
+ boundSessionId: binding?.sessionId ?? null,
945
+ };
946
+ }
947
+
948
+ /** Designs 页的风格项目列表(F-43);带「哪些 deck 在用这个风格」(F-51)。 */
949
+ function designProjects() {
950
+ const associations = designRegistry.listDeckDesigns();
951
+ return designRegistry.listDesigns().map((design) => ({
952
+ ...designProjectView(design),
953
+ decks: Object.entries(associations)
954
+ .filter(([, value]) => value?.designId === design.designId)
955
+ .map(([deckId]) => ({ deckId, label: config.decks.find((deck) => deck.id === deckId)?.label ?? deckId })),
956
+ }));
957
+ }
958
+
959
+ /**
960
+ * Designs 页「+ 添加风格」:建 design 记录 + 0 页空白 deck 画布 + 风格目录骨架。
961
+ * 会话由客户端建好后调 session-bind 绑定;这里只负责实体。
962
+ */
963
+ function createDesign(payload = {}) {
964
+ const name = String(payload.name ?? "").trim();
965
+ if (!name) throw new Error("风格名称不能为空");
966
+ const description = String(payload.description ?? "").trim();
967
+ if (!description) throw new Error("先描述画面(风格提示词不能为空)");
968
+ const abbr = normalizeAbbr(payload.abbr);
969
+ if (designRegistry.findDesignByAbbr(abbr)) throw new Error(`缩写 ${abbr} 已被其他风格占用`);
970
+ const constraints = String(payload.constraints ?? "").trim();
971
+ const reference = String(payload.reference ?? "").trim();
972
+ const now = new Date();
973
+ const pad = (n) => String(n).padStart(2, "0");
974
+ const designId = `design-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}-${Math.random().toString(36).slice(2, 6)}`;
975
+
976
+ // 空白画布:0 页 deck(不出现在「正式 deck」心智里,Decks 里标「风格画布」)。
977
+ const canvas = createDeck({
978
+ title: `${name} · 风格画布`,
979
+ blank: true,
980
+ intent: description,
981
+ style: { abbr, description },
982
+ kind: "design-canvas",
983
+ designId,
984
+ });
985
+
986
+ const adapter = adapterOf(canvas.deckId);
987
+ // F-72:styleBrief(精确画面提示词)之前被丢掉,导致出样张时无提示词可复用。
988
+ // 用户没单独填时,弹窗里的画面描述本身就是提示词本体。
989
+ const styleBrief = String(payload.styleBrief ?? "").trim() || description;
990
+ const written = writeStyleDefinition(adapter?.sourceRoot ?? canvas.sourceRoot, {
991
+ abbr, name, description, constraints, reference, styleBrief, designId,
992
+ });
993
+
994
+ const design = { designId, name, abbr, description, constraints, reference, styleBrief: written.definition.styleBrief, canvasDeckId: canvas.deckId, createdAt: now.toISOString() };
995
+ designRegistry.upsertDesign(design);
996
+ store.recordEvent({
997
+ type: "design.created",
998
+ deckId: canvas.deckId,
999
+ actor: "user",
1000
+ status: "succeeded",
1001
+ payload: { designId, abbr, name, canvasDeckId: canvas.deckId },
1002
+ });
1003
+ return { design: designProjectView(design), deckId: canvas.deckId };
1004
+ }
1005
+
1006
+ /** 预设风格包里某个缩写的 styleBrief(老 design 迁移用;没有就 null)。 */
1007
+ function readSeedStyleBrief(abbr) {
1008
+ const file = join(SEED_STYLES_DIR, `${String(abbr ?? "").toUpperCase()}.json`);
1009
+ if (!existsSync(file)) return "";
1010
+ try {
1011
+ const seed = JSON.parse(readFileSync(file, "utf8"));
1012
+ return String(seed?.styleBrief ?? "").trim();
1013
+ } catch {
1014
+ return "";
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * F-72:读一个 design 的完整风格定义 + 已有样张的 prompt 记录。
1020
+ * agent 在「再补两张样张」前先调它,避免重造风格提示词。
1021
+ */
1022
+ function designStyle(designId) {
1023
+ const key = String(designId ?? "").trim();
1024
+ const design = designRegistry.findDesign(key) ?? designRegistry.findDesignByAbbr(key);
1025
+ if (!design) throw new Error(`未知 design:${key || "(空)"}`);
1026
+ const adapter = adapterOf(design.canvasDeckId);
1027
+ const definition = readStyleDefinition(adapter?.sourceRoot ?? "", design.abbr, design);
1028
+ const samples = readSampleRecords(definition.dir).map((record) => ({
1029
+ revision: record.revision ?? "",
1030
+ brief: record.brief ?? "",
1031
+ prompt: record.prompt ?? "",
1032
+ model: record.model ?? "",
1033
+ size: record.size ?? "",
1034
+ imagePath: record.imagePath ?? "",
1035
+ createdAt: record.createdAt ?? "",
1036
+ }));
1037
+ return {
1038
+ designId: design.designId,
1039
+ canvasDeckId: design.canvasDeckId,
1040
+ abbr: definition.abbr || design.abbr,
1041
+ name: definition.name || design.name,
1042
+ description: definition.description || design.description || "",
1043
+ constraints: definition.constraints || design.constraints || "",
1044
+ reference: definition.reference || design.reference || "",
1045
+ styleBrief: definition.styleBrief || design.styleBrief || "",
1046
+ styleDir: definition.dir,
1047
+ styleJsonPath: definition.jsonPath,
1048
+ styleMdPath: definition.mdPath,
1049
+ samples,
1050
+ };
1051
+ }
1052
+
1053
+ /**
1054
+ * F-72:更新风格定义(只写画布 deck 的 designs/<ABBR>/,不动客户源 deck)。
1055
+ * 样张出图会自动带上新的 styleBrief,不需要重新建 design。
1056
+ */
1057
+ function updateDesignStyle(payload = {}) {
1058
+ const key = String(payload.designId ?? "").trim();
1059
+ const design = designRegistry.findDesign(key) ?? designRegistry.findDesignByAbbr(key);
1060
+ if (!design) throw new Error(`未知 design:${key || "(空)"}`);
1061
+ const adapter = adapterOf(design.canvasDeckId);
1062
+ if (!adapter?.sourceRoot) throw new Error(`design ${design.abbr} 的画布 deck 不在源目录,无法写风格定义`);
1063
+ const pick = (value, fallback) => (typeof value === "string" ? value.trim() : fallback);
1064
+ const next = {
1065
+ abbr: design.abbr,
1066
+ name: pick(payload.name, design.name),
1067
+ description: pick(payload.description, design.description),
1068
+ constraints: pick(payload.constraints, design.constraints),
1069
+ reference: pick(payload.reference, design.reference),
1070
+ styleBrief: pick(payload.styleBrief, design.styleBrief),
1071
+ designId: design.designId,
1072
+ };
1073
+ const written = writeStyleDefinition(adapter.sourceRoot, next);
1074
+ designRegistry.upsertDesign({
1075
+ ...design,
1076
+ name: written.definition.name,
1077
+ description: written.definition.description,
1078
+ constraints: written.definition.constraints,
1079
+ reference: written.definition.reference,
1080
+ styleBrief: written.definition.styleBrief,
1081
+ });
1082
+ store.recordEvent({
1083
+ type: "design.style-updated",
1084
+ deckId: design.canvasDeckId,
1085
+ actor: "tool",
1086
+ status: "succeeded",
1087
+ payload: { designId: design.designId, abbr: design.abbr, fields: ["name", "description", "constraints", "reference", "styleBrief"].filter((field) => typeof payload[field] === "string") },
1088
+ });
1089
+ return { designId: design.designId, abbr: design.abbr, styleDir: written.dir, style: written.definition };
1090
+ }
1091
+
1092
+ /**
1093
+ * F-72:老 design 迁移——style.json 里没有 styleBrief 时,从预设风格包补上。
1094
+ * 幂等:已有 styleBrief 的不覆盖(用户改过的保持)。
1095
+ */
1096
+ function backfillStyleBriefs() {
1097
+ const updated = [];
1098
+ for (const design of designRegistry.listDesigns()) {
1099
+ try {
1100
+ const adapter = adapterOf(design.canvasDeckId);
1101
+ if (!adapter?.sourceRoot) continue;
1102
+ const definition = readStyleDefinition(adapter.sourceRoot, design.abbr, design);
1103
+ if (definition.styleBrief) continue;
1104
+ const seedBrief = readSeedStyleBrief(design.abbr);
1105
+ if (!seedBrief) continue;
1106
+ const written = writeStyleDefinition(adapter.sourceRoot, {
1107
+ abbr: design.abbr,
1108
+ name: design.name,
1109
+ description: design.description,
1110
+ constraints: design.constraints,
1111
+ reference: design.reference,
1112
+ styleBrief: seedBrief,
1113
+ designId: design.designId,
1114
+ });
1115
+ designRegistry.upsertDesign({ ...design, styleBrief: written.definition.styleBrief });
1116
+ updated.push(design.abbr);
1117
+ } catch (error) {
1118
+ logger?.warn?.(`${name}: backfill styleBrief(${design.abbr}) 失败:${error?.message ?? error}`);
1119
+ }
1120
+ }
1121
+ return { updated };
1122
+ }
1123
+
1124
+ /**
1125
+ * 删除一个 design(F-51):登记表 + 0 页空白画布 + 画布数据目录 + 会话绑定 + deck 关联。
1126
+ * 源 deck 里的风格目录 / 样张不动(那些是源资产,只在画布里扫描引用)。
1127
+ */
1128
+ function deleteDesign(payload = {}) {
1129
+ const designId = String(payload.designId ?? "").trim();
1130
+ const design = designRegistry.findDesign(designId);
1131
+ if (!design) throw new Error(`未知 design:${designId}`);
1132
+ const canvasDeckId = design.canvasDeckId;
1133
+ // 1) 摘掉画布 deck 注册(只处理 design-canvas)
1134
+ const canvasEntry = config.decks.find((deck) => deck.id === canvasDeckId) ?? null;
1135
+ if (canvasEntry?.kind === "design-canvas") {
1136
+ config.decks = config.decks.filter((deck) => deck.id !== canvasDeckId);
1137
+ const index = adapters.findIndex((item) => item.id === canvasDeckId);
1138
+ if (index >= 0) adapters.splice(index, 1);
1139
+ const entries = readLocalDeckEntries().filter((entry) => entry?.id !== canvasDeckId);
1140
+ writeFileSync(decksRegistryFile, `${JSON.stringify({ decks: entries }, null, 2)}\n`, "utf8");
1141
+ // 2) 删画布数据目录(内部目录,只在数据目录下)
1142
+ try {
1143
+ const deckDir = workspace.deckDir(canvasDeckId);
1144
+ if (deckDir) rmSync(deckDir, { recursive: true, force: true });
1145
+ } catch (error) {
1146
+ logger?.warn?.(`${name}: 删画布目录失败 ${canvasDeckId}:${error?.message ?? error}`);
1147
+ }
1148
+ removeLocalDir(join(config.dataRoot, "projects", canvasDeckId));
1149
+ }
1150
+ // 3) 解绑这个 design 的会话;清掉 deck → design 关联
1151
+ const unboundSessions = designRegistry.removeBindingsFor("design", designId);
1152
+ for (const [deckId, value] of Object.entries(designRegistry.listDeckDesigns())) {
1153
+ if (value?.designId === designId) designRegistry.setDeckDesign(deckId, null);
1154
+ }
1155
+ // 4) 摘登记表
1156
+ designRegistry.removeDesign(designId);
1157
+ store.recordEvent({
1158
+ type: "design.deleted",
1159
+ deckId: null,
1160
+ actor: "user",
1161
+ status: "succeeded",
1162
+ payload: { designId, abbr: design.abbr, name: design.name, canvasDeckId, unboundSessions },
1163
+ });
1164
+ return { designId, canvasDeckId, unboundSessions };
1165
+ }
1166
+
1167
+ /**
1168
+ * 登记「这个 deck 用哪个 design」(F-51)。designId 传空 = 解除关联。
1169
+ * 只写插件侧关联表,不动源 deck 的任何文件。
1170
+ */
1171
+ function setDeckDesign(payload = {}) {
1172
+ const deckId = String(payload.deckId ?? "").trim();
1173
+ if (!adapterOf(deckId)) throw new Error(`未知 deck:${deckId}`);
1174
+ const rawDesignId = payload.designId;
1175
+ const designId = rawDesignId === null || rawDesignId === undefined || rawDesignId === "" ? "" : String(rawDesignId).trim();
1176
+ const previous = designRegistry.deckDesignFor(deckId)?.designId ?? null;
1177
+ if (designId && !designRegistry.findDesign(designId)) throw new Error(`未知 design:${designId}`);
1178
+ designRegistry.setDeckDesign(deckId, designId || null);
1179
+ store.recordEvent({
1180
+ type: designId ? "deck.design.set" : "deck.design.clear",
1181
+ deckId,
1182
+ actor: "user",
1183
+ status: "succeeded",
1184
+ payload: { designId: designId || null, previous },
1185
+ });
1186
+ const design = designId ? designRegistry.findDesign(designId) : null;
1187
+ return { deckId, previous, design: design ? designProjectView(design) : null };
1188
+ }
1189
+
1190
+ /**
1191
+ * 删除一个本机新建的 deck(F-51):工作副本 + 本机源目录 + 注册表 + 会话绑定 + 风格关联。
1192
+ * 受控源 deck 只能从 profile 配置移除,这里明确拒绝。
1193
+ */
1194
+ function deleteDeck(payload = {}) {
1195
+ const deckId = String(payload.deckId ?? "").trim();
1196
+ const entry = readLocalDeckEntries().find((item) => item?.id === deckId) ?? null;
1197
+ if (!entry) throw new Error("只能删除本机新建的 deck;受控源 deck 请在 profile 的 decks 里移除");
1198
+ if ((entry.kind ?? "deck") === "design-canvas") throw new Error("风格画布请到 Designs 页删除对应风格");
1199
+ const adapter = adapterOf(deckId);
1200
+ const sourceRoot = String(adapter?.sourceRoot ?? entry.sourceRoot ?? "");
1201
+ const unboundSessions = designRegistry.removeBindingsFor("deck", deckId);
1202
+ designRegistry.setDeckDesign(deckId, null);
1203
+ try {
1204
+ const deckDir = workspace.deckDir(deckId);
1205
+ if (deckDir) rmSync(deckDir, { recursive: true, force: true });
1206
+ } catch (error) {
1207
+ logger?.warn?.(`${name}: 删 deck 工作副本失败 ${deckId}:${error?.message ?? error}`);
1208
+ }
1209
+ const removedSource = removeLocalDir(sourceRoot);
1210
+ config.decks = config.decks.filter((deck) => deck.id !== deckId);
1211
+ const index = adapters.findIndex((item) => item.id === deckId);
1212
+ if (index >= 0) adapters.splice(index, 1);
1213
+ const entries = readLocalDeckEntries().filter((item) => item?.id !== deckId);
1214
+ writeFileSync(decksRegistryFile, `${JSON.stringify({ decks: entries }, null, 2)}\n`, "utf8");
1215
+ store.recordEvent({
1216
+ type: "deck.deleted",
1217
+ deckId: null,
1218
+ actor: "user",
1219
+ status: "succeeded",
1220
+ payload: { deckId, label: entry.label ?? deckId, sourceRoot: removedSource || sourceRoot, unboundSessions },
1221
+ });
1222
+ return { deckId, removedSource, unboundSessions };
1223
+ }
1224
+
1225
+ /** 会话 ↔ deck|design 绑定(F-43:每个会话必须有绑定)。 */
1226
+ function listBindings() {
1227
+ return designRegistry.listBindings();
1228
+ }
1229
+
1230
+ function bindSession(payload = {}) {
1231
+ const sessionId = String(payload.sessionId ?? "").trim();
1232
+ if (!sessionId) throw new Error("sessionId 不能为空");
1233
+ const kind = payload.kind;
1234
+ const id = String(payload.id ?? "").trim();
1235
+ if (!id) throw new Error("绑定的 deck/design 不能为空");
1236
+ if (kind === "deck") {
1237
+ if (!adapterOf(id)) throw new Error(`未知 deck:${id}`);
1238
+ } else if (kind === "design") {
1239
+ if (!designRegistry.findDesign(id)) throw new Error(`未知 design:${id}`);
1240
+ } else {
1241
+ throw new Error("kind 只能是 deck / design");
1242
+ }
1243
+ const binding = designRegistry.upsertBinding({ sessionId, kind, id, title: payload.title });
1244
+ store.recordEvent({
1245
+ type: "session.bound",
1246
+ deckId: kind === "deck" ? id : null,
1247
+ actor: "user",
1248
+ status: "succeeded",
1249
+ payload: { sessionId, kind, id, title: binding.title },
1250
+ });
1251
+ return binding;
1252
+ }
1253
+
1254
+ function unbindSession(sessionId) {
1255
+ return designRegistry.removeBinding(String(sessionId ?? ""));
1256
+ }
1257
+
1258
+ /** 出图运行时:按当前选择解析节点 → renderer + key(key 只在内存里)。 */
1259
+ function renderRuntime(deckId) {
1260
+ // F-148:profile 根挂载(设置页)和 preset 子树(deck 工具)是两个 service 实例,
1261
+ // 在设置页保存节点只更新根实例;工具挂载这边每次出图前来读一次本机设置文件,
1262
+ // 否则「面板里换了节点,Agent 出图仍走旧节点」。
1263
+ const stored = readRenderSettings(config.dataRoot);
1264
+ if (stored.values) applyRenderSettings(config, stored);
1265
+ const resolved = resolveSelection(config.render, currentRenderSelection(deckId));
1266
+ if (!resolved) throw new Error("没有可用的出图节点/模型:检查 profile 的 render.nodes / render.default");
1267
+ const picked = selectRenderer({
1268
+ ...config.render,
1269
+ endpoint: resolved.endpoint,
1270
+ apiKeyEnv: resolved.apiKeyEnv,
1271
+ apiKeyFile: resolved.apiKeyFile,
1272
+ }, env);
1273
+ return {
1274
+ renderer: picked.renderer,
1275
+ // F-146:设置页里直接保存的 key 优先;没有才回退 env / key 文件
1276
+ apiKey: resolved.apiKey || picked.apiKey,
1277
+ model: resolved.model,
1278
+ endpoint: resolved.endpoint,
1279
+ editEndpoint: resolved.editEndpoint || editEndpointFor(resolved.endpoint),
1280
+ editTransport: resolved.editTransport || "multipart",
1281
+ nodeId: resolved.nodeId,
1282
+ nodeName: resolved.nodeName,
1283
+ label: resolved.label,
1284
+ mode: config.render.mode,
1285
+ timeoutMs: config.render.timeoutMs,
1286
+ };
1287
+ }
1288
+
1289
+ /** 某 deck 最近事件(修改日志用:渲染/导出/交接/QA/说明)。 */
1290
+ function deckEvents(deckId, limit = 500) {
1291
+ // 事件日志只存 payloadRef;文档(尤其修改日志的「为什么」)要读回 payload。
1292
+ // F-78:以前只看最后 200 条事件,日志一长(评估/预设批量出图)就会把 deck.created
1293
+ // 挤出去,新 deck 的「修改日志」明明有初始化事件却显示为空。现在按 deck 过滤后取最近 N 条。
1294
+ return store
1295
+ .events(0)
1296
+ .filter((event) => event.deckId === deckId)
1297
+ .slice(-limit)
1298
+ .map((event) => ({ ...event, payload: store.eventPayload(event.eventId)?.payload ?? null }));
1299
+ }
1300
+
1301
+ /** script revision 列表(修改日志用:revisions/vNNN.md + 同名 note)。 */
1302
+ function deckRevisions(deckId) {
1303
+ let dir;
1304
+ try {
1305
+ dir = join(workspace.deckDir(deckId), "script", "revisions");
1306
+ } catch {
1307
+ return [];
1308
+ }
1309
+ if (!existsSync(dir)) return [];
1310
+ return readdirSync(dir)
1311
+ .filter((name) => /^v\d+\.md$/.test(name))
1312
+ .sort()
1313
+ .reverse()
1314
+ .map((name) => {
1315
+ const revision = name.replace(/\.md$/, "");
1316
+ const noteFile = join(dir, `${revision}.note.txt`);
1317
+ return {
1318
+ revision,
1319
+ savedAt: statSync(join(dir, name)).mtime.toISOString(),
1320
+ note: existsSync(noteFile) ? readFileSync(noteFile, "utf8").trim() : "",
1321
+ };
1322
+ });
1323
+ }
1324
+
1325
+ /** 9 份必备 deck 文档的治理状态(不含大段正文)。 */
1326
+ function deckDocs(deckId) {
1327
+ let deckDir;
1328
+ try {
1329
+ deckDir = workspace.deckDir(deckId);
1330
+ } catch {
1331
+ return { docs: [], governance: { deckId, score: 0, counts: {}, total: 0, missing: [], partial: [] } };
1332
+ }
1333
+ return resolveDeckDocs({ adapter: adapterOf(deckId), deckDir, render: config.render, revisions: deckRevisions(deckId), events: deckEvents(deckId) });
1334
+ }
1335
+
1336
+ /** 单份文档(中间区标签用):正文 + 结构化视图。 */
1337
+ function deckDoc(deckId, docId) {
1338
+ let deckDir;
1339
+ try {
1340
+ deckDir = workspace.deckDir(deckId);
1341
+ } catch {
1342
+ return null;
1343
+ }
1344
+ return readDeckDoc({ adapter: adapterOf(deckId), deckDir, docId, render: config.render, revisions: deckRevisions(deckId), events: deckEvents(deckId) });
1345
+ }
1346
+
1347
+ /** 给缺失文档打骨架(只写 .deck 工作副本,不动源 deck)。 */
1348
+ function deckDocsScaffold(deckId, docIds) {
1349
+ const adapter = adapterOf(deckId);
1350
+ if (!adapter) throw new Error(`unknown deck: ${deckId}`);
1351
+ return scaffoldDeckDocs({ adapter, deckDir: workspace.deckDir(deckId), docIds });
1352
+ }
1353
+
1354
+ /** 一页的可编辑视图(页面微调弹窗预填)。 */
1355
+ function pageForEdit(deckId, pageId) {
1356
+ const adapter = adapterOf(deckId);
1357
+ if (!adapter?.available()) return null;
1358
+ return pageEditView(adapter, pageId);
1359
+ }
1360
+
1361
+ /**
1362
+ * 页面微调保存:写回源 script.md / prompts/Pnn.txt,重建 manifest,并自动 git 提交。
1363
+ * 这是唯一允许直接写源 deck 的动作(ADR-0006 的例外,见 ADR-0008)。
1364
+ */
1365
+ function savePage(deckId, pageId, patch) {
1366
+ const adapter = adapterOf(deckId);
1367
+ if (!adapter?.available()) throw new Error(`deck 不可用:${deckId}`);
1368
+ const page = Number(String(pageId).replace(/^p/i, ""));
1369
+ const result = savePageEdit({
1370
+ sourceRoot: adapter.sourceRoot,
1371
+ page,
1372
+ title: patch?.title ?? "",
1373
+ subtitle: patch?.subtitle ?? "",
1374
+ body: patch?.body ?? "",
1375
+ prompt: typeof patch?.prompt === "string" ? patch.prompt : undefined,
1376
+ });
1377
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief, forceRebuild: true });
1378
+ const message = `deck(${deckId}):微调 P${String(page).padStart(2, "0")}${patch?.title ? `「${String(patch.title).slice(0, 30)}」` : ""}`;
1379
+ const git = commitFiles({ dir: adapter.sourceRoot, files: result.files, message });
1380
+ store.recordEvent({
1381
+ type: "deck.script.save",
1382
+ deckId,
1383
+ slideIds: [result.pageId],
1384
+ actor: "user",
1385
+ status: git.ok ? "succeeded" : "saved",
1386
+ payload: { page: result.page, files: result.files, commit: git.commit ?? null, gitError: git.error ?? null },
1387
+ });
1388
+ return {
1389
+ deckId,
1390
+ page: result.page,
1391
+ pageId: result.pageId,
1392
+ files: result.files.map((file) => file.replace(`${adapter.sourceRoot}/`, "")),
1393
+ commit: git.commit ?? null,
1394
+ gitOk: git.ok,
1395
+ gitError: git.error ?? null,
1396
+ deckTitle: deck?.title ?? null,
1397
+ };
1398
+ }
1399
+
1400
+ /** 导出整份 deck:每页一张整页图 → PDF / PPTX。 */
1401
+ /** F-141:首页「使用手册」读取 docs/manual(.en).md(只读仓库公开文档)。 */
1402
+ function readManual(lang = "zh") {
1403
+ const file = join(REPO_ROOT, "docs", lang === "en" ? "manual.en.md" : "manual.md");
1404
+ const primary = existsSync(file) ? file : join(REPO_ROOT, "docs", "manual.md");
1405
+ if (!existsSync(primary)) throw new Error("本机没有找到使用手册(docs/manual.md);公开包里带 docs/ 目录时才有。");
1406
+ return { markdown: readFileSync(primary, "utf8"), path: relative(REPO_ROOT, primary) };
1407
+ }
1408
+
1409
+ /** F-150:手册里链到的公开文档(install / styles / sample-deck / compat / productization)也在应用内读。 */
1410
+ function readDoc(docPath = "manual.md") {
1411
+ const name = String(docPath ?? "").trim().replace(/^docs\//, "");
1412
+ if (!/^(manual|install|styles|sample-deck|compat|productization)(\.en)?\.md$/.test(name)) {
1413
+ throw new Error(`不支持的文档:${name || "(空)"}`);
1414
+ }
1415
+ const file = join(REPO_ROOT, "docs", name);
1416
+ if (!existsSync(file)) throw new Error(`本机没有找到文档:docs/${name}`);
1417
+ return { markdown: readFileSync(file, "utf8"), path: `docs/${name}` };
1418
+ }
1419
+
1420
+ /**
1421
+ * F-141:手册里的截图(docs/assets/screenshots/**)。
1422
+ * 只允许 png/jpg/webp/svg,路径必须落在 screenshots 目录内——不给任意文件读取留口子。
1423
+ */
1424
+ function docsAsset(assetPath) {
1425
+ const raw = String(assetPath ?? "").replace(/^\/+/, "");
1426
+ if (!/^[A-Za-z0-9._/-]+\.(png|jpg|jpeg|webp|svg)$/i.test(raw)) return null;
1427
+ const base = join(REPO_ROOT, "docs", "assets", "screenshots");
1428
+ const full = resolve(base, raw);
1429
+ if (!full.startsWith(resolve(base) + sep)) return null;
1430
+ if (!existsSync(full) || !statSync(full).isFile()) return null;
1431
+ return { file: full, mime: ASSET_MIME[extname(full).toLowerCase()] ?? "application/octet-stream" };
1432
+ }
1433
+
1434
+ function exportDeckFile(deckId, format) {
1435
+ const deck = workspace.ensureDeck(adapterOf(deckId) ?? { id: deckId, sourceRoot: "", readScriptRaw: () => null }, { styleBrief: config.styleBrief });
1436
+ const slides = (deck?.slides ?? [])
1437
+ .map((slide) => {
1438
+ const render = slideRenderInfo(deckId, slide.id);
1439
+ if (!render) return null;
1440
+ const file = assetFile(deckId, slide.id, render.revision);
1441
+ return file ? { pageId: slide.id, file } : null;
1442
+ })
1443
+ .filter(Boolean);
1444
+ const missing = (deck?.slides ?? []).filter((slide) => !slideRenderInfo(deckId, slide.id)).map((slide) => slide.id);
1445
+ // F-137:缺图不再整包拒绝——先导出已出图的页,缺页清单一起返回,用户可以先拿到能看的一版。
1446
+ if (!slides.length) throw new Error(`一页都还没有图(共 ${(deck?.slides ?? []).length} 页),先出图再导出`);
1447
+ const result = exportDeck({ format, slides });
1448
+ store.recordEvent({ type: "deck.export", deckId, actor: "user", status: "succeeded", payload: { format, pages: slides.length, total: (deck?.slides ?? []).length, missing, bytes: result.buffer.length } });
1449
+ const safeTitle = String(deck?.title ?? deckId).replace(/[\\/:*?"<>|]/g, "-").slice(0, 60);
1450
+ return { ...result, filename: `${safeTitle}.${result.ext}`, pages: slides.length, total: (deck?.slides ?? []).length, missing };
1451
+ }
1452
+
1453
+ /**
1454
+ * 交接包(handoff):把接手人需要的全部内容打成一个 zip。
1455
+ * 含 manifest、当前 script、9 份文档、每页 prompt/qa/最终图、出图选择、最近事件、README。
1456
+ * 只读工作副本,不改任何文件。
1457
+ */
1458
+ function exportHandoff(deckId) {
1459
+ const adapter = adapterOf(deckId);
1460
+ if (!adapter) throw new Error(`未知 deck:${deckId}`);
1461
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1462
+ let dir;
1463
+ try {
1464
+ dir = workspace.deckDir(deckId);
1465
+ } catch {
1466
+ throw new Error(`未知 deck:${deckId}`);
1467
+ }
1468
+ const entries = [];
1469
+ const seen = new Set();
1470
+ const push = (name, data, { store = false } = {}) => {
1471
+ if (seen.has(name)) return;
1472
+ seen.add(name);
1473
+ entries.push({ name, data, store });
1474
+ };
1475
+ const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".webp", ".svg"]);
1476
+ const walk = (base, prefix, depth = 0) => {
1477
+ if (!existsSync(base) || depth > 6) return;
1478
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
1479
+ const full = join(base, entry.name);
1480
+ if (entry.isDirectory()) {
1481
+ walk(full, `${prefix}${entry.name}/`, depth + 1);
1482
+ continue;
1483
+ }
1484
+ if (!entry.isFile()) continue;
1485
+ const rel = `${prefix}${entry.name}`;
1486
+ const ext = extname(entry.name).toLowerCase();
1487
+ push(rel, readFileSync(full), { store: IMAGE_EXT.has(ext) });
1488
+ }
1489
+ };
1490
+ if (existsSync(join(dir, "deck.json"))) push("deck.json", readFileSync(join(dir, "deck.json")));
1491
+ walk(join(dir, "script"), "script/");
1492
+ walk(join(dir, "prompts"), "prompts/");
1493
+ walk(join(dir, "docs"), "docs/");
1494
+ walk(join(dir, "slides"), "slides/");
1495
+ // 每页最终图压成 JPEG 放进 images/(源出图的页也带,接手人不用回源目录找)
1496
+ const tmpDir = join(config.dataRoot, "tmp", `handoff-${deckId}`);
1497
+ mkdirSync(tmpDir, { recursive: true });
1498
+ let imageCount = 0;
1499
+ for (const slide of deck?.slides ?? []) {
1500
+ const pageId = slide.id;
1501
+ const render = slideRenderInfo(deckId, pageId);
1502
+ const localFile = render ? assetFile(deckId, pageId, render.revision) : null;
1503
+ const sourceFile = adapterOf(deckId)?.sourceImages?.()?.[pageId]?.path ?? null;
1504
+ const file = localFile ?? sourceFile;
1505
+ if (!file) continue;
1506
+ const ext = extname(file).toLowerCase();
1507
+ try {
1508
+ if (ext === ".png" || ext === ".jpg" || ext === ".jpeg") {
1509
+ push(`images/${pageId}.jpg`, toJpeg(file, tmpDir), { store: true });
1510
+ imageCount += 1;
1511
+ } else {
1512
+ push(`images/${pageId}${ext}`, readFileSync(file), { store: true });
1513
+ imageCount += 1;
1514
+ }
1515
+ } catch {
1516
+ // sips 不可用 / 图读不到:跳过这一页的图,其余内容照常导出
1517
+ }
1518
+ }
1519
+ if (existsSync(join(dir, "render-selection.json"))) push("render-selection.json", readFileSync(join(dir, "render-selection.json")));
1520
+ // 源里的风格规范 / 反馈 / 研究台账也带上(如果存在)
1521
+ const sourceRoot = adapter.sourceRoot ?? "";
1522
+ for (const name of ["style-reference.md", "feedback.md", "external-case-research.md", "CURRENT.md"]) {
1523
+ const file = join(sourceRoot, name);
1524
+ if (existsSync(file)) push(`source/${name}`, readFileSync(file));
1525
+ }
1526
+ const events = store.events(500).filter((event) => event.deckId === deckId).slice(-200);
1527
+ push("events.json", `${JSON.stringify({ deckId, exportedAt: new Date().toISOString(), events }, null, 2)}\n`);
1528
+ const pageCount = deck?.slides?.length ?? 0;
1529
+ push(
1530
+ "README.md",
1531
+ [
1532
+ `# 交接包:${deck?.title ?? deckId}`,
1533
+ "",
1534
+ `- deckId:${deckId}`,
1535
+ `- 导出时间:${new Date().toISOString()}`,
1536
+ `- 页数:${pageCount};已出图:${imageCount}`,
1537
+ "",
1538
+ "## 目录",
1539
+ "",
1540
+ "- `deck.json`:页序与每页内容(标题/副标题/正文/提示词)manifest",
1541
+ "- `script/current.md`:当前脚本(唯一事实来源)",
1542
+ "- `docs/`:9 份治理文档(意图/脚本/设计/修改日志/反馈/调研/支撑材料/审稿/事实核对)",
1543
+ "- `prompts/Pnn.txt`:每页出图提示词",
1544
+ "- `images/Pnn.jpg`:每页最终图(JPEG,含源出图页,接手人可直接看)",
1545
+ "- `slides/<page>/versions/<rev>/`:工作副本里的图 + prompt + qa(如果有本地重出)",
1546
+ "- `render-selection.json`:导出时生效的出图节点/模型",
1547
+ "- `events.json`:该 deck 最近 200 条任务/事件轨迹",
1548
+ "- `source/`:源里的风格规范、反馈、研究台账(只读快照)",
1549
+ "",
1550
+ "## 接手怎么继续",
1551
+ "",
1552
+ "1. 读 `docs/intent.md` 确认目标与顺序(叙事主线写在 intent 里);",
1553
+ "2. 改 `script/current.md`(不要只改图);",
1554
+ "3. 出图用当前节点/模型(`render-selection.json`),单页重出、保留旧版本;",
1555
+ "4. 反馈先登记到 `docs/feedback.md` 再动手。",
1556
+ "",
1557
+ ].join("\n"),
1558
+ );
1559
+ const buffer = zip(entries);
1560
+ const safeTitle = String(deck?.title ?? deckId).replace(/[\\/:*?"<>|]/g, "-").slice(0, 60);
1561
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
1562
+ store.recordEvent({ type: "deck.handoff", deckId, actor: "user", status: "succeeded", payload: { files: entries.length, bytes: buffer.length, pages: pageCount } });
1563
+ return { buffer, mime: "application/zip", filename: `${safeTitle}-handoff-${stamp}.zip`, files: entries.length, pages: pageCount };
1564
+ }
1565
+
1566
+ /** 首页目录:风格(designs/<ABBR>/)与资源库(resources/ + assets/)。 */
1567
+ function homeCatalog(deckId) {
1568
+ const adapter = adapterOf(deckId);
1569
+ let deckDir = "";
1570
+ try {
1571
+ deckDir = workspace.deckDir(deckId);
1572
+ } catch {
1573
+ deckDir = "";
1574
+ }
1575
+ const sourceRoot = adapter?.sourceRoot ?? "";
1576
+ return {
1577
+ deckId,
1578
+ designStyles: scanDesignStyles({ deckId, sourceRoot, deckDir }),
1579
+ // F-134:共享资源库放 <dataRoot>/resources,任何 deck 的「资源库」都能看到
1580
+ resources: scanResources({ deckId, sourceRoot, deckDir, sharedRoot: config.dataRoot }),
1581
+ };
1582
+ }
1583
+
1584
+ /** media 路由解析:只放行白名单目录的图片。 */
1585
+ function mediaFile(deckId, scope, path) {
1586
+ const adapter = adapterOf(deckId);
1587
+ let deckDir = "";
1588
+ try {
1589
+ deckDir = workspace.deckDir(deckId);
1590
+ } catch {
1591
+ deckDir = "";
1592
+ }
1593
+ return resolveMediaFile({ deckId, scope, path, sourceRoot: adapter?.sourceRoot ?? "", deckDir, sharedRoot: config.dataRoot });
1594
+ }
1595
+
1596
+ /** 设计样张:源里的风格样图(samples / probe / assets / styles)。 */
1597
+ function designSamples(deckId) {
1598
+ const adapter = adapterOf(deckId);
1599
+ if (!adapter) return [];
1600
+ const out = [];
1601
+ const dirs = ["samples", "probe", "assets", "styles", "style-samples"];
1602
+ for (const dir of dirs) {
1603
+ const full = join(adapter.sourceRoot, dir);
1604
+ if (!existsSync(full)) continue;
1605
+ for (const name of readdirSync(full)) {
1606
+ if (!/\.(png|jpg|jpeg|webp)$/i.test(name)) continue;
1607
+ const file = join(full, name);
1608
+ if (!statSync(file).isFile()) continue; // 同名目录(如 probe/cover-v4-*/)跳过
1609
+ out.push({
1610
+ name: `${dir}/${name}`,
1611
+ dir,
1612
+ sizeBytes: statSync(file).size,
1613
+ url: `${ROUTES.sourcefile}?deckId=${encodeURIComponent(deckId)}&name=${encodeURIComponent(`${dir}/${name}`)}`,
1614
+ });
1615
+ }
1616
+ }
1617
+ return out;
1618
+ }
1619
+
1620
+ /** 源里的图片文件(设计样张预览用):只允许 图片扩展名 + 白名单目录。 */
1621
+ function sourceFile(deckId, name) {
1622
+ const adapter = adapterOf(deckId);
1623
+ if (!adapter || !name || name.includes("..")) return null;
1624
+ const allowed = /^(samples|probe|assets|styles|style-samples)\/[\w.\-\u4e00-\u9fa5/]+$/;
1625
+ if (!allowed.test(name) || !/\.(png|jpg|jpeg|webp)$/i.test(name)) return null;
1626
+ const file = join(adapter.sourceRoot, name);
1627
+ return existsSync(file) && statSync(file).isFile() ? { file, name, sizeBytes: statSync(file).size } : null;
1628
+ }
1629
+
1630
+ /** 工作副本里的文档文件(草案 / 调研原文),只允许 docs/ 与 research/files/。 */
1631
+ function deckDocFile(deckId, docId, name) {
1632
+ let deckDir;
1633
+ try {
1634
+ deckDir = workspace.deckDir(deckId);
1635
+ } catch {
1636
+ return null;
1637
+ }
1638
+ return resolveDocFile({ deckDir, docId, name });
1639
+ }
1640
+
1641
+ /** status 载荷:面板横幅 + 健康检查。源缺失也 200(把“哪儿不对”说清楚)。 */
1642
+ async function status() {
1643
+ const decks = [];
1644
+ for (const adapter of adapters) {
1645
+ const manifest = workspace.manifest(adapter.id);
1646
+ const view = adapter.available() ? adapter.overview() : null;
1647
+ decks.push({
1648
+ deckId: adapter.id,
1649
+ label: adapter.label,
1650
+ sourceRoot: adapter.sourceRoot,
1651
+ sourceAvailable: Boolean(view),
1652
+ title: manifest?.title ?? view?.title ?? adapter.label,
1653
+ pageCount: manifest?.slides?.length ?? view?.pageCount ?? 0,
1654
+ status: manifest?.status ?? view?.status ?? "",
1655
+ warnings: [...(view?.warnings ?? []), ...(manifest?.buildWarnings ?? [])],
1656
+ ...deckKind(adapter.id),
1657
+ });
1658
+ }
1659
+ for (const deckId of workspace.listDeckIds()) {
1660
+ if (decks.some((deck) => deck.deckId === deckId)) continue;
1661
+ const manifest = workspace.manifest(deckId);
1662
+ decks.push({ deckId, label: manifest?.title ?? deckId, sourceRoot: manifest?.source?.root ?? "", sourceAvailable: Boolean(manifest?.source?.root), title: manifest?.title ?? deckId, pageCount: manifest?.slides?.length ?? 0, status: manifest?.status ?? "", warnings: [] });
1663
+ }
1664
+ const tasks = store.taskList(200);
1665
+ const counts = {};
1666
+ for (const task of tasks) counts[task.status] = (counts[task.status] ?? 0) + 1;
1667
+ const pending = adapters.length
1668
+ ? adapters.flatMap((adapter) => workspace.listProposals(adapter.id, "waiting-user"))
1669
+ : workspace.listDeckIds().flatMap((deckId) => workspace.listProposals(deckId, "waiting-user"));
1670
+ // F-108:标记「已过期」提案——工作副本里已经找不到它的 before 行(被后续 revision 改过)。
1671
+ // 以前要等用户点了确认才报「部分失败」;现在 UI 可以直接标出来、禁用确认、给清理入口。
1672
+ const scriptTextCache = new Map();
1673
+ const scriptTextOf = (deckId) => {
1674
+ if (!scriptTextCache.has(deckId)) {
1675
+ let text = "";
1676
+ try {
1677
+ text = String(workspace.scriptCurrent(deckId) ?? "");
1678
+ } catch {
1679
+ text = "";
1680
+ }
1681
+ scriptTextCache.set(deckId, text);
1682
+ }
1683
+ return scriptTextCache.get(deckId);
1684
+ };
1685
+ // 过期判断要按「这一页」找 before 行:新 deck 每页都有同样的
1686
+ // `**标题**:待定` 占位,用整份 script 判断会永远「不过期」。
1687
+ const pageTextOf = (deckId, page) => {
1688
+ const text = scriptTextOf(deckId);
1689
+ const pageNo = Number(page);
1690
+ if (!Number.isFinite(pageNo) || pageNo <= 0) return text;
1691
+ const lines = text.split("\n");
1692
+ const range = scriptSectionRange(text, pageNo);
1693
+ if (!range) return text; // 表格惯例没有 `### Pxx` 分节,退回整份文本
1694
+ return lines.slice(range.start, range.end).join("\n");
1695
+ };
1696
+ const isStaleProposal = (proposal) =>
1697
+ proposal.kind === "script"
1698
+ && typeof proposal.before === "string"
1699
+ && proposal.before.length > 0
1700
+ && !pageTextOf(proposal.deckId, proposal.page).includes(proposal.before);
1701
+ return {
1702
+ ok: true,
1703
+ plugin: name,
1704
+ version: pluginVersion(),
1705
+ uiVersion: uiVersion(),
1706
+ displayName: config.displayName,
1707
+ hideShellSidebar: config.hideShellSidebar === true,
1708
+ dataRoot: config.dataRoot,
1709
+ toolsMounted: config.tools,
1710
+ renderMode: config.render.mode,
1711
+ renderModel: config.render.model,
1712
+ decks,
1713
+ taskCounts: counts,
1714
+ pendingProposals: pending.map((proposal) => ({
1715
+ proposalId: proposal.proposalId,
1716
+ deckId: proposal.deckId,
1717
+ pageId: proposal.pageId,
1718
+ // F-108:脚本页要按页号把待确认提案挂到对应页卡片上。
1719
+ page: Number.isFinite(Number(proposal.page)) ? Number(proposal.page) : null,
1720
+ kind: proposal.kind ?? "",
1721
+ field: proposal.field,
1722
+ reason: proposal.reason,
1723
+ // F-90:带上提案正文(截断),供评估判定产物质量 + 前端预览;不是写入,只是展示。
1724
+ after: typeof proposal.after === "string" ? proposal.after.slice(0, 4000) : "",
1725
+ // F-108:Context 时间线要能直接看 diff,而不是只有「待确认」四个字。
1726
+ diff: typeof proposal.diff === "string" ? proposal.diff.slice(0, 4000) : "",
1727
+ // F-108 修订:脚本页要显示「当前值 → 建议值」,否则卡片上的标题/副标题和提案行
1728
+ // 看起来是「两套」内容,用户分不清哪个是现在的、哪个是待确认的。
1729
+ before: typeof proposal.before === "string" ? proposal.before.slice(0, 4000) : "",
1730
+ // 已过期:工作副本变了,这条提案的 before 行不存在,确认会「工作副本已漂移」。
1731
+ stale: isStaleProposal(proposal),
1732
+ createdAt: proposal.createdAt,
1733
+ })),
1734
+ generatedAt: new Date().toISOString(),
1735
+ };
1736
+ }
1737
+
1738
+ /** state 载荷:面板全量(tasks + events + proposals)。 */
1739
+ async function state() {
1740
+ const base = await status();
1741
+ const events = store.events(60).slice().reverse();
1742
+ const eventDetails = events.map((event) => ({ ...event, payload: store.eventPayload(event.eventId)?.payload ?? null }));
1743
+ return { ...base, tasks: store.taskList(50), events: eventDetails };
1744
+ }
1745
+
1746
+ /**
1747
+ * 某页最新已渲染图片(image deck 的 ppt 本体)。
1748
+ * 布局:`.deck/decks/<deckId>/slides/<pageId>/versions/vNNN/image.png`。
1749
+ * 只返回**已存在**的版本;未出图返回 null(前端显示占位 + 出图入口)。
1750
+ */
1751
+ function slideRenderInfo(deckId, pageId) {
1752
+ // 1) .deck 里已渲染的版本优先(vNNN)
1753
+ const versionsDir = join(workspace.slideDir(deckId, pageId), "versions");
1754
+ if (existsSync(versionsDir)) {
1755
+ const revisions = readdirSync(versionsDir).filter((name) => /^v\d+$/.test(name)).sort().reverse();
1756
+ for (const revision of revisions) {
1757
+ for (const ext of ASSET_EXTS) {
1758
+ const file = join(versionsDir, revision, `image${ext}`);
1759
+ if (!existsSync(file)) continue;
1760
+ return {
1761
+ revision,
1762
+ ext,
1763
+ url: `${ROUTES.asset}?deckId=${encodeURIComponent(deckId)}&pageId=${encodeURIComponent(pageId)}&rev=${revision}`,
1764
+ sizeBytes: statSync(file).size,
1765
+ renderedAt: statSync(file).mtime.toISOString(),
1766
+ };
1767
+ }
1768
+ }
1769
+ }
1770
+ // 2) 源里已出好的整页图作为 baseline(只读引用,不复制、不占双份空间)
1771
+ const source = adapterOf(deckId)?.sourceImages?.()[pageId];
1772
+ if (source) {
1773
+ return {
1774
+ revision: "source",
1775
+ ext: source.ext,
1776
+ url: `${ROUTES.asset}?deckId=${encodeURIComponent(deckId)}&pageId=${encodeURIComponent(pageId)}&rev=source`,
1777
+ sizeBytes: source.sizeBytes,
1778
+ renderedAt: statSync(source.path).mtime.toISOString(),
1779
+ fromSource: true,
1780
+ };
1781
+ }
1782
+ return null;
1783
+ }
1784
+
1785
+ /**
1786
+ * deck 封面预览图(F-48):源里第一页 baseline 优先,其次 .deck 里第一张已渲染版本;
1787
+ * 风格画布(design-canvas)退回该风格的样张。只返回 URL,不复制文件、不新增落盘。
1788
+ */
1789
+ function deckCoverUrl(deckId) {
1790
+ try {
1791
+ const adapter = adapterOf(deckId);
1792
+ const sourceImages = adapter?.sourceImages?.() ?? {};
1793
+ const pageIds = Object.keys(sourceImages).sort((a, b) => a.localeCompare(b));
1794
+ for (const pageId of pageIds) {
1795
+ const info = slideRenderInfo(deckId, pageId);
1796
+ if (info?.url) return info.url;
1797
+ }
1798
+ const manifest = workspace.manifest(deckId);
1799
+ for (const slide of manifest?.slides ?? []) {
1800
+ if (!slide?.id) continue;
1801
+ const info = slideRenderInfo(deckId, slide.id);
1802
+ if (info?.url) return info.url;
1803
+ }
1804
+ const { designId } = deckKind(deckId);
1805
+ if (designId) {
1806
+ const design = designRegistry.findDesign(designId);
1807
+ if (design) {
1808
+ const project = designProjectView(design);
1809
+ if (project.samples?.[0]?.url) return project.samples[0].url;
1810
+ }
1811
+ }
1812
+ } catch (error) {
1813
+ logger?.warn?.(`${name}: deckCoverUrl(${deckId}) 失败:${error?.message ?? error}`);
1814
+ }
1815
+ return null;
1816
+ }
1817
+
1818
+ /** 资产读取:只允许 versions/vNNN/image.<ext> 或源 baseline(rev=source),防目录穿越。 */
1819
+ function assetFile(deckId, pageId, revision) {
1820
+ if (revision === "source") {
1821
+ const image = adapterOf(deckId)?.sourceImages?.()[pageId];
1822
+ return image && existsSync(image.path) ? image.path : null;
1823
+ }
1824
+ if (!/^v\d+$/.test(revision ?? "")) return null;
1825
+ let dir;
1826
+ try {
1827
+ dir = join(workspace.slideDir(deckId, pageId), "versions", revision);
1828
+ } catch {
1829
+ return null;
1830
+ }
1831
+ for (const ext of ASSET_EXTS) {
1832
+ const file = join(dir, `image${ext}`);
1833
+ if (existsSync(file)) return file;
1834
+ }
1835
+ return null;
1836
+ }
1837
+
1838
+ /**
1839
+ * F-88:deck 项目级规则(deck.rules.md)的只读展示载荷。
1840
+ * 内容会随 workbench 载荷下发给工作台(≤8KB,仅用于展示);不提供页面内写入,
1841
+ * 受控源保持只读,用户自己在 deck 项目根建文件。
1842
+ */
1843
+ function projectRulesPayload(sourceRoot) {
1844
+ const rules = readDeckRules(sourceRoot);
1845
+ return {
1846
+ found: rules.found,
1847
+ path: rules.path,
1848
+ bytes: rules.bytes,
1849
+ truncated: rules.truncated,
1850
+ flags: rules.flags ?? [],
1851
+ content: rules.found ? rules.content : "",
1852
+ ...(rules.warning ? { warning: rules.warning } : {}),
1853
+ };
1854
+ }
1855
+
1856
+ /**
1857
+ * workbench 载荷:工作台前端(三栏 Observation)需要的真实数据。
1858
+ * active deck 的 manifest/script 在首次访问时幂等初始化(ensureDeck 不覆盖已有文件)。
1859
+ */
1860
+ async function workbench(requestedDeckId = null) {
1861
+ const base = await state();
1862
+ const localIds = localDeckIds();
1863
+ const decks = base.decks.map((deck) => ({
1864
+ ...deck,
1865
+ // F-51:首页只列正式 deck;风格画布仍在 Designs 页(deletable 只对本机新建 deck 为 true)
1866
+ local: localIds.has(deck.deckId),
1867
+ deletable: localIds.has(deck.deckId) && (deck.kind ?? "deck") !== "design-canvas",
1868
+ design: deckDesignBrief(deck.deckId),
1869
+ // F-52:每张 deck 卡都显示自己的 9 份文档状态(不再只有当前 active deck 有点点)
1870
+ ...(() => {
1871
+ if ((deck.kind ?? "deck") === "design-canvas") return { documents: [], missing: 0, score: null };
1872
+ try {
1873
+ const { docs, governance } = deckDocs(deck.deckId);
1874
+ return {
1875
+ documents: docs.map((doc) => ({ id: doc.id, label: doc.label, status: doc.status })),
1876
+ missing: docs.filter((doc) => doc.status === "missing").length,
1877
+ score: governance?.score ?? null,
1878
+ };
1879
+ } catch (error) {
1880
+ logger?.warn?.(`${name}: deckDocs(${deck.deckId}) 失败:${error?.message ?? error}`);
1881
+ return { documents: [], missing: 0, score: null };
1882
+ }
1883
+ })(),
1884
+ // F-88:首页 deck 卡显示「规则」标记
1885
+ rules: projectRulesPayload(adapterOf(deck.deckId)?.sourceRoot ?? "").found,
1886
+ cover: deckCoverUrl(deck.deckId),
1887
+ appendixCount: adapterOf(deck.deckId)?.available() ? adapterOf(deck.deckId).overview().appendixCount ?? 0 : workspace.manifest(deck.deckId)?.appendix?.count ?? 0,
1888
+ appendixRows: workspace.manifest(deck.deckId)?.appendix?.rows ?? [],
1889
+ }));
1890
+ const activeId = (requestedDeckId && decks.some((deck) => deck.deckId === requestedDeckId) ? requestedDeckId : null) ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
1891
+ let active = null;
1892
+ if (activeId) {
1893
+ const adapter = adapterOf(activeId);
1894
+ let deck = workspace.manifest(activeId);
1895
+ if (adapter?.available()) {
1896
+ try {
1897
+ // ensureDeck 幂等:manifest 缺失时初始化;源变了且本地还没改过时按源刷新派生字段。
1898
+ deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1899
+ } catch (error) {
1900
+ logger?.warn?.(`${name}: ensureDeck(${activeId}) 失败:${error?.message ?? error}`);
1901
+ deck = workspace.manifest(activeId);
1902
+ }
1903
+ }
1904
+ const view = adapter?.available() ? adapter.overview() : null;
1905
+ const activeKind = deckKind(activeId).kind ?? "deck";
1906
+ const canvasDesign = activeKind === "design-canvas"
1907
+ ? designRegistry.listDesigns().find((item) => item.canvasDeckId === activeId) ?? null
1908
+ : null;
1909
+ // F-67:定义页要显示「这个风格被哪些 deck 用」+「有多少个会话」,decks 由 designProjects 视图补齐
1910
+ const canvasProject = canvasDesign
1911
+ ? designProjects().find((item) => item.designId === canvasDesign.designId) ?? null
1912
+ : null;
1913
+ active = {
1914
+ deckId: activeId,
1915
+ ...deckKind(activeId),
1916
+ title: deck?.title ?? view?.title ?? activeId,
1917
+ status: deck?.status ?? view?.status ?? "draft",
1918
+ sourceRoot: adapter?.sourceRoot ?? deck?.source?.root ?? "",
1919
+ slides: (deck?.slides ?? []).map((slide) => ({
1920
+ id: slide.id,
1921
+ order: slide.order,
1922
+ type: slide.type,
1923
+ title: slide.title,
1924
+ question: slide.question,
1925
+ claim: slide.claim,
1926
+ // F-109:正文是独立字段,工作台要能读到(确认 body 提案后立刻可见)。
1927
+ body: slide.body ?? "",
1928
+ source: slide.source,
1929
+ asset: slide.asset,
1930
+ visualBrief: slide.visualBrief ?? "",
1931
+ // F-114:这条画面要求写于哪个关联风格(换风格时 UI 标「旧风格 brief」)。
1932
+ visualBriefDesign: slide.visualBriefDesign ?? null,
1933
+ render: slideRenderInfo(activeId, slide.id),
1934
+ })),
1935
+ appendix: deck?.appendix ?? { count: view?.appendixCount ?? 0, rows: view?.appendixRows ?? [] },
1936
+ // 9 份必备文档的治理状态(正文按标签懒加载 /api/deck-studio.doc)
1937
+ // F-63:design 画布没有 deck 文档,直接给空 documents,避免误导成「缺 9 份文档」
1938
+ ...(canvasDesign
1939
+ ? { documents: [], governance: null }
1940
+ : (() => {
1941
+ const { docs, governance } = deckDocs(activeId);
1942
+ return { documents: docs, governance };
1943
+ })()),
1944
+ // F-63:design 工作区只保留风格定义 + 样张(Design 标签页 / 右栏 Design Library)
1945
+ designProject: canvasProject,
1946
+ designStyle: canvasDesign ? readDesignStyleFiles(canvasDesign) : null,
1947
+ sources: sourceFiles(adapter?.sourceRoot ?? deck?.source?.root ?? ""),
1948
+ script: workspace.scriptCurrent(activeId) ?? "",
1949
+ // 当前 deck 关联的登记风格(F-51:Design 文档页「关联风格」区)
1950
+ design: (() => {
1951
+ const association = designRegistry.deckDesignFor(activeId);
1952
+ const design = association?.designId ? designRegistry.findDesign(association.designId) : null;
1953
+ return design ? designProjectView(design) : null;
1954
+ })(),
1955
+ // F-124:内容密度(新建时选,之后可在工具栏改),决定 script 正文 / 画面要求的目标字数
1956
+ contentDensity: normalizeContentDensity(deck?.contentDensity),
1957
+ contentDensityOptions: contentDensityOptions(),
1958
+ // F-88:deck 项目级规则(可选 deck.rules.md),只读展示用
1959
+ projectRules: canvasDesign ? { found: false, path: "deck.rules.md", bytes: 0, truncated: false, flags: [], content: "" } : projectRulesPayload(adapter?.sourceRoot ?? deck?.source?.root ?? ""),
1960
+ // 当前 deck 生效的出图节点/模型(下拉可换)
1961
+ renderNodes: renderState(activeId),
1962
+ // 首页/标签用:deck 列表、风格(designs/<ABBR>/ 样张)、资源库
1963
+ decks: decks.map((item) => ({ deckId: item.deckId, label: item.label, pageCount: item.pageCount, status: item.status ?? "", sourceRoot: item.sourceRoot ?? "", kind: item.kind ?? "deck" })),
1964
+ designs: designSamples(activeId),
1965
+ ...homeCatalog(activeId),
1966
+ sourceRoot: adapter?.sourceRoot ?? deck?.source?.root ?? "",
1967
+ };
1968
+ }
1969
+ return {
1970
+ ...base,
1971
+ cwd: repoRootFrom(PLUGIN_DIR),
1972
+ config: {
1973
+ preset: "deck-studio",
1974
+ renderMode: config.render.mode,
1975
+ renderModel: config.render.model,
1976
+ renderEndpoint: config.render.endpoint,
1977
+ renderSize: config.render.size,
1978
+ dataRoot: config.dataRoot,
1979
+ displayName: config.displayName,
1980
+ hideShellSidebar: config.hideShellSidebar === true,
1981
+ defaultStyleAbbr: config.defaultStyleAbbr,
1982
+ },
1983
+ user: { name: config.userName },
1984
+ render: renderStatus(),
1985
+ renderSettings: renderSettings(),
1986
+ decks,
1987
+ active,
1988
+ designProjects: designProjects(),
1989
+ bindings: listBindings(),
1990
+ proposals: base.pendingProposals,
1991
+ };
1992
+ }
1993
+
1994
+ // F-157:资源和预设风格自举放在所有内部常量初始化之后(否则会碰 TDZ)。
1995
+ if (config.bootstrap === true) {
1996
+ bootstrapSharedResources();
1997
+ bootstrapDesignStyles();
1998
+ }
1999
+
2000
+ return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, createDesign, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff }
2001
+ }
2002
+
2003
+ /**
2004
+ * 注册路由与(可选)工具。标准 Cordis apply。
2005
+ * @param {import('@deepseek-ai/cordis').Context} ctx
2006
+ * @param {Record<string, unknown>} [rawConfig]
2007
+ */
2008
+ export function apply(ctx, rawConfig) {
2009
+ const config = mergeConfig(rawConfig, { pluginDir: PLUGIN_DIR, env: process.env });
2010
+ const service = createService(config, ctx.logger);
2011
+ // F-72:老 design 的 style.json 没存 styleBrief(模板库提示词被丢),启动时幂等补齐。
2012
+ try {
2013
+ const backfilled = service.backfillStyleBriefs();
2014
+ if (backfilled.updated.length) ctx.logger?.info?.(`${name}: styleBrief 已补齐 ${backfilled.updated.join(", ")}`);
2015
+ } catch (error) {
2016
+ ctx.logger?.warn?.(`${name}: styleBrief 迁移失败:${error?.message ?? error}`);
2017
+ }
2018
+
2019
+ /** @type {Array<() => unknown>} */
2020
+ const disposers = [];
2021
+ const registerRoute = (path, respond, methods = ["GET"]) => {
2022
+ if (!config.routes) return;
2023
+ disposers.push(
2024
+ ctx.connection.fetch.register({
2025
+ path,
2026
+ methods,
2027
+ requestBody: "buffered",
2028
+ fetch: async (request) => {
2029
+ try {
2030
+ return await respond(request);
2031
+ } catch (error) {
2032
+ ctx.logger?.warn?.(`${name}: ${path} failed: ${error?.message ?? error}`);
2033
+ return json({ error: { message: error instanceof Error ? error.message : String(error) } }, 500);
2034
+ }
2035
+ },
2036
+ }),
2037
+ );
2038
+ };
2039
+ const route = (path, handler) => registerRoute(path, async (request) => json(await handler(request)));
2040
+
2041
+ route(ROUTES.status, () => service.status());
2042
+ route(ROUTES.state, () => service.state());
2043
+ route(ROUTES.workbench, (request) => service.workbench(new URL(request?.url ?? "/", "http://localhost").searchParams.get("deckId")));
2044
+ registerRoute(
2045
+ ROUTES.app,
2046
+ () => new Response(workbenchPage(), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }),
2047
+ );
2048
+ registerRoute(ROUTES.export, (request) => {
2049
+ const url = new URL(request?.url ?? "/", "http://localhost");
2050
+ const format = (url.searchParams.get("format") ?? "pptx").toLowerCase();
2051
+ const result = service.exportDeckFile(url.searchParams.get("deckId") ?? "", format);
2052
+ return new Response(result.buffer, {
2053
+ headers: {
2054
+ "content-type": result.mime,
2055
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(result.filename)}`,
2056
+ "cache-control": "no-store",
2057
+ "x-deck-pages": String(result.pages),
2058
+ "x-deck-total": String(result.total ?? result.pages),
2059
+ "x-deck-missing": (result.missing ?? []).join(","),
2060
+ },
2061
+ });
2062
+ });
2063
+
2064
+ registerRoute(ROUTES.pageEdit, (request) => {
2065
+ const url = new URL(request?.url ?? "/", "http://localhost");
2066
+ const view = service.pageForEdit(url.searchParams.get("deckId") ?? "", url.searchParams.get("pageId") ?? "");
2067
+ return view ? json({ ok: true, ...view }) : json({ ok: false, error: { message: "page not found" } }, 404);
2068
+ });
2069
+
2070
+ registerRoute(ROUTES.pageSave, async (request) => {
2071
+ const body = await request.json().catch(() => ({}));
2072
+ const result = service.savePage(body.deckId ?? "", body.pageId ?? "", body.fields ?? body);
2073
+ return json({ ok: true, ...result });
2074
+ }, ["POST"]);
2075
+
2076
+ registerRoute(ROUTES.handoff, (request) => {
2077
+ const url = new URL(request?.url ?? "/", "http://localhost");
2078
+ const result = service.exportHandoff(url.searchParams.get("deckId") ?? "");
2079
+ return new Response(result.buffer, {
2080
+ headers: {
2081
+ "content-type": result.mime,
2082
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(result.filename)}`,
2083
+ "cache-control": "no-store",
2084
+ "x-deck-files": String(result.files),
2085
+ },
2086
+ });
2087
+ });
2088
+
2089
+ registerRoute(ROUTES.deckCreate, async (request) => {
2090
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2091
+ const body = await request.json().catch(() => ({}));
2092
+ return json({ ok: true, deck: service.createDeck(body) });
2093
+ }, ["POST"]);
2094
+
2095
+ // 生图设置(F-126):body { mode, endpoint, model, apiKeyEnv, apiKeyFile, size },存本机数据目录并即刻生效。
2096
+ registerRoute(ROUTES.renderSettings, async (request) => {
2097
+ if ((request?.method ?? "GET").toUpperCase() === "GET") return json({ ok: true, ...service.renderSettings() });
2098
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "GET / POST only" } }, 405);
2099
+ const body = await request.json().catch(() => ({}));
2100
+ return json({ ok: true, ...service.saveRenderConfig(body) });
2101
+ }, ["GET", "POST"]);
2102
+
2103
+ // 本机用户名(F-158):GET 读 / POST {name} 存;之后问候语与新 deck owner / 封面作者默认用它。
2104
+ registerRoute(ROUTES.userSettings, async (request) => {
2105
+ const method = (request?.method ?? "GET").toUpperCase();
2106
+ if (method === "GET") return json({ ok: true, user: { name: config.userName } });
2107
+ if (method !== "POST") return json({ ok: false, error: { message: "GET / POST only" } }, 405);
2108
+ const body = await request.json().catch(() => ({}));
2109
+ try {
2110
+ const name = service.saveUserName(body?.name);
2111
+ return json({ ok: true, user: { name } });
2112
+ } catch (error) {
2113
+ return json({ ok: false, error: { message: error.message } }, 400);
2114
+ }
2115
+ }, ["GET", "POST"]);
2116
+
2117
+ // 内容密度(F-124):body { deckId, density }。
2118
+ registerRoute(ROUTES.deckDensity, async (request) => {
2119
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2120
+ const body = await request.json().catch(() => ({}));
2121
+ return json({ ok: true, ...service.setDeckDensity(body.deckId ?? "", body.density ?? body.contentDensity ?? "") });
2122
+ }, ["POST"]);
2123
+
2124
+ // Designs 页「+ 添加风格」:建 design 实体 + 0 页空白 deck 画布(F-43)。
2125
+ registerRoute(ROUTES.designCreate, async (request) => {
2126
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2127
+ const body = await request.json().catch(() => ({}));
2128
+ return json({ ok: true, ...service.createDesign(body) });
2129
+ }, ["POST"]);
2130
+
2131
+ // 给风格贴图/上传样张(F-55):body { designId, files:[{name,mime,data(base64)}] }。
2132
+ registerRoute(ROUTES.designSample, async (request) => {
2133
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2134
+ const body = await request.json().catch(() => ({}));
2135
+ return json({ ok: true, ...service.addDesignSamples(body) });
2136
+ }, ["POST"]);
2137
+
2138
+ // 导入插件自带的预设风格(F-56)。
2139
+ registerRoute(ROUTES.stylesSeed, async (request) => {
2140
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2141
+ return json({ ok: true, ...service.seedStyleDesigns() });
2142
+ }, ["POST"]);
2143
+
2144
+ // 删除风格:design 登记 + 0 页画布 + 绑定关系(F-51)。
2145
+ registerRoute(ROUTES.designDelete, async (request) => {
2146
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2147
+ const body = await request.json().catch(() => ({}));
2148
+ return json({ ok: true, ...service.deleteDesign(body) });
2149
+ }, ["POST"]);
2150
+
2151
+ // 删除本机新建的 deck(受控源 deck 不可删,后端也拒)。
2152
+ registerRoute(ROUTES.deckDelete, async (request) => {
2153
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2154
+ const body = await request.json().catch(() => ({}));
2155
+ return json({ ok: true, ...service.deleteDeck(body) });
2156
+ }, ["POST"]);
2157
+
2158
+ // 登记 / 解除 deck → design 关联(F-51)。
2159
+ registerRoute(ROUTES.deckDesign, async (request) => {
2160
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2161
+ const body = await request.json().catch(() => ({}));
2162
+ return json({ ok: true, ...service.setDeckDesign(body) });
2163
+ }, ["POST"]);
2164
+
2165
+ // 会话绑定:客户端建好会话后落表(DSH 会话没有绑定字段,绑定表在插件侧)。
2166
+ registerRoute(ROUTES.sessionBind, async (request) => {
2167
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2168
+ const body = await request.json().catch(() => ({}));
2169
+ if (body?.action === "unbind") return json({ ok: true, removed: service.unbindSession(body.sessionId) });
2170
+ return json({ ok: true, binding: service.bindSession(body) });
2171
+ }, ["POST"]);
2172
+
2173
+ registerRoute(ROUTES.sessionBindings, () => json({ ok: true, bindings: service.listBindings() }));
2174
+
2175
+ // F-108:提案裁决。用户在脚本页看到「待确认修改」,点确认就直接落 revision(不必再等 Agent 回合)。
2176
+ registerRoute(
2177
+ ROUTES.proposalDecide,
2178
+ async (request) => {
2179
+ if ((request?.method ?? "POST").toUpperCase() !== "POST") return json({ ok: false, error: { message: "POST only" } }, 405);
2180
+ const body = await request.json().catch(() => ({}));
2181
+ const ids = (Array.isArray(body?.proposalIds) ? body.proposalIds : [body?.proposalId])
2182
+ .map((id) => String(id ?? "").trim())
2183
+ .filter(Boolean);
2184
+ if (!ids.length) return json({ ok: false, error: { message: "缺少 proposalIds" } }, 400);
2185
+ const confirmed = body?.confirmed !== false;
2186
+ const quote = String(body?.quote ?? "").trim()
2187
+ || (confirmed ? "(工作台:用户点了「确认落版」)" : "(工作台:用户点了「驳回」)");
2188
+ const syncScriptToSource = createScriptToSourceSync({ adapters: service.adapters, isLocalDeck: (deckId) => service.isLocalDeck(deckId) });
2189
+ const results = [];
2190
+ for (const proposalId of ids) {
2191
+ const proposal = service.workspace.proposal(proposalId);
2192
+ if (!proposal) {
2193
+ results.push({ proposalId, ok: false, error: "提案不存在" });
2194
+ continue;
2195
+ }
2196
+ try {
2197
+ const decided = decideProposal({ store: service.store, workspace: service.workspace, proposal, confirmed, quote, sessionId: null, syncScriptToSource });
2198
+ results.push({ proposalId, ok: true, status: decided.status, revision: decided.revision ?? null, text: decided.text });
2199
+ } catch (error) {
2200
+ results.push({ proposalId, ok: false, error: String(error?.message ?? error) });
2201
+ }
2202
+ }
2203
+ return json({ ok: true, results, decided: results.filter((item) => item.ok).length });
2204
+ },
2205
+ ["POST"],
2206
+ );
2207
+
2208
+ // 同一路径只能注册一次(connection 按 path 去重):GET 读状态 / POST 切换选择。
2209
+ registerRoute(
2210
+ ROUTES.renderModel,
2211
+ async (request) => {
2212
+ const url = new URL(request?.url ?? "/", "http://localhost");
2213
+ if ((request?.method ?? "GET").toUpperCase() === "POST") {
2214
+ const body = await request.json().catch(() => ({}));
2215
+ return json({ ok: true, ...service.setRenderSelection(body.deckId ?? "", { nodeId: body.nodeId, model: body.model }) });
2216
+ }
2217
+ return json({ ok: true, ...service.renderState(url.searchParams.get("deckId") ?? "") });
2218
+ },
2219
+ ["GET", "POST"],
2220
+ );
2221
+
2222
+ registerRoute(ROUTES.media, (request) => {
2223
+ const url = new URL(request?.url ?? "/", "http://localhost");
2224
+ const hit = service.mediaFile(url.searchParams.get("deckId") ?? "", url.searchParams.get("scope") ?? "source", url.searchParams.get("path") ?? "");
2225
+ if (!hit) return new Response("Not found", { status: 404 });
2226
+ const mime = ASSET_MIME[extname(hit.file).toLowerCase()] ?? "application/octet-stream";
2227
+ return new Response(readFileSync(hit.file), { headers: { "content-type": mime, "cache-control": "no-store" } });
2228
+ });
2229
+
2230
+ registerRoute(ROUTES.manual, (request) => {
2231
+ const url = new URL(request?.url ?? "/", "http://localhost");
2232
+ try {
2233
+ return json({ ok: true, ...service.readManual(url.searchParams.get("lang") ?? "zh") });
2234
+ } catch (error) {
2235
+ return json({ ok: false, error: { message: error.message } }, 404);
2236
+ }
2237
+ });
2238
+
2239
+ registerRoute(ROUTES.manualDoc, (request) => {
2240
+ const url = new URL(request?.url ?? "/", "http://localhost");
2241
+ try {
2242
+ return json({ ok: true, ...service.readDoc(url.searchParams.get("path") ?? "manual.md") });
2243
+ } catch (error) {
2244
+ return json({ ok: false, error: { message: error.message } }, 404);
2245
+ }
2246
+ });
2247
+
2248
+ registerRoute(ROUTES.docsAsset, (request) => {
2249
+ const url = new URL(request?.url ?? "/", "http://localhost");
2250
+ const asset = service.docsAsset(url.searchParams.get("path") ?? "");
2251
+ if (!asset) return new Response("not found", { status: 404 });
2252
+ return new Response(readFileSync(asset.file), {
2253
+ headers: { "content-type": asset.mime, "cache-control": "public, max-age=300" },
2254
+ });
2255
+ });
2256
+
2257
+ registerRoute(ROUTES.faq, (request) => {
2258
+ const url = new URL(request?.url ?? "/", "http://localhost");
2259
+ const query = String(url.searchParams.get("q") ?? "").trim();
2260
+ const hits = query ? searchFaq(query, 5) : [];
2261
+ return json({ ok: true, query, index: faqIndex(), results: hits.map((entry) => ({ id: entry.id, title: entry.title, answer: entry.answer })) });
2262
+ });
2263
+
2264
+ registerRoute(ROUTES.sourcefile, (request) => {
2265
+ const url = new URL(request?.url ?? "/", "http://localhost");
2266
+ const hit = service.sourceFile(url.searchParams.get("deckId") ?? "", url.searchParams.get("name") ?? "");
2267
+ if (!hit) return new Response("Not found", { status: 404 });
2268
+ const mime = ASSET_MIME[extname(hit.file).toLowerCase()] ?? "application/octet-stream";
2269
+ return new Response(readFileSync(hit.file), { headers: { "content-type": mime, "cache-control": "no-store" } });
2270
+ });
2271
+
2272
+ registerRoute(ROUTES.doc, (request) => {
2273
+ const url = new URL(request?.url ?? "/", "http://localhost");
2274
+ const deckId = url.searchParams.get("deckId") ?? "";
2275
+ const docId = url.searchParams.get("docId") ?? "";
2276
+ const doc = service.deckDoc(deckId, docId);
2277
+ if (!doc) return json({ ok: false, error: { message: "unknown deck or doc" } }, 404);
2278
+ return json({ ok: true, deckId, ...doc });
2279
+ });
2280
+
2281
+ registerRoute(ROUTES.docfile, (request) => {
2282
+ const url = new URL(request?.url ?? "/", "http://localhost");
2283
+ const hit = service.deckDocFile(url.searchParams.get("deckId") ?? "", url.searchParams.get("docId") ?? "", url.searchParams.get("name") ?? "");
2284
+ if (!hit) return new Response("Not found", { status: 404 });
2285
+ const ext = extname(hit.file).toLowerCase();
2286
+ return new Response(readFileSync(hit.file), {
2287
+ headers: {
2288
+ "content-type": DOC_FILE_EXTS[ext] ?? "application/octet-stream",
2289
+ "cache-control": "no-store",
2290
+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(hit.name)}`,
2291
+ },
2292
+ });
2293
+ });
2294
+
2295
+ registerRoute(ROUTES.asset, (request) => {
2296
+ const url = new URL(request?.url ?? "/", "http://localhost");
2297
+ const file = service.assetFile(url.searchParams.get("deckId") ?? "", url.searchParams.get("pageId") ?? "", url.searchParams.get("rev") ?? "");
2298
+ if (!file) return new Response("Not found", { status: 404 });
2299
+ return new Response(readFileSync(file), {
2300
+ headers: { "content-type": ASSET_MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "cache-control": "no-store" },
2301
+ });
2302
+ });
2303
+
2304
+ ctx.effect(
2305
+ () => async () => {
2306
+ await Promise.allSettled(disposers.map((dispose) => dispose()));
2307
+ },
2308
+ `${name}: fetch routes`,
2309
+ );
2310
+
2311
+ // preset 子树挂载(config.tools = true)才注册工具;"tools" 服务不存在时显式报错,
2312
+ // 不静默吞——「工具没挂上」必须可见。
2313
+ if (config.tools) {
2314
+ const toolRuntime = ctx.get("tools");
2315
+ if (!toolRuntime?.register) {
2316
+ ctx.logger?.warn?.(`${name}: config.tools=true 但 tools 服务不可用——deck 工具未注册`);
2317
+ } else {
2318
+ const tools = defineDeckTools({ store: service.store, workspace: service.workspace, adapters: service.adapters, config, env: process.env, repoRoot: repoRootFrom(PLUGIN_DIR), isLocalDeck: (deckId) => service.isLocalDeck(deckId), resolveRender: (deckId) => service.renderRuntime(deckId), subagents: () => ctx.get("subagents") ?? null, reviewPreset: config.review?.presetId });
2319
+ for (const tool of tools) {
2320
+ disposers.push(toolRuntime.register(tool));
2321
+ }
2322
+ ctx.logger?.info?.(`${name}: deck tools registered (${tools.map((tool) => tool.name).join(", ")})`);
2323
+ }
2324
+ }
2325
+
2326
+ // 服务树里的 deckStudio 是单例:**只由根挂载(routes 开启)提供**。
2327
+ // preset 子树里的工具挂载不能再 provide——Cordis 同一 realm 内重复 provide 会抛
2328
+ // `service "deckStudio" has been registered at <deck-studio>`,进而使 preset 激活失败,
2329
+ // 新会话报 agent-preset/invalid(2026-10-20 真机踩到并复现)。
2330
+ if (config.routes) {
2331
+ try {
2332
+ ctx.provide("deckStudio", service);
2333
+ } catch (error) {
2334
+ ctx.logger?.debug?.(`${name}: deckStudio 已有提供方,跳过重复 provide(${error?.message ?? error})`);
2335
+ }
2336
+ }
2337
+ ctx.logger?.info?.(`${name} ready (routes=${config.routes ? Object.values(ROUTES).join(", ") : "off"}, tools=${config.tools ? "on" : "off"}, decks=${service.adapters.length}, dataRoot=${config.dataRoot})`);
2338
+ }
2339
+
2340
+ export { mergeConfig, createRuntimeStore, createDeckWorkspace, createLocalSourceAdapter, defineDeckTools };