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/lib/tools.js ADDED
@@ -0,0 +1,2594 @@
1
+ /**
2
+ * Deck Agent 工具面(ADR-0006 D3)。裸工具定义 + `ctx.tools.register`,零第三方依赖。
3
+ *
4
+ * 每个工具调用都通过 withToolEvent 留 `agent.tool` 事件;edit / render 另起 task 记录
5
+ * 生命周期(queued → running → waiting-user → succeeded/failed/cancelled)。
6
+ *
7
+ * @module dsh-plugin-deck-studio/tools
8
+ */
9
+
10
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
12
+ import { buildPrompt, qaCheck, nextRevision, selectRenderer, editEndpointFor, imageMime, createFakeEditRenderer, createRealEditRenderer } from "./render.js";
13
+ import { applyScriptFieldEdit, scriptDiff, newProposalId, proposalNow, SCRIPT_FIELD_CELL, scriptSectionRange } from "./deck-workspace.js";
14
+ import { appendChangelogNote, readDeckDoc, resolveDeckDocs, scaffoldDeckDocs } from "./deck-docs.js";
15
+ import { createDesignRegistry } from "./designs.js";
16
+ import { appendSampleRecord, composeSamplePrompt, readSampleRecords, readStyleDefinition, writeStyleDefinition } from "./design-style.js";
17
+ import { createLocalSourceAdapter, parseSectionedPages } from "./source-adapter.js";
18
+ import { readDeckRules, renderDeckRules } from "./deck-rules.js";
19
+ import { readDeckBrief } from "./deck-brief.js";
20
+ import { scriptStyleHints } from "./script-style.js";
21
+ import { DEFAULT_MAX_REVIEWERS, HARD_MAX_REVIEWERS, deriveReviewers, mergeIssues, parseReviewerOutput, renderReviewMarkdown, reviewerPrompt } from "./review-personas.js";
22
+ import { densityLine } from "./content-density.js";
23
+ import { faqIndex, searchFaq } from "./faq.js";
24
+ import { WATERMARK_POSITIONS, WATERMARK_POSITION_LABELS, findAsset, listAssets, positionLine, readPngFile, watermarkPng } from "./watermark.js";
25
+ import { PLUGIN_DIR, repoRootFrom } from "./config.js";
26
+ import { buildIntentMarkdown } from "./deck-templates.js";
27
+ import { createScriptToSourceSync, decideProposal } from "./proposal-decision.js";
28
+
29
+ /** 本次调用的 sessionId(非 agent 环境——冒烟测试——为 null)。 */
30
+ function sessionIdOf(exec) {
31
+ return exec?.agent?.id ?? null;
32
+ }
33
+
34
+ /**
35
+ * Agent 可读的参考资料白名单(F-71)。
36
+ *
37
+ * 背景:Agent 只有 deck_* 工具,没有通用文件工具——弹窗「参考资料」里给的
38
+ * README / docs / materials 路径它根本读不到,于是只能按聊天里的几句话编内容。
39
+ * 这里提供一个**只读、白名单**的文件读取工具:只允许本仓库里对外可见的
40
+ * 资料(README / 对外 4 篇 docs / samples / logo / 插件代码),
41
+ * 不碰客户 deck 源、.deck 运行数据、内部台账和密钥文件。
42
+ */
43
+ const REPO_ROOT = repoRootFrom(PLUGIN_DIR);
44
+ const READABLE_FILES = new Set([
45
+ "README.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE",
48
+ "docs/productization.md",
49
+ "docs/install.md",
50
+ "docs/styles.md",
51
+ "docs/sample-deck.md",
52
+ ]);
53
+ const READABLE_DIRS = ["samples/", "assets/logo/", "plugin/deck-studio/"];
54
+ const READABLE_EXTS = new Set([".md", ".txt", ".json", ".yml", ".yaml", ".js", ".mjs", ".css", ".html", ".svg", ".csv"]);
55
+
56
+ function resolveReadablePath(rawPath) {
57
+ const raw = String(rawPath ?? "").trim().replace(/\\/g, "/");
58
+ if (!raw) throw new Error("path 不能为空");
59
+ if (raw.startsWith("/") || raw.startsWith("~") || raw.split("/").includes("..")) {
60
+ throw new Error(`不允许的路径:${raw}(只接受本仓库相对路径,不能跳出仓库)`);
61
+ }
62
+ const rel = relative(REPO_ROOT, resolve(REPO_ROOT, raw)).replace(/\\/g, "/");
63
+ if (!rel || rel.startsWith("..")) throw new Error(`不允许的路径:${raw}(只接受本仓库相对路径)`);
64
+ const allowed = READABLE_FILES.has(rel) || READABLE_DIRS.some((dir) => rel.startsWith(dir));
65
+ if (!allowed) {
66
+ throw new Error(
67
+ `path 不在只读白名单:${rel};可读:README.md / CHANGELOG.md / docs 的 productization·install·styles·sample-deck / samples/ / assets/logo/ / plugin/deck-studio/`,
68
+ );
69
+ }
70
+ const file = join(REPO_ROOT, rel);
71
+ if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`文件不存在:${rel}`);
72
+ const ext = extname(file).toLowerCase();
73
+ if (!READABLE_EXTS.has(ext)) throw new Error(`不支持的文件类型:${ext || "(无扩展名)"};本工具只读文本 / 代码 / 配置(png/jpg 图片不能读内容)`);
74
+ const bytes = statSync(file).size;
75
+ if (bytes > 200_000) throw new Error(`文件过大:${bytes} 字节(上限 200KB),请换更小的材料`);
76
+ return { file, rel, bytes };
77
+ }
78
+
79
+ /**
80
+ * 工具包装:记录 agent.tool started / succeeded / failed 事件。
81
+ * @param {ReturnType<import('./store.js').createRuntimeStore>} store
82
+ */
83
+ function withToolEvent(store, toolName, run) {
84
+ return async (args, exec) => {
85
+ const sessionId = sessionIdOf(exec);
86
+ const envelope = store.recordEvent({
87
+ type: "agent.tool",
88
+ sessionId,
89
+ actor: "agent",
90
+ status: "started",
91
+ payload: { tool: toolName, args: summarizeArgs(args) },
92
+ });
93
+ const started = Date.now();
94
+ try {
95
+ const value = await run(args, exec);
96
+ store.recordEvent({
97
+ type: "agent.tool",
98
+ sessionId,
99
+ taskId: value?.taskId ?? null,
100
+ deckId: value?.deckId ?? args?.deckId ?? null,
101
+ slideIds: value?.slideIds ?? [],
102
+ actor: "agent",
103
+ status: "succeeded",
104
+ payload: { tool: toolName, eventId: envelope.eventId, durationMs: Date.now() - started },
105
+ });
106
+ return value;
107
+ } catch (error) {
108
+ store.recordEvent({
109
+ type: "agent.tool",
110
+ sessionId,
111
+ actor: "agent",
112
+ status: "failed",
113
+ payload: { tool: toolName, eventId: envelope.eventId, durationMs: Date.now() - started, error: String(error?.message ?? error) },
114
+ });
115
+ throw error;
116
+ }
117
+ };
118
+ }
119
+
120
+ /** 事件里的参数摘要:截断长文本,绝不落 API key。 */
121
+ function summarizeArgs(args) {
122
+ const out = {};
123
+ for (const [key, value] of Object.entries(args ?? {})) {
124
+ const text = typeof value === "string" ? value : JSON.stringify(value);
125
+ out[key] = typeof text === "string" && text.length > 160 ? `${text.slice(0, 160)}…(${text.length})` : value;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ const strEnum = (values) => ({ type: "string", enum: values });
131
+
132
+ /**
133
+ * 构建全部工具定义。
134
+ * @param {object} services
135
+ * @param {import('./store.js').createRuntimeStore} services.store
136
+ * @param {import('./deck-workspace.js').createDeckWorkspace} services.workspace
137
+ * @param {Array<ReturnType<import('./source-adapter.js').createLocalSourceAdapter>>} services.adapters
138
+ * @param {import('./config.js').mergeConfig} services.config
139
+ * @param {NodeJS.ProcessEnv} [services.env]
140
+ */
141
+ export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = null, reviewPreset = null }) {
142
+ const resolveSubagents = () => (typeof subagents === "function" ? subagents() : subagents);
143
+ const designs = createDesignRegistry({ dataRoot: config.dataRoot });
144
+ const decksRegistryFile = join(config.dataRoot, "decks-registry.json");
145
+
146
+ /**
147
+ * F-111:deck 关联的 design(「这个 deck 用哪个登记风格」)。
148
+ * 出图 prompt 必须带它的 styleBrief——否则 Design 页显示的「已关联 SKT」只是装饰,
149
+ * 出图仍用全局 styleBrief,用户看到的就是「我定的风格没生效」。
150
+ */
151
+ const linkedDesignStyle = (deckId) => {
152
+ const association = designs.deckDesignFor(deckId);
153
+ if (!association?.designId) return null;
154
+ const design = designs.findDesign(association.designId);
155
+ if (!design) return null;
156
+ let definition = null;
157
+ const canvas = adapters.find((item) => item.id === design.canvasDeckId) ?? null;
158
+ if (canvas?.sourceRoot) {
159
+ try {
160
+ definition = readStyleDefinition(canvas.sourceRoot, design.abbr ?? "", design);
161
+ } catch {
162
+ definition = null;
163
+ }
164
+ }
165
+ const brief = String(design.styleBrief || definition?.styleBrief || definition?.description || design.description || "").trim();
166
+ if (!brief) return null;
167
+ return {
168
+ designId: design.designId,
169
+ abbr: design.abbr ?? "",
170
+ name: design.name ?? "",
171
+ brief,
172
+ constraints: String(design.constraints || definition?.constraints || "").trim(),
173
+ };
174
+ };
175
+ // 启动时从注册表补进来的 deck 不算静态源;之后再被删掉就应从工具实例里移除。
176
+ const staticDeckIds = new Set((config.decks ?? []).filter((deck) => deck?.id && !deck.fromRegistry).map((deck) => deck.id));
177
+
178
+ /**
179
+ * F-71:root 挂载(routes: true)和 preset 挂载(tools: true)是**两个 service 实例**,
180
+ * 各自在启动时按注册表建了 adapters。「新建 Deck / 新建风格」只更新 root 实例的
181
+ * adapters + 落注册表,preset 工具实例的 adapters 会停在启动那一刻——于是 Agent 报
182
+ * 「未知 deck:deck-xxx」,用户看到的就是「新建完对话里全是错误」。
183
+ *
184
+ * 修法:每次工具调用前按 `decks-registry.json` 对齐 adapters——
185
+ * 注册表里有、adapters 里没有的补上;既不在静态配置、也不在注册表里的移除。
186
+ * 一次小文件读,代价可忽略;换来的是新建 deck 立刻可被 Agent 看见。
187
+ */
188
+ function readRegistryDecks() {
189
+ try {
190
+ const raw = JSON.parse(readFileSync(decksRegistryFile, "utf8"));
191
+ const entries = Array.isArray(raw?.decks) ? raw.decks : [];
192
+ return new Map(entries.filter((entry) => entry?.id && entry?.sourceRoot).map((entry) => [entry.id, entry]));
193
+ } catch {
194
+ return new Map();
195
+ }
196
+ }
197
+
198
+ function refreshAdapters() {
199
+ const registered = readRegistryDecks();
200
+ for (const [id, entry] of registered) {
201
+ if (adapters.some((candidate) => candidate.id === id)) continue;
202
+ adapters.push(
203
+ createLocalSourceAdapter({ id, label: entry.label || id, sourceRoot: entry.sourceRoot, kind: entry.kind ?? "deck", designId: entry.designId ?? null }),
204
+ );
205
+ }
206
+ for (let index = adapters.length - 1; index >= 0; index -= 1) {
207
+ const id = adapters[index].id;
208
+ if (staticDeckIds.has(id) || registered.has(id)) continue;
209
+ adapters.splice(index, 1);
210
+ }
211
+ }
212
+
213
+ function adapterFor(deckId) {
214
+ const adapter = adapters.find((candidate) => candidate.id === deckId);
215
+ if (!adapter) {
216
+ throw new Error(`未知 deck:${deckId}(可用:${adapters.map((candidate) => candidate.id).join(", ") || "无——检查 preset 配置里的 decks"})`);
217
+ }
218
+ if (!adapter.available()) throw new Error(`源不可读:${deckId} → ${adapter.sourceRoot}`);
219
+ return adapter;
220
+ }
221
+
222
+ /**
223
+ * 本机新建 deck:把确认后的脚本内容回写源 script.md + git 留痕(F-100 / ADR-0022)。
224
+ *
225
+ * 受控客户源(isLocalDeck=false)仍然只读:只写 .deck 工作副本,不回写源。
226
+ * git 不可用 / 源不在仓库里只返回错误,不影响已经写好的文件(与 ADR-0008 一致)。
227
+ */
228
+ const syncScriptToSource = createScriptToSourceSync({ adapters, isLocalDeck });
229
+
230
+ function manifestFor(deckId) {
231
+ const deck = workspace.manifest(deckId);
232
+ if (!deck) throw new Error(`deck 未初始化:${deckId}(先调 deck_script_read 触发初始化)`);
233
+ return deck;
234
+ }
235
+
236
+ function slideFor(deckId, page) {
237
+ const deck = manifestFor(deckId);
238
+ const pageId = `p${String(Number(page)).padStart(2, "0")}`;
239
+ const slide = deck.slides.find((candidate) => candidate.id === pageId);
240
+ if (!slide) throw new Error(`页不存在:${pageId}(${deckId} 共 ${deck.slides.length} 页)`);
241
+ return { deck, slide, pageId };
242
+ }
243
+
244
+ /**
245
+ * deck_design_sample 的 design 解析(F-61)。
246
+ *
247
+ * 模型偶尔会把 design id 截断(`…-u14f` → `…-u14`),直接按完整 id 查会误报
248
+ * 「先用 design/create 建风格」——design 其实存在。这里按可信度依次尝试:
249
+ * 1. 完整 designId;2. 缩写(大小写不敏感);3. designId 的唯一前缀;
250
+ * 4. 当前会话绑定的 design(design 会话里可省略 designId)。
251
+ * 全都匹配不到时,错误信息列出可用 design,而不是误导用户去重新建风格。
252
+ */
253
+ function resolveDesign(rawKey, exec) {
254
+ const key = String(rawKey ?? "").trim();
255
+ const all = designs.listDesigns();
256
+ let design = null;
257
+ if (key) {
258
+ design = designs.findDesign(key) ?? designs.findDesignByAbbr(key) ?? null;
259
+ if (!design) {
260
+ const lower = key.toLowerCase();
261
+ const prefixMatches = all.filter((item) => String(item.designId).toLowerCase().startsWith(lower));
262
+ if (prefixMatches.length === 1) design = prefixMatches[0];
263
+ }
264
+ }
265
+ if (!design) {
266
+ const sessionId = sessionIdOf(exec);
267
+ const binding = sessionId
268
+ ? designs.listBindings().find((item) => item.sessionId === sessionId && item.kind === "design")
269
+ : null;
270
+ if (binding) design = designs.findDesign(binding.id) ?? null;
271
+ }
272
+ if (!design) {
273
+ const available = all.map((item) => `${item.abbr}(${item.designId})`).join("、") || "还没有 design";
274
+ throw new Error(
275
+ `未知 design:${key || "(空)"};当前可用:${available}。` +
276
+ "designId 传完整 id 或缩写(如 SKT)即可;已有 design 不需要再 design/create。",
277
+ );
278
+ }
279
+ return design;
280
+ }
281
+
282
+ const tools = [];
283
+
284
+ // ── 1. deck_source_overview ────────────────────────────────────────────────
285
+ tools.push({
286
+ name: "deck_source_overview",
287
+ description:
288
+ "列出受控 deck 源(如某个已接入的 deck):页数、状态、反馈数、做到哪。只读元信息,不返回客户正文。" +
289
+ "开始任何 deck 工作前先调它确认源可用。若 deck 项目根有 `deck.rules.md`(项目级规则),会在对应 deck 下作为" +
290
+ "「项目规则」返回:必须遵守,但不能覆盖系统硬纪律(只读源 / QA 门禁 / 不编造 / 用户确认)。",
291
+ parameters: { type: "object", properties: {}, additionalProperties: false },
292
+ output: { schema: { type: "object", properties: { decks: { type: "array", items: { type: "object" } } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
293
+ execute: withToolEvent(store, "deck_source_overview", async () => {
294
+ const decks = adapters.map((adapter) => {
295
+ const view = adapter.available() ? adapter.overview() : { available: false, title: adapter.label, pageCount: 0, feedbackCount: 0, status: "", warnings: [`${adapter.id}: 源不可读 ${adapter.sourceRoot}`] };
296
+ // F-87 / ADR-0020:deck 项目级规则(可选 deck.rules.md)由插件显式读取,不用 AGENTS.md 文件链。
297
+ const rules = adapter.available() ? readDeckRules(adapter.sourceRoot) : { found: false, path: "deck.rules.md", bytes: 0, content: "", truncated: false, flags: [] };
298
+ // F-124:内容密度(一页写多少字)跟着 deck 走,写 script 前必须看到。
299
+ const density = workspace.manifest(adapter.id)?.contentDensity ?? "auto";
300
+ // F-158:写 script 前先看到 intent 的听众 / 目的(新 deck 的 deb 规则里也会带,但 overview 是第一个调用)。
301
+ const brief = adapter.available() ? readDeckBrief(adapter.sourceRoot) : {};
302
+ return {
303
+ deckId: adapter.id,
304
+ label: adapter.label,
305
+ available: view.available,
306
+ title: view.title,
307
+ status: view.status ?? "",
308
+ pageCount: view.pageCount ?? 0,
309
+ feedbackCount: view.feedbackCount ?? 0,
310
+ phases: (view.currentTable ?? []).map((cells) => ({ phase: cells[0], state: cells[1] })),
311
+ warnings: view.warnings ?? [],
312
+ sourceRoot: adapter.sourceRoot,
313
+ contentDensity: density,
314
+ projectRules: {
315
+ found: rules.found,
316
+ path: rules.path,
317
+ bytes: rules.bytes,
318
+ truncated: rules.truncated,
319
+ flags: rules.flags ?? [],
320
+ ...(rules.warning ? { warning: rules.warning } : {}),
321
+ },
322
+ _rulesText: renderDeckRules(rules),
323
+ _briefText: brief.audience || brief.takeaway || brief.success
324
+ ? `受众 / 目的:${[brief.audience, brief.takeaway || brief.success].filter(Boolean).join(" · ")}`
325
+ : "",
326
+ };
327
+ });
328
+ return {
329
+ decks: decks.map(({ _rulesText, _briefText, ...deck }) => deck),
330
+ deckId: null,
331
+ text: decks
332
+ .map((deck) =>
333
+ [
334
+ `• ${deck.deckId}(${deck.label})${deck.available ? "" : "【源不可读】"}`,
335
+ ` 标题:${deck.title} · 状态:${deck.status || "—"} · 页数:${deck.pageCount} · 反馈:${deck.feedbackCount} 条`,
336
+ ...(deck.phases.length ? [` 做到哪:${deck.phases.map((phase) => `${phase.phase}=${phase.state}`).join(";")}`] : []),
337
+ ` ${densityLine(deck.contentDensity)}`,
338
+ ...(deck.warnings.length ? [` 警告:${deck.warnings.join(";")}`] : []),
339
+ ...(deck._briefText ? [` ${deck._briefText}`] : []),
340
+ ...(deck._rulesText ? [deck._rulesText] : []),
341
+ ].join("\n"),
342
+ )
343
+ .join("\n"),
344
+ };
345
+ }),
346
+ });
347
+
348
+ // ── 1.5 deck_file_read ─────────────────────────────────────────────────────
349
+ // F-71:资料白名单只读。用于读「新建 Deck」弹窗参考资料 / materials.md 里列的
350
+ // README、docs、samples 材料、logo 与插件代码;不读客户源 / .deck / 内部台账 / 密钥。
351
+ tools.push({
352
+ name: "deck_file_read",
353
+ description:
354
+ "只读读取本仓库里的参考资料(弹窗「参考资料」里列的路径,或 samples/deckline-intro/materials.md 的清单):" +
355
+ "README.md / CHANGELOG.md / docs 的 productization·install·styles·sample-deck / samples/ / assets/logo/ / plugin/deck-studio/。" +
356
+ "不读客户 deck 源、.deck 运行数据、内部台账、密钥。路径是仓库相对路径;最多返回 400 行(maxLines 可到 800)。",
357
+ parameters: {
358
+ type: "object",
359
+ properties: {
360
+ path: { type: "string", description: "仓库相对路径,如 samples/deckline-intro/materials/project-summary.md" },
361
+ maxLines: { type: "number", description: "最多返回行数(默认 400,上限 800)" },
362
+ },
363
+ required: ["path"],
364
+ additionalProperties: false,
365
+ },
366
+ output: { schema: { type: "object", properties: { path: { type: "string" }, lines: { type: "number" }, truncated: { type: "boolean" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
367
+ execute: withToolEvent(store, "deck_file_read", async (args) => {
368
+ const { file, rel, bytes } = resolveReadablePath(args.path);
369
+ const all = readFileSync(file, "utf8").split("\n");
370
+ const limit = Math.min(Math.max(20, Number(args.maxLines) || 400), 800);
371
+ const shown = all.slice(0, limit);
372
+ const truncated = all.length > limit;
373
+ return {
374
+ path: rel,
375
+ lines: all.length,
376
+ truncated,
377
+ text: `文件 ${rel}(${all.length} 行 · ${bytes} 字节${truncated ? `,只显示前 ${limit} 行` : ""})\n\n${shown.join("\n")}${truncated ? "\n…(已截断;需要更多内容请传更大的 maxLines,或读更具体的文件)" : ""}`,
378
+ };
379
+ }),
380
+ });
381
+
382
+ // ── 1.5 deck_doc_read(F-76:Agent 读得到 deck 文档正文) ──────────────────
383
+ tools.push({
384
+ name: "deck_doc_read",
385
+ description:
386
+ "读当前 deck 的治理文档正文(intent 意图 / design 画面规范 / changelog 修改日志 / feedback 反馈 / research 调研 / materials 支撑材料 / review 审稿 / fact-check 事实核对)。" +
387
+ "补意图、提 script 提案前先读 intent 与 design,别靠猜;资料里没有的事实要问用户,不要编。",
388
+ parameters: {
389
+ type: "object",
390
+ properties: {
391
+ deckId: { type: "string" },
392
+ docId: { type: "string", enum: ["intent", "design", "changelog", "feedback", "research", "materials", "review", "factcheck"], description: "文档 id" },
393
+ maxLines: { type: "number", description: "最多返回多少行(默认 400,上限 800)" },
394
+ },
395
+ required: ["deckId", "docId"],
396
+ additionalProperties: false,
397
+ },
398
+ output: {
399
+ schema: { type: "object", properties: { docId: { type: "string" }, status: { type: "string" }, text: { type: "string" } } },
400
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
401
+ },
402
+ execute: withToolEvent(store, "deck_doc_read", async (args) => {
403
+ const adapter = adapterFor(args.deckId);
404
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
405
+ const docId = String(args.docId ?? "");
406
+ const limit = Math.min(Math.max(Number(args.maxLines) || 400, 20), 800);
407
+ const doc = readDeckDoc({
408
+ adapter,
409
+ deckDir: workspace.deckDir(deck.id),
410
+ docId,
411
+ render: config.render,
412
+ revisions: workspace.manifest(deck.id)?.revisions ?? [],
413
+ events: store.events(200).filter((event) => event.deckId === deck.id),
414
+ });
415
+ if (!doc) throw new Error(`未知文档:${docId}(可选 intent / design / changelog / feedback / research / materials / review / factcheck)`);
416
+ const lines = String(doc.content ?? "").split("\n");
417
+ const truncated = lines.length > limit;
418
+ const body = lines.slice(0, limit).join("\n");
419
+ const head = doc.status === "missing"
420
+ ? `文档 ${docId}(${doc.label ?? docId})还没有内容${doc.issues?.length ? `:${doc.issues.join(";")}` : ""}。`
421
+ : `文档 ${docId}(${doc.label ?? docId})· 状态 ${doc.status}${doc.primary ? ` · ${doc.primary}` : ""}${truncated ? ` · 只显示前 ${limit} 行(共 ${lines.length} 行)` : ""}`;
422
+ return {
423
+ deckId: deck.id,
424
+ docId,
425
+ status: doc.status,
426
+ issues: doc.issues ?? [],
427
+ text: `${head}\n\n${body || "(空)"}`,
428
+ };
429
+ }),
430
+ });
431
+
432
+ // ── 1.6 deck_doc_update(F-78:Agent 把分析结果写进文档) ──────────────────
433
+ const INTENT_FIELD_ALIASES = {
434
+ "听众": "听众", audience: "听众", "受众": "听众", "对象": "听众",
435
+ "主题": "主题", topic: "主题", "话题": "主题",
436
+ "带走": "带走", takeaway: "带走", "希望带走": "带走", "收获": "带走",
437
+ "场合/时长": "场合 / 时长", "场合 / 时长": "场合 / 时长", occasion: "场合 / 时长",
438
+ "场合": "场合 / 时长", "时长": "场合 / 时长", "时间": "场合 / 时长", "场景": "场合 / 时长",
439
+ duration: "场合 / 时长", time: "场合 / 时长", venue: "场合 / 时长",
440
+ "成功标准": "成功标准", success: "成功标准", "成功": "成功标准", "目的": "成功标准",
441
+ "目标": "成功标准", goal: "成功标准", objective: "成功标准",
442
+ "不讲的": "不讲的", "不讲": "不讲的", scopeOut: "不讲的", scope: "不讲的",
443
+ "边界": "不讲的", "排除": "不讲的", "不包括": "不讲的", "nonGoals": "不讲的",
444
+ };
445
+ const DOC_DRAFT_PATH = (dir, docId) => (docId === "research" ? join(dir, "docs", "research", "index.md") : join(dir, "docs", `${docId}.md`));
446
+ const DOC_TITLES = { materials: "支撑材料", research: "调研", feedback: "反馈" };
447
+
448
+ tools.push({
449
+ name: "deck_doc_update",
450
+ description:
451
+ "把分析结果写进当前 deck 的文档。intent:补结构化字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的)," +
452
+ "本机新建 deck 写回源 intent.md;materials / research / feedback:写 .deck 工作副本(确认后回写源)。" +
453
+ "补之前先用 deck_doc_read / deck_file_read 读依据;没有依据的字段不要编,缺什么问用户。",
454
+ parameters: {
455
+ type: "object",
456
+ properties: {
457
+ deckId: { type: "string" },
458
+ docId: { type: "string", enum: ["intent", "materials", "research", "feedback"] },
459
+ fields: { type: "object", description: "仅 intent:字段名 → 值(支持中文名或 audience/topic/takeaway/occasion/success/scopeOut)" },
460
+ content: { type: "string", description: "仅 materials / research / feedback:要写入的 Markdown" },
461
+ mode: { type: "string", enum: ["append", "replace"], description: "写入方式(默认 append)" },
462
+ reason: { type: "string", description: "为什么补(写进修改日志)" },
463
+ },
464
+ required: ["deckId", "docId"],
465
+ additionalProperties: false,
466
+ },
467
+ output: { schema: { type: "object", properties: { docId: { type: "string" }, status: { type: "string" }, text: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
468
+ execute: withToolEvent(store, "deck_doc_update", async (args, exec) => {
469
+ const adapter = adapterFor(args.deckId);
470
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
471
+ const deckDir = workspace.deckDir(deck.id);
472
+ const docId = String(args.docId ?? "");
473
+ const reason = String(args.reason ?? "").trim();
474
+ const localDeck = typeof isLocalDeck === "function" && isLocalDeck(deck.id);
475
+ const sessionId = sessionIdOf(exec);
476
+ let target = "";
477
+ let text = "";
478
+
479
+ const record = (note) => store.recordEvent({
480
+ type: "deck.doc.update",
481
+ deckId: deck.id,
482
+ sessionId,
483
+ actor: "agent",
484
+ status: "succeeded",
485
+ payload: { docId, note: String(note ?? reason).slice(0, 300) || `更新 ${docId}` },
486
+ });
487
+
488
+ if (docId === "intent") {
489
+ const sourceIntent = join(adapter.sourceRoot ?? "", "intent.md");
490
+ const draftIntent = join(deckDir, "docs", "intent.md");
491
+ const canWriteSource = localDeck && Boolean(adapter.sourceRoot) && existsSync(dirname(sourceIntent));
492
+ const currentFile = canWriteSource ? sourceIntent : draftIntent;
493
+ const current = existsSync(currentFile) ? readFileSync(currentFile, "utf8") : buildIntentMarkdown({ title: deck.label ?? deck.id });
494
+ const fields = args.fields && typeof args.fields === "object" ? args.fields : {};
495
+ // F-100:故事线是整段正文(模板里的 `## 故事线(叙事结构)`),不是单行字段。
496
+ const STORYLINE_KEYS = new Set(["故事线", "叙事", "叙事结构", "故事线(叙事结构)", "storyline", "narrative", "story"]);
497
+ const merged = [];
498
+ let storyline = "";
499
+ for (const [rawKey, rawValue] of Object.entries(fields)) {
500
+ if (STORYLINE_KEYS.has(String(rawKey).trim())) {
501
+ storyline = String(rawValue ?? "").trim();
502
+ continue;
503
+ }
504
+ const fieldName = INTENT_FIELD_ALIASES[String(rawKey).trim()] ?? null;
505
+ if (!fieldName) continue;
506
+ // 字段是 `- **字段**:值` 单行结构:多行值折成一行,避免把后续行写成裸正文 / 重复插入。
507
+ const value = String(rawValue ?? "").replace(/\r?\n+/g, ";").replace(/\s+/g, " ").trim();
508
+ if (!value) continue;
509
+ merged.push([fieldName, value]);
510
+ }
511
+ if (!merged.length && !storyline) throw new Error("intent 至少要给一个字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的)或故事线正文");
512
+ const lines = current.split("\n");
513
+ const lineRe = /^(\s*-\s*\*\*)([^*]+)(\*\*\s*[::]\s*)(.*)$/;
514
+ let lastFieldIndex = -1;
515
+ const matchedNames = new Set();
516
+ for (let i = 0; i < lines.length; i += 1) {
517
+ const match = lineRe.exec(lines[i]);
518
+ if (!match) continue;
519
+ lastFieldIndex = i;
520
+ const existing = INTENT_FIELD_ALIASES[match[2].trim()] ?? match[2].trim();
521
+ const hit = merged.find(([name]) => name === existing && !matchedNames.has(name));
522
+ if (hit) {
523
+ lines[i] = `${match[1]}${match[2]}${match[3]}${hit[1]}`;
524
+ matchedNames.add(hit[0]);
525
+ }
526
+ }
527
+ const missingRows = merged.filter(([name]) => !matchedNames.has(name));
528
+ if (missingRows.length) {
529
+ const insertAt = lastFieldIndex >= 0 ? lastFieldIndex + 1 : lines.findIndex((line) => line.startsWith("## 参考资料"));
530
+ const rows = missingRows.map(([name, value]) => `- **${name}**:${value}`);
531
+ lines.splice(insertAt >= 0 ? insertAt : lines.length, 0, ...rows);
532
+ }
533
+ // F-78:Agent 判断「没依据」时也不能把模板的「(待补:…)」原样留给用户
534
+ // (评估 S08 抓到过:字段没编是对的,但占位还在)。改成明确的待确认口径。
535
+ for (let i = 0; i < lines.length; i += 1) {
536
+ const match = lineRe.exec(lines[i]);
537
+ if (!match) continue;
538
+ if ((match[4] ?? "").includes("(待补")) {
539
+ lines[i] = `${match[1]}${match[2]}${match[3]}(待确认:缺依据,已列问题问我)`;
540
+ }
541
+ }
542
+ if (storyline) {
543
+ // 写入或替换 `## 故事线(叙事结构)` 整段:多行不折叠,重复写是替换而不是追加。
544
+ const bodyLines = storyline.split("\n").map((line) => line.replace(/\s+$/, ""));
545
+ while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop();
546
+ const start = lines.findIndex((line) => /^##\s*故事线/.test(line));
547
+ if (start >= 0) {
548
+ let end = lines.length;
549
+ for (let i = start + 1; i < lines.length; i += 1) {
550
+ if (/^##\s/.test(lines[i])) { end = i; break; }
551
+ }
552
+ lines.splice(start + 1, Math.max(0, end - start - 1), "", ...bodyLines, "");
553
+ } else {
554
+ // 老 intent 没有这段:插在「## 参考资料」之后,保持模板顺序。
555
+ const refAt = lines.findIndex((line) => /^##\s*参考资料/.test(line));
556
+ const insertAt = refAt >= 0 ? lines.length : lines.length;
557
+ lines.splice(insertAt, 0, "", "## 故事线(叙事结构)", "", ...bodyLines, "");
558
+ }
559
+ }
560
+ mkdirSync(dirname(currentFile), { recursive: true });
561
+ text = lines.join("\n").replace(/\n{3,}/g, "\n\n");
562
+ writeFileSync(currentFile, text.endsWith("\n") ? text : `${text}\n`, "utf8");
563
+ target = currentFile;
564
+ record(`补意图字段:${merged.map(([name]) => name).join(" / ")}`);
565
+ } else if (docId === "materials" || docId === "research" || docId === "feedback") {
566
+ const file = DOC_DRAFT_PATH(deckDir, docId);
567
+ const content = String(args.content ?? "").trim();
568
+ if (!content) throw new Error(`${docId} 需要 content`);
569
+ const mode = args.mode === "replace" ? "replace" : "append";
570
+ const heading = DOC_TITLES[docId];
571
+ const body = content.startsWith("---") ? content : `---\ntitle: ${deck.label ?? deck.id} · ${heading}\nstatus: draft\n---\n\n# ${heading}\n\n${content}`;
572
+ // F-78:模板里的「(待补:…)」是给 Agent 看的占位;只要 Agent 写了正文,
573
+ // 就把占位行清掉,否则用户看到的支撑材料仍像没做(评估 S08 抓到过)。
574
+ const cleanPlaceholders = (text) =>
575
+ text
576
+ .replace(/^[>\s]*(待补:每条资料里的可引用事实[^\n]*\n?/gm, "")
577
+ .replace(/\n{3,}/g, "\n\n");
578
+ mkdirSync(dirname(file), { recursive: true });
579
+ if (mode === "replace" || !existsSync(file)) {
580
+ const next = cleanPlaceholders(body);
581
+ writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`, "utf8");
582
+ text = `${heading} 已${mode === "replace" ? "重写" : "写入"}(工作副本):${file}`;
583
+ } else {
584
+ const existing = readFileSync(file, "utf8");
585
+ const next = `${existing.replace(/\s+$/, "")}\n\n${body.replace(/^---[\s\S]*?---\n\n/, "")}\n`;
586
+ writeFileSync(file, cleanPlaceholders(next), "utf8");
587
+ text = `${heading} 已追加(工作副本):${file}`;
588
+ }
589
+ target = file;
590
+ record(reason || `补${heading}`);
591
+ } else {
592
+ throw new Error(`不支持的 docId:${docId}(可选 intent / materials / research / feedback)`);
593
+ }
594
+
595
+ return {
596
+ deckId: deck.id,
597
+ docId,
598
+ status: "ok",
599
+ target,
600
+ text: `${text}\n目标:${target}${localDeck ? "(本机 deck:intent 直接写源,其余文档写 .deck 工作副本)" : "(受控源 deck:只写 .deck 工作副本)"}`,
601
+ };
602
+ }),
603
+ });
604
+
605
+ // ── 2. deck_script_read ────────────────────────────────────────────────────
606
+ tools.push({
607
+ name: "deck_script_read",
608
+ description:
609
+ "只读读取 deck 源 script 的逐页行(页号 / 标题 / 这页回答 / 主张 / 来源),可指定页段。" +
610
+ "首次调用会初始化该 deck 的 .deck 工作区(manifest + script 工作副本),不修改源。",
611
+ parameters: {
612
+ type: "object",
613
+ properties: {
614
+ deckId: { type: "string", description: "deck id,见 deck_source_overview" },
615
+ from: { type: "number", description: "起始页号(含),缺省 = 1" },
616
+ to: { type: "number", description: "结束页号(含),缺省 = 末页" },
617
+ },
618
+ required: ["deckId"],
619
+ additionalProperties: false,
620
+ },
621
+ output: { schema: { type: "object", properties: { deckId: { type: "string" }, count: { type: "number" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
622
+ execute: withToolEvent(store, "deck_script_read", async (args, exec) => {
623
+ const adapter = adapterFor(args.deckId);
624
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
625
+ const from = Number.isFinite(args.from) ? Math.max(1, Number(args.from)) : 1;
626
+ const to = Number.isFinite(args.to) ? Number(args.to) : Number.MAX_SAFE_INTEGER;
627
+ const slides = deck.slides.filter((slide) => slide.order >= from && slide.order <= to);
628
+ // F-110:把「能不能出图」所依赖的 visualBrief 一并读出来,并附上当前待确认提案清单。
629
+ // 否则 Agent 会凭上文里旧的提案状态作答(真机:整 deck 已全部落版,Agent 仍说「visualBrief 是空的、
630
+ // 有一条 11:50 的提案还在 waiting-user」,让我去确认一条早就不存在的提案)。
631
+ const currentScript = workspace.scriptCurrent(deck.id) ?? "";
632
+ const pending = workspace.listProposals(deck.id, "waiting-user");
633
+ const staleOf = (proposal) => {
634
+ const range = scriptSectionRange(currentScript, proposal.page);
635
+ const text = range ? currentScript.split("\n").slice(range.start, range.end).join("\n") : currentScript;
636
+ return Boolean(proposal.before) && !text.includes(proposal.before);
637
+ };
638
+ const briefOf = (slide) => String(slide.visualBrief ?? "").trim();
639
+ const lines = slides.map((slide) => {
640
+ const brief = briefOf(slide);
641
+ const briefStyle = slide.visualBriefDesign ? `(写于 ${slide.visualBriefDesign} 风格)` : "(未记录写作风格)";
642
+ const briefText = brief ? `${briefStyle}${brief.slice(0, 120)}${brief.length > 120 ? "…" : ""}` : "(空 · 这一页还不能出图)";
643
+ return `P${String(slide.order).padStart(2, "0")} [${slide.id}] ${slide.title}\n 回答:${slide.question || "—"}\n 主张:${slide.claim || "—"}\n 画面要求:${briefText}\n 来源:${slide.source || "—"}`;
644
+ });
645
+ const linked = linkedDesignStyle(deck.id);
646
+ const styleLine = linked
647
+ ? `\n关联风格:${linked.abbr} ${linked.name}(出图时自动带上它的 styleBrief;visualBrief 只写这页具体画面,不要另起一套视觉语言)\n styleBrief:${linked.brief.slice(0, 200)}${linked.brief.length > 200 ? "…" : ""}${linked.constraints ? `\n 不要什么:${linked.constraints}` : ""}`
648
+ : "\n关联风格:未关联 design(出图只带全局风格)";
649
+ // F-124:写 / 改 script 前先看到目标字数(用户可改,auto 时自己判断)。
650
+ const densityLineText = densityLine(deck.contentDensity);
651
+ // F-158:写 script 前把 intent 的听众 / 目的和文案纪律一起读给 Agent,不然它会按「通用文案」写。
652
+ const brief = readDeckBrief(adapter.sourceRoot);
653
+ const briefLine = [
654
+ brief.audience ? `听众:${brief.audience}(术语密度 / 例子 / 取舍按这个来)` : "听众:intent 里没写——先问用户,或按最小假设写明再写正文",
655
+ brief.takeaway || brief.success ? `目的 / 成功标准:${brief.takeaway || brief.success}` : "",
656
+ "文案纪律:标题≤16 字给判断(比喻 / 对仗 / 问句 / 「不是…而是…」都不要);副标题一句话说这页回答什么;正文先结论后证据,按听众调术语密度;不写 AI 黑话(赋能 / 闭环 / 抓手 / 生态位 / 底层逻辑 / 范式)。",
657
+ ].filter(Boolean).join("\n");
658
+ const pendingText = pending.length
659
+ ? pending.map((proposal) => ` ${proposal.proposalId} · ${proposal.pageId} · ${proposal.field}${staleOf(proposal) ? " · 已过期(工作副本已变,需重新提案)" : ""}`).join("\n")
660
+ : " 0 条(当前没有待确认提案;可以直接继续提案或出图,不要让用户去确认不存在的提案)";
661
+ // F-120:工作副本与 manifest 错位时(历史镜像 bug),提案会落到错的页;读脚本时先体检并提示修复。
662
+ const parsedWorking = parseSectionedPages(currentScript);
663
+ const titleOfWorking = new Map(parsedWorking.map((page) => [page.page, String(page.title ?? "").trim()]));
664
+ const outOfSync = deck.slides.filter((slide, index) => {
665
+ const working = titleOfWorking.get(index + 1);
666
+ return working != null && working !== String(slide.title ?? "").trim();
667
+ });
668
+ const syncLine = outOfSync.length
669
+ ? `\n⚠ 工作副本与 manifest 有 ${outOfSync.length} 页错位(${outOfSync.map((slide, index) => `P${String(slide.order).padStart(2, "0")}`).join("、")})——此时逐页提案会落到错页;先调用 deck_script_repair 修复。`
670
+ : "";
671
+ // F-138:主张超长 = 多半把正文写进了 claim(工作台卡片不显示主张,用户看不到改动)。
672
+ const longClaimSlides = deck.slides.filter((slide) => [...String(slide.claim ?? "").trim()].length > 120);
673
+ const longClaimLine = longClaimSlides.length
674
+ ? `\n⚠ ${longClaimSlides.length} 页主张过长(${longClaimSlides.map((slide) => slide.id).join("、")}):claim 是一句话判断,长内容应在正文 body。若确认写反了,用 deck_script_propose_edit 把长内容提到 body、claim 收成一句。`
675
+ : "";
676
+ const appendix = Array.isArray(deck.appendix?.rows) ? deck.appendix.rows : [];
677
+ const appendixText = appendix.length
678
+ ? `\n附录(${deck.appendix.count} 页,区间登记):\n${appendix.map((row) => ` ${row.id}:${row.content}`).join("\n")}`
679
+ : "";
680
+ return {
681
+ deckId: deck.id,
682
+ count: slides.length,
683
+ slideIds: slides.map((slide) => slide.id),
684
+ pendingProposals: pending.map((proposal) => ({ proposalId: proposal.proposalId, pageId: proposal.pageId, field: proposal.field, stale: staleOf(proposal) })),
685
+ text: `deck ${deck.id}(${deck.title})· 正文 ${slides.length} 页${styleLine}\n${densityLineText}\n${briefLine}${syncLine}${longClaimLine}\n${lines.join("\n")}${appendixText}\n待确认提案(${pending.length}):\n${pendingText}\n(源:${adapter.sourceRoot} · 只读)`,
686
+ outOfSyncPages: outOfSync.map((slide) => slide.id),
687
+ };
688
+ }),
689
+ });
690
+
691
+ // ── 3. deck_script_propose_edit ────────────────────────────────────────────
692
+ tools.push({
693
+ name: "deck_script_propose_edit",
694
+ description:
695
+ "提出一页的修改提案:生成 unified diff 并进入 waiting-user,等用户在 Chat 里确认(或在工作台点确认)。" +
696
+ "可改字段:title(标题)/ question(这页回答)/ body(正文,整页 3-5 句)/ claim(一句话判断,老 deck 兼容)/ visualBrief(画面要求,仅 manifest)。" +
697
+ "写整页内容时 title+question+body 一起提;多页 / 整本重写用 deck_script_propose_edits 一次提交;" +
698
+ "严禁在用户确认前调用 deck_revision_confirm。",
699
+ parameters: {
700
+ type: "object",
701
+ properties: {
702
+ deckId: { type: "string" },
703
+ page: { type: "number", description: "页号(script 里的 N)" },
704
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
705
+ newValue: { type: "string", description: "新值全文(不是增量)" },
706
+ reason: { type: "string", description: "为什么改(写进 trace)" },
707
+ },
708
+ required: ["deckId", "page", "field", "newValue"],
709
+ additionalProperties: false,
710
+ },
711
+ output: { schema: { type: "object", properties: { proposalId: { type: "string" }, status: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
712
+ execute: withToolEvent(store, "deck_script_propose_edit", async (args, exec) => {
713
+ const adapter = adapterFor(args.deckId);
714
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
715
+ const pageId = `p${String(Number(args.page)).padStart(2, "0")}`;
716
+ const slide = deck.slides.find((candidate) => candidate.id === pageId);
717
+ if (!slide) throw new Error(`页不存在:${pageId}(${deck.id} 共 ${deck.slides.length} 页)`);
718
+
719
+ // F-138:主张(claim)只放一句话结论。把整段正文写进 claim 会让工作台看不到改动(卡片不显示主张),
720
+ // 页面上也只剩旧正文——用户会以为「审批完还是不对」。这类字段写反必须在提案时就挡住。
721
+ if (args.field === "claim") {
722
+ const claimText = String(args.newValue ?? "").trim();
723
+ const claimChars = [...claimText].length;
724
+ if (claimChars > 120) {
725
+ throw new Error(`claim(主张)只放一句话判断(建议 ≤ 60 字),这版 ${claimChars} 字,看起来是把正文写进了主张。长内容请用 field="body" 写「正文」;claim 留一句结论即可(工作台卡片会显示正文,主张不显示)。`);
726
+ }
727
+ }
728
+
729
+ let before = "";
730
+ let after = "";
731
+ let kind = "";
732
+ let diff = "";
733
+ let taskInputRefs = [];
734
+
735
+ if (args.field === "visualBrief") {
736
+ kind = "manifest";
737
+ before = slide.visualBrief ?? "";
738
+ after = String(args.newValue ?? "").trim();
739
+ if (!after) throw new Error("visualBrief 不能为空");
740
+ diff = scriptDiff(`visualBrief: ${before}`, `visualBrief: ${after}`);
741
+ taskInputRefs = [`deck:${deck.id}#${pageId}.visualBrief`];
742
+ } else {
743
+ kind = "script";
744
+ const current = workspace.scriptCurrent(deck.id);
745
+ if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
746
+ const applied = applyScriptFieldEdit(current, args.page, args.field, args.newValue);
747
+ if (!applied) throw new Error(`script 里找不到页 ${args.page}(表格行 / 分节字段都没有匹配项)`);
748
+ before = applied.oldRow;
749
+ after = applied.newRow;
750
+ diff = scriptDiff(current, applied.text);
751
+ taskInputRefs = [`script:${deck.id}#P${String(args.page).padStart(2, "0")}#${args.field}`];
752
+ }
753
+
754
+ const proposal = {
755
+ proposalId: newProposalId(),
756
+ deckId: deck.id,
757
+ pageId,
758
+ page: Number(args.page),
759
+ kind,
760
+ field: args.field,
761
+ before,
762
+ after,
763
+ // F-74:分节惯例的 after 是整行 `**标题**:值`,manifest 同步需要纯值。
764
+ value: String(args.newValue ?? "").trim(),
765
+ diff,
766
+ reason: String(args.reason ?? ""),
767
+ status: "waiting-user",
768
+ createdAt: proposalNow(),
769
+ sessionId: sessionIdOf(exec),
770
+ // F-114:记下这条 visualBrief 是在哪个关联风格下写的,换风格时才能确定哪些页需要刷新。
771
+ ...(args.field === "visualBrief" ? { writtenUnderDesign: linkedDesignStyle(deck.id)?.abbr ?? null } : {}),
772
+ };
773
+ // F-108 修订:同一页同一字段被重复提案时,旧提案作废(superseded)。
774
+ // 否则脚本页会出现「两套标题」——两轮提案都挂在同一页上,用户分不清哪个算数。
775
+ const superseded = workspace
776
+ .listProposals(deck.id, "waiting-user")
777
+ .filter((item) => item.pageId === pageId && item.field === args.field && item.proposalId !== proposal.proposalId);
778
+ for (const stale of superseded) {
779
+ workspace.saveProposal({ ...stale, status: "superseded", supersededBy: proposal.proposalId, decidedAt: proposal.createdAt, decidedBy: "agent" });
780
+ }
781
+ workspace.saveProposal(proposal);
782
+
783
+ const task = store.taskCreate({
784
+ kind: "edit",
785
+ deckId: deck.id,
786
+ slideIds: [pageId],
787
+ actor: "agent",
788
+ sessionId: proposal.sessionId,
789
+ inputRefs: taskInputRefs,
790
+ });
791
+ store.taskUpdate(task.taskId, { status: "running", actor: "agent" });
792
+ store.taskUpdate(task.taskId, {
793
+ status: "waiting-user",
794
+ actor: "agent",
795
+ inputRefs: [...taskInputRefs, `proposal:${proposal.proposalId}`],
796
+ });
797
+ store.recordEvent({
798
+ type: "deck.edit",
799
+ sessionId: proposal.sessionId,
800
+ taskId: task.taskId,
801
+ deckId: deck.id,
802
+ slideIds: [pageId],
803
+ actor: "agent",
804
+ status: "waiting-user",
805
+ payload: { proposalId: proposal.proposalId, field: args.field, reason: proposal.reason, diff },
806
+ });
807
+
808
+ const styleHints = scriptStyleHints({
809
+ field: args.field,
810
+ value: String(args.newValue ?? ""),
811
+ title: slide.title,
812
+ question: slide.question,
813
+ body: slide.body,
814
+ });
815
+ const pendingNow = workspace.listProposals(deck.id, "waiting-user");
816
+ const pendingForPage = pendingNow.filter((item) => item.pageId === pageId).length;
817
+ const queueNote = `队列:本页 ${pendingForPage} 条 · 整个 deck ${pendingNow.length} 条待确认(同一页同一字段只认最新一条)。`;
818
+ const supersededNote = superseded.length ? `旧的 ${superseded.length} 条同字段提案已自动作废。` : "";
819
+ return {
820
+ proposalId: proposal.proposalId,
821
+ taskId: task.taskId,
822
+ deckId: deck.id,
823
+ slideIds: [pageId],
824
+ status: "waiting-user",
825
+ pendingCount: pendingNow.length,
826
+ diff,
827
+ styleHints,
828
+ text: `提案 ${proposal.proposalId}(${pageId} · ${args.field})已进入 waiting-user:\n\n${diff || "(无文本差异)"}\n${queueNote}${supersededNote ? ` ${supersededNote}` : ""}${styleHints.length ? `\n文案提醒(不阻断提案,建议先改):\n${styleHints.map((hint) => `- ${hint}`).join("\n")}` : ""}\n请在 Chat 里确认(我会引用提案号);确认后才写入 revision。`,
829
+ };
830
+ }),
831
+ });
832
+
833
+ // ── 3.5 deck_script_add_page ───────────────────────────────────────────────
834
+ tools.push({
835
+ name: "deck_script_add_page",
836
+ description:
837
+ "给 deck 追加页(F-99:页数可以在对话里改)。只追加到最后一页之后,不重排既有页;" +
838
+ "生成 `### Pxx · 标题` 分节 + diff 并进入 waiting-user,等用户在 Chat 里确认后再用 " +
839
+ "deck_revision_confirm 落 revision。一次可以加多页(pages 数组,或 count + titles)。" +
840
+ "不支持在中间插入 / 删除 / 换位——那会让 slides/<pageId> 版本目录与 asset 错配。",
841
+ parameters: {
842
+ type: "object",
843
+ properties: {
844
+ deckId: { type: "string" },
845
+ count: { type: "number", description: "追加页数(默认 1;给了 pages 时忽略)" },
846
+ titles: { type: "array", items: { type: "string" }, description: "新页标题(按顺序;缺的补占位)" },
847
+ pages: {
848
+ type: "array",
849
+ description: "逐页内容(title / question / claim / body / visualBrief),优先于 count + titles",
850
+ items: {
851
+ type: "object",
852
+ properties: {
853
+ title: { type: "string" },
854
+ question: { type: "string" },
855
+ claim: { type: "string" },
856
+ body: { type: "string" },
857
+ visualBrief: { type: "string" },
858
+ },
859
+ additionalProperties: false,
860
+ },
861
+ },
862
+ reason: { type: "string", description: "为什么加页(写进 trace)" },
863
+ },
864
+ required: ["deckId"],
865
+ additionalProperties: false,
866
+ },
867
+ output: { schema: { type: "object", properties: { proposalId: { type: "string" }, status: { type: "string" }, pageCount: { type: "number" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
868
+ execute: withToolEvent(store, "deck_script_add_page", async (args, exec) => {
869
+ const adapter = adapterFor(args.deckId);
870
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
871
+ const current = workspace.scriptCurrent(deck.id);
872
+ if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
873
+ const explicit = Array.isArray(args.pages) ? args.pages.filter(Boolean) : [];
874
+ const requested = explicit.length
875
+ ? explicit
876
+ : Array.from({ length: Math.max(1, Math.min(Number(args.count) || 1, 20)) }, (_, offset) => ({
877
+ title: Array.isArray(args.titles) ? String(args.titles[offset] ?? "") : "",
878
+ }));
879
+ if (requested.length > 20) throw new Error("一次最多追加 20 页(更多请拆成两次提案)");
880
+ const existing = workspace.scriptPageNumbers(current);
881
+ const startPage = (existing.length ? Math.max(...existing) : deck.slides.length) + 1;
882
+ const specs = requested.map((page, offset) => {
883
+ const pageNo = startPage + offset;
884
+ return {
885
+ page: pageNo,
886
+ title: String(page?.title ?? "").trim() || `待补章节 ${pageNo}`,
887
+ question: String(page?.question ?? "").trim(),
888
+ claim: String(page?.claim ?? "").trim(),
889
+ body: String(page?.body ?? "").trim(),
890
+ visualBrief: String(page?.visualBrief ?? "").trim(),
891
+ };
892
+ });
893
+ const preview = workspace.appendScriptPages(current, specs);
894
+ const pageIds = specs.map((spec) => `p${String(spec.page).padStart(2, "0")}`);
895
+ const diff = scriptDiff(current, preview.text);
896
+ const proposal = {
897
+ proposalId: newProposalId(),
898
+ deckId: deck.id,
899
+ pageId: pageIds[0],
900
+ page: specs[0].page,
901
+ pageIds,
902
+ kind: "script-add-page",
903
+ field: "add-page",
904
+ before: "",
905
+ after: preview.text,
906
+ // F-74 的 value 语义是「单字段纯值」,追加页没有单值,留空。
907
+ value: "",
908
+ pages: specs,
909
+ pageCountBefore: deck.slides.length,
910
+ pageCountAfter: preview.pageCount,
911
+ diff,
912
+ reason: String(args.reason ?? ""),
913
+ status: "waiting-user",
914
+ createdAt: proposalNow(),
915
+ sessionId: sessionIdOf(exec),
916
+ };
917
+ workspace.saveProposal(proposal);
918
+
919
+ const task = store.taskCreate({
920
+ kind: "edit",
921
+ deckId: deck.id,
922
+ slideIds: pageIds,
923
+ actor: "agent",
924
+ sessionId: proposal.sessionId,
925
+ inputRefs: [`script:${deck.id}#append-page`],
926
+ });
927
+ store.taskUpdate(task.taskId, { status: "running", actor: "agent" });
928
+ store.taskUpdate(task.taskId, {
929
+ status: "waiting-user",
930
+ actor: "agent",
931
+ inputRefs: [`script:${deck.id}#append-page`, `proposal:${proposal.proposalId}`],
932
+ });
933
+ store.recordEvent({
934
+ type: "deck.edit",
935
+ sessionId: proposal.sessionId,
936
+ taskId: task.taskId,
937
+ deckId: deck.id,
938
+ slideIds: pageIds,
939
+ actor: "agent",
940
+ status: "waiting-user",
941
+ payload: { proposalId: proposal.proposalId, field: "add-page", reason: proposal.reason, diff, pages: specs.map((spec) => spec.page) },
942
+ });
943
+
944
+ return {
945
+ proposalId: proposal.proposalId,
946
+ taskId: task.taskId,
947
+ deckId: deck.id,
948
+ slideIds: pageIds,
949
+ status: "waiting-user",
950
+ pageCount: preview.pageCount,
951
+ diff,
952
+ text:
953
+ `追加页提案 ${proposal.proposalId} 已进入 waiting-user:${deck.slides.length} 页 → ${preview.pageCount} 页` +
954
+ `(新增 ${pageIds.join(" / ")},只追加到末尾,既有页不动)。\n\n${diff || "(无文本差异)"}\n` +
955
+ `请在 Chat 里确认;确认后才写 script revision + manifest。`,
956
+ };
957
+ }),
958
+ });
959
+
960
+ // ── 4. deck_revision_confirm ───────────────────────────────────────────────
961
+ tools.push({
962
+ name: "deck_revision_confirm",
963
+ description:
964
+ "对 waiting-user 的提案执行用户裁决。confirmed=true 表示用户已确认(Chat 明确同意或工作台点了确认);" +
965
+ "confirmationQuote 引用用户原话最好,没带也能落版(会标 quoteMissing 留痕)——不要为了凑原话反复问用户;" +
966
+ "写入 script revision(copy-on-write,不覆盖旧版)并同步 manifest。" +
967
+ "确认支持脚本字段(含 body 正文)与 visualBrief / 追加页;confirmed=false 记为用户驳回。",
968
+ parameters: {
969
+ type: "object",
970
+ properties: {
971
+ proposalId: { type: "string" },
972
+ confirmed: { type: "boolean" },
973
+ confirmationQuote: { type: "string", description: "确认时用户的原话(可选;缺省也能落版,会标 quoteMissing 留痕)" },
974
+ },
975
+ required: ["proposalId", "confirmed"],
976
+ additionalProperties: false,
977
+ },
978
+ output: { schema: { type: "object", properties: { proposalId: { type: "string" }, status: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
979
+ execute: withToolEvent(store, "deck_revision_confirm", async (args, exec) => {
980
+ const proposal = workspace.proposal(String(args.proposalId ?? ""));
981
+ if (!proposal) throw new Error(`提案不存在:${args.proposalId}`);
982
+ // F-108:落版逻辑抽到 lib/proposal-decision.js,工作台「确认落版」按钮与工具共用。
983
+ return decideProposal({
984
+ store,
985
+ workspace,
986
+ proposal,
987
+ confirmed: args.confirmed === true,
988
+ quote: String(args.confirmationQuote ?? ""),
989
+ sessionId: sessionIdOf(exec),
990
+ syncScriptToSource,
991
+ });
992
+ }),
993
+ });
994
+
995
+
996
+ // ── 5. deck_render_page ────────────────────────────────────────────────────
997
+ tools.push({
998
+ name: "deck_render_page",
999
+ description:
1000
+ "对一页出图:slide 内容 → prompt(含全局视觉 preset)→ QA 门禁 → image adapter。" +
1001
+ "一次调用只出一页;用户点名多页 / 整套时按页序连续调用即可——批量是正常用法,不用每页停下来等确认。" +
1002
+ "版本 copy-on-write(image.svg/png + prompt.txt + qa.json),失败不覆盖旧版本。" +
1003
+ "visualBrief 为空也可以直接出:系统用标题 / 回答 / 正文拼「自动草稿」prompt 并在 qa.json 标 briefSource=draft。" +
1004
+ "想一次到位就带上 visualBrief 参数(只写这一页的画面要求,随出图一起落 manifest,不占提案队列)。",
1005
+ parameters: {
1006
+ type: "object",
1007
+ properties: {
1008
+ deckId: { type: "string" },
1009
+ page: { type: "number" },
1010
+ size: { type: "string", description: "覆盖默认尺寸(如 2560x1440)" },
1011
+ },
1012
+ required: ["deckId", "page"],
1013
+ additionalProperties: false,
1014
+ },
1015
+ output: { schema: { type: "object", properties: { revision: { type: "string" }, imagePath: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1016
+ execute: withToolEvent(store, "deck_render_page", async (args, exec) => {
1017
+ // F-137:出图不再要求「先调 deck_script_read」——直接 ensureDeck,删掉无意义的调用顺序。
1018
+ const renderAdapter = adapterFor(args.deckId);
1019
+ const deck = workspace.ensureDeck(renderAdapter, { styleBrief: config.styleBrief });
1020
+ const pageId = `p${String(Number(args.page)).padStart(2, "0")}`;
1021
+ const slide = deck.slides.find((candidate) => candidate.id === pageId);
1022
+ if (!slide) throw new Error(`页不存在:${pageId}(${deck.id} 共 ${deck.slides.length} 页)`);
1023
+ const sessionId = sessionIdOf(exec);
1024
+ const signal = exec?.signal;
1025
+ const task = store.taskCreate({
1026
+ kind: "render",
1027
+ deckId: deck.id,
1028
+ slideIds: [pageId],
1029
+ actor: "agent",
1030
+ sessionId,
1031
+ inputRefs: [`deck:${deck.id}#${pageId}`],
1032
+ });
1033
+ store.taskUpdate(task.taskId, { status: "running", actor: "agent" });
1034
+
1035
+ const tryFinish = async (result) => {
1036
+ store.taskUpdate(task.taskId, { status: result.status, actor: "agent", error: result.error ?? null, outputRefs: result.outputRefs ?? [] });
1037
+ return result.value;
1038
+ };
1039
+
1040
+ try {
1041
+ // F-137:内联 visualBrief(只写 manifest,不占提案队列)——一次调用补 brief + 出图。
1042
+ const inlineBrief = String(args.visualBrief ?? "").trim();
1043
+ let briefSource = "existing";
1044
+ if (inlineBrief) {
1045
+ slide.visualBrief = inlineBrief;
1046
+ slide.visualBriefDesign = linkedDesignStyle(deck.id)?.abbr ?? null;
1047
+ workspace.saveManifest(deck);
1048
+ briefSource = "inline";
1049
+ store.recordEvent({ type: "deck.brief", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "agent", status: "succeeded", payload: { source: "inline", chars: inlineBrief.length } });
1050
+ }
1051
+ const size = String(args.size ?? config.render.size);
1052
+ const linked = linkedDesignStyle(deck.id);
1053
+ // F-137:没有 visualBrief 不再硬拒——用标题 / 回答 / 正文拼「自动草稿」,旧版本不覆盖,重出成本可控。
1054
+ if (!String(slide.visualBrief ?? "").trim()) {
1055
+ briefSource = "draft";
1056
+ const draft = [`第 ${slide.order ?? args.page} 页:${slide.title ?? ""}`, String(slide.question ?? "").trim(), String(slide.body ?? "").trim().slice(0, 160)]
1057
+ .filter(Boolean).join("\n");
1058
+ slide.visualBrief = draft || "整页信息图,按标题与正文排版";
1059
+ store.recordEvent({ type: "deck.brief", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "system", status: "succeeded", payload: { source: "draft" } });
1060
+ }
1061
+ // 风格 = deck 关联 design 的 styleBrief(+ 它的「不要什么」);没关联才退回全局配置。
1062
+ const styleBrief = linked
1063
+ ? [linked.brief, linked.constraints ? `不要什么:${linked.constraints}` : ""].filter(Boolean).join("\n")
1064
+ : config.styleBrief;
1065
+ const prompt = buildPrompt(slide, styleBrief, { brief: readDeckBrief(renderAdapter.sourceRoot) });
1066
+ const qa = qaCheck(prompt, size);
1067
+ if (!qa.ok) {
1068
+ store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "system", status: "failed", payload: { gate: "qa", qa } });
1069
+ return tryFinish({ status: "failed", error: "qa-gate", value: { taskId: task.taskId, deckId: deck.id, slideIds: [pageId], qa, text: `QA 门禁未通过:${qa.checks.filter((check) => !check.ok).map((check) => `${check.name}(${check.detail})`).join(";")}` } });
1070
+ }
1071
+
1072
+ const outDir = join(workspace.slideDir(deck.id, pageId), "versions", nextRevision(workspace.slideDir(deck.id, pageId)));
1073
+ mkdirSync(outDir, { recursive: true });
1074
+ writeFileSync(join(outDir, "prompt.txt"), prompt, "utf8");
1075
+ writeFileSync(join(outDir, "content.json"), `${JSON.stringify(slide, null, 2)}\n`, "utf8");
1076
+
1077
+ // 出图节点/模型按当前 deck 的生效选择解析(ADR-0010);没有 resolveRender 的老调用退回 config.render。
1078
+ const runtime = typeof resolveRender === "function"
1079
+ ? resolveRender(deck.id)
1080
+ : { renderer: null, apiKey: "", model: config.render.model, endpoint: config.render.endpoint, nodeName: "", label: config.render.model, mode: config.render.mode, timeoutMs: config.render.timeoutMs };
1081
+ if (!runtime.renderer) {
1082
+ const { selectRenderer } = await import("./render.js");
1083
+ const picked = selectRenderer(config.render, env);
1084
+ runtime.renderer = picked.renderer;
1085
+ runtime.apiKey = picked.apiKey;
1086
+ }
1087
+ const briefStale = Boolean(slide.visualBriefDesign && linked && slide.visualBriefDesign !== linked.abbr);
1088
+ const renderResult = await runtime.renderer.render({
1089
+ prompt,
1090
+ slideId: pageId,
1091
+ revision: outDir.split("/").pop(),
1092
+ model: runtime.model,
1093
+ endpoint: runtime.endpoint,
1094
+ size,
1095
+ outDir,
1096
+ apiKey: runtime.apiKey,
1097
+ timeoutMs: runtime.timeoutMs,
1098
+ signal,
1099
+ });
1100
+ writeFileSync(join(outDir, "qa.json"), `${JSON.stringify({ ok: true, checks: qa.checks, briefSource, provenance: { model: renderResult.model, endpoint: renderResult.endpoint, size: renderResult.size, provider: renderResult.provider, durationMs: renderResult.durationMs, node: runtime.nodeName || null }, renderedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
1101
+ store.recordEvent({
1102
+ type: "deck.render",
1103
+ sessionId,
1104
+ taskId: task.taskId,
1105
+ deckId: deck.id,
1106
+ slideIds: [pageId],
1107
+ actor: "tool",
1108
+ status: "succeeded",
1109
+ payload: { revision: outDir.split("/").pop(), imagePath: renderResult.imagePath, provider: renderResult.provider, model: renderResult.model, endpoint: renderResult.endpoint, node: runtime.nodeName || null, size: renderResult.size, durationMs: renderResult.durationMs, designAbbr: linked?.abbr ?? null, briefDesign: slide.visualBriefDesign ?? null },
1110
+ });
1111
+ store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "system", status: "succeeded", payload: { qa } });
1112
+ const revision = outDir.split("/").pop();
1113
+ return tryFinish({
1114
+ status: "succeeded",
1115
+ outputRefs: [`revision:${deck.id}/${pageId}/${revision}`, `asset:${renderResult.imagePath}`],
1116
+ value: {
1117
+ taskId: task.taskId,
1118
+ deckId: deck.id,
1119
+ slideIds: [pageId],
1120
+ revision,
1121
+ imagePath: renderResult.imagePath,
1122
+ provider: renderResult.provider,
1123
+ node: runtime.nodeName || null,
1124
+ model: renderResult.model,
1125
+ durationMs: renderResult.durationMs,
1126
+ designAbbr: linked?.abbr ?? null,
1127
+ briefStale,
1128
+ text: `${pageId} ${revision} 出图完成(${renderResult.provider} · ${runtime.nodeName ? `${runtime.nodeName} / ` : ""}${renderResult.model} · ${renderResult.size} · ${renderResult.durationMs}ms)\n风格:${linked ? `${linked.abbr} ${linked.name}(来自 deck 关联的 design)` : "未关联 design,用全局风格"}${briefSource === "draft" ? "\n⚠ 这页原来没有 visualBrief,这次用的是「标题 + 正文」自动草稿 prompt;想更贴风格,可以再补一条 visualBrief 重出(旧图不覆盖)。" : ""}${briefStale ? `\n⚠ 这页画面要求写于 ${slide.visualBriefDesign} 风格下(当前 ${linked?.abbr}),可能风格混搭;想要更好效果可先让 Agent 按新风格重写画面要求。` : ""}\n图片:${renderResult.imagePath}`,
1129
+ },
1130
+ });
1131
+ } catch (error) {
1132
+ const message = String(error?.message ?? error);
1133
+ const hint = /超时|timeout|aborted/i.test(message)
1134
+ ? `\n这次是节点响应慢(超时 ${Math.round((config.render.timeoutMs ?? 420_000) / 1000)}s),不是内容问题:旧版本未被覆盖,等 1-2 分钟重试这一页即可。`
1135
+ : "";
1136
+ store.recordEvent({ type: "deck.render", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: [pageId], actor: "tool", status: "failed", payload: { error: message } });
1137
+ return tryFinish({ status: "failed", error: message, value: { taskId: task.taskId, deckId: deck.id, slideIds: [pageId], text: `出图失败(旧版本未受影响):${message}${hint}` } });
1138
+ }
1139
+ }),
1140
+ });
1141
+
1142
+ // ── 5b. deck_image_edit(修图:以图生图改局部,F-144) ─────────────────────
1143
+ const IMAGE_SOURCE_RE = /\.(png|jpe?g|webp|svg)$/i;
1144
+
1145
+ function resolvePageNumber(value) {
1146
+ const match = /^(?:p)?0*(\d+)$/i.exec(String(value ?? "").trim());
1147
+ if (!match) throw new Error(`page 只接受页号(如 3 或 "P03"),收到:${value}`);
1148
+ return Number(match[1]);
1149
+ }
1150
+
1151
+ /** 这页最新版本的图(vNNN/image.png|jpg|webp|svg)。 */
1152
+ function latestRevisionImage(slideDir) {
1153
+ const versionsDir = join(slideDir, "versions");
1154
+ if (!existsSync(versionsDir)) return null;
1155
+ const revisions = readdirSync(versionsDir).filter((name) => /^v\d+$/.test(name)).sort().reverse();
1156
+ for (const revision of revisions) {
1157
+ const dir = join(versionsDir, revision);
1158
+ for (const name of readdirSync(dir)) {
1159
+ if (/^image\.(png|jpe?g|webp|svg)$/i.test(name)) return { revision, filePath: join(dir, name) };
1160
+ }
1161
+ }
1162
+ return null;
1163
+ }
1164
+
1165
+ /** deck 源目录内的底图路径:只收图片后缀,禁止绝对路径 / 目录穿越。 */
1166
+ function resolveSourceImage(sourceRoot, sourceImage) {
1167
+ const rel = String(sourceImage ?? "").trim().replace(/^\.\//, "");
1168
+ if (!rel) throw new Error("sourceImage 为空");
1169
+ if (!IMAGE_SOURCE_RE.test(rel)) throw new Error(`sourceImage 只支持 png / jpg / jpeg / webp / svg:${rel}`);
1170
+ if (rel.startsWith("/") || rel.split("/").includes("..")) throw new Error(`sourceImage 必须是 deck 源目录内的相对路径,不能穿越:${rel}`);
1171
+ const root = resolve(sourceRoot);
1172
+ const target = resolve(root, rel);
1173
+ if (target !== root && !target.startsWith(root + sep)) throw new Error(`sourceImage 越出 deck 源目录:${rel}`);
1174
+ if (!existsSync(target)) throw new Error(`sourceImage 不存在:${rel}(相对 deck 源目录:${sourceRoot})`);
1175
+ return target;
1176
+ }
1177
+
1178
+ tools.push({
1179
+ name: "deck_image_edit",
1180
+ description:
1181
+ "修图专用(以图生图):拿一张已有图片当底图,只改你点名的地方——去水印 / 改标题 / 删掉某个元素 / 换背景 / 补细节," +
1182
+ "输出新版本,旧图不覆盖。一次调用修一张(多张就按页调多次)。" +
1183
+ "底图二选一:page(页号,取这页最新版本的图)或 sourceImage(deck 源目录里的图片路径,如 assets/logo.png)。" +
1184
+ "只改画面、不改 script / 正文(要改文案先用提案);整页重新出图用 deck_render_page,不要拿这个工具重跑整页。",
1185
+ parameters: {
1186
+ type: "object",
1187
+ properties: {
1188
+ deckId: { type: "string" },
1189
+ page: { type: "string", description: "页号(如 3 或 \"P03\"),用这页最新版本的图当底图" },
1190
+ sourceImage: { type: "string", description: "deck 源目录内的图片相对路径(png/jpg/jpeg/webp/svg),如 assets/logo.png" },
1191
+ instruction: { type: "string", description: "要改什么,写局部、写具体(如:把标题改大两号,去掉右下角水印)" },
1192
+ size: { type: "string", description: "输出尺寸,默认沿用出图设置" },
1193
+ },
1194
+ required: ["deckId", "instruction"],
1195
+ additionalProperties: false,
1196
+ },
1197
+ output: { schema: { type: "object", properties: { revision: { type: "string" }, imagePath: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1198
+ execute: withToolEvent(store, "deck_image_edit", async (args, exec) => {
1199
+ const instruction = String(args.instruction ?? "").trim();
1200
+ if (instruction.length < 4) throw new Error("instruction 太短:写清楚改哪里、改成什么(例:把标题改大两号,去掉右下角水印)");
1201
+ const hasPage = args.page !== undefined && args.page !== null && String(args.page).trim() !== "";
1202
+ const hasSource = String(args.sourceImage ?? "").trim() !== "";
1203
+ if (!hasPage && !hasSource) throw new Error("要给 page(页号)或 sourceImage(底图路径),二选一");
1204
+ if (hasPage && hasSource) throw new Error("page 与 sourceImage 只能二选一:修页面图传 page,修素材图传 sourceImage");
1205
+ const adapter = adapterFor(args.deckId);
1206
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1207
+ const sessionId = sessionIdOf(exec);
1208
+ const signal = exec?.signal;
1209
+ const size = String(args.size ?? config.render.size);
1210
+ const pageNo = hasPage ? resolvePageNumber(args.page) : null;
1211
+ const pageId = pageNo === null ? null : `p${String(pageNo).padStart(2, "0")}`;
1212
+ const slide = pageId ? deck.slides.find((candidate) => candidate.id === pageId) : null;
1213
+ if (pageId && !slide) throw new Error(`页不存在:${pageId}(${deck.id} 共 ${deck.slides.length} 页)`);
1214
+ // 底图先解析:参数/路径错了要直接抛给 Agent(不是包成一次「修图失败」的 task)。
1215
+ let source;
1216
+ if (pageId) {
1217
+ const latest = latestRevisionImage(workspace.slideDir(deck.id, pageId));
1218
+ if (!latest) throw new Error(`${pageId} 还没有可修的图:先 deck_render_page 出图(或用 sourceImage 指定底图)`);
1219
+ source = latest;
1220
+ } else {
1221
+ source = { revision: null, filePath: resolveSourceImage(adapter.sourceRoot, args.sourceImage) };
1222
+ }
1223
+ const sourceRel = relative(adapter.sourceRoot, source.filePath) || source.filePath;
1224
+ const task = store.taskCreate({ kind: "edit", deckId: deck.id, slideIds: pageId ? [pageId] : [], actor: "agent", sessionId, inputRefs: [pageId ? `deck:${deck.id}#${pageId}` : `asset:${sourceRel}`] });
1225
+ store.taskUpdate(task.taskId, { status: "running", actor: "agent" });
1226
+ const finish = (result) => {
1227
+ store.taskUpdate(task.taskId, { status: result.status, actor: "agent", error: result.error ?? null, outputRefs: result.outputRefs ?? [] });
1228
+ return result.value;
1229
+ };
1230
+ try {
1231
+ const prompt = `# 修图(局部修改,其余保持不变)\n${instruction}\n\n要求:只改点名的地方;不换整页构图;图里的文字保持可读,不要新增没提到的文字或 logo。`;
1232
+ const qa = qaCheck(prompt, size);
1233
+ if (!qa.ok) {
1234
+ store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: pageId ? [pageId] : [], actor: "system", status: "failed", payload: { gate: "qa", qa } });
1235
+ return finish({ status: "failed", error: "qa-gate", value: { taskId: task.taskId, qa, text: `修图前 QA 门禁未通过:${qa.checks.filter((check) => !check.ok).map((check) => `${check.name}(${check.detail})`).join(";")}` } });
1236
+ }
1237
+ // 输出位置:页面修图 → 新版本目录(copy-on-write);素材修图 → .deck/edits/ 独立产物。
1238
+ let outDir;
1239
+ let revision = "";
1240
+ if (pageId) {
1241
+ revision = nextRevision(workspace.slideDir(deck.id, pageId));
1242
+ outDir = join(workspace.slideDir(deck.id, pageId), "versions", revision);
1243
+ } else {
1244
+ const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
1245
+ outDir = join(workspace.deckDir(deck.id), "edits", `${stamp}-${Math.random().toString(36).slice(2, 6)}`);
1246
+ }
1247
+ mkdirSync(outDir, { recursive: true });
1248
+ writeFileSync(join(outDir, "prompt.txt"), prompt, "utf8");
1249
+ if (pageId) writeFileSync(join(outDir, "content.json"), `${JSON.stringify(slide, null, 2)}\n`, "utf8");
1250
+ const runtime = typeof resolveRender === "function"
1251
+ ? resolveRender(deck.id)
1252
+ : { renderer: null, apiKey: "", model: config.render.model, endpoint: config.render.endpoint, editEndpoint: editEndpointFor(config.render.endpoint), editTransport: "multipart", nodeName: "", timeoutMs: config.render.timeoutMs, mode: config.render.mode };
1253
+ const fake = runtime.mode !== "real" || runtime.renderer?.provider === "fake";
1254
+ const info = imageMime(extname(source.filePath));
1255
+ if (!fake && !info) throw new Error(`修图底图需要 png / jpg / jpeg / webp(当前:${extname(source.filePath) || "未知"})`);
1256
+ const renderer = fake ? createFakeEditRenderer() : createRealEditRenderer();
1257
+ const editResult = await renderer.edit({
1258
+ filePath: source.filePath,
1259
+ ext: extname(source.filePath),
1260
+ outDir,
1261
+ prompt,
1262
+ model: runtime.model,
1263
+ endpoint: runtime.editEndpoint || editEndpointFor(runtime.endpoint),
1264
+ size,
1265
+ apiKey: runtime.apiKey,
1266
+ timeoutMs: runtime.timeoutMs,
1267
+ signal,
1268
+ transport: runtime.editTransport || "multipart",
1269
+ });
1270
+ writeFileSync(join(outDir, "qa.json"), `${JSON.stringify({
1271
+ ok: true,
1272
+ checks: qa.checks,
1273
+ kind: "edit",
1274
+ edit: { instruction, sourceRevision: source.revision, sourceImage: sourceRel },
1275
+ provenance: { model: runtime.model, endpoint: editResult.endpoint || null, size: editResult.size || size, provider: editResult.provider, durationMs: editResult.durationMs, node: runtime.nodeName || null, transport: editResult.transport || "n/a" },
1276
+ editedAt: new Date().toISOString(),
1277
+ }, null, 2)}\n`, "utf8");
1278
+ store.recordEvent({
1279
+ type: "deck.render",
1280
+ sessionId,
1281
+ taskId: task.taskId,
1282
+ deckId: deck.id,
1283
+ slideIds: pageId ? [pageId] : [],
1284
+ actor: "tool",
1285
+ status: "succeeded",
1286
+ payload: { edit: true, revision: revision || null, imagePath: editResult.imagePath, instruction, sourceRevision: source.revision, sourceImage: sourceRel, provider: editResult.provider, model: runtime.model, node: runtime.nodeName || null, size: editResult.size || size, durationMs: editResult.durationMs },
1287
+ });
1288
+ store.recordEvent({ type: "deck.qa", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: pageId ? [pageId] : [], actor: "system", status: "succeeded", payload: { qa } });
1289
+ const where = pageId ? `${pageId} → ${revision}` : "独立产物(不挂页面)";
1290
+ return finish({
1291
+ status: "succeeded",
1292
+ outputRefs: pageId ? [`revision:${deck.id}/${pageId}/${revision}`, `asset:${editResult.imagePath}`] : [`asset:${editResult.imagePath}`],
1293
+ value: {
1294
+ taskId: task.taskId,
1295
+ deckId: deck.id,
1296
+ slideIds: pageId ? [pageId] : [],
1297
+ revision: revision || null,
1298
+ imagePath: editResult.imagePath,
1299
+ provider: editResult.provider,
1300
+ model: runtime.model,
1301
+ durationMs: editResult.durationMs,
1302
+ sourceRevision: source.revision,
1303
+ text: `修图完成:${where}(${editResult.provider} · ${runtime.nodeName ? `${runtime.nodeName} / ` : ""}${runtime.model} · ${size})\n改了什么:${instruction}\n底图:${sourceRel}${source.revision ? `(${source.revision})` : ""}\n图片:${editResult.imagePath}\n旧版本保留,没有覆盖。${pageId ? "" : "\n注意:这是独立产物,不会自动变成某一页的图;要挂到页面上得重新出一版整页图,或把这张图当参考再出。"}`,
1304
+ },
1305
+ });
1306
+ } catch (error) {
1307
+ const message = String(error?.message ?? error);
1308
+ store.recordEvent({ type: "deck.render", sessionId, taskId: task.taskId, deckId: deck.id, slideIds: pageId ? [pageId] : [], actor: "tool", status: "failed", payload: { edit: true, error: message, instruction } });
1309
+ return finish({ status: "failed", error: message, value: { taskId: task.taskId, deckId: deck.id, slideIds: pageId ? [pageId] : [], text: `修图失败(旧版本未受影响):${message}` } });
1310
+ }
1311
+ }),
1312
+ });
1313
+
1314
+ // ── 6. deck_docs_check / deck_docs_scaffold ────────────────────────────────
1315
+ const deckRevisions = (deckId) => {
1316
+ const dir = join(workspace.deckDir(deckId), "script", "revisions");
1317
+ if (!existsSync(dir)) return [];
1318
+ return readdirSync(dir)
1319
+ .filter((name) => /^v\d+\.md$/.test(name))
1320
+ .sort()
1321
+ .reverse()
1322
+ .map((name) => {
1323
+ const revision = name.replace(/\.md$/, "");
1324
+ const noteFile = join(dir, `${revision}.note.txt`);
1325
+ return { revision, savedAt: statSync(join(dir, name)).mtime.toISOString(), note: existsSync(noteFile) ? readFileSync(noteFile, "utf8").trim() : "" };
1326
+ });
1327
+ };
1328
+
1329
+ const deckDocsOf = (deckId) => {
1330
+ const adapter = adapters.find((item) => item.id === deckId) ?? null;
1331
+ if (!adapter) throw new Error(`unknown deck: ${deckId}`);
1332
+ return resolveDeckDocs({ adapter, deckDir: workspace.deckDir(deckId), render: config.render, revisions: deckRevisions(deckId), events: store.events(200).filter((event) => event.deckId === deckId) });
1333
+ };
1334
+
1335
+ // ── F-140:内置 FAQ(产品操作问答) ─────────────────────────────────────
1336
+ tools.push({
1337
+ name: "deck_help",
1338
+ description:
1339
+ "查工作台内置 FAQ:怎么改稿 / 出图 / 导出 / 换风格 / 加页 / 看会话 / 失败排查 / 安装 key。" +
1340
+ "用户问「怎么操作 / 在哪点 / 为什么不行」时先查它,按返回步骤回答(按当前界面口径,不要凭记忆编 UI);" +
1341
+ "查不到就直说没覆盖到,并指路 README / docs/manual.md。",
1342
+ parameters: {
1343
+ type: "object",
1344
+ properties: {
1345
+ query: { type: "string", description: "关键字或整句问题,如「怎么导出 PPTX」「换风格」;省略则返回目录" },
1346
+ },
1347
+ additionalProperties: false,
1348
+ },
1349
+ output: { schema: { type: "object", properties: {} }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1350
+ execute: withToolEvent(store, "deck_help", async (args) => {
1351
+ const query = String(args?.query ?? "").trim();
1352
+ if (!query) {
1353
+ const index = faqIndex();
1354
+ return {
1355
+ query,
1356
+ hits: [],
1357
+ text: `工作台 FAQ(${index.length} 条):\n${index.map((entry) => `· ${entry.title}(${entry.id})`).join("\n")}\n\n用 deck_help 带关键字查具体步骤(如 query="导出")。`,
1358
+ };
1359
+ }
1360
+ const hits = searchFaq(query, 3);
1361
+ if (!hits.length) {
1362
+ return {
1363
+ query,
1364
+ hits: [],
1365
+ text: `FAQ 里没有匹配「${query}」的条目。不要凭记忆编操作步骤:直接说这块没覆盖到,建议看 README / docs/manual.md,或让用户描述具体界面位置。`,
1366
+ };
1367
+ }
1368
+ return {
1369
+ query,
1370
+ hits: hits.map((entry) => entry.id),
1371
+ text: `${hits.map((entry) => `【${entry.title}】\n${entry.answer}`).join("\n\n")}\n\n(把步骤讲清楚即可,不要把整段原文念给用户;界面细节以这份答案为准。)`,
1372
+ };
1373
+ }),
1374
+ });
1375
+
1376
+ tools.push({
1377
+ name: "deck_docs_check",
1378
+ description: "检查这份 deck 的 9 份文档(意图 / 脚本 / Design / 修改日志 / 反馈 / 调研 / 支撑材料 / 审稿 / 事实核对)齐不齐、字段缺什么。这是「交接完备度」,出图不需要文档齐全;交接 / 导出前再跑。",
1379
+ parameters: {
1380
+ type: "object",
1381
+ properties: { deckId: { type: "string", description: "deck id;省略则取第一个" } },
1382
+ additionalProperties: false,
1383
+ },
1384
+ output: {
1385
+ schema: { type: "object", properties: { governance: { type: "object" } } },
1386
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1387
+ },
1388
+ execute: withToolEvent(store, "deck_docs_check", async (args) => {
1389
+ const deckId = args?.deckId ?? adapters[0]?.id;
1390
+ const { docs, governance } = deckDocsOf(deckId);
1391
+ const icon = { ok: "✓", derived: "≈", draft: "✎", partial: "!", missing: "✗" };
1392
+ const lines = [
1393
+ `交接完备度 ${governance.score}/100(${Object.entries(governance.counts).map(([key, count]) => `${key} ${count}`).join(" · ")})——不影响出图 / 改稿 / 导出。`,
1394
+ "",
1395
+ ...docs.map((doc) => `${icon[doc.status] ?? "?"} ${doc.label}(${doc.status})${doc.primary ? ` · ${doc.primary}` : ""}${doc.issues.length ? `\n ${doc.issues.join("\n ")}` : ""}`),
1396
+ "",
1397
+ governance.score === 100 ? "9 份文档齐全,交接包可以直接用。" : `交接可选缺项:${docs.filter((doc) => doc.status !== "ok").map((doc) => doc.label).join(" / ")}(用到再补,不用为了分数停下出图)`,
1398
+ ];
1399
+ return { deckId, governance, docs, text: lines.join("\n") };
1400
+ }),
1401
+ });
1402
+
1403
+ tools.push({
1404
+ name: "deck_docs_scaffold",
1405
+ description: "给缺失的 deck 文档打骨架(写 .deck 工作副本 docs/,不动客户源目录),再由 Agent 按模板补内容。",
1406
+ parameters: {
1407
+ type: "object",
1408
+ properties: {
1409
+ deckId: { type: "string", description: "deck id;省略则取第一个" },
1410
+ docIds: { type: "array", items: { type: "string" }, description: "只补指定文档 id(intent / script / design / changelog / feedback / research / materials)" },
1411
+ },
1412
+ additionalProperties: false,
1413
+ },
1414
+ output: {
1415
+ schema: { type: "object", properties: { created: { type: "array", items: { type: "string" } } } },
1416
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1417
+ },
1418
+ execute: withToolEvent(store, "deck_docs_scaffold", async (args) => {
1419
+ const deckId = args?.deckId ?? adapters[0]?.id;
1420
+ const adapter = adapters.find((item) => item.id === deckId);
1421
+ if (!adapter) throw new Error(`unknown deck: ${deckId}`);
1422
+ const result = scaffoldDeckDocs({ adapter, deckDir: workspace.deckDir(deckId), docIds: args?.docIds });
1423
+ return {
1424
+ deckId,
1425
+ ...result,
1426
+ text: result.created.length
1427
+ ? `已在工作副本创建:${result.created.join("、")}${result.skipped.length ? `(已存在跳过:${result.skipped.join("、")})` : ""}
1428
+ 下一步:按模板补内容,确认后再回写源 deck。`
1429
+ : `没有创建新文件${result.skipped.length ? `(已存在:${result.skipped.join("、")})` : ""}。`,
1430
+ };
1431
+ }),
1432
+ });
1433
+
1434
+ // ── 7. deck_trace ──────────────────────────────────────────────────────────
1435
+ tools.push({
1436
+ name: "deck_trace",
1437
+ description: "读取 deck 工作的 task 与 event 流水(chat / edit / render / qa)。排障与交接时用。",
1438
+ parameters: {
1439
+ type: "object",
1440
+ properties: { limit: { type: "number", description: "各取最近 N 条,默认 20" } },
1441
+ additionalProperties: false,
1442
+ },
1443
+ output: { schema: { type: "object", properties: { tasks: { type: "array", items: { type: "object" } } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1444
+ execute: withToolEvent(store, "deck_trace", async (args) => {
1445
+ const limit = Number.isFinite(args?.limit) && args.limit > 0 ? Math.min(100, Math.floor(args.limit)) : 20;
1446
+ const tasks = store.taskList(limit);
1447
+ const events = store.events(limit).slice().reverse();
1448
+ const formatTask = (task) => `${task.taskId} [${task.kind}] ${task.status}${task.error ? ` · ${task.error}` : ""} · ${task.deckId ?? "—"} ${(task.slideIds ?? []).join(",")} · ${task.createdAt}`;
1449
+ const formatEvent = (event) => `${event.timestamp} ${event.type} ${event.status} · ${event.deckId ?? "—"} ${(event.slideIds ?? []).join(",")} · ${event.actor}`;
1450
+ return {
1451
+ tasks,
1452
+ events,
1453
+ text: [`最近任务(${tasks.length}):`, ...tasks.map(formatTask), "", `最近事件(${events.length}):`, ...events.map(formatEvent)].join("\n"),
1454
+ };
1455
+ }),
1456
+ });
1457
+
1458
+ // ── N. deck_note_change ────────────────────────────────────────────────────
1459
+ tools.push({
1460
+ name: "deck_note_change",
1461
+ description:
1462
+ "给修改日志补一条「为什么」:系统已经从 revision/事件自动生成变更时间线,你只需在每次改完 deck 后补一句人话(改了什么、为什么、影响哪些页)。会追加到 docs/changelog.md 并记事件。",
1463
+ parameters: {
1464
+ type: "object",
1465
+ properties: {
1466
+ deckId: { type: "string", description: "deck id;省略则取第一个" },
1467
+ note: { type: "string", description: "一句话说明:为什么改 / 影响了什么" },
1468
+ pageId: { type: "string", description: "相关页 id(可选,如 p03)" },
1469
+ },
1470
+ required: ["note"],
1471
+ additionalProperties: false,
1472
+ },
1473
+ output: {
1474
+ schema: { type: "object", properties: { ok: { type: "boolean" }, line: { type: "string" } } },
1475
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1476
+ },
1477
+ execute: withToolEvent(store, "deck_note_change", async (args) => {
1478
+ const deckId = args?.deckId ?? adapters[0]?.id;
1479
+ const adapter = adapters.find((item) => item.id === deckId);
1480
+ if (!adapter) throw new Error(`unknown deck: ${deckId}`);
1481
+ const pageId = typeof args?.pageId === "string" ? args.pageId : "";
1482
+ const { line } = appendChangelogNote({ deckDir: workspace.deckDir(deckId), note: args?.note, pageId, actor: "agent" });
1483
+ store.recordEvent({
1484
+ type: "deck.changelog.note",
1485
+ deckId,
1486
+ slideIds: pageId ? [pageId] : [],
1487
+ actor: "agent",
1488
+ status: "succeeded",
1489
+ payload: { note: String(args.note).slice(0, 300) },
1490
+ });
1491
+ return { ok: true, line, text: `已记入修改日志:${line}` };
1492
+ }),
1493
+ });
1494
+
1495
+ // ── N+0. deck_style_read / deck_style_update(F-72:复用已有风格提示词)──────
1496
+ tools.push({
1497
+ name: "deck_style_read",
1498
+ description:
1499
+ "读一个 design(风格)的定义:style.json / style.md 里的风格提示词(styleBrief)、画面语言、不要什么、参考," +
1500
+ "以及已有样张的 brief + 实际 prompt 记录。补样张前先读它:新样张要沿用已有风格提示词,只补「这张要试的画面」,不要重造提示词。",
1501
+ parameters: {
1502
+ type: "object",
1503
+ properties: {
1504
+ designId: { type: "string", description: "design id 或缩写(如 SKT);省略则用当前会话绑定的 design" },
1505
+ },
1506
+ additionalProperties: false,
1507
+ },
1508
+ output: { schema: { type: "object", properties: { abbr: { type: "string" }, text: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1509
+ execute: withToolEvent(store, "deck_style_read", async (args, exec) => {
1510
+ const design = resolveDesign(args.designId, exec);
1511
+ const adapter = adapterFor(design.canvasDeckId);
1512
+ const definition = readStyleDefinition(adapter.sourceRoot, design.abbr, design);
1513
+ const samples = readSampleRecords(definition.dir);
1514
+ const text = [
1515
+ `# design ${definition.abbr || design.abbr} · ${definition.name || design.name}`,
1516
+ `designId: ${design.designId}(画布 deck ${design.canvasDeckId})`,
1517
+ `style.json: ${definition.jsonPath}`,
1518
+ "",
1519
+ "## 风格提示词(styleBrief,出样张时自动带上)",
1520
+ definition.styleBrief || "(还没有 styleBrief;只有画面描述,会用 description 兜底)",
1521
+ definition.description ? `\n## 画面语言\n${definition.description}` : "",
1522
+ definition.constraints ? `\n## 不要什么\n${definition.constraints}` : "",
1523
+ definition.reference ? `\n## 参考\n${definition.reference}` : "",
1524
+ "",
1525
+ `## 已有样张 prompt(${samples.length} 条记录)`,
1526
+ ...(samples.length
1527
+ ? samples.map((row) => `- ${row.revision || "?"}|brief:${row.brief || "(未记录)"}|prompt ${String(row.prompt ?? "").length} 字|${row.createdAt || ""}`)
1528
+ : ["(还没有 prompt 记录;老样张是本次改动之前出的,无法回溯当时的 prompt)"]),
1529
+ "",
1530
+ "补新样张时:沿用上面的 styleBrief,只把「这张要试什么画面」写进 brief。",
1531
+ ].filter((line) => line !== undefined).join("\n");
1532
+ return { designId: design.designId, abbr: definition.abbr || design.abbr, styleBrief: definition.styleBrief, samples: samples.length, text };
1533
+ }),
1534
+ });
1535
+
1536
+ tools.push({
1537
+ name: "deck_style_update",
1538
+ description:
1539
+ "更新 design(风格)的定义:只写画布 deck 的 designs/<ABBR>/style.json + style.md,不动客户源 deck。" +
1540
+ "只在用户明确要改风格(改提示词 / 约束 / 参考)并确认草案后调用;改完后出样张会自动带上新 styleBrief。" +
1541
+ "不要用它改 deck 页面内容。",
1542
+ parameters: {
1543
+ type: "object",
1544
+ properties: {
1545
+ designId: { type: "string", description: "design id 或缩写;省略则用当前会话绑定的 design" },
1546
+ styleBrief: { type: "string", description: "新的风格提示词本体(精确画面语言;出样张自动带上)" },
1547
+ description: { type: "string", description: "给人看的画面语言描述(可选)" },
1548
+ constraints: { type: "string", description: "不要什么(可选)" },
1549
+ reference: { type: "string", description: "参考(可选)" },
1550
+ },
1551
+ additionalProperties: false,
1552
+ },
1553
+ output: { schema: { type: "object", properties: { styleBrief: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1554
+ execute: withToolEvent(store, "deck_style_update", async (args, exec) => {
1555
+ const design = resolveDesign(args.designId, exec);
1556
+ const adapter = adapterFor(design.canvasDeckId);
1557
+ const patch = {};
1558
+ for (const field of ["styleBrief", "description", "constraints", "reference"]) {
1559
+ if (typeof args[field] === "string" && args[field].trim()) patch[field] = args[field].trim();
1560
+ }
1561
+ if (!Object.keys(patch).length) throw new Error("没有要更新的字段:至少给 styleBrief / description / constraints / reference 之一");
1562
+ const written = writeStyleDefinition(adapter.sourceRoot, {
1563
+ abbr: design.abbr,
1564
+ name: design.name,
1565
+ description: design.description,
1566
+ constraints: design.constraints,
1567
+ reference: design.reference,
1568
+ styleBrief: design.styleBrief,
1569
+ designId: design.designId,
1570
+ ...patch,
1571
+ });
1572
+ designs.upsertDesign({
1573
+ ...design,
1574
+ name: written.definition.name,
1575
+ description: written.definition.description,
1576
+ constraints: written.definition.constraints,
1577
+ reference: written.definition.reference,
1578
+ styleBrief: written.definition.styleBrief,
1579
+ });
1580
+ store.recordEvent({
1581
+ type: "design.style-updated",
1582
+ deckId: design.canvasDeckId,
1583
+ actor: "tool",
1584
+ status: "succeeded",
1585
+ payload: { designId: design.designId, abbr: design.abbr, fields: Object.keys(patch) },
1586
+ });
1587
+ return {
1588
+ designId: design.designId,
1589
+ abbr: design.abbr,
1590
+ styleBrief: written.definition.styleBrief,
1591
+ text: `design ${design.abbr} 风格定义已更新(${Object.keys(patch).join(" / ")})\nstyle.json:${written.jsonPath}\n后面的样张会自动沿用新的 styleBrief。`,
1592
+ };
1593
+ }),
1594
+ });
1595
+
1596
+ // ── N+1. deck_design_sample ────────────────────────────────────────────────
1597
+ tools.push({
1598
+ name: "deck_design_sample",
1599
+ description:
1600
+ "给绑定的 design(风格)出一张样张:风格描述 + 这张要试的画面 → QA → 图片模型," +
1601
+ "保存到画布 deck 的 designs/<ABBR>/sample-vNNN.png(不覆盖旧样张)。" +
1602
+ "design 会话里用它试风格;不要用它改 deck 页面。" +
1603
+ "designId 可传完整 id 或缩写(如 SKT);design 会话里可省略(自动用会话绑定的 design)。" +
1604
+ "若报「未知 design」,说明 id 写错/被截断,改用缩写重试,不要建议用户重新创建风格。",
1605
+ parameters: {
1606
+ type: "object",
1607
+ properties: {
1608
+ designId: { type: "string", description: "design id 或缩写(如 SKT);省略则用当前会话绑定的 design" },
1609
+ brief: { type: "string", description: "这张样张要试的画面/版面说明" },
1610
+ size: { type: "string", description: "覆盖默认尺寸(如 2560x1440)" },
1611
+ },
1612
+ required: ["brief"],
1613
+ additionalProperties: false,
1614
+ },
1615
+ output: { schema: { type: "object", properties: { revision: { type: "string" }, imagePath: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1616
+ execute: withToolEvent(store, "deck_design_sample", async (args, exec) => {
1617
+ const design = resolveDesign(args.designId, exec);
1618
+ const brief = String(args.brief ?? "").trim();
1619
+ if (!brief) throw new Error("brief 不能为空:说明这张样张要试什么画面");
1620
+ const adapter = adapterFor(design.canvasDeckId);
1621
+ const sessionId = sessionIdOf(exec);
1622
+ const size = String(args.size ?? config.render.size);
1623
+ // F-72:沿用 style.json 里已有的风格提示词(styleBrief),新 brief 只补「这张要试的画面」,
1624
+ // 不再用 description 现拼一套新提示词。
1625
+ const definition = readStyleDefinition(adapter.sourceRoot, design.abbr, design);
1626
+ const prompt = composeSamplePrompt(definition, brief);
1627
+ const qa = qaCheck(prompt, size);
1628
+ if (!qa.ok) throw new Error(`QA 门禁未通过:${qa.checks.filter((check) => !check.ok).map((check) => `${check.name}(${check.detail})`).join(";")}`);
1629
+
1630
+ const runtime = typeof resolveRender === "function"
1631
+ ? resolveRender(design.canvasDeckId)
1632
+ : (() => {
1633
+ const picked = selectRenderer(config.render, env);
1634
+ return { renderer: picked.renderer, apiKey: picked.apiKey, model: config.render.model, endpoint: config.render.endpoint, nodeName: "", label: config.render.model, mode: config.render.mode, timeoutMs: config.render.timeoutMs };
1635
+ })();
1636
+ const styleDir = join(adapter.sourceRoot, "designs", design.abbr);
1637
+ mkdirSync(styleDir, { recursive: true });
1638
+ const used = readdirSync(styleDir).filter((name) => /^sample-v\d+\.(png|jpe?g|webp|svg)$/i.test(name)).length;
1639
+ const revision = `v${String(used + 1).padStart(3, "0")}`;
1640
+ const renderResult = await runtime.renderer.render({
1641
+ prompt,
1642
+ slideId: `design-${design.abbr}`,
1643
+ revision,
1644
+ model: runtime.model,
1645
+ endpoint: runtime.endpoint,
1646
+ size,
1647
+ outDir: styleDir,
1648
+ apiKey: runtime.apiKey,
1649
+ timeoutMs: runtime.timeoutMs,
1650
+ signal: exec?.signal,
1651
+ });
1652
+ const produced = renderResult.imagePath;
1653
+ const target = join(styleDir, `sample-${revision}${extname(produced) || ".png"}`);
1654
+ if (produced !== target) renameSync(produced, target);
1655
+ // F-72:把这张样张实际用的 brief + prompt 落盘,后续「再加两张」可以接着用。
1656
+ const record = appendSampleRecord(styleDir, {
1657
+ designId: design.designId,
1658
+ abbr: design.abbr,
1659
+ revision,
1660
+ brief,
1661
+ prompt,
1662
+ model: renderResult.model,
1663
+ size: renderResult.size,
1664
+ imagePath: target,
1665
+ durationMs: renderResult.durationMs,
1666
+ sessionId,
1667
+ });
1668
+ store.recordEvent({
1669
+ type: "design.sample",
1670
+ sessionId,
1671
+ deckId: design.canvasDeckId,
1672
+ actor: "tool",
1673
+ status: "succeeded",
1674
+ payload: { designId: design.designId, abbr: design.abbr, revision, imagePath: target, model: renderResult.model, size: renderResult.size, durationMs: renderResult.durationMs, promptChars: prompt.length, brief, recordAt: record.createdAt },
1675
+ });
1676
+ return {
1677
+ designId: design.designId,
1678
+ revision,
1679
+ imagePath: target,
1680
+ text: `design ${design.abbr} 样张 ${revision} 完成(${renderResult.provider} · ${renderResult.model} · ${renderResult.size} · ${renderResult.durationMs}ms)\n图片:${target}`,
1681
+ };
1682
+ }),
1683
+ });
1684
+
1685
+ // ── N+1.4 deck_design_bind(F-113:deck ↔ design 换绑) ────────────────────
1686
+ tools.push({
1687
+ name: "deck_design_bind",
1688
+ description:
1689
+ "把 deck 关联到某个登记风格(design),或传空设计解除关联。用户明确要求「换风格 / 换 design / 关联到 X」时用;" +
1690
+ "不要自己替用户换风格。换绑只改「出图用哪个 styleBrief」,不改已有页面内容;换完提醒用户旧 visualBrief 可能还是旧风格的画面语言。",
1691
+ parameters: {
1692
+ type: "object",
1693
+ properties: {
1694
+ deckId: { type: "string", description: "deck id;省略取第一个" },
1695
+ designId: { type: "string", description: "design id 或缩写(如 GRD);传空字符串 = 解除关联" },
1696
+ },
1697
+ additionalProperties: false,
1698
+ },
1699
+ output: { schema: { type: "object", properties: { deckId: { type: "string" }, designId: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1700
+ execute: withToolEvent(store, "deck_design_bind", async (args) => {
1701
+ const deckId = String(args?.deckId ?? adapters[0]?.id ?? "");
1702
+ const adapter = adapterFor(deckId);
1703
+ const raw = String(args?.designId ?? "").trim();
1704
+ const previous = designs.deckDesignFor(deckId)?.designId ?? null;
1705
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1706
+ const briefPages = () => deck.slides
1707
+ .filter((slide) => String(slide.visualBrief ?? "").trim())
1708
+ .map((slide) => ({ pageId: slide.id, page: slide.order, designAbbr: slide.visualBriefDesign ?? null }));
1709
+ if (!raw) {
1710
+ designs.setDeckDesign(deckId, null);
1711
+ store.recordEvent({ type: "deck.design.bind", deckId, actor: "agent", status: "succeeded", payload: { designId: null, previous } });
1712
+ return { deckId, designId: "", text: `deck ${deckId}(${adapter.label})已解除风格关联;之后出图只带全局 styleBrief。` };
1713
+ }
1714
+ const design = resolveDesign(raw, null);
1715
+ designs.setDeckDesign(deckId, design.designId);
1716
+ store.recordEvent({ type: "deck.design.bind", deckId, actor: "agent", status: "succeeded", payload: { designId: design.designId, abbr: design.abbr, previous } });
1717
+ // F-114:换风格后,哪些页的 visualBrief 不是在新风格下写的 → 这些页的 brief 需要用户决定是否刷新。
1718
+ const staleBriefs = briefPages().filter((item) => item.designAbbr !== design.abbr);
1719
+ store.recordEvent({ type: "deck.design.briefs-check", deckId, actor: "tool", status: "succeeded", payload: { designAbbr: design.abbr, stale: staleBriefs.map((item) => item.pageId) } });
1720
+ const staleText = staleBriefs.length
1721
+ ? `\n有 ${staleBriefs.length} 页的画面要求不是 ${design.abbr} 风格下写的:${staleBriefs.map((item) => `${item.pageId}${item.designAbbr ? `(写于 ${item.designAbbr})` : "(未记录)"}`).join("、")}。\n`
1722
+ + `问用户选哪种(必须问,不要自己决定):① 只换风格、保留现有画面要求(下次出图 = 现有 brief + 新风格,可能出现风格混搭);`
1723
+ + `② 按新风格重写这些页的 visualBrief(逐页提案,确认后再出图,效果更好)。`
1724
+ : `\n所有页的画面要求都与新风格一致,不需要刷新。`;
1725
+ return {
1726
+ deckId,
1727
+ designId: design.designId,
1728
+ designAbbr: design.abbr,
1729
+ staleBriefs,
1730
+ text: `deck ${deckId}(${adapter.label})的出图风格已换成 ${design.abbr} ${design.name}。\n`
1731
+ + `之后 deck_render_page 的 prompt 会带它的 styleBrief(${String(design.styleBrief || design.description || "").slice(0, 80)}…)。${staleText}`,
1732
+ };
1733
+ }),
1734
+ });
1735
+
1736
+ // ── N+1.5 deck_research_plan(F-96 P2 分批调研) ───────────────────────────
1737
+ tools.push({
1738
+ name: "deck_research_plan",
1739
+ description:
1740
+ "把 deck 的参考资料拆成「调研批次」:先规划(不传 batchIndex)→ 再逐批读(batchIndex=1,2,…)。" +
1741
+ "每批返回该批文件的前若干行摘要(有字数预算),避免一次读完 10+ 份资料把上下文占满。" +
1742
+ "读完一批就把可引用事实 + 出处写进调研台账(deck_doc_update,docId=research),再读下一批。" +
1743
+ "不要跳过规划、也不要一次把全部资料读完。",
1744
+ parameters: {
1745
+ type: "object",
1746
+ properties: {
1747
+ deckId: { type: "string", description: "deck id,见 deck_source_overview" },
1748
+ batchIndex: { type: "number", description: "要读的批次号(从 1 开始);不传 = 只生成计划" },
1749
+ batchSize: { type: "number", description: "每批几份资料(默认 3,上限 6)" },
1750
+ charBudget: { type: "number", description: "该批摘要总字数预算(默认 4000,范围 800–12000)" },
1751
+ write: { type: "boolean", description: "是否把批次计划写进调研台账(默认 true)" },
1752
+ },
1753
+ required: ["deckId"],
1754
+ additionalProperties: false,
1755
+ },
1756
+ output: {
1757
+ schema: { type: "object", properties: { deckId: { type: "string" }, totalBatches: { type: "number" }, batchIndex: { type: "number" } } },
1758
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1759
+ },
1760
+ execute: withToolEvent(store, "deck_research_plan", async (args, exec) => {
1761
+ const adapter = adapterFor(args.deckId);
1762
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1763
+ const deckDir = workspace.deckDir(deck.id);
1764
+ const { docs } = resolveDeckDocs({
1765
+ adapter,
1766
+ deckDir,
1767
+ render: config.render,
1768
+ revisions: workspace.manifest(deck.id)?.revisions ?? [],
1769
+ events: store.events(200).filter((event) => event.deckId === deck.id),
1770
+ });
1771
+ const materials = docs.find((doc) => doc.id === "materials");
1772
+ const refs = [];
1773
+ const seen = new Set();
1774
+ for (const row of materials?.facts?.items ?? []) {
1775
+ const ref = String(row.name || row.link || "").trim();
1776
+ if (!ref || seen.has(ref)) continue;
1777
+ seen.add(ref);
1778
+ refs.push(ref);
1779
+ }
1780
+ const sources = refs.map((ref) => {
1781
+ if (/^https?:\/\//i.test(ref)) return { ref, readable: false, reason: "链接(需联网 / 由用户提供原文)" };
1782
+ try {
1783
+ const { rel, bytes } = resolveReadablePath(ref);
1784
+ return { ref: rel, readable: true, bytes };
1785
+ } catch (error) {
1786
+ return { ref, readable: false, reason: String(error?.message ?? error) };
1787
+ }
1788
+ });
1789
+ const readable = sources.filter((item) => item.readable);
1790
+ // 批次大小要跨调用一致:读第 N 批时如果没传 batchSize,沿用计划里写下的大小(默认 3)。
1791
+ const researchPath = join(deckDir, "docs", "research", "index.md");
1792
+ const researchText = existsSync(researchPath) ? readFileSync(researchPath, "utf8") : "";
1793
+ const plannedSize = Number((researchText.match(/批次大小:(\d+)/) ?? [])[1]) || 0;
1794
+ const batchSize = Math.min(Math.max(Number(args.batchSize) || plannedSize || 3, 1), 6);
1795
+ const batches = [];
1796
+ for (let i = 0; i < readable.length; i += batchSize) batches.push(readable.slice(i, i + batchSize));
1797
+ const totalBatches = batches.length;
1798
+ if (!readable.length) {
1799
+ return {
1800
+ deckId: deck.id,
1801
+ totalBatches: 0,
1802
+ text: `deck ${deck.id} 没有可读的本地参考资料(${sources.length} 条登记里有 ${sources.filter((item) => !item.readable).length} 条是链接 / 未登记文件)。先把资料路径登记进「支撑材料」,或让用户提供可读文件。`,
1803
+ };
1804
+ }
1805
+
1806
+ // 计划写进调研台账:只写「## 调研批次计划」这一节,幂等替换。
1807
+ let planWritten = false;
1808
+ if (args.write !== false) {
1809
+ const target = join(deckDir, "docs", "research", "index.md");
1810
+ mkdirSync(dirname(target), { recursive: true });
1811
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "---\ntitle: 调研台账\nstatus: draft\n---\n\n# 调研台账\n";
1812
+ const planLines = [
1813
+ "## 调研批次计划",
1814
+ "",
1815
+ `> 分批读,避免一次把上下文占满;批次大小:${batchSize}(共 ${totalBatches} 批)。读完一批把事实写进下面的台账。`,
1816
+ "",
1817
+ "| 批次 | 文件 | 状态 |",
1818
+ "|---|---|---|",
1819
+ ...batches.map((batch, index) => `| ${index + 1} | ${batch.map((item) => item.ref).join(" · ")} | 待读 |`),
1820
+ "",
1821
+ ].join("\n");
1822
+ const stripped = existing.replace(/## 调研批次计划[\s\S]*?(?=\n## |$)/, "").replace(/\s+$/, "");
1823
+ writeFileSync(target, `${stripped}\n\n${planLines}`, "utf8");
1824
+ planWritten = true;
1825
+ }
1826
+
1827
+ const planText = [
1828
+ `deck ${deck.id} 参考资料 ${sources.length} 条(可读 ${readable.length} · 链接/不可读 ${sources.length - readable.length})`,
1829
+ `分 ${totalBatches} 批(每批 ≤${batchSize} 份):`,
1830
+ ...batches.map((batch, index) => ` 批次 ${index + 1}:${batch.map((item) => `${item.ref}(${Math.round(item.bytes / 1024)}KB)`).join(" · ")}`),
1831
+ ...(sources.filter((item) => !item.readable).length ? [`不可读 / 需另找:${sources.filter((item) => !item.readable).map((item) => item.ref).join(" · ")}`] : []),
1832
+ "",
1833
+ `下一步:逐批调用 deck_research_plan({deckId, batchIndex: 1..${totalBatches}}) 读该批摘要;每批读完把可引用事实 + 出处写进调研台账(deck_doc_update,docId=research)。`,
1834
+ planWritten ? "批次计划已写入调研台账。" : "(未写文件)",
1835
+ ].join("\n");
1836
+
1837
+ const logPlan = (payload) => store.recordEvent({
1838
+ type: "deck.research.plan",
1839
+ deckId: deck.id,
1840
+ sessionId: sessionIdOf(exec),
1841
+ actor: "agent",
1842
+ status: "succeeded",
1843
+ payload: { totalBatches, batchSize, sources: sources.length, readable: readable.length, ...payload },
1844
+ });
1845
+
1846
+ if (!Number.isFinite(Number(args.batchIndex))) {
1847
+ logPlan({ mode: "plan", written: planWritten });
1848
+ return { deckId: deck.id, totalBatches, sources, text: planText };
1849
+ }
1850
+ const index = Math.floor(Number(args.batchIndex));
1851
+ const batch = batches[index - 1];
1852
+ if (!batch) throw new Error(`batchIndex 越界:${index}(共 ${totalBatches} 批)`);
1853
+ const budget = Math.min(Math.max(Number(args.charBudget) || 4000, 800), 12000);
1854
+ const perFile = Math.max(400, Math.floor(budget / batch.length) - 200);
1855
+ const digests = batch.map((item) => {
1856
+ const { file } = resolveReadablePath(item.ref);
1857
+ const text = readFileSync(file, "utf8");
1858
+ const lines = text.split("\n");
1859
+ const shownLines = lines.slice(0, 120).join("\n");
1860
+ const digest = shownLines.length > perFile ? `${shownLines.slice(0, perFile)}\n…(本批预算内截断,需要更多细节单独再读这份文件)` : shownLines;
1861
+ return `### ${item.ref}(${lines.length} 行 · ${Math.round(item.bytes / 1024)}KB,显示前 ${Math.min(lines.length, 120)} 行)\n${digest}`;
1862
+ });
1863
+ logPlan({ mode: "batch", batchIndex: index, files: batch.map((item) => item.ref) });
1864
+ return {
1865
+ deckId: deck.id,
1866
+ totalBatches,
1867
+ batchIndex: index,
1868
+ files: batch.map((item) => item.ref),
1869
+ text: [
1870
+ `批次 ${index}/${totalBatches}(${batch.length} 份,摘要预算 ${budget} 字)`,
1871
+ "",
1872
+ ...digests,
1873
+ "",
1874
+ `读完这批后:把「可引用事实 / 出处(文件 + 位置)/ 能支撑哪条主张 / 缺口」写进调研台账(deck_doc_update,docId=research),再读下一批。`,
1875
+ ].join("\n"),
1876
+ };
1877
+ }),
1878
+ });
1879
+
1880
+ // ── N+2. deck_review(F-95 P1 审稿人 / 红队) ─────────────────────────────
1881
+ tools.push({
1882
+ name: "deck_review",
1883
+ description:
1884
+ "审稿(红队):读 intent / design / script / materials / research / changelog,找出交付前必修的问题" +
1885
+ "(缺内容、空 visualBrief、无来源支撑、前后不一致),按严重度排序写进 review.md。" +
1886
+ "只审不改、不出图。每条必须带:严重度 P0/P1/P2、位置(文档名或页号 P0x)、证据原文、修复建议;" +
1887
+ "没有真问题时要写明「无问题 + 依据」,不要为了凑数编问题。交付前必须审一次。",
1888
+ parameters: {
1889
+ type: "object",
1890
+ properties: {
1891
+ deckId: { type: "string", description: "deck id,见 deck_source_overview" },
1892
+ write: { type: "boolean", description: "是否把意见写进 review.md(默认 true;dry run 传 false)" },
1893
+ },
1894
+ required: ["deckId"],
1895
+ additionalProperties: false,
1896
+ },
1897
+ output: {
1898
+ schema: {
1899
+ type: "object",
1900
+ properties: { deckId: { type: "string" }, verdict: { type: "string" }, issues: { type: "array" } },
1901
+ },
1902
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1903
+ },
1904
+ execute: withToolEvent(store, "deck_review", async (args, exec) => {
1905
+ const adapter = adapterFor(args.deckId);
1906
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
1907
+ const deckDir = workspace.deckDir(deck.id);
1908
+ const sessionId = sessionIdOf(exec);
1909
+ const { docs, governance } = resolveDeckDocs({
1910
+ adapter,
1911
+ deckDir,
1912
+ render: config.render,
1913
+ revisions: workspace.manifest(deck.id)?.revisions ?? [],
1914
+ events: store.events(200).filter((event) => event.deckId === deck.id),
1915
+ });
1916
+ const pages = adapter.storyPages?.() ?? [];
1917
+ const prompts = adapter.prompts?.() ?? {};
1918
+ const docOf = (id) => docs.find((item) => item.id === id);
1919
+ const isPlaceholder = (value) => !String(value ?? "").trim() || /TODO|todo|待补|待填|待确认/.test(String(value));
1920
+ const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n+/g, " ").slice(0, 240);
1921
+ const issues = [];
1922
+ const add = (severity, category, where, evidence, fix) => issues.push({ severity, category, where, evidence, fix });
1923
+
1924
+ // 1) 结构:页数 / 每页主张 / visualBrief / 内容
1925
+ if (!pages.length) add("P0", "结构", "script.md", "读不到任何正文页", "先补 script 页骨架(P01–P06)");
1926
+ else if (pages.length < 3) add("P0", "结构", "script.md", `正文只有 ${pages.length} 页`, "补齐页数,或明确说明这是只讲一页的短 deck");
1927
+ for (const page of pages) {
1928
+ const display = `P${String(page.page).padStart(2, "0")}`;
1929
+ const key = `p${String(page.page).padStart(2, "0")}`;
1930
+ const brief = String(prompts[key] ?? "").trim();
1931
+ if (isPlaceholder(page.claim)) add("P1", "主张", display, `claim=${JSON.stringify(page.claim ?? "")}`, "给这一页一句可被资料支撑的结论(不是栏目名)");
1932
+ if (!brief) add("P1", "画面要求", display, "visualBrief 为空", "按 design 风格补这一页画面要求,参考已有页的句式");
1933
+ else if (isPlaceholder(brief)) add("P1", "画面要求", display, `visualBrief 仍是占位:${cell(brief)}`, "把占位换成具体画面要求");
1934
+ if (isPlaceholder(page.content ?? "")) add("P2", "内容", display, "页面内容还是占位", "补 2–4 条支撑主张的要点");
1935
+ }
1936
+
1937
+ // 2) 意图 / 设计 / 支撑材料 / 调研 / 修改日志
1938
+ const intent = docOf("intent");
1939
+ if (!intent || intent.status === "missing") add("P0", "意图", "intent.md", "文档缺失", "补 听众 / 主题 / 带走 等字段");
1940
+ else for (const issue of intent.issues ?? []) add(intent.status === "partial" ? "P0" : "P1", "意图", "intent.md", issue, "补齐字段后再交付;没依据的写成待确认问题问用户");
1941
+ const design = docOf("design");
1942
+ if (!design || design.status === "missing") add("P0", "风格", "design.md", "文档缺失", "补画面约束 / 模板 / 出图参数");
1943
+ else for (const issue of design.issues ?? []) add(design.status === "partial" ? "P1" : "P2", "风格", "design.md", issue, "补齐后才能保证各页风格一致");
1944
+ const materials = docOf("materials");
1945
+ if (!materials || materials.status === "missing") add("P1", "支撑材料", "materials.md", "文档缺失", "登记每条资料的 文件名 / 链接 / 总结");
1946
+ else if (/TODO|待补/.test(materials.primary ? String(readFileSync(join(deckDir, "docs", "materials.md"), "utf8")) : "")) add("P2", "支撑材料", "materials.md", "还有占位 / 未登记", "把资料逐条登记成可引用事实");
1947
+ const research = docOf("research");
1948
+ if (!research || research.status === "missing") add("P2", "调研", "research/index.md", "没有调研台账", "登记来源;没找到的要标「需要外部检索」");
1949
+ else {
1950
+ const entries = research.facts?.entries ?? [];
1951
+ const noSource = entries.filter((entry) => !String(entry.source ?? "").trim());
1952
+ if (noSource.length) add("P2", "调研", "research/index.md", `${noSource.length} 条缺出处`, "补出处,或标「未提供」");
1953
+ }
1954
+ const changelog = docOf("changelog");
1955
+ const entries = changelog?.facts?.entries ?? [];
1956
+ if (!entries.length) add("P2", "修改日志", "changelog.md", "还没有改动记录", "至少留一条「新建 / 为什么」记录,方便交接");
1957
+
1958
+ const bySeverity = { P0: 0, P1: 0, P2: 0 };
1959
+ for (const issue of issues) bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
1960
+ const verdict = bySeverity.P0 ? "不可交付:先修 P0" : bySeverity.P1 ? "需修改后才能交付" : bySeverity.P2 ? "可交付(有优化建议)" : "可交付";
1961
+ const scope = `intent / design / script(${pages.length} 页)/ materials / research / changelog`;
1962
+ const now = new Date().toISOString();
1963
+ const table = issues.length
1964
+ ? ["| 严重度 | 类别 | 位置 | 证据 | 修复建议 |", "|---|---|---|---|---|", ...issues.map((issue) => `| ${issue.severity} | ${issue.category} | ${issue.where} | ${cell(issue.evidence)} | ${cell(issue.fix)} |`)].join("\n")
1965
+ : "| 严重度 | 类别 | 位置 | 证据 | 修复建议 |\n|---|---|---|---|---|\n| — | — | — | 未发现问题 | — |";
1966
+ const evidence = `治理评分 ${governance.score}/100 · 文档 ${docs.filter((doc) => doc.status !== "missing").length}/${docs.length} 有内容 · 正文 ${pages.length} 页`;
1967
+ const body = [
1968
+ "---",
1969
+ `title: ${deck.label ?? deck.id} · 审稿意见`,
1970
+ "status: draft",
1971
+ "---",
1972
+ "",
1973
+ "# 审稿意见",
1974
+ "",
1975
+ `> 生成时间:${now} · 审核范围:${scope} · 只审不改、不出图。`,
1976
+ "",
1977
+ "## 结论",
1978
+ "",
1979
+ `**${verdict}**(P0 × ${bySeverity.P0} · P1 × ${bySeverity.P1} · P2 × ${bySeverity.P2})`,
1980
+ "",
1981
+ `依据:${evidence}`,
1982
+ "",
1983
+ "## 问题清单",
1984
+ "",
1985
+ table,
1986
+ "",
1987
+ ];
1988
+ if (!issues.length) body.push("## 无问题时的依据", "", `- 已逐页检查 claim / visualBrief / 内容:${pages.length} 页`, `- 已检查 7 类治理文档与治理评分(${governance.score}/100)`, "- 结论:当前没有发现必修问题;交付前仍建议用户复核客户口径。", "");
1989
+ const content = body.join("\n");
1990
+ const target = join(deckDir, "docs", "review.md");
1991
+ let written = false;
1992
+ if (args.write !== false) {
1993
+ mkdirSync(dirname(target), { recursive: true });
1994
+ writeFileSync(target, content.endsWith("\n") ? content : `${content}\n`, "utf8");
1995
+ written = true;
1996
+ appendChangelogNote({ deckDir, note: `审稿:${verdict}(P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2})`, actor: "agent" });
1997
+ }
1998
+ store.recordEvent({
1999
+ type: "deck.review",
2000
+ deckId: deck.id,
2001
+ sessionId,
2002
+ actor: "agent",
2003
+ status: "succeeded",
2004
+ payload: { verdict, total: issues.length, bySeverity, written, scope },
2005
+ });
2006
+ const lines = issues.length
2007
+ ? issues.map((issue) => `${issue.severity} · ${issue.category} · ${issue.where}:${issue.evidence} → ${issue.fix}`)
2008
+ : ["未发现问题(依据见 review.md / 治理检查)"];
2009
+ return {
2010
+ deckId: deck.id,
2011
+ docId: "review",
2012
+ verdict,
2013
+ issues,
2014
+ bySeverity,
2015
+ written,
2016
+ text: `审稿结论:${verdict}(P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2})\n${lines.join("\n")}${written ? `\n已写入:${target}` : "\n(dry run,未写文件)"}`,
2017
+ };
2018
+ }),
2019
+ });
2020
+
2021
+ // ── 4.5 deck_script_review(F-159)────────────────────────────────────────
2022
+ tools.push({
2023
+ name: "deck_script_review",
2024
+ description:
2025
+ "听众评审(动态 persona):按 intent 的「听众」生成独立审稿人(每个子 agent 只从该听众视角读整份 script)," +
2026
+ "返回 P0/P1/P2 问题 + 原文证据 + 可直接落版的改法,写进 docs/review.experts.md。" +
2027
+ "只审不改:评审结果不改 script,改稿仍走 deck_script_propose_edits + 用户确认。" +
2028
+ "第一次整份 script 写完落版后跑一次;之后用户说「再审一次 / 再评一次」时再跑。",
2029
+ parameters: {
2030
+ type: "object",
2031
+ properties: {
2032
+ deckId: { type: "string", description: "deck id,见 deck_source_overview" },
2033
+ maxReviewers: { type: "number", description: `最多几个审稿人(1–${HARD_MAX_REVIEWERS},默认按 profile 配置,通常 2–3)` },
2034
+ write: { type: "boolean", description: "是否写 docs/review.experts.md(默认 true;dry run 传 false)" },
2035
+ },
2036
+ required: ["deckId"],
2037
+ additionalProperties: false,
2038
+ },
2039
+ output: {
2040
+ schema: {
2041
+ type: "object",
2042
+ properties: { deckId: { type: "string" }, reviewers: { type: "array" }, issues: { type: "array" } },
2043
+ },
2044
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
2045
+ },
2046
+ execute: withToolEvent(store, "deck_script_review", async (args, exec) => {
2047
+ const adapter = adapterFor(args.deckId);
2048
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
2049
+ const deckDir = workspace.deckDir(deck.id);
2050
+ const sessionId = sessionIdOf(exec);
2051
+ const brief = readDeckBrief(adapter.sourceRoot);
2052
+ const script = workspace.scriptCurrent(deck.id) ?? "";
2053
+ const rules = readDeckRules(adapter.sourceRoot);
2054
+ const runtime = resolveSubagents();
2055
+ const maxReviewers = Math.min(
2056
+ HARD_MAX_REVIEWERS,
2057
+ Math.max(1, Number.isFinite(Number(args.maxReviewers)) ? Number(args.maxReviewers) : Number(config.review?.maxReviewers ?? DEFAULT_MAX_REVIEWERS)),
2058
+ );
2059
+ const derived = deriveReviewers(brief, { max: maxReviewers });
2060
+
2061
+ const textOnly = (value) => [
2062
+ `deck ${deck.id}(${deck.title})· 听众评审`,
2063
+ value,
2064
+ ].join("\n");
2065
+
2066
+ if (!String(brief.audience ?? "").trim()) {
2067
+ return {
2068
+ deckId: deck.id,
2069
+ reviewers: [],
2070
+ issues: [],
2071
+ text: textOnly("先补 intent.md 的「听众」再评审:没有听众,审稿人就没有立场,写出来的只会是通用写作建议。"),
2072
+ };
2073
+ }
2074
+ if (!script.trim()) {
2075
+ return {
2076
+ deckId: deck.id,
2077
+ reviewers: derived.reviewers,
2078
+ issues: [],
2079
+ text: textOnly("script 工作副本还是空的:先写初稿(提案 → 确认落版)再来评审。"),
2080
+ };
2081
+ }
2082
+ if (!runtime?.startActivation) {
2083
+ return {
2084
+ deckId: deck.id,
2085
+ reviewers: derived.reviewers,
2086
+ issues: [],
2087
+ text: textOnly(
2088
+ "当前 profile 没有挂 subagent 服务(@deepseek-ai/dsh-subagent + dsh-subagent-spawn-in-process),无法起独立审稿人。\n" +
2089
+ "在 profile 里挂上这三行后重试:dsh-subagent / dsh-subagent-spawn-in-process / dsh-tool-subagent。",
2090
+ ),
2091
+ };
2092
+ }
2093
+ if (!exec?.agent) {
2094
+ return { deckId: deck.id, reviewers: derived.reviewers, issues: [], text: textOnly("评审需要在一个 Agent 会话里调用(找不到调用方 agent)。") };
2095
+ }
2096
+
2097
+ const timeoutMs = 300_000;
2098
+ const activationResultText = (result) => {
2099
+ const blocks = Array.isArray(result?.output) ? result.output : [];
2100
+ return blocks
2101
+ .filter((block) => block?.type === "text" && String(block.text ?? "").trim())
2102
+ .map((block) => block.text)
2103
+ .join("\n")
2104
+ .trim();
2105
+ };
2106
+ const runReviewer = async (reviewer) => {
2107
+ const controller = new AbortController();
2108
+ const onAbort = () => controller.abort();
2109
+ if (exec.signal?.aborted) controller.abort();
2110
+ else exec.signal?.addEventListener?.("abort", onAbort, { once: true });
2111
+ let timer;
2112
+ let activation;
2113
+ const timeout = new Promise((_resolve, reject) => {
2114
+ timer = setTimeout(() => {
2115
+ controller.abort();
2116
+ reject(new Error(`评审超时(${Math.round(timeoutMs / 1000)}s)`));
2117
+ }, timeoutMs);
2118
+ });
2119
+ try {
2120
+ activation = await runtime.startActivation({
2121
+ provider: "spawn",
2122
+ label: `deck-review:${deck.id}:${reviewer.id}`,
2123
+ request: {
2124
+ prompt: [
2125
+ {
2126
+ type: "text",
2127
+ text: reviewerPrompt({
2128
+ reviewer,
2129
+ brief,
2130
+ deckTitle: deck.title,
2131
+ script,
2132
+ rulesText: String(rules.content ?? "").slice(0, 4000),
2133
+ }),
2134
+ },
2135
+ ],
2136
+ parent: exec.agent,
2137
+ ...(reviewPreset || config.review?.presetId ? { agentPreset: reviewPreset || config.review?.presetId } : {}),
2138
+ },
2139
+ signal: controller.signal,
2140
+ delivery: "caller",
2141
+ });
2142
+ const result = await Promise.race([activation.result, timeout]);
2143
+ const text = activationResultText(result);
2144
+ const parsed = parseReviewerOutput(text);
2145
+ return { reviewer, label: reviewer.label, parsed, text, stopReason: result?.stopReason ?? "" };
2146
+ } catch (error) {
2147
+ return { reviewer, label: reviewer.label, error: error instanceof Error ? error.message : String(error) };
2148
+ } finally {
2149
+ clearTimeout(timer);
2150
+ exec.signal?.removeEventListener?.("abort", onAbort);
2151
+ try {
2152
+ await activation?.dispose?.();
2153
+ } catch {
2154
+ /* 清理失败不影响评审结果 */
2155
+ }
2156
+ }
2157
+ };
2158
+
2159
+ // 审稿人并发跑:每个都是一次独立子 agent,互不影响;失败一个不拖垮整份评审。
2160
+ const outcomes = await Promise.all(derived.reviewers.map((reviewer) => runReviewer(reviewer)));
2161
+ const ok = outcomes.filter((outcome) => !outcome.error);
2162
+ const failed = outcomes.filter((outcome) => outcome.error).map((outcome) => ({ label: outcome.label, error: outcome.error }));
2163
+ const results = ok.map((outcome) => ({
2164
+ reviewer: outcome.parsed.reviewer || outcome.reviewer.label,
2165
+ label: outcome.reviewer.label,
2166
+ issues: outcome.parsed.issues,
2167
+ }));
2168
+ const issues = mergeIssues(results);
2169
+ const bySeverity = { P0: 0, P1: 0, P2: 0 };
2170
+ for (const issue of issues) bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
2171
+ const generatedAt = new Date().toISOString();
2172
+ const target = join(deckDir, "docs", "review.experts.md");
2173
+ let written = false;
2174
+ if (args.write !== false) {
2175
+ mkdirSync(dirname(target), { recursive: true });
2176
+ const unparsed = ok
2177
+ .filter((outcome) => outcome.parsed.unparsed)
2178
+ .map((outcome) => `### ${outcome.label}\n\n${outcome.text.slice(0, 4000)}`)
2179
+ .join("\n\n");
2180
+ const body = [
2181
+ renderReviewMarkdown({
2182
+ deckTitle: deck.title,
2183
+ brief,
2184
+ reviewers: derived.reviewers,
2185
+ issues,
2186
+ generatedAt,
2187
+ failed,
2188
+ }),
2189
+ ...(unparsed ? ["", "## 未按 JSON 输出、原文保留", "", unparsed] : []),
2190
+ ].join("\n");
2191
+ writeFileSync(target, body.endsWith("\n") ? body : `${body}\n`, "utf8");
2192
+ written = true;
2193
+ appendChangelogNote({
2194
+ deckDir,
2195
+ note: `听众评审(${derived.reviewers.map((reviewer) => reviewer.label).join(" / ")}):P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2}`,
2196
+ actor: "agent",
2197
+ });
2198
+ }
2199
+ store.recordEvent({
2200
+ type: "deck.review.experts",
2201
+ deckId: deck.id,
2202
+ sessionId,
2203
+ actor: "agent",
2204
+ status: failed.length === derived.reviewers.length ? "failed" : "succeeded",
2205
+ payload: {
2206
+ reviewers: derived.reviewers.map((reviewer) => reviewer.id),
2207
+ audience: brief.audience ?? "",
2208
+ total: issues.length,
2209
+ bySeverity,
2210
+ failed: failed.map((item) => item.label),
2211
+ written,
2212
+ },
2213
+ });
2214
+ const lines = issues.length
2215
+ ? issues.map((issue) => `${issue.severity} · ${issue.page || "—"} · ${issue.problem} → ${issue.suggestion}${issue.reviewer ? `(${issue.reviewer})` : ""}`)
2216
+ : ["没有可操作的问题(可能听众视角下确实没问题,也可能审稿人没读懂——先看 docs/review.experts.md 的原始评审)"];
2217
+ const failureLine = failed.length
2218
+ ? `\n${failed.length} 位审稿人失败:${failed.map((item) => `${item.label}(${item.error})`).join(" / ")}`
2219
+ : "";
2220
+ return {
2221
+ deckId: deck.id,
2222
+ reviewers: derived.reviewers.map((reviewer) => ({ id: reviewer.id, label: reviewer.label, audience: reviewer.audience })),
2223
+ issues,
2224
+ bySeverity,
2225
+ written,
2226
+ text: `听众评审(${derived.reviewers.map((reviewer) => reviewer.label).join(" / ")}):P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2}${failureLine}\n${lines.join("\n")}${written ? `\n已写入:${target}` : "\n(dry run,未写文件)"}\n这些是评审意见,不是已改稿:按 P0/P1 用 deck_script_propose_edits 提案,等用户确认后再落版。`,
2227
+ };
2228
+ }),
2229
+ });
2230
+
2231
+ // F-120:脚本工作副本错位修复。历史上「提案落错页」把 P01–P06 的内容写反(标题行没跟着改),
2232
+ // 之后每次 propose_edit 的 before/after 都对不上、改到的也不是用户看到的页。manifest 是出图/UI
2233
+ // 的事实来源,这里按 manifest 顺序重建工作副本(正文按标题回收,避免丢内容)。
2234
+ tools.push({
2235
+ name: "deck_script_repair",
2236
+ description:
2237
+ "体检并修复脚本工作副本:如果 script/current.md 的页内容与 manifest(工作台/出图看到的标题)错位," +
2238
+ "按 manifest 顺序重建 script/current.md,正文按标题回收不丢内容,保存为新 revision。" +
2239
+ "dryRun=true 时只体检不写。用户遇到「提案落错页 / 镜像」时先用它。",
2240
+ parameters: {
2241
+ type: "object",
2242
+ properties: {
2243
+ deckId: { type: "string" },
2244
+ dryRun: { type: "boolean", description: "只体检,不写文件(默认 false)" },
2245
+ },
2246
+ required: ["deckId"],
2247
+ additionalProperties: false,
2248
+ },
2249
+ output: {
2250
+ schema: {
2251
+ type: "object",
2252
+ properties: {
2253
+ deckId: { type: "string" },
2254
+ mismatched: { type: "number" },
2255
+ repaired: { type: "boolean" },
2256
+ revision: { type: "string" },
2257
+ },
2258
+ },
2259
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
2260
+ },
2261
+ execute: withToolEvent(store, "deck_script_repair", async (args) => {
2262
+ const adapter = adapterFor(args.deckId);
2263
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
2264
+ const current = workspace.scriptCurrent(deck.id);
2265
+ if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
2266
+ const parsed = parseSectionedPages(current);
2267
+ const byTitle = new Map(parsed.map((page) => [String(page.title ?? "").trim(), page]));
2268
+ const problems = [];
2269
+ const blocks = [];
2270
+ deck.slides.forEach((slide, index) => {
2271
+ const pageNo = index + 1;
2272
+ const pageId = `p${String(pageNo).padStart(2, "0")}`;
2273
+ const matched = byTitle.get(String(slide.title ?? "").trim()) || parsed.find((page) => page.page === pageNo) || null;
2274
+ if (!matched || matched.page !== pageNo) {
2275
+ problems.push(`${pageId} 位置错位(manifest「${slide.title}」/ 副本第 ${matched?.page ?? "?"} 页是它)`);
2276
+ }
2277
+ const body = String(matched?.fields?.["正文"] ?? matched?.content ?? "").trim();
2278
+ const lines = [
2279
+ `### P${String(pageNo).padStart(2, "0")} · ${slide.title}`,
2280
+ `**标题**:${slide.title}`,
2281
+ `**副标题**:${slide.question ?? ""}`,
2282
+ `**主张**:${slide.claim ?? ""}`,
2283
+ `**正文**:${body || "(待补:3–5 句正文)"}`,
2284
+ `**提示词**:${slide.visualBrief ?? "(待补:visualBrief,说明画面要看到什么)"}`,
2285
+ ];
2286
+ blocks.push(lines.join("\n"));
2287
+ });
2288
+ const headMatch = /^([\s\S]*?)(?=^### P\d+)/m.exec(current);
2289
+ const preamble = (headMatch ? headMatch[1] : "").replace(/\n+$/, "");
2290
+ const nextText = `${preamble ? `${preamble}\n\n` : ""}${blocks.join("\n\n")}\n`;
2291
+ const mismatched = problems.length;
2292
+ if (!mismatched) {
2293
+ return { deckId: deck.id, mismatched: 0, repaired: false, revision: "", text: `工作副本 ${deck.slides.length} 页与 manifest 一致,不需要修复。` };
2294
+ }
2295
+ if (args?.dryRun) {
2296
+ return { deckId: deck.id, mismatched, repaired: false, revision: "", text: `发现 ${mismatched} 页错位:\n${problems.join("\n")}\n(dryRun:未写文件;去掉 dryRun 即按 manifest 顺序重建)` };
2297
+ }
2298
+ const saved = workspace.saveScriptRevision(deck.id, nextText, `repair: rebuild ${mismatched} page(s) from manifest (F-120)`);
2299
+ store.recordEvent({
2300
+ type: "deck.script.repair",
2301
+ deckId: deck.id,
2302
+ actor: "agent",
2303
+ status: "succeeded",
2304
+ payload: { mismatched, revision: saved.revision, problems },
2305
+ });
2306
+ return {
2307
+ deckId: deck.id,
2308
+ mismatched,
2309
+ repaired: true,
2310
+ revision: saved.revision,
2311
+ text: `已修复 ${mismatched} 页错位,工作副本重建为 ${saved.revision}(按 manifest 顺序;正文按标题回收)。\n${problems.join("\n")}\n现在可以正常逐页提案了。`,
2312
+ };
2313
+ }),
2314
+ });
2315
+
2316
+ // F-119:批量提案——整本 / 多页重写时一次调用提交。没有这个工具时,Agent 会图省事把
2317
+ // 「可直接粘贴的 markdown」甩给用户,让用户自己覆盖 script 文件(工作台看不到、也没法确认)。
2318
+ const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
2319
+ tools.push({
2320
+ name: "deck_script_propose_edits",
2321
+ description:
2322
+ "批量提案:一次提交同一 deck 多条字段修改(例如整本重写 10 页的 title/question/body/visualBrief)。" +
2323
+ "每条 edit 都会生成一条 waiting-user 提案并返回 proposalId;用户仍需确认后才落 revision。" +
2324
+ "多页改动优先用这个工具,不要逐条重复调用 deck_script_propose_edit,更不要把内容输出成" +
2325
+ "「可直接粘贴」的 markdown 让用户手工覆盖文件。",
2326
+ parameters: {
2327
+ type: "object",
2328
+ properties: {
2329
+ deckId: { type: "string" },
2330
+ edits: {
2331
+ type: "array",
2332
+ minItems: 1,
2333
+ maxItems: 60,
2334
+ items: {
2335
+ type: "object",
2336
+ properties: {
2337
+ page: { type: "number", description: "页号(script 里的 N)" },
2338
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
2339
+ newValue: { type: "string", description: "新值全文(不是增量)" },
2340
+ reason: { type: "string" },
2341
+ },
2342
+ required: ["page", "field", "newValue"],
2343
+ additionalProperties: false,
2344
+ },
2345
+ },
2346
+ },
2347
+ required: ["deckId", "edits"],
2348
+ additionalProperties: false,
2349
+ },
2350
+ output: {
2351
+ schema: {
2352
+ type: "object",
2353
+ properties: {
2354
+ deckId: { type: "string" },
2355
+ pendingCount: { type: "number" },
2356
+ proposals: {
2357
+ type: "array",
2358
+ items: {
2359
+ type: "object",
2360
+ properties: { pageId: { type: "string" }, field: { type: "string" }, proposalId: { type: "string" } },
2361
+ },
2362
+ },
2363
+ },
2364
+ },
2365
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
2366
+ },
2367
+ execute: withToolEvent(store, "deck_script_propose_edits", async (args, exec) => {
2368
+ const edits = Array.isArray(args?.edits) ? args.edits : [];
2369
+ if (!edits.length) throw new Error("edits 不能为空");
2370
+ if (edits.length > 60) throw new Error(`一次最多 60 条(收到 ${edits.length})`);
2371
+ const single = toolMap.get("deck_script_propose_edit");
2372
+ if (!single) throw new Error("内部错误:找不到 deck_script_propose_edit");
2373
+ const created = [];
2374
+ const styleHints = [];
2375
+ for (const edit of edits) {
2376
+ const value = await single.execute(
2377
+ {
2378
+ deckId: args.deckId,
2379
+ page: edit?.page,
2380
+ field: edit?.field,
2381
+ newValue: edit?.newValue,
2382
+ reason: edit?.reason ?? "",
2383
+ },
2384
+ exec,
2385
+ );
2386
+ if (Array.isArray(value?.styleHints)) styleHints.push(...value.styleHints);
2387
+ created.push({
2388
+ pageId: value?.slideIds?.[0] ?? `p${String(edit?.page).padStart(2, "0")}`,
2389
+ field: String(edit?.field ?? ""),
2390
+ proposalId: String(value?.proposalId ?? ""),
2391
+ });
2392
+ }
2393
+ const pendingNow = workspace.listProposals(args.deckId, "waiting-user");
2394
+ const lines = created.map((item) => `${item.pageId} · ${item.field} → ${item.proposalId}`);
2395
+ return {
2396
+ deckId: args.deckId,
2397
+ proposals: created,
2398
+ pendingCount: pendingNow.length,
2399
+ styleHints,
2400
+ text: `已提交 ${created.length} 条提案(waiting-user):\n${lines.join("\n")}\n队列:整个 deck ${pendingNow.length} 条待确认。可以逐条确认,或在工作台点「全部确认落版」;确认前不要渲染。${styleHints.length ? `\n文案提醒(不阻断提案,建议先改):\n${styleHints.map((hint) => `- ${hint}`).join("\n")}` : ""}`,
2401
+ };
2402
+ }),
2403
+ });
2404
+
2405
+ // F-71:所有工具执行前先对齐 adapters(新建 / 删除的 deck 立刻可见)。
2406
+ for (const tool of tools) {
2407
+ const execute = tool.execute;
2408
+ tool.execute = async (args, exec) => {
2409
+ refreshAdapters();
2410
+ return execute(args, exec);
2411
+ };
2412
+ }
2413
+
2414
+ // ── F-125:logo 水印 ──────────────────────────────────────────────────────
2415
+ tools.push({
2416
+ name: "deck_watermark_assets",
2417
+ description:
2418
+ "列出 assets/ 里可用的水印素材(按分类,如 logo)。用户说「加水印」「盖个 logo」「用某某水印」时先调它," +
2419
+ "把可选的 id 报给用户确认用哪个(只有一个时可以直接用并说明)。只列 PNG 可水印素材;SVG 会标出来但需要先转 PNG。",
2420
+ parameters: { type: "object", properties: { category: { type: "string", description: "只看某一分类,如 logo(可选)" } }, additionalProperties: false },
2421
+ output: { schema: { type: "object", properties: {} }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
2422
+ execute: withToolEvent(store, "deck_watermark_assets", async (args) => {
2423
+ const { categories, root } = listAssets(REPO_ROOT);
2424
+ const filtered = args?.category ? categories.filter((category) => category.category === String(args.category).toLowerCase()) : categories;
2425
+ const lines = [];
2426
+ for (const category of filtered) {
2427
+ lines.push(`分类「${category.category}」:`);
2428
+ for (const item of category.items) {
2429
+ const size = `${Math.max(1, Math.round(item.bytes / 1024))}KB`;
2430
+ lines.push(item.watermarkable ? ` • ${item.id}(${size},PNG,可直接水印)` : ` • ${item.id}(${size},${item.format.toUpperCase()},需先转 PNG 才能水印)`);
2431
+ }
2432
+ }
2433
+ if (!lines.length) lines.push("assets/ 下还没有可用的水印素材(把 logo PNG 放到 assets/logo/ 即可)。");
2434
+ lines.push(`默认位置:右上角;可改 ${positionLine()}。`);
2435
+ return { root, categories: filtered, deckId: null, text: lines.join("\n") };
2436
+ }),
2437
+ });
2438
+
2439
+ tools.push({
2440
+ name: "deck_watermark_apply",
2441
+ description:
2442
+ "给 deck 的页面盖 logo 水印(每个页面一张,默认右上角)。用户说「加水印 / 每个页面打个 logo」时用;" +
2443
+ "先用 deck_watermark_assets 选素材。原图不覆盖:每次盖出一版新 revision(vNNN),旧图保留、可回看。" +
2444
+ "只处理已出图的 PNG 页面;没出图的页会列在跳过里。",
2445
+ parameters: {
2446
+ type: "object",
2447
+ properties: {
2448
+ deckId: { type: "string", description: "deck id" },
2449
+ asset: { type: "string", description: "水印素材 id / 文件名,如 logo/deckline-logo.png" },
2450
+ position: strEnum(WATERMARK_POSITIONS),
2451
+ opacity: { type: "number", description: "不透明度 0.1–1,默认 0.85" },
2452
+ scale: { type: "number", description: "logo 宽度占页宽比例 0.03–0.5,默认 0.14" },
2453
+ pages: { type: "array", items: { type: "string" }, description: "只盖这些页(如 [\"p01\",\"p02\"]);不传=全部" },
2454
+ dryRun: { type: "boolean", description: "只体检不落盘" },
2455
+ },
2456
+ required: ["deckId", "asset"],
2457
+ additionalProperties: false,
2458
+ },
2459
+ output: { schema: { type: "object", properties: {} }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
2460
+ execute: withToolEvent(store, "deck_watermark_apply", async (args) => {
2461
+ const deckId = String(args?.deckId ?? "").trim();
2462
+ const adapter = adapters.find((item) => item.id === deckId);
2463
+ if (!adapter) throw new Error(`未知 deck:${deckId}(可用:${adapters.map((item) => item.id).join("、") || "无"})`);
2464
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
2465
+ if (!deck) throw new Error(`manifest 缺失:${deckId}`);
2466
+
2467
+ const position = String(args?.position ?? "top-right");
2468
+ if (!WATERMARK_POSITIONS.includes(position)) throw new Error(`position 只能是 ${WATERMARK_POSITIONS.join(" / ")}`);
2469
+ const opacity = Math.min(Math.max(Number(args?.opacity ?? 0.85) || 0.85, 0.1), 1);
2470
+ const scale = Math.min(Math.max(Number(args?.scale ?? 0.14) || 0.14, 0.03), 0.5);
2471
+ const dryRun = args?.dryRun === true;
2472
+
2473
+ const found = findAsset(REPO_ROOT, args?.asset);
2474
+ if (!found.ok) {
2475
+ const candidates = (found.candidates ?? []).map((item) => item.id);
2476
+ const reason = found.reason === "ambiguous" ? "有多个素材匹配" : found.reason === "empty" ? "没给素材名" : "没找到这个素材";
2477
+ throw new Error(`${reason}:「${args?.asset ?? ""}」。可选:${candidates.join("、") || "(assets/logo/ 里没有 PNG)"}。需要先转成 PNG 才能合成水印。`);
2478
+ }
2479
+ const asset = found.asset;
2480
+ if (!asset.watermarkable) throw new Error(`${asset.id} 不是 PNG(${asset.format}),水印合成只支持 PNG;请先导出 PNG 再放到 assets/${asset.category}/。`);
2481
+ const logoBuffer = readPngFile(asset.path);
2482
+
2483
+ const wanted = Array.isArray(args?.pages) ? new Set(args.pages.map((page) => String(page))) : null;
2484
+ const applied = [];
2485
+ const skipped = [];
2486
+ for (const slide of deck.slides ?? []) {
2487
+ const label = slide.id;
2488
+ if (wanted && !wanted.has(slide.id)) continue;
2489
+ const slideDir = workspace.slideDir(deckId, slide.id);
2490
+ const versionsDir = join(slideDir, "versions");
2491
+ let sourceFile = null;
2492
+ let sourceRevision = null;
2493
+ if (existsSync(versionsDir)) {
2494
+ const revisions = readdirSync(versionsDir).filter((name) => /^v\d+$/.test(name)).sort().reverse();
2495
+ for (const revision of revisions) {
2496
+ const candidate = join(versionsDir, revision, "image.png");
2497
+ if (existsSync(candidate)) {
2498
+ sourceFile = candidate;
2499
+ sourceRevision = revision;
2500
+ break;
2501
+ }
2502
+ }
2503
+ }
2504
+ if (!sourceFile) {
2505
+ const baseline = adapter.sourceImages?.()[slide.id];
2506
+ if (baseline?.ext === ".png") {
2507
+ sourceFile = baseline.path;
2508
+ sourceRevision = "source";
2509
+ }
2510
+ }
2511
+ if (!sourceFile) {
2512
+ skipped.push(`${label}:还没有出图(没有 PNG 版本可盖)`);
2513
+ continue;
2514
+ }
2515
+ let pageBuffer;
2516
+ try {
2517
+ pageBuffer = readPngFile(sourceFile);
2518
+ } catch (error) {
2519
+ skipped.push(`${label}:${error.message}`);
2520
+ continue;
2521
+ }
2522
+ let output;
2523
+ try {
2524
+ output = watermarkPng({ pageBuffer, logoBuffer, position, opacity, scale, margin: 0.04 });
2525
+ } catch (error) {
2526
+ skipped.push(`${label}:合成失败(${error.message})`);
2527
+ continue;
2528
+ }
2529
+ if (dryRun) {
2530
+ applied.push({ pageId: slide.id, revision: "(dryRun)", size: output.size, placement: output.placement });
2531
+ continue;
2532
+ }
2533
+ const revision = nextRevision(slideDir);
2534
+ const outDir = join(slideDir, "versions", revision);
2535
+ mkdirSync(outDir, { recursive: true });
2536
+ writeFileSync(join(outDir, "image.png"), output.buffer);
2537
+ // 同一页的 prompt / content / QA 跟着复制:水印只是新一版,不是另一页
2538
+ if (sourceRevision && sourceRevision !== "source") {
2539
+ const sourceDir = join(versionsDir, sourceRevision);
2540
+ for (const name of ["prompt.txt", "content.json"]) {
2541
+ const from = join(sourceDir, name);
2542
+ if (existsSync(from)) copyFileSync(from, join(outDir, name));
2543
+ }
2544
+ const qaFile = join(sourceDir, "qa.json");
2545
+ if (existsSync(qaFile)) {
2546
+ try {
2547
+ const qa = JSON.parse(readFileSync(qaFile, "utf8"));
2548
+ qa.checks = [...(qa.checks ?? []), { name: "watermark", ok: true, detail: `${asset.id} · ${WATERMARK_POSITION_LABELS[position]}` }];
2549
+ qa.watermark = { asset: asset.id, position, opacity, scale, appliedAt: new Date().toISOString(), sourceRevision };
2550
+ writeFileSync(join(outDir, "qa.json"), `${JSON.stringify(qa, null, 2)}\n`, "utf8");
2551
+ } catch {
2552
+ writeFileSync(join(outDir, "watermark.json"), `${JSON.stringify({ asset: asset.id, position, opacity, scale, sourceRevision, appliedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
2553
+ }
2554
+ } else {
2555
+ writeFileSync(join(outDir, "watermark.json"), `${JSON.stringify({ asset: asset.id, position, opacity, scale, sourceRevision, appliedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
2556
+ }
2557
+ } else {
2558
+ writeFileSync(join(outDir, "watermark.json"), `${JSON.stringify({ asset: asset.id, position, opacity, scale, sourceRevision: "source", appliedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
2559
+ }
2560
+ store.recordEvent({
2561
+ type: "deck.watermark.applied",
2562
+ deckId,
2563
+ slideIds: [slide.id],
2564
+ actor: "agent",
2565
+ status: "succeeded",
2566
+ payload: { asset: asset.id, position, opacity, scale, revision, sourceRevision, size: output.size },
2567
+ });
2568
+ applied.push({ pageId: slide.id, revision, size: output.size, placement: output.placement });
2569
+ }
2570
+
2571
+ const lines = [
2572
+ dryRun
2573
+ ? `体检:${applied.length} 页可以盖「${asset.id}」(${WATERMARK_POSITION_LABELS[position]} · 透明度 ${opacity} · 宽 ${Math.round(scale * 100)}%)`
2574
+ : `已给 ${applied.length} 页盖水印:${asset.id}(${WATERMARK_POSITION_LABELS[position]} · 透明度 ${opacity} · 宽 ${Math.round(scale * 100)}%)`,
2575
+ ...applied.map((item) => ` • ${item.pageId} → ${item.revision}(${item.size})`),
2576
+ ...(skipped.length ? [`跳过 ${skipped.length} 页:`, ...skipped.map((line) => ` • ${line}`)] : []),
2577
+ dryRun ? "确认后去掉 dryRun 再调一次就会落盘。" : "原图保留:每页多出一版 revision,可在工作台翻看;要换位置 / 换 logo 再调一次即可(会再生成一版,不覆盖)。",
2578
+ ];
2579
+ return {
2580
+ deckId,
2581
+ asset: { id: asset.id, path: asset.path, bytes: asset.bytes },
2582
+ position,
2583
+ opacity,
2584
+ scale,
2585
+ applied,
2586
+ skipped,
2587
+ slideIds: applied.map((item) => item.pageId),
2588
+ text: lines.join("\n"),
2589
+ };
2590
+ }),
2591
+ });
2592
+
2593
+ return tools;
2594
+ }