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
@@ -0,0 +1,258 @@
1
+ /**
2
+ * F-159:按 intent 的「听众」动态生成审稿人(不是写死几个固定 role)。
3
+ *
4
+ * 评审的立场来自用户填的听众,而不是产品预先规定的「技术 / 业务 / 编辑」三件套:
5
+ * - 听众是一个群体(`技术负责人、后端工程师`)→ 每个群体一个「本人」评审;
6
+ * - 只有一个听众 → 加一个「这堆人里最挑剔的那位」;
7
+ * - 听众没填 → 不猜,直接让主笔先补 intent。
8
+ *
9
+ * 审稿人输出严格 JSON(见 `REVIEW_OUTPUT_CONTRACT`),由工具聚合;本模块无 IO,smoke 直接测。
10
+ * @module dsh-plugin-deck-studio/review-personas
11
+ */
12
+
13
+ /** 审稿人最多几个(每个 = 一次子 agent 调用;default 3,配置可调)。 */
14
+ export const DEFAULT_MAX_REVIEWERS = 3;
15
+ export const HARD_MAX_REVIEWERS = 5;
16
+
17
+ /** 给子 agent 的输出契约;解析器与 persona 都引用同一份措辞。 */
18
+ export const REVIEW_OUTPUT_CONTRACT = `只输出一个 JSON 对象,不要 markdown 代码块之外的解释:
19
+ {
20
+ "reviewer": "你的身份(一句话)",
21
+ "verdict": "看完这份 script 的一句话总评",
22
+ "issues": [
23
+ {
24
+ "page": "P01",
25
+ "severity": "P0 | P1 | P2",
26
+ "problem": "一句话问题(站在你的身份上)",
27
+ "evidence": "script 原文片段(必须逐字引用)",
28
+ "suggestion": "具体怎么改(给可直接落版的句子,不是「优化一下」)"
29
+ }
30
+ ]
31
+ }
32
+ 规则:
33
+ - 只从「${"${audience}"}」这个身份判断,不写通用写作建议;
34
+ - 最多 8 条,按严重度排序;没有真问题就输出空数组,不要凑数;
35
+ - 每条必须有 script 原文证据;没有证据的意见不要写;
36
+ - 不改稿、不调用任何工具、不提提案;你只输出这份 JSON。`;
37
+
38
+ /**
39
+ * 逗号 / 顿号 / 分号 / 斜杠 / 换行 分隔的听众列表。
40
+ *
41
+ * 真实 intent 里「听众」常带解释:`第一次接触 Deckline 的人:会用 DSH、被改稿折磨过…`。
42
+ * 冒号后是补充说明,不是第二个听众——先按第一个冒号切开,只用前半段做身份,
43
+ * 后半段留给 prompt 里的完整 audience 文本(reviewerPrompt 直接用 brief.audience)。
44
+ */
45
+ export function splitAudience(audience) {
46
+ const raw = String(audience ?? "").trim();
47
+ if (!raw) return [];
48
+ const colon = raw.search(/[::]/);
49
+ const head = colon >= 0 ? raw.slice(0, colon).trim() : raw;
50
+ return head
51
+ .split(/[、,,;;\/|\n]+/)
52
+ .map((part) => part.trim())
53
+ .filter((part) => part.length > 0)
54
+ // 「技术负责人和技术团队」这种句子里再拆一层
55
+ .flatMap((part) => part.split(/\s*(?:以及|还有|和|与|及)\s*/))
56
+ .map((part) => part.replace(/^[·\-*\s]+|[。;;,,.\s]+$/g, "").trim())
57
+ .filter((part) => part.length > 0 && part.length <= 40);
58
+ }
59
+
60
+ /** 同一个听众去重(大小写 / 空格不敏感)。 */
61
+ function uniqueBy(list, keyOf) {
62
+ const seen = new Set();
63
+ const out = [];
64
+ for (const item of list) {
65
+ const key = keyOf(item);
66
+ if (seen.has(key)) continue;
67
+ seen.add(key);
68
+ out.push(item);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * 按 intent 的听众生成审稿人。
75
+ * @param {{audience?:string, takeaway?:string, success?:string, topic?:string}} brief
76
+ * @param {{max?:number}} [options]
77
+ * @returns {{reviewers: Array<{id:string,label:string,audience:string,stance:string,focus:string}>, reason:string}}
78
+ */
79
+ export function deriveReviewers(brief, options = {}) {
80
+ const max = Math.min(
81
+ HARD_MAX_REVIEWERS,
82
+ Math.max(1, Number.isFinite(Number(options.max)) ? Number(options.max) : DEFAULT_MAX_REVIEWERS),
83
+ );
84
+ const audience = String(brief?.audience ?? "").trim();
85
+ const segments = uniqueBy(splitAudience(audience), (segment) => segment.toLowerCase());
86
+ if (!segments.length) {
87
+ return { reviewers: [], reason: "intent.md 没写「听众」,评审没有立场可站;先补听众再跑评审。" };
88
+ }
89
+ const reviewers = [];
90
+ for (const [index, segment] of segments.entries()) {
91
+ reviewers.push({
92
+ id: `audience-${index + 1}`,
93
+ label: `${segment}(本人)`,
94
+ audience: segment,
95
+ stance: "本人",
96
+ focus: `你就是「${segment}」,坐在这场 deck 的听众席里。只从你的处境和关注点判断:哪里听不懂、哪里不信、哪里觉得和我无关、听完哪页还是不知道要我做什么。`,
97
+ });
98
+ if (reviewers.length >= max) break;
99
+ }
100
+ // 听众只有一个 / 还留有名额时,补一个「同一群听众里最挑剔的那位」:不引入外部角色,仍是听众视角。
101
+ if (reviewers.length < max && segments.length === 1) {
102
+ reviewers.push({
103
+ id: "audience-skeptic",
104
+ label: `${segments[0]}(挑刺的那位)`,
105
+ audience: segments[0],
106
+ stance: "挑剔者",
107
+ focus: `你也是「${segments[0]}」,但是现场最不客气的那位:专找站不住的数据、被跳过的前提、过度承诺、经不起追问的结论;你会当场举手问「凭什么」「所以呢」「这跟我有什么关系」。`,
108
+ });
109
+ }
110
+ return {
111
+ reviewers,
112
+ reason: reviewers.length
113
+ ? `按 intent 听众生成 ${reviewers.length} 位审稿人:${reviewers.map((reviewer) => reviewer.label).join(" / ")}`
114
+ : "没有可用听众",
115
+ };
116
+ }
117
+
118
+ /** 审稿 prompt:身份 + 这份 deck 的意图 + script + 输出契约。 */
119
+ export function reviewerPrompt({ reviewer, brief, deckTitle, script, rulesText = "", maxScriptChars = 60_000 }) {
120
+ const scriptText = String(script ?? "");
121
+ const clipped = scriptText.length > maxScriptChars
122
+ ? `${scriptText.slice(0, maxScriptChars)}\n\n(script 过长,以上为前 ${maxScriptChars} 字符;按已给部分评审,超出部分在 verdict 里注明未覆盖)`
123
+ : scriptText;
124
+ const intentLines = [
125
+ brief?.topic ? `主题:${brief.topic}` : "",
126
+ brief?.audience ? `听众:${brief.audience}` : "",
127
+ brief?.takeaway ? `听完要带走:${brief.takeaway}` : "",
128
+ brief?.success ? `怎么算讲成功:${brief.success}` : "",
129
+ brief?.scopeOut ? `不讲的:${brief.scopeOut}` : "",
130
+ ].filter(Boolean);
131
+ return [
132
+ `你是这份 deck 的独立审稿人:${reviewer.label}`,
133
+ reviewer.focus,
134
+ "",
135
+ "## 这份 deck 的意图(用户写的)",
136
+ intentLines.join("\n") || "(intent 未提供)",
137
+ "",
138
+ deckTitle ? `## deck 标题\n${deckTitle}` : "",
139
+ rulesText ? `## 这份 deck 的创作规则\n${rulesText}` : "",
140
+ "## 待审 script(原文,含每页标题 / 这页回答 / 主张 / 正文 / 提示词)",
141
+ clipped,
142
+ "",
143
+ "## 你的任务",
144
+ "以你的身份从头读一遍:哪些页让你信、哪些页让你走神、哪些页听完仍然不知道要做什么。",
145
+ "只提你能用原文证据支撑的问题;每条给出可以直接落版的改法(具体到句子 / 数字 / 结构)。",
146
+ "",
147
+ "## 输出契约",
148
+ REVIEW_OUTPUT_CONTRACT.replace("${audience}", reviewer.audience),
149
+ ]
150
+ .filter((line) => line !== "")
151
+ .join("\n");
152
+ }
153
+
154
+ /** 从子 agent 的最终文本里取 JSON(容忍 ```json 围栏 / 前后废话)。 */
155
+ export function parseReviewerOutput(text) {
156
+ const raw = String(text ?? "").trim();
157
+ if (!raw) return { reviewer: "", verdict: "", issues: [], raw: "" };
158
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
159
+ const candidates = [fenced?.[1], raw].filter(Boolean);
160
+ for (const candidate of candidates) {
161
+ const start = candidate.indexOf("{");
162
+ const end = candidate.lastIndexOf("}");
163
+ if (start < 0 || end <= start) continue;
164
+ try {
165
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
166
+ const issues = Array.isArray(parsed.issues)
167
+ ? parsed.issues
168
+ .map((issue) => ({
169
+ page: String(issue?.page ?? "").trim(),
170
+ severity: ["P0", "P1", "P2"].includes(String(issue?.severity ?? "").toUpperCase())
171
+ ? String(issue.severity).toUpperCase()
172
+ : "P2",
173
+ problem: String(issue?.problem ?? "").trim(),
174
+ evidence: String(issue?.evidence ?? "").trim(),
175
+ suggestion: String(issue?.suggestion ?? "").trim(),
176
+ }))
177
+ .filter((issue) => issue.problem || issue.suggestion)
178
+ : [];
179
+ return {
180
+ reviewer: String(parsed.reviewer ?? "").trim(),
181
+ verdict: String(parsed.verdict ?? "").trim(),
182
+ issues,
183
+ raw,
184
+ };
185
+ } catch {
186
+ /* 试下一个候选 */
187
+ }
188
+ }
189
+ // 模型没按 JSON 输出:保留原文,至少让主笔能看到意见,不要静默丢。
190
+ return { reviewer: "", verdict: "", issues: [], raw, unparsed: true };
191
+ }
192
+
193
+ /** 多审稿人结果合并:带来源、去重、按严重度排序。 */
194
+ export function mergeIssues(results = []) {
195
+ const issues = [];
196
+ for (const result of results) {
197
+ for (const issue of result?.issues ?? []) {
198
+ issues.push({
199
+ ...issue,
200
+ reviewer: result.reviewer || result.label || "",
201
+ from: result.label || result.reviewer || "",
202
+ });
203
+ }
204
+ }
205
+ const seen = new Set();
206
+ const merged = [];
207
+ for (const issue of issues) {
208
+ const key = `${issue.page}|${issue.severity}|${issue.problem.replace(/\s+/g, "").slice(0, 40)}`;
209
+ if (seen.has(key)) continue;
210
+ seen.add(key);
211
+ merged.push(issue);
212
+ }
213
+ const rank = { P0: 0, P1: 1, P2: 2 };
214
+ return merged.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9));
215
+ }
216
+
217
+ /** 写进 docs/review.experts.md 的 markdown。 */
218
+ export function renderReviewMarkdown({ deckTitle, brief, reviewers, issues, generatedAt, failed = [] }) {
219
+ const bySeverity = { P0: 0, P1: 0, P2: 0 };
220
+ for (const issue of issues) bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
221
+ const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n+/g, " ").slice(0, 300);
222
+ const table = issues.length
223
+ ? [
224
+ "| 严重度 | 页 | 问题 | 证据 | 改法 | 来自 |",
225
+ "|---|---|---|---|---|---|",
226
+ ...issues.map(
227
+ (issue) =>
228
+ `| ${issue.severity} | ${cell(issue.page || "—")} | ${cell(issue.problem)} | ${cell(issue.evidence)} | ${cell(issue.suggestion)} | ${cell(issue.reviewer)} |`,
229
+ ),
230
+ ].join("\n")
231
+ : "| — | — | 未发现问题 | — | — | — |";
232
+ return [
233
+ "---",
234
+ `title: ${deckTitle ?? ""} · 听众评审`,
235
+ "status: draft",
236
+ "---",
237
+ "",
238
+ "# 听众评审(动态 persona)",
239
+ "",
240
+ `> 生成时间:${generatedAt} · 只审不改:评审结果不直接改 script,改稿仍走提案 + 用户确认。`,
241
+ "",
242
+ "## 评审人(按 intent 听众生成)",
243
+ reviewers.length
244
+ ? reviewers.map((reviewer) => `- ${reviewer.label}:${reviewer.focus}`).join("\n")
245
+ : "- (无)",
246
+ ...(failed.length ? ["", `失效评审:${failed.map((item) => item.label).join(" / ")}`] : []),
247
+ "",
248
+ "## 结论",
249
+ "",
250
+ `P0 × ${bySeverity.P0} · P1 × ${bySeverity.P1} · P2 × ${bySeverity.P2}(共 ${issues.length} 条)`,
251
+ "",
252
+ "## 问题清单",
253
+ "",
254
+ table,
255
+ "",
256
+ ...(issues.length ? [] : ["(没有可操作的问题;主笔可以按原稿交付,仍建议用户复核事实与口径。)", ""]),
257
+ ].join("\n");
258
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 文案风格体检(F-158):把「AI 腔 / 散文腔」变成可执行的提醒,而不是只靠 persona 自觉。
3
+ *
4
+ * 只返回提示,不阻断提案——文案是创作判断,最终由用户确认;但提案结果里必须出现具体哪一条、
5
+ * 改成什么方向,Agent 才能在下一轮改掉。
6
+ * @module dsh-plugin-deck-studio/script-style
7
+ */
8
+
9
+ /** 空泛 / AI 黑话(命中就建议换成具体名词或数字)。 */
10
+ const BUZZWORDS = ["赋能", "闭环", "抓手", "生态位", "底层逻辑", "范式", "全链路", "组合拳", "护城河", "飞轮", "降维打击"];
11
+
12
+ /** 散文 / 修辞腔(标题和副标题里出现就提醒)。 */
13
+ const PROSE_PATTERNS = [
14
+ { pattern: /不是[^。!?\n]{1,24}[,,]\s*(?:而是|是)/, hint: "「不是…而是…」是对仗句式" },
15
+ { pattern: /(?:真正|所谓)的/, hint: "「真正的 / 所谓的」是空泛强调" },
16
+ { pattern: /^(?:一场|一段|一次)[^。]{2,}/, hint: "「一场 / 一段 / 一次…」像散文开头" },
17
+ { pattern: /[,,][^。!?\n]{2,6}[,,][^。!?\n]{2,6}[,,][^。!?\n]{2,6}/, hint: "连续短句排比,像文案而不像结论" },
18
+ { pattern: /[!!]/, hint: "标题里的感叹号" },
19
+ { pattern: /[??]$/, hint: "标题以问句结尾(问句放副标题,标题给判断)" },
20
+ { pattern: /(?:答案|密码|魔法|旅程|心跳|温度|边界|底色)$/, hint: "抽象名词收尾,读者得不到信息" },
21
+ ];
22
+
23
+ const charCount = (value) => [...String(value ?? "").trim()].length;
24
+
25
+ /**
26
+ * @param {{field?:string, value?:string, title?:string, question?:string, body?:string}} input
27
+ * @returns {string[]} 人话提醒;空数组 = 没看出明显问题
28
+ */
29
+ export function scriptStyleHints({ field = "", value = "", title = "", question = "", body = "" } = {}) {
30
+ const hints = [];
31
+ const targetTitle = field === "title" ? value : title;
32
+ const targetQuestion = field === "question" ? value : question;
33
+ const targetBody = field === "body" ? value : body;
34
+
35
+ if (targetTitle) {
36
+ const n = charCount(targetTitle);
37
+ if (n > 20) hints.push(`标题 ${n} 字偏长:标题给判断(≤ 16 字),解释放副标题 / 正文`);
38
+ for (const { pattern, hint } of PROSE_PATTERNS) {
39
+ if (pattern.test(targetTitle)) {
40
+ hints.push(`标题有「${hint}」:改成「名词 + 判断 / 动作」,例如「三个页面状态都要能回退」`);
41
+ break;
42
+ }
43
+ }
44
+ const hit = BUZZWORDS.find((word) => targetTitle.includes(word));
45
+ if (hit) hints.push(`标题里有「${hit}」:换成具体名词或数字,读者才知道你说什么`);
46
+ }
47
+ if (targetQuestion && charCount(targetQuestion) > 40) hints.push("副标题超过 40 字:副标题只说「这页回答什么」,一句话");
48
+ if (targetBody) {
49
+ const n = charCount(targetBody);
50
+ if (n > 0 && n < 20) hints.push(`正文只有 ${n} 字:正文要么删掉让标题承担,要么补事实/例子`);
51
+ const hit = BUZZWORDS.find((word) => targetBody.includes(word));
52
+ if (hit) hints.push(`正文里有「${hit}」:换成机制、数字或具体做法`);
53
+ }
54
+ return hints;
55
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 逐页 script 微调写回(页面内编辑 → 源 script.md + prompts/Pxx.txt)。
3
+ *
4
+ * 只允许两件事:
5
+ * 1. 改某一页的 **标题** / **副标题(或页面标签)** 字段值;
6
+ * 2. 改这一页标题行以下的**正文**(其它字段、bullet 原样交回);
7
+ * 3. 改这一页的 image prompt(prompts/Pnn.txt)。
8
+ *
9
+ * 不新增页、不删页、不动其它页。写回后由调用方 git 提交。
10
+ * 表格惯例(`| 页 | 标题 | … |`)的 deck 不支持正文级编辑(源头是表格,逐页正文没有单独段落)。
11
+ * @module dsh-plugin-deck-studio/script-writer
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ import { parseSectionedPages } from "./source-adapter.js";
18
+
19
+ const readText = (file) => (existsSync(file) ? readFileSync(file, "utf8") : null);
20
+
21
+ /** `### P04 · 案例闪卡 01:商业运营 / 数据分析` */
22
+ const pageHeadRe = (page) => new RegExp(`^#{2,3}\\s*P0*${page}\\s*[·.\\-–—:]?`);
23
+ const anyHeading = /^#{1,4}\s/;
24
+
25
+ /** 页号 → 该页 heading 在行数组里的范围。 */
26
+ function sectionRange(lines, page) {
27
+ const headRe = pageHeadRe(page);
28
+ const start = lines.findIndex((line) => headRe.test(line.trim()));
29
+ if (start < 0) return null;
30
+ let end = lines.length;
31
+ for (let i = start + 1; i < lines.length; i += 1) {
32
+ if (anyHeading.test(lines[i])) {
33
+ end = i;
34
+ break;
35
+ }
36
+ }
37
+ return { start, end };
38
+ }
39
+
40
+ /** 读取一页的可编辑视图(编辑器预填)。 */
41
+ export function pageEditView(adapter, pageId) {
42
+ const page = Number(String(pageId).replace(/^p/i, ""));
43
+ if (!Number.isFinite(page) || page <= 0) return null;
44
+ const script = adapter?.readScriptRaw?.() ?? readText(join(adapter?.sourceRoot ?? "", "script.md"));
45
+ if (!script) return null;
46
+ const pages = parseSectionedPages(script);
47
+ const hit = pages.find((item) => item.page === page);
48
+ if (!hit) {
49
+ return { page, unsupported: true, reason: "该 deck 的 script 不是「### Pxx + **字段**:值」分节惯例,暂不支持逐页正文编辑" };
50
+ }
51
+ const lines = script.split("\n");
52
+ const range = sectionRange(lines, page);
53
+ if (!range) return null;
54
+ const heading = lines[range.start];
55
+ // 标题 / 副标题 / 页面标签 三个字段单独编辑,其余原文进 body。
56
+ const titleKey = "标题" in hit.fields ? "标题" : null;
57
+ const subtitleKey = "副标题" in hit.fields ? "副标题" : "页面标签" in hit.fields ? "页面标签" : null;
58
+ const bodyLines = lines
59
+ .slice(range.start + 1, range.end)
60
+ .filter((line) => {
61
+ if (titleKey && new RegExp(`^\\s*\\*\\*${titleKey}\\*\\*\\s*[::]`).test(line)) return false;
62
+ if (subtitleKey && new RegExp(`^\\s*\\*\\*${subtitleKey}\\*\\*\\s*[::]`).test(line)) return false;
63
+ return true;
64
+ });
65
+ const pageIdNormalized = `p${String(page).padStart(2, "0")}`;
66
+ // F-133:分节里显式写了「正文」字段时,Inspector 的正文框只显示正文本身;
67
+ // 以前把整节(主张 / 正文 / 提示词 / 画面说明)都塞进来,用户看到的其实是意图和提示词。
68
+ const hasBodyField = "正文" in hit.fields;
69
+ return {
70
+ page,
71
+ pageId: pageIdNormalized,
72
+ heading,
73
+ titleKey: titleKey ?? "标题",
74
+ subtitleKey: subtitleKey ?? "副标题",
75
+ bodyKey: hasBodyField ? "正文" : null,
76
+ title: titleKey ? hit.fields[titleKey] ?? "" : hit.section ?? "",
77
+ subtitle: subtitleKey ? hit.fields[subtitleKey] ?? "" : "",
78
+ body: hasBodyField
79
+ ? hit.fields["正文"] ?? ""
80
+ : bodyLines.join("\n").replace(/^\n+/, "").replace(/\n+$/, ""),
81
+ prompt: adapter?.prompts?.()[pageIdNormalized] ?? "",
82
+ };
83
+ }
84
+
85
+ /**
86
+ * 写回一页。
87
+ * @param {object} input
88
+ * @param {string} input.sourceRoot
89
+ * @param {number} input.page
90
+ * @param {string} input.title
91
+ * @param {string} input.subtitle
92
+ * @param {string} input.body
93
+ * @param {string|null} [input.prompt] null / undefined = 不改 prompt
94
+ * @returns {{files: string[], changed: string[]}}
95
+ */
96
+ export function savePageEdit({ sourceRoot, page, title, subtitle, body, prompt }) {
97
+ const scriptPath = join(sourceRoot, "script.md");
98
+ const script = readText(scriptPath);
99
+ if (!script) throw new Error("源 deck 没有 script.md");
100
+ const lines = script.split("\n");
101
+ const range = sectionRange(lines, page);
102
+ if (!range) throw new Error(`script.md 里找不到 P${page} 分节(当前 deck 可能不是分节惯例)`);
103
+
104
+ const current = parseSectionedPages(script).find((item) => item.page === page);
105
+ const titleKey = current && "标题" in current.fields ? "标题" : "标题";
106
+ const subtitleKey = current && "副标题" in current.fields ? "副标题" : current && "页面标签" in current.fields ? "页面标签" : null;
107
+
108
+ const bodyKey = current && "正文" in current.fields ? "正文" : null;
109
+ const bodyText = String(body ?? "").replace(/^\n+/, "").replace(/\n+$/, "");
110
+ const escapeRe = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
111
+ // 字段级替换:`**字段**:值` 这一行换成新值;字段不存在就插到这一节开头。
112
+ const setFieldLine = (sectionLines, key, value) => {
113
+ const pattern = new RegExp(`^\\s*\\*\\*${escapeRe(key)}\\*\\*\\s*[::]`);
114
+ const index = sectionLines.findIndex((line) => pattern.test(line));
115
+ const next = String(value ?? "").trim();
116
+ if (index >= 0) {
117
+ if (next) sectionLines[index] = `**${key}**:${next}`;
118
+ else sectionLines.splice(index, 1);
119
+ return;
120
+ }
121
+ if (next) sectionLines.unshift(`**${key}**:${next}`);
122
+ };
123
+
124
+ let replacement;
125
+ if (bodyKey) {
126
+ // F-133:只改 标题 / 副标题 / 正文 三个字段,主张、画面说明、提示词等原样保留。
127
+ const sectionLines = lines.slice(range.start + 1, range.end);
128
+ while (sectionLines.length && !sectionLines[0].trim()) sectionLines.shift();
129
+ while (sectionLines.length && !sectionLines[sectionLines.length - 1].trim()) sectionLines.pop();
130
+ setFieldLine(sectionLines, titleKey, title);
131
+ if (subtitleKey) setFieldLine(sectionLines, subtitleKey, subtitle);
132
+ // 正文按单行字段写:换行折叠成空格,避免续行滑出字段、落回整节正文。
133
+ setFieldLine(sectionLines, bodyKey, bodyText.replace(/\s*\n+\s*/g, " "));
134
+ replacement = ["", ...sectionLines, ""];
135
+ } else {
136
+ const paragraphs = [];
137
+ if (String(title ?? "").trim()) paragraphs.push(`**${titleKey}**:${String(title).trim()}`);
138
+ if (subtitleKey && String(subtitle ?? "").trim()) paragraphs.push(`**${subtitleKey}**:${String(subtitle).trim()}`);
139
+ if (bodyText) paragraphs.push(bodyText);
140
+ replacement = ["", ...paragraphs.join("\n\n").split("\n"), ""];
141
+ }
142
+ const nextLines = [...lines.slice(0, range.start + 1), ...replacement, ...lines.slice(range.end)];
143
+ let nextScript = nextLines.join("\n");
144
+ nextScript = nextScript.replace(/\n{3,}/g, "\n\n");
145
+ if (!nextScript.endsWith("\n")) nextScript += "\n";
146
+
147
+ const changed = [];
148
+ if (nextScript !== script) {
149
+ writeFileSync(scriptPath, nextScript, "utf8");
150
+ changed.push(scriptPath);
151
+ }
152
+
153
+ const pageId = `p${String(page).padStart(2, "0")}`;
154
+ if (typeof prompt === "string") {
155
+ const dir = join(sourceRoot, "prompts");
156
+ mkdirSync(dir, { recursive: true });
157
+ const promptPath = join(dir, `P${String(page).padStart(2, "0")}.txt`);
158
+ const existing = readText(promptPath);
159
+ if (existing !== prompt) {
160
+ writeFileSync(promptPath, prompt.endsWith("\n") ? prompt : `${prompt}\n`, "utf8");
161
+ changed.push(promptPath);
162
+ }
163
+ }
164
+ return { files: changed, changed, page, pageId };
165
+ }