dsh-plugin-deck-studio 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/client.js +500 -0
- package/cordis.example.yml +163 -0
- package/cordis.patch.yml +147 -0
- package/index.js +2340 -0
- package/lib/config.js +216 -0
- package/lib/content-density.js +98 -0
- package/lib/deck-brief.js +87 -0
- package/lib/deck-docs.js +1095 -0
- package/lib/deck-rules.js +124 -0
- package/lib/deck-templates.js +149 -0
- package/lib/deck-workspace.js +480 -0
- package/lib/design-style.js +157 -0
- package/lib/designs.js +175 -0
- package/lib/diff.js +88 -0
- package/lib/exporter.js +336 -0
- package/lib/faq.js +300 -0
- package/lib/git-commit.js +87 -0
- package/lib/home-catalog.js +249 -0
- package/lib/png.js +275 -0
- package/lib/proposal-decision.js +204 -0
- package/lib/render-nodes.js +171 -0
- package/lib/render-settings.js +213 -0
- package/lib/render.js +287 -0
- package/lib/review-personas.js +258 -0
- package/lib/script-style.js +55 -0
- package/lib/script-writer.js +165 -0
- package/lib/source-adapter.js +327 -0
- package/lib/store.js +212 -0
- package/lib/tools.js +2594 -0
- package/lib/watermark.js +137 -0
- package/package.json +67 -4
- package/seeds/docs/assets/screenshots/00-hero.png +0 -0
- package/seeds/docs/assets/screenshots/01-home.png +0 -0
- package/seeds/docs/assets/screenshots/02-deck-pages.png +0 -0
- package/seeds/docs/assets/screenshots/03-script.png +0 -0
- package/seeds/docs/assets/screenshots/04-design.png +0 -0
- package/seeds/docs/assets/screenshots/05-governance.png +0 -0
- package/seeds/docs/assets/screenshots/06-settings.png +0 -0
- package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
- package/seeds/docs/assets/screenshots/manual/01-home.png +0 -0
- package/seeds/docs/assets/screenshots/manual/02-deck-pages.png +0 -0
- package/seeds/docs/assets/screenshots/manual/03-script.png +0 -0
- package/seeds/docs/assets/screenshots/manual/04-design.png +0 -0
- package/seeds/docs/assets/screenshots/manual/05-governance.png +0 -0
- package/seeds/docs/assets/screenshots/manual/06-settings.png +0 -0
- package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
- package/seeds/docs/assets/screenshots/manual/legend.json +12 -0
- package/seeds/docs/compat.md +134 -0
- package/seeds/docs/install.en.md +252 -0
- package/seeds/docs/install.md +304 -0
- package/seeds/docs/manual.en.md +294 -0
- package/seeds/docs/manual.md +258 -0
- package/seeds/docs/productization.en.md +212 -0
- package/seeds/docs/productization.md +193 -0
- package/seeds/docs/sample-deck.en.md +114 -0
- package/seeds/docs/sample-deck.md +104 -0
- package/seeds/docs/styles.en.md +112 -0
- package/seeds/docs/styles.md +100 -0
- package/seeds/resources/deckline-logo.png +0 -0
- package/seeds/resources/deckline-logo.svg +12 -0
- package/seeds/resources/deckline-mark.png +0 -0
- package/seeds/resources/index.json +19 -0
- package/seeds/samples/BIZ-01.jpg +0 -0
- package/seeds/samples/BIZ-02.jpg +0 -0
- package/seeds/samples/BIZ-03.jpg +0 -0
- package/seeds/samples/EDT-01.jpg +0 -0
- package/seeds/samples/EDT-02.jpg +0 -0
- package/seeds/samples/EDT-03.jpg +0 -0
- package/seeds/samples/FYI-01.jpg +0 -0
- package/seeds/samples/FYI-02.jpg +0 -0
- package/seeds/samples/FYI-03.jpg +0 -0
- package/seeds/samples/GRD-01.jpg +0 -0
- package/seeds/samples/GRD-02.jpg +0 -0
- package/seeds/samples/GRD-03.jpg +0 -0
- package/seeds/samples/JPN-01.jpg +0 -0
- package/seeds/samples/JPN-02.jpg +0 -0
- package/seeds/samples/JPN-03.jpg +0 -0
- package/seeds/samples/SKT-01.jpg +0 -0
- package/seeds/samples/SKT-02.jpg +0 -0
- package/seeds/samples/SKT-03.jpg +0 -0
- package/seeds/samples/SWS-01.jpg +0 -0
- package/seeds/samples/SWS-02.jpg +0 -0
- package/seeds/samples/SWS-03.jpg +0 -0
- package/seeds/samples/TEC-01.jpg +0 -0
- package/seeds/samples/TEC-02.jpg +0 -0
- package/seeds/samples/TEC-03.jpg +0 -0
- package/seeds/samples/deckline-intro/README.md +41 -0
- package/seeds/samples/deckline-intro/assets/deckline-logo.png +0 -0
- package/seeds/samples/deckline-intro/assets/deckline-logo.svg +12 -0
- package/seeds/samples/deckline-intro/assets/deckline-mark.png +0 -0
- package/seeds/samples/deckline-intro/deckline-intro.pptx +0 -0
- package/seeds/samples/deckline-intro/intent.md +48 -0
- package/seeds/samples/deckline-intro/materials/project-summary.md +90 -0
- package/seeds/samples/deckline-intro/materials/screenshots/01-workbench-deck.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/02-home-decks.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/03-home-designs.png +0 -0
- package/seeds/samples/deckline-intro/materials/screenshots/04-new-deck-popup.png +0 -0
- package/seeds/samples/deckline-intro/materials.md +110 -0
- package/seeds/samples/deckline-intro/pages/p01.png +0 -0
- package/seeds/samples/deckline-intro/pages/p02.png +0 -0
- package/seeds/samples/deckline-intro/pages/p03.png +0 -0
- package/seeds/samples/deckline-intro/pages/p04.png +0 -0
- package/seeds/samples/deckline-intro/pages/p05.png +0 -0
- package/seeds/samples/deckline-intro/pages/p06.png +0 -0
- package/seeds/samples/deckline-intro/pages/p07.png +0 -0
- package/seeds/samples/deckline-intro/pages/p08.png +0 -0
- package/seeds/samples/deckline-intro/pages/p09.png +0 -0
- package/seeds/samples/deckline-intro/pages/p10.png +0 -0
- package/seeds/samples/deckline-intro/popup.md +87 -0
- package/seeds/samples/deckline-intro/prompts/p01.txt +4 -0
- package/seeds/samples/deckline-intro/prompts/p02.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p03.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p04.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p05.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p06.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p07.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p08.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p09.txt +6 -0
- package/seeds/samples/deckline-intro/prompts/p10.txt +6 -0
- package/seeds/samples/deckline-intro/script.md +88 -0
- package/seeds/styles/BIZ.json +22 -0
- package/seeds/styles/EDT.json +22 -0
- package/seeds/styles/FYI.json +22 -0
- package/seeds/styles/GRD.json +22 -0
- package/seeds/styles/JPN.json +22 -0
- package/seeds/styles/SKT.json +22 -0
- package/seeds/styles/SWS.json +22 -0
- package/seeds/styles/TEC.json +22 -0
- package/ui/logo.svg +20 -0
- package/ui/workbench.css +687 -0
- package/ui/workbench.html +4554 -0
package/lib/deck-docs.js
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck 文档治理(每份 deck 都必须有的 9 份文档 + 治理检查)。
|
|
3
|
+
*
|
|
4
|
+
* 这份模块是「一个 deck 应该长什么样」的唯一事实来源(docs/deck-documents.md 是给
|
|
5
|
+
* 人看的契约,这里是可执行的实现):
|
|
6
|
+
*
|
|
7
|
+
* 0. 意图 intent.md —— 听众 / 主题 / 希望带走什么(缺了它,后面全是猜)
|
|
8
|
+
* (故事线已并入脚本派生概览 + intent.md 的叙事小节,不再单独成文,见 ADR-0016)
|
|
9
|
+
* 2. 脚本 script.md —— 每页 正标题 / 副标题 / 内容 / 提示词
|
|
10
|
+
* 3. Design design.md —— 画面约束、模板、色板、出图参数
|
|
11
|
+
* 4. 修改日志 changelog.md —— 谁在什么时候改了什么、为什么
|
|
12
|
+
* 5. 反馈 feedback.md —— 用户原话 / 处理 / 分层 / 标签
|
|
13
|
+
* 6. 调研 research/ —— 每条有出处、发表时间、原文链接、本地存档、总结
|
|
14
|
+
* 7. 支撑材料 materials.md —— 内部项目记录:文件名 / 链接 / 总结
|
|
15
|
+
*
|
|
16
|
+
* 两级位置:
|
|
17
|
+
* - **源 deck(客户目录,只读)**:现实里已有的文档,我们只读、只检查;
|
|
18
|
+
* - **工作副本 `.deck/decks/<deckId>/`**:缺的文档由 Agent 起草到这里(draft),
|
|
19
|
+
* 调研原文下载到 `research/files/`,人确认后再回写源(不在本模块做)。
|
|
20
|
+
*
|
|
21
|
+
* 状态:ok / partial(有文件但字段不齐)/ derived(从 script 自动派生)/ draft(仅工作副本)/
|
|
22
|
+
* missing(完全没有)。缺文档不阻塞读,但进治理清单。
|
|
23
|
+
* @module dsh-plugin-deck-studio/deck-docs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import { parseFrontmatter } from "./source-adapter.js";
|
|
30
|
+
|
|
31
|
+
/* ------------------------------------------------------------------ *
|
|
32
|
+
* 文档规格
|
|
33
|
+
* ------------------------------------------------------------------ */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} DocSpec
|
|
37
|
+
* @property {string} id
|
|
38
|
+
* @property {string} label
|
|
39
|
+
* @property {string} purpose
|
|
40
|
+
* @property {string[]} candidates 源 deck 里的候选文件名(按优先级)
|
|
41
|
+
* @property {string[]} aliases 相关但不算主文件的(retro / case-cards / research-status)
|
|
42
|
+
* @property {boolean} [derivable] 没有专门文件时能否从 script 派生
|
|
43
|
+
* @property {string} [template] scaffold 用的模板 key
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** @type {DocSpec[]} */
|
|
47
|
+
export const DOC_SPECS = [
|
|
48
|
+
{
|
|
49
|
+
id: "intent",
|
|
50
|
+
label: "意图",
|
|
51
|
+
purpose: "讲给谁、讲什么、希望大家带走什么",
|
|
52
|
+
candidates: ["intent.md"],
|
|
53
|
+
template: "intent",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "script",
|
|
57
|
+
label: "脚本",
|
|
58
|
+
purpose: "逐页 正标题 / 副标题 / 内容 / 提示词",
|
|
59
|
+
candidates: ["script.md"],
|
|
60
|
+
aliases: ["prompts"],
|
|
61
|
+
template: "script",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "design",
|
|
65
|
+
label: "Design",
|
|
66
|
+
purpose: "画面约束、模板、色板、出图参数",
|
|
67
|
+
candidates: ["design.md", "style-reference.md"],
|
|
68
|
+
template: "design",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "changelog",
|
|
72
|
+
label: "修改日志",
|
|
73
|
+
purpose: "谁在什么时候改了什么、为什么、谁确认",
|
|
74
|
+
candidates: ["changelog.md", "CURRENT.md"],
|
|
75
|
+
template: "changelog",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "feedback",
|
|
79
|
+
label: "反馈",
|
|
80
|
+
purpose: "用户原话 / 处理 / 分层 / 标签,改稿前先落档",
|
|
81
|
+
candidates: ["feedback.md"],
|
|
82
|
+
aliases: ["retro.md"],
|
|
83
|
+
template: "feedback",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "research",
|
|
87
|
+
label: "调研",
|
|
88
|
+
purpose: "每条:出处 / 发表时间 / 原文链接 / 本地存档 / 总结",
|
|
89
|
+
candidates: ["research/index.md", "external-case-research.md"],
|
|
90
|
+
aliases: ["research-status.md", "case-cards.md"],
|
|
91
|
+
template: "research",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "materials",
|
|
95
|
+
label: "支撑材料",
|
|
96
|
+
purpose: "内部项目记录与素材:文件名 / 链接 / 总结",
|
|
97
|
+
candidates: ["materials.md"],
|
|
98
|
+
template: "materials",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "review",
|
|
102
|
+
label: "审稿",
|
|
103
|
+
purpose: "交付前问题清单:严重度 / 位置 / 证据 / 修复建议 + 是否可交付",
|
|
104
|
+
candidates: ["review.md"],
|
|
105
|
+
template: "review",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "factcheck",
|
|
109
|
+
label: "事实核对",
|
|
110
|
+
purpose: "核对表:断言 → 来源 → 一致 / 冲突 / 无依据",
|
|
111
|
+
candidates: ["fact-check.md", "factcheck.md"],
|
|
112
|
+
template: "factcheck",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** 必需字段(用于 partial 判定)。 */
|
|
117
|
+
const REQUIRED_FIELDS = {
|
|
118
|
+
intent: ["听众", "主题", "带走"],
|
|
119
|
+
design: ["画面约束", "模板"],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------ *
|
|
123
|
+
* 解析
|
|
124
|
+
* ------------------------------------------------------------------ */
|
|
125
|
+
|
|
126
|
+
/** 模板占位符(TODO / 待填 / 待确认)不算已填。 */
|
|
127
|
+
const isPlaceholder = (value) => !value || /TODO|todo|待填|待确认|(待/.test(String(value));
|
|
128
|
+
|
|
129
|
+
const readText = (file) => {
|
|
130
|
+
try {
|
|
131
|
+
return existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const sizeOf = (file) => {
|
|
138
|
+
try {
|
|
139
|
+
return statSync(file).size;
|
|
140
|
+
} catch {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** `**字段**:值` 与 `- **字段**:值` 两种写法都收。 */
|
|
146
|
+
export function parseFieldLines(text) {
|
|
147
|
+
const fields = {};
|
|
148
|
+
if (!text) return fields;
|
|
149
|
+
for (const line of text.split("\n")) {
|
|
150
|
+
const match = /^\s*(?:[-*]\s*)?\*\*([^*]+)\*\*\s*[::]\s*(.*)$/.exec(line);
|
|
151
|
+
if (!match) continue;
|
|
152
|
+
const key = match[1].trim();
|
|
153
|
+
if (fields[key] == null) fields[key] = match[2].trim();
|
|
154
|
+
}
|
|
155
|
+
return fields;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 某个 `## 标题` 下的正文(到下一个同级/更高级标题为止)。 */
|
|
159
|
+
export function sectionBody(text, keyword) {
|
|
160
|
+
if (!text) return "";
|
|
161
|
+
const lines = text.split("\n");
|
|
162
|
+
const start = lines.findIndex((line) => /^#{2,3}\s/.test(line) && line.includes(keyword));
|
|
163
|
+
if (start < 0) return "";
|
|
164
|
+
const level = (lines[start].match(/^#+/) ?? ["##"])[0].length;
|
|
165
|
+
const body = [];
|
|
166
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
167
|
+
const heading = /^(#{1,6})\s/.exec(lines[i]);
|
|
168
|
+
if (heading && heading[1].length <= level) break;
|
|
169
|
+
body.push(lines[i]);
|
|
170
|
+
}
|
|
171
|
+
return body.join("\n").trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 解析第一个 markdown 表(返回 {head, rows},rows 为对象数组)。 */
|
|
175
|
+
export function parseTable(text) {
|
|
176
|
+
return parseTables(text)[0] ?? { head: [], rows: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 解析文本里**所有**表格块(F-96:分批调研每批追加一张表,只取第一张会漏掉后续批次)。 */
|
|
180
|
+
export function parseTables(text) {
|
|
181
|
+
if (!text) return [];
|
|
182
|
+
const lines = text.split("\n");
|
|
183
|
+
const tables = [];
|
|
184
|
+
for (let i = 0; i < lines.length - 1; i += 1) {
|
|
185
|
+
if (!lines[i].trim().startsWith("|")) continue;
|
|
186
|
+
const head = lines[i].trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
|
187
|
+
if (!/^[\s:|-]+$/.test(lines[i + 1].trim())) continue;
|
|
188
|
+
const rows = [];
|
|
189
|
+
let j = i + 2;
|
|
190
|
+
for (; j < lines.length; j += 1) {
|
|
191
|
+
if (!lines[j].trim().startsWith("|")) break;
|
|
192
|
+
const cells = lines[j].trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
|
193
|
+
rows.push(Object.fromEntries(head.map((key, index) => [key, cells[index] ?? ""])));
|
|
194
|
+
}
|
|
195
|
+
tables.push({ head, rows });
|
|
196
|
+
i = j;
|
|
197
|
+
}
|
|
198
|
+
return tables;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 设计约束:`## 视觉规则` 下的 bullet 列表。 */
|
|
202
|
+
export function parseDesignRules(text) {
|
|
203
|
+
// 兼容三种既有写法:`## 视觉规则` / `## 画面约束` / 风格规范里的 `## 三条铁律`。
|
|
204
|
+
// 条目既可能是 bullet,也可能是编号列表(1. …),统一归一。
|
|
205
|
+
const body =
|
|
206
|
+
sectionBody(text, "视觉规则") ||
|
|
207
|
+
sectionBody(text, "画面约束") ||
|
|
208
|
+
sectionBody(text, "规则") ||
|
|
209
|
+
sectionBody(text, "铁律");
|
|
210
|
+
return body
|
|
211
|
+
.split("\n")
|
|
212
|
+
.filter((line) => /^\s*(?:[-*]|\d+[.、])\s+/.test(line))
|
|
213
|
+
.map((line) => line.replace(/^\s*(?:[-*]|\d+[.、])\s+/, "").trim())
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 调研台账条目:
|
|
219
|
+
*
|
|
220
|
+
* A. 标准表格:`| 出处 | 标题 | 发表时间 | 原文链接 | 本地文件 | 总结 |`
|
|
221
|
+
* B. 现有源里的编号列表:`1. Axtria — xxx` + 下一行 URL
|
|
222
|
+
*
|
|
223
|
+
* 返回 [{source,title,date,url,localFile,summary}]
|
|
224
|
+
*/
|
|
225
|
+
export function parseResearchEntries(text) {
|
|
226
|
+
if (!text) return [];
|
|
227
|
+
const entries = [];
|
|
228
|
+
const tables = parseTables(text);
|
|
229
|
+
const urlHead = (table) => table.head.some((key) => /原文链接|链接|url/i.test(key));
|
|
230
|
+
if (tables.some(urlHead)) {
|
|
231
|
+
for (const table of tables.filter(urlHead)) {
|
|
232
|
+
for (const row of table.rows) {
|
|
233
|
+
const url = row["原文链接"] ?? row["链接"] ?? row["URL"] ?? "";
|
|
234
|
+
if (!/^https?:\/\//.test(url) && !row["标题"]) continue;
|
|
235
|
+
// 模板占位行(TODO / YYYY-MM-DD / https://)不算调研条目。
|
|
236
|
+
if (/TODO|待补|待填|YYYY-MM-DD/.test(`${url} ${row["标题"]} ${row["总结"] ?? ""} ${row["出处"] ?? ""}`)) continue;
|
|
237
|
+
entries.push({
|
|
238
|
+
source: row["出处"] ?? row["来源"] ?? "",
|
|
239
|
+
title: row["标题"] ?? "",
|
|
240
|
+
date: row["发表时间"] ?? row["时间"] ?? "",
|
|
241
|
+
url: /^https?:\/\//.test(url) ? url : "",
|
|
242
|
+
localFile: row["本地文件"] ?? row["本地存档"] ?? "",
|
|
243
|
+
summary: row["总结"] ?? "",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (entries.length) return entries;
|
|
248
|
+
}
|
|
249
|
+
// 内部台账模式(F-96):没有 URL 但写了「事实 → 出处」的表(分批调研每批落一张表)。
|
|
250
|
+
for (const table of tables) {
|
|
251
|
+
const sourceKey = table.head.find((key) => /出处|来源|文件|位置/.test(key));
|
|
252
|
+
const summaryKey = table.head.find((key) => /总结|事实|摘要|说明/.test(key));
|
|
253
|
+
if (!sourceKey || !summaryKey) continue;
|
|
254
|
+
for (const row of table.rows) {
|
|
255
|
+
const source = String(row[sourceKey] ?? "").trim();
|
|
256
|
+
const summary = String(row[summaryKey] ?? "").trim();
|
|
257
|
+
if (!source && !summary) continue;
|
|
258
|
+
if (/TODO|待补|待填|YYYY-MM-DD/.test(`${source} ${summary}`)) continue;
|
|
259
|
+
entries.push({
|
|
260
|
+
source,
|
|
261
|
+
title: row["标题"] ?? row["断言"] ?? "",
|
|
262
|
+
date: row["发表时间"] ?? row["时间"] ?? "",
|
|
263
|
+
url: (summary.match(/https?:\/\/[^\s))|]+/) ?? source.match(/https?:\/\/[^\s))|]+/) ?? [])[0] ?? "",
|
|
264
|
+
localFile: row["本地文件"] ?? row["本地存档"] ?? "",
|
|
265
|
+
summary,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (entries.length) return entries;
|
|
270
|
+
// 条目清单模式(F-96):- **可引用事实**:… 出处:README.md 开头段;也认「F1|事实 + 下一行 出处:」。
|
|
271
|
+
const bulletLines = String(text).split("\n");
|
|
272
|
+
for (let i = 0; i < bulletLines.length; i += 1) {
|
|
273
|
+
const bullet = /^\s*[-*+]\s+(.+)$/.exec(bulletLines[i]);
|
|
274
|
+
if (!bullet) continue;
|
|
275
|
+
const body = bullet[1].trim();
|
|
276
|
+
const inline = /出处\s*[::]\s*(.+)$/.exec(body);
|
|
277
|
+
let source = inline ? inline[1] : "";
|
|
278
|
+
if (!source) {
|
|
279
|
+
// 出处写在缩进续行(最多向下看 2 行,遇到下一个 bullet 就停)。
|
|
280
|
+
for (let j = i + 1; j <= i + 2 && j < bulletLines.length; j += 1) {
|
|
281
|
+
if (/^\s*[-*+]\s+/.test(bulletLines[j])) break;
|
|
282
|
+
const cont = /出处\s*[::]\s*(.+)$/.exec(bulletLines[j]);
|
|
283
|
+
if (cont) {
|
|
284
|
+
source = cont[1];
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!source) continue;
|
|
290
|
+
let summary = inline ? body.replace(/出处\s*[::].*$/, "") : body;
|
|
291
|
+
summary = summary
|
|
292
|
+
.replace(/^\**\s*(?:可引用事实|事实|要点|总结)\s*\**\s*[::++\s]*/, "")
|
|
293
|
+
.replace(/^[FF]\d+\s*[||]\s*/, "")
|
|
294
|
+
.replace(/\*\*/g, "")
|
|
295
|
+
.trim();
|
|
296
|
+
source = source.replace(/[))]\s*[。.]?\s*$/, "").replace(/`/g, "").trim();
|
|
297
|
+
if (!summary && !source) continue;
|
|
298
|
+
if (/TODO|待补|待填|YYYY-MM-DD/.test(`${source} ${summary}`)) continue;
|
|
299
|
+
entries.push({ source, title: "", date: "", url: "", localFile: "", summary: summary.slice(0, 300) });
|
|
300
|
+
}
|
|
301
|
+
if (entries.length) return entries;
|
|
302
|
+
if (entries.length) return entries;
|
|
303
|
+
// 编号列表模式:优先只看「外部来源索引 / 来源索引」段落(避免把正文编号误当调研条目),
|
|
304
|
+
// 且必须有原文链接才算一条调研。
|
|
305
|
+
const scoped = sectionBody(text, "外部来源") || sectionBody(text, "来源索引") || text;
|
|
306
|
+
const lines = scoped.split("\n");
|
|
307
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
308
|
+
const match = /^\s*(\d+)[.、]\s+(.+)$/.exec(lines[i]);
|
|
309
|
+
if (!match) continue;
|
|
310
|
+
let label = match[2].trim();
|
|
311
|
+
let url = "";
|
|
312
|
+
let localFile = "";
|
|
313
|
+
for (let j = i + 1; j < Math.min(i + 4, lines.length); j += 1) {
|
|
314
|
+
const line = lines[j].trim();
|
|
315
|
+
const link = /^<?(https?:\/\/[^\s>]+)>?$/.exec(line);
|
|
316
|
+
if (link && !url) {
|
|
317
|
+
url = link[1];
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const local = /^本地[::]\s*(.+)$/.exec(line);
|
|
321
|
+
if (local) localFile = local[1].trim();
|
|
322
|
+
}
|
|
323
|
+
if (!url) continue;
|
|
324
|
+
const date = /[((](\d{4}-\d{2}-\d{2})[))]/.exec(label)?.[1] ?? "";
|
|
325
|
+
label = label.replace(/[((]\d{4}-\d{2}-\d{2}[))]\s*$/, "").trim();
|
|
326
|
+
const dash = label.split(/\s+—\s+|\s+-\s+/);
|
|
327
|
+
entries.push({
|
|
328
|
+
source: dash.length > 1 ? dash[0].trim() : "",
|
|
329
|
+
title: dash.length > 1 ? dash.slice(1).join(" — ").trim() : label,
|
|
330
|
+
date,
|
|
331
|
+
url,
|
|
332
|
+
localFile,
|
|
333
|
+
summary: "",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return entries;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 反馈表 → 行对象。 */
|
|
340
|
+
export function parseFeedbackRows(text) {
|
|
341
|
+
const { head, rows } = parseTable(text);
|
|
342
|
+
if (!head.some((key) => key.includes("日期"))) return [];
|
|
343
|
+
return rows.map((row) => ({
|
|
344
|
+
date: row["日期"] ?? "",
|
|
345
|
+
target: row["对象"] ?? "",
|
|
346
|
+
quote: row["原话"] ?? "",
|
|
347
|
+
action: row["处理"] ?? "",
|
|
348
|
+
layer: row["层"] ?? "",
|
|
349
|
+
tags: row["标签"] ?? "",
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 故事结构表 → 段落。 */
|
|
354
|
+
export function parseStorySegments(text) {
|
|
355
|
+
const { head, rows } = parseTable(text);
|
|
356
|
+
if (!head.some((key) => key.includes("段落"))) return [];
|
|
357
|
+
return rows.map((row) => ({ segment: row["段落"] ?? "", pages: row["页码"] ?? "", task: row["任务"] ?? "" }));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 修改日志:`- YYYY-MM-DD · 版本 · 说明` 或表格。 */
|
|
361
|
+
export function parseChangelogEntries(text) {
|
|
362
|
+
if (!text) return [];
|
|
363
|
+
const { head, rows } = parseTable(text);
|
|
364
|
+
if (head.some((key) => /日期|时间/.test(key)) && head.some((key) => /改动|内容|说明/.test(key))) {
|
|
365
|
+
return rows.map((row) => ({
|
|
366
|
+
date: row["日期"] ?? row["时间"] ?? "",
|
|
367
|
+
revision: row["版本"] ?? "",
|
|
368
|
+
note: row["改动"] ?? row["内容"] ?? row["说明"] ?? "",
|
|
369
|
+
who: row["确认"] ?? row["人"] ?? "",
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
return text
|
|
373
|
+
.split("\n")
|
|
374
|
+
.map((line) => /^\s*[-*]\s+(\d{4}-\d{2}-\d{2})\s*[·::-]?\s*(.*)$/.exec(line))
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.map((match) => ({ date: match[1], revision: "", note: match[2].trim(), who: "" }));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ------------------------------------------------------------------ *
|
|
380
|
+
* 修改日志:系统自动生成变更流 + agent 补充说明
|
|
381
|
+
* ------------------------------------------------------------------ */
|
|
382
|
+
|
|
383
|
+
/** 关键事件 → 修改日志里的动作名。 */
|
|
384
|
+
const CHANGE_KIND_LABEL = {
|
|
385
|
+
// F-73:新建本身就是第一条修改痕迹;不加它,新 deck 的「修改日志」会显示"缺失"。
|
|
386
|
+
"deck.created": "新建",
|
|
387
|
+
"deck.deleted": "删除",
|
|
388
|
+
"deck.render": "重出图",
|
|
389
|
+
"deck.export": "导出",
|
|
390
|
+
"deck.handoff": "交接包",
|
|
391
|
+
"deck.qa": "QA",
|
|
392
|
+
"deck.changelog.note": "说明",
|
|
393
|
+
// F-78:Agent 补 intent / 支撑材料 / 调研,也要进修改日志(否则「为什么」丢了)。
|
|
394
|
+
"deck.doc.update": "补文档",
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 把「手写条目 + script 版本 + 关键事件」合成一份变更流(新的在前)。
|
|
399
|
+
* 这是修改日志的唯一来源:系统负责"发生了什么",人/agent 只补"为什么"。
|
|
400
|
+
*/
|
|
401
|
+
export function buildChangeFeed({ entries = [], revisions = [], events = [] }) {
|
|
402
|
+
const rows = [];
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
rows.push({
|
|
405
|
+
date: entry.date || "",
|
|
406
|
+
kind: "手写",
|
|
407
|
+
page: "",
|
|
408
|
+
note: entry.note || "",
|
|
409
|
+
actor: entry.who || "",
|
|
410
|
+
revision: entry.revision || "",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
for (const revision of revisions) {
|
|
414
|
+
const pageIds = revision.pageIds ?? revision.slideIds ?? (revision.page ? [revision.page] : []);
|
|
415
|
+
rows.push({
|
|
416
|
+
date: revision.savedAt || "",
|
|
417
|
+
kind: "改脚本",
|
|
418
|
+
page: pageIds.join(","),
|
|
419
|
+
note: revision.note || "script 编辑",
|
|
420
|
+
actor: revision.actor || "",
|
|
421
|
+
revision: revision.revision || "",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
for (const event of events) {
|
|
425
|
+
if (!CHANGE_KIND_LABEL[event.type]) continue;
|
|
426
|
+
// deck.script.save 已经由 revision 行表达,避免重复。
|
|
427
|
+
rows.push({
|
|
428
|
+
date: event.timestamp || "",
|
|
429
|
+
kind: CHANGE_KIND_LABEL[event.type],
|
|
430
|
+
page: (event.slideIds ?? []).join(","),
|
|
431
|
+
note: event.payload?.note || "",
|
|
432
|
+
actor: event.actor || "",
|
|
433
|
+
revision: "",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return rows.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* agent 补一条变更说明:追加到工作副本 docs/changelog.md(交付物里可读),
|
|
441
|
+
* 同时由调用方记 event(deck.changelog.note)。
|
|
442
|
+
*/
|
|
443
|
+
export function appendChangelogNote({ deckDir, note, pageId = "", actor = "agent" }) {
|
|
444
|
+
if (!note || !String(note).trim()) throw new Error("note 不能为空");
|
|
445
|
+
const file = draftPath(deckDir, "changelog");
|
|
446
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
447
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
448
|
+
const scope = pageId ? ` ${pageId}` : "";
|
|
449
|
+
const line = `- ${date} · ${scope}${String(note).trim()} · ${actor}\n`;
|
|
450
|
+
if (!existsSync(file)) {
|
|
451
|
+
writeFileSync(file, `---\ntitle: 修改日志\nstatus: active\n---\n\n# 修改日志\n\n> 系统自动记录变更;这里只补"为什么"。\n\n${line}`, "utf8");
|
|
452
|
+
} else {
|
|
453
|
+
appendFileSync(file, line, "utf8");
|
|
454
|
+
}
|
|
455
|
+
return { file, line: line.trim() };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ------------------------------------------------------------------ *
|
|
459
|
+
* 解析后的文档视图
|
|
460
|
+
* ------------------------------------------------------------------ */
|
|
461
|
+
|
|
462
|
+
function draftPath(deckDir, docId) {
|
|
463
|
+
if (docId === "research") return join(deckDir, "docs", "research", "index.md");
|
|
464
|
+
if (docId === "factcheck") return join(deckDir, "docs", "fact-check.md");
|
|
465
|
+
return join(deckDir, "docs", `${docId}.md`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function listResearchArchive(deckDir) {
|
|
469
|
+
const dir = join(deckDir, "research", "files");
|
|
470
|
+
if (!existsSync(dir)) return [];
|
|
471
|
+
return readdirSync(dir)
|
|
472
|
+
.filter((name) => !name.startsWith("."))
|
|
473
|
+
.map((name) => {
|
|
474
|
+
const file = join(dir, name);
|
|
475
|
+
return { name, sizeBytes: sizeOf(file), path: file };
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** 读工作副本 manifest(deck.json);读不到就返回 null(治理解析不因坏文件崩)。 */
|
|
480
|
+
function readManifestJson(deckDir) {
|
|
481
|
+
try {
|
|
482
|
+
const file = join(deckDir, "deck.json");
|
|
483
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function slugOf(value) {
|
|
490
|
+
return String(value ?? "")
|
|
491
|
+
.toLowerCase()
|
|
492
|
+
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
|
|
493
|
+
.replace(/^-+|-+$/g, "")
|
|
494
|
+
.slice(0, 60);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 治理解析:返回 docs(不含大段 content)+ governance 汇总。
|
|
499
|
+
* @param {object} input
|
|
500
|
+
* @param {ReturnType<import('./source-adapter.js').createLocalSourceAdapter>|null} input.adapter
|
|
501
|
+
* @param {string} input.deckDir
|
|
502
|
+
* @param {object} [input.render] 渲染配置(design 文档要展示模型/尺寸)
|
|
503
|
+
* @param {Array} [input.revisions] script revision 列表
|
|
504
|
+
*/
|
|
505
|
+
export function resolveDeckDocs({ adapter, deckDir, render = {}, revisions = [], events = [] }) {
|
|
506
|
+
const sourceRoot = adapter?.sourceRoot ?? "";
|
|
507
|
+
const script = adapter?.readScriptRaw?.() ?? readText(join(sourceRoot, "script.md")) ?? "";
|
|
508
|
+
const meta = parseFrontmatter(script);
|
|
509
|
+
const pages = adapter?.storyPages?.() ?? [];
|
|
510
|
+
const prompts = adapter?.prompts?.() ?? {};
|
|
511
|
+
const researchFiles = listResearchArchive(deckDir);
|
|
512
|
+
|
|
513
|
+
const findSource = (names) => {
|
|
514
|
+
for (const name of names) {
|
|
515
|
+
const file = join(sourceRoot, name);
|
|
516
|
+
if (existsSync(file)) return { name, file };
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const docs = DOC_SPECS.map((spec) => {
|
|
522
|
+
const sourceHit = findSource(spec.candidates);
|
|
523
|
+
const draftFile = draftPath(deckDir, spec.id);
|
|
524
|
+
const draftName = spec.id === "research" ? "research/index.md" : spec.id === "factcheck" ? "fact-check.md" : `${spec.id}.md`;
|
|
525
|
+
const draft = existsSync(draftFile) ? { name: `docs/${draftName}`, file: draftFile } : null;
|
|
526
|
+
const primary = sourceHit ?? draft;
|
|
527
|
+
const kind = sourceHit ? "source" : draft ? "draft" : "none";
|
|
528
|
+
const content = primary ? readText(primary.file) ?? "" : "";
|
|
529
|
+
const doc = {
|
|
530
|
+
id: spec.id,
|
|
531
|
+
label: spec.label,
|
|
532
|
+
purpose: spec.purpose,
|
|
533
|
+
status: "missing",
|
|
534
|
+
kind,
|
|
535
|
+
primary: primary ? primary.name : null,
|
|
536
|
+
// F-81:文档内容版本(mtime + size)——工作台轮询拿它判断「文件被外部改过」,
|
|
537
|
+
// 没有它时状态/路径不变就永远不会刷新文档缓存(用户会一直看到旧正文)。
|
|
538
|
+
version: primary ? `${primary.name}:${statSync(primary.file).mtimeMs}:${statSync(primary.file).size}` : "none",
|
|
539
|
+
files: [],
|
|
540
|
+
issues: [],
|
|
541
|
+
facts: {},
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// 相关文件(存在即列)
|
|
545
|
+
for (const alias of spec.aliases ?? []) {
|
|
546
|
+
const dir = /\.md$|\.yaml$/.test(alias) ? join(sourceRoot, alias) : join(sourceRoot, alias);
|
|
547
|
+
if (existsSync(dir) && statSync(dir).isFile()) doc.files.push({ name: alias, exists: true, role: "相关", sizeBytes: sizeOf(dir) });
|
|
548
|
+
else if (existsSync(dir)) doc.files.push({ name: `${alias}/`, exists: true, role: "相关目录", sizeBytes: 0 });
|
|
549
|
+
}
|
|
550
|
+
if (primary) doc.files.unshift({ name: primary.name, exists: true, role: sourceHit ? "源文档" : "工作副本草案", sizeBytes: sizeOf(primary.file) });
|
|
551
|
+
|
|
552
|
+
// 各文档的字段/结构检查
|
|
553
|
+
if (spec.id === "intent") {
|
|
554
|
+
const fields = parseFieldLines(content);
|
|
555
|
+
const pick = (...values) => values.map((value) => (isPlaceholder(value) ? "" : value)).find(Boolean) ?? "";
|
|
556
|
+
const legacy = {
|
|
557
|
+
听众: pick(fields["听众"], fields["Audience"]),
|
|
558
|
+
主题: pick(fields["主题"], fields["Topic"], meta.title),
|
|
559
|
+
带走: pick(fields["带走"], fields["希望带走"], fields["Takeaway"]),
|
|
560
|
+
};
|
|
561
|
+
// F-78:除了治理必需的三个字段,也要把「场合 / 时长」「成功标准」「不讲的」等
|
|
562
|
+
// 结构化字段交给「意图」标签页的卡片(之前只传 legacy 三项,卡片永远显示 —)。
|
|
563
|
+
const allFields = Object.fromEntries(
|
|
564
|
+
Object.entries(fields).map(([key, value]) => [key, isPlaceholder(value) ? "" : value]),
|
|
565
|
+
);
|
|
566
|
+
doc.facts = { fields: { ...allFields, ...legacy }, derived: { 主题: meta.title ?? "", 类型: meta["deck-type"] ?? "", 目的: meta.purpose ?? "" } };
|
|
567
|
+
const missing = REQUIRED_FIELDS.intent.filter((key) => !legacy[key]);
|
|
568
|
+
if (!primary) {
|
|
569
|
+
doc.status = "missing";
|
|
570
|
+
doc.issues.push("缺 intent.md:听众 / 主题 / 带走 还没有被明确写下来(主题可从 script frontmatter 派生,其余需人补)");
|
|
571
|
+
} else if (missing.length) {
|
|
572
|
+
doc.status = "partial";
|
|
573
|
+
doc.issues.push(`缺字段:${missing.join(" / ")}`);
|
|
574
|
+
} else {
|
|
575
|
+
doc.status = "ok";
|
|
576
|
+
}
|
|
577
|
+
} else if (spec.id === "script") {
|
|
578
|
+
const promptIds = Object.keys(prompts);
|
|
579
|
+
// F-110:画面要求(visualBrief)住在工作副本 manifest 里,不是源 prompts/Pxx.txt。
|
|
580
|
+
// 以前只数 prompts/ 文件,新建 deck 永远显示「提示词覆盖 0/10」,把 Agent 和人都误导成「不能出图」。
|
|
581
|
+
const manifest = readManifestJson(deckDir);
|
|
582
|
+
const briefOf = (page) => {
|
|
583
|
+
const pageId = `p${String(page.page).padStart(2, "0")}`;
|
|
584
|
+
const slide = (manifest?.slides ?? []).find((item) => item.id === pageId);
|
|
585
|
+
return String(slide?.visualBrief ?? "").trim();
|
|
586
|
+
};
|
|
587
|
+
const covered = pages.filter((page) => {
|
|
588
|
+
const pageId = `p${String(page.page).padStart(2, "0")}`;
|
|
589
|
+
const brief = briefOf(page);
|
|
590
|
+
const fieldPrompt = String(page.fields?.["提示词"] ?? "").trim();
|
|
591
|
+
return (brief && !isPlaceholder(brief)) || promptIds.includes(pageId) || (fieldPrompt && !isPlaceholder(fieldPrompt));
|
|
592
|
+
});
|
|
593
|
+
doc.facts = {
|
|
594
|
+
pageCount: pages.length,
|
|
595
|
+
promptCount: promptIds.length,
|
|
596
|
+
promptCoverage: covered.length,
|
|
597
|
+
// 故事线概览并入脚本页:分段表从 script 的 `## 段落 / 页码 / 任务` 解析,零副本。
|
|
598
|
+
segments: parseStorySegments(script),
|
|
599
|
+
};
|
|
600
|
+
const derived = !primary && pages.length;
|
|
601
|
+
if (derived) {
|
|
602
|
+
doc.status = "derived";
|
|
603
|
+
doc.files.unshift({ name: "script.md", exists: false, role: "缺失", sizeBytes: 0 });
|
|
604
|
+
} else if (!primary) {
|
|
605
|
+
doc.status = "missing";
|
|
606
|
+
doc.issues.push("缺 script.md:没有逐页脚本");
|
|
607
|
+
} else {
|
|
608
|
+
doc.status = pages.length ? "ok" : "partial";
|
|
609
|
+
if (!pages.length) doc.issues.push("script.md 里没解析出逐页(表格或 P 分节都算)");
|
|
610
|
+
}
|
|
611
|
+
if (pages.length && covered.length < pages.length) {
|
|
612
|
+
doc.status = doc.status === "ok" ? "partial" : doc.status;
|
|
613
|
+
doc.issues.push(`画面要求(visualBrief)覆盖 ${covered.length}/${pages.length} 页(缺 ${pages.length - covered.length} 页)`);
|
|
614
|
+
}
|
|
615
|
+
} else if (spec.id === "design") {
|
|
616
|
+
const rules = (parseDesignRules(content) || (parseFieldLines(content)["画面约束"]?.split(/[;;]/) ?? [])).filter((rule) => !isPlaceholder(rule));
|
|
617
|
+
doc.facts = {
|
|
618
|
+
rules,
|
|
619
|
+
render: { model: render.model ?? "", size: render.size ?? "", mode: render.mode ?? "", endpoint: render.endpoint ?? "" },
|
|
620
|
+
};
|
|
621
|
+
if (!primary) {
|
|
622
|
+
doc.status = "missing";
|
|
623
|
+
doc.issues.push("缺 design.md:画面约束 / 模板 / 色板 / 出图参数没有落档");
|
|
624
|
+
} else if (rules.length < 3) {
|
|
625
|
+
doc.status = "partial";
|
|
626
|
+
doc.issues.push("画面约束不足 3 条(建议在「## 视觉规则」下列成 bullet)");
|
|
627
|
+
} else {
|
|
628
|
+
doc.status = "ok";
|
|
629
|
+
}
|
|
630
|
+
} else if (spec.id === "changelog") {
|
|
631
|
+
// 修改日志 = 手写条目 + 版本记录 + 关键事件(系统自动生成),见 ADR-0016。
|
|
632
|
+
const entries = parseChangelogEntries(content);
|
|
633
|
+
// 变更流 = 手写条目 + script 版本 + 渲染/导出/交接/QA/说明事件(系统自动生成)。
|
|
634
|
+
const feed = buildChangeFeed({ entries, revisions, events });
|
|
635
|
+
doc.facts = { entries, feed, revisionCount: revisions.length, eventCount: events.length };
|
|
636
|
+
if (!feed.length) {
|
|
637
|
+
doc.status = "missing";
|
|
638
|
+
doc.issues.push("还没有任何修改痕迹:改一次 script / 出一次图,系统会自动记录;agent 再补一句为什么即可");
|
|
639
|
+
} else if (!entries.length) {
|
|
640
|
+
// 有自动记录、但没有手写说明:能追溯"发生了什么",还缺"为什么"。
|
|
641
|
+
doc.status = "derived";
|
|
642
|
+
doc.issues.push("没有手写说明:变更已由系统自动记录;建议 Agent 每次改完 deck 用 deck_note_change 补一句「为什么」");
|
|
643
|
+
} else {
|
|
644
|
+
doc.status = "ok";
|
|
645
|
+
}
|
|
646
|
+
} else if (spec.id === "feedback") {
|
|
647
|
+
const rows = parseFeedbackRows(content);
|
|
648
|
+
doc.facts = { rows };
|
|
649
|
+
const retro = findSource(["retro.md"]);
|
|
650
|
+
if (retro) doc.files.push({ name: "retro.md", exists: true, role: "回归台账", sizeBytes: sizeOf(retro.file) });
|
|
651
|
+
if (!primary) {
|
|
652
|
+
doc.status = "missing";
|
|
653
|
+
doc.issues.push("缺 feedback.md:用户反馈没有留档,改稿依据不可追溯");
|
|
654
|
+
} else if (!rows.length) {
|
|
655
|
+
doc.status = "partial";
|
|
656
|
+
doc.issues.push("没有解析出反馈行(表头需含 日期 / 对象 / 原话 / 处理)");
|
|
657
|
+
} else {
|
|
658
|
+
doc.status = "ok";
|
|
659
|
+
}
|
|
660
|
+
} else if (spec.id === "research") {
|
|
661
|
+
const entries = parseResearchEntries(content);
|
|
662
|
+
const archiveNames = new Set(researchFiles.map((file) => file.name));
|
|
663
|
+
const merged = entries.map((entry) => {
|
|
664
|
+
const localName = entry.localFile ? entry.localFile.replace(/^research\/files\//, "") : "";
|
|
665
|
+
const local = [localName, ...archiveNames].find((name) => name && archiveNames.has(name)) ?? "";
|
|
666
|
+
return { ...entry, localFile: local ? `files/${local}` : "", localUrl: local ? `research/files/${local}` : "" };
|
|
667
|
+
});
|
|
668
|
+
doc.facts = { entries: merged, archiveCount: researchFiles.length };
|
|
669
|
+
if (!primary) {
|
|
670
|
+
doc.status = "missing";
|
|
671
|
+
doc.issues.push("缺调研台账(research/index.md 或 external-case-research.md)");
|
|
672
|
+
} else if (!merged.length) {
|
|
673
|
+
doc.status = "partial";
|
|
674
|
+
doc.issues.push("没有解析出调研条目(支持表格 出处/标题/发表时间/原文链接/本地文件/总结,或编号列表 + URL)");
|
|
675
|
+
} else {
|
|
676
|
+
const noLocal = merged.filter((entry) => !entry.localFile).length;
|
|
677
|
+
if (!noLocal) doc.status = "ok";
|
|
678
|
+
else {
|
|
679
|
+
doc.status = "partial";
|
|
680
|
+
doc.issues.push(`${merged.length} 条调研中 ${noLocal} 条缺本地原文存档(research/files/)`);
|
|
681
|
+
}
|
|
682
|
+
const noDate = merged.filter((entry) => !entry.date).length;
|
|
683
|
+
if (noDate) doc.issues.push(`${noDate} 条缺发表时间`);
|
|
684
|
+
}
|
|
685
|
+
} else if (spec.id === "materials") {
|
|
686
|
+
// F-122:原来无脑取「第一张表」,表头不是材料清单时每列都取空 → 工作台渲染成
|
|
687
|
+
// 一排「(未命名)目录 / 未登记总结」的空卡片(真机:materials.md 里放的是引用清单)。
|
|
688
|
+
// 现在按表头挑真正像材料清单的那张表;找不到就老实报「表头不对」,不造假条目。
|
|
689
|
+
const NAME_KEYS = ["文件名", "名称", "材料", "素材", "文件"];
|
|
690
|
+
const LINK_KEYS = ["链接", "路径", "出处", "文件路径", "URL", "url"];
|
|
691
|
+
const SUMMARY_KEYS = ["总结", "说明", "摘要", "用途", "备注"];
|
|
692
|
+
const headMatches = (head, keys) => head.some((cell) => keys.some((key) => String(cell).includes(key)));
|
|
693
|
+
const firstValue = (row, keys) => {
|
|
694
|
+
for (const head of Object.keys(row)) {
|
|
695
|
+
if (!keys.some((key) => head.includes(key))) continue;
|
|
696
|
+
const value = row[head];
|
|
697
|
+
if (value != null && String(value).trim()) return String(value).trim();
|
|
698
|
+
}
|
|
699
|
+
return "";
|
|
700
|
+
};
|
|
701
|
+
const tables = parseTables(content);
|
|
702
|
+
// 表头越像材料清单(名称 + 链接/总结)越优先;一张都不像就不选,避免把引用清单当材料。
|
|
703
|
+
const picked = tables
|
|
704
|
+
.map((table) => {
|
|
705
|
+
const score = (headMatches(table.head, NAME_KEYS) ? 2 : 0)
|
|
706
|
+
+ (headMatches(table.head, LINK_KEYS) ? 1 : 0)
|
|
707
|
+
+ (headMatches(table.head, SUMMARY_KEYS) ? 1 : 0);
|
|
708
|
+
return { table, score };
|
|
709
|
+
})
|
|
710
|
+
.filter((item) => item.score >= 3)
|
|
711
|
+
.sort((a, b) => b.score - a.score)[0] ?? null;
|
|
712
|
+
const items = picked
|
|
713
|
+
? picked.table.rows
|
|
714
|
+
.map((row) => ({
|
|
715
|
+
name: firstValue(row, NAME_KEYS) || firstValue(row, LINK_KEYS),
|
|
716
|
+
link: firstValue(row, LINK_KEYS),
|
|
717
|
+
summary: firstValue(row, SUMMARY_KEYS),
|
|
718
|
+
}))
|
|
719
|
+
.filter((item) => item.name || item.link)
|
|
720
|
+
: [];
|
|
721
|
+
const hasAnyTable = tables.length > 0;
|
|
722
|
+
doc.facts = { items };
|
|
723
|
+
if (!primary) {
|
|
724
|
+
// 兜底:把源目录里没被其它文档消费的文件列成「未登记素材」
|
|
725
|
+
const inventory = [];
|
|
726
|
+
if (existsSync(sourceRoot)) {
|
|
727
|
+
const countFiles = (dir, depth = 0) => {
|
|
728
|
+
if (depth > 3) return 0;
|
|
729
|
+
let total = 0;
|
|
730
|
+
for (const entry of readdirSync(dir)) {
|
|
731
|
+
if (entry.startsWith(".")) continue;
|
|
732
|
+
const full = join(dir, entry);
|
|
733
|
+
total += statSync(full).isDirectory() ? countFiles(full, depth + 1) : 1;
|
|
734
|
+
}
|
|
735
|
+
return total;
|
|
736
|
+
};
|
|
737
|
+
for (const name of readdirSync(sourceRoot)) {
|
|
738
|
+
if (name.startsWith(".")) continue;
|
|
739
|
+
const file = join(sourceRoot, name);
|
|
740
|
+
if (statSync(file).isDirectory()) {
|
|
741
|
+
const count = countFiles(file);
|
|
742
|
+
if (count) inventory.push({ name: `${name}/`, link: `${name}/`, summary: `目录 · ${count} 个文件` });
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (/^(script|intent|storyline|design|changelog|feedback|materials|CURRENT|retro|case-cards|research-status|external-case-research|style-reference|implementation-principles)\.md$/.test(name)) continue;
|
|
746
|
+
inventory.push({ name, link: name, summary: "" });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
doc.facts = { items: inventory };
|
|
750
|
+
doc.status = inventory.length || pages.length ? "partial" : "missing";
|
|
751
|
+
doc.issues.push("缺 materials.md:支撑材料只有文件清单,没有登记「来源 / 链接 / 总结」");
|
|
752
|
+
} else if (!items.length) {
|
|
753
|
+
doc.status = "partial";
|
|
754
|
+
doc.issues.push(
|
|
755
|
+
hasAnyTable
|
|
756
|
+
? "materials.md 里有表格,但没有一张是材料清单(表头需含 文件名 / 名称 + 链接 / 路径 + 总结),所以这里不显示条目;请让 Agent 用 deck_doc_update 补一张清单"
|
|
757
|
+
: "materials.md 里还没有材料清单表(建议表头:文件名 / 链接 / 总结)",
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
doc.status = "ok";
|
|
761
|
+
}
|
|
762
|
+
} else if (spec.id === "review" || spec.id === "factcheck") {
|
|
763
|
+
const { rows } = parseTable(content);
|
|
764
|
+
doc.facts = { rows };
|
|
765
|
+
if (!primary) {
|
|
766
|
+
doc.status = "missing";
|
|
767
|
+
doc.issues.push(spec.id === "review" ? "缺审稿意见(review.md):交付时建议跑一次 deck_review(不挡出图)" : "缺事实核对表(fact-check.md)");
|
|
768
|
+
} else if (!rows.length) {
|
|
769
|
+
doc.status = "partial";
|
|
770
|
+
doc.issues.push("没有解析出条目(建议表格 + 表头;审稿用 严重度/类别/位置/证据/修复建议)");
|
|
771
|
+
} else if (/TODO|待填|待确认/.test(content)) {
|
|
772
|
+
doc.status = "partial";
|
|
773
|
+
doc.issues.push("还有占位 / 待确认内容");
|
|
774
|
+
} else {
|
|
775
|
+
doc.status = "ok";
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// 只在工作副本里的草案:字段即使写全也还没进源 deck,治理上按 draft 计。
|
|
779
|
+
if (kind === "draft" && doc.status === "ok") {
|
|
780
|
+
doc.status = "draft";
|
|
781
|
+
doc.issues.push("内容在工作副本 docs/,确认后回写源 deck");
|
|
782
|
+
}
|
|
783
|
+
return doc;
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const counts = docs.reduce((acc, doc) => {
|
|
787
|
+
acc[doc.status] = (acc[doc.status] ?? 0) + 1;
|
|
788
|
+
return acc;
|
|
789
|
+
}, {});
|
|
790
|
+
const weight = { ok: 1, derived: 0.85, draft: 0.5, partial: 0.5, missing: 0 };
|
|
791
|
+
const score = Math.round((docs.reduce((sum, doc) => sum + (weight[doc.status] ?? 0), 0) / docs.length) * 100);
|
|
792
|
+
return {
|
|
793
|
+
docs,
|
|
794
|
+
governance: {
|
|
795
|
+
deckId: adapter?.id ?? null,
|
|
796
|
+
score,
|
|
797
|
+
counts,
|
|
798
|
+
total: docs.length,
|
|
799
|
+
missing: docs.filter((doc) => doc.status === "missing").map((doc) => doc.id),
|
|
800
|
+
partial: docs.filter((doc) => doc.status === "partial" || doc.status === "draft").map((doc) => doc.id),
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/* ------------------------------------------------------------------ *
|
|
806
|
+
* 单文档(含正文)
|
|
807
|
+
* ------------------------------------------------------------------ */
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 读取单份文档:正文 + 结构化视图,供中间区对应标签渲染。
|
|
811
|
+
* @param {object} input
|
|
812
|
+
*/
|
|
813
|
+
export function readDeckDoc({ adapter, deckDir, docId, render = {}, revisions = [], events = [] }) {
|
|
814
|
+
const spec = DOC_SPECS.find((item) => item.id === docId);
|
|
815
|
+
if (!spec) return null;
|
|
816
|
+
const { docs } = resolveDeckDocs({ adapter, deckDir, render, revisions, events });
|
|
817
|
+
const doc = docs.find((item) => item.id === docId);
|
|
818
|
+
const sourceRoot = adapter?.sourceRoot ?? "";
|
|
819
|
+
const draftFile = draftPath(deckDir, docId);
|
|
820
|
+
const sourceFile = spec.candidates.map((name) => join(sourceRoot, name)).find((file) => existsSync(file));
|
|
821
|
+
const primaryFile = sourceFile ?? (existsSync(draftFile) ? draftFile : null);
|
|
822
|
+
const content = primaryFile ? readText(primaryFile) ?? "" : "";
|
|
823
|
+
const script = adapter?.readScriptRaw?.() ?? readText(join(sourceRoot, "script.md")) ?? "";
|
|
824
|
+
const pages = adapter?.storyPages?.() ?? [];
|
|
825
|
+
const prompts = adapter?.prompts?.() ?? {};
|
|
826
|
+
|
|
827
|
+
const parsed = {};
|
|
828
|
+
if (docId === "intent") {
|
|
829
|
+
parsed.fields = doc.facts.fields;
|
|
830
|
+
parsed.derived = doc.facts.derived;
|
|
831
|
+
} else if (docId === "script") {
|
|
832
|
+
parsed.pages = pages.map((page) => {
|
|
833
|
+
const pageId = `p${String(page.page).padStart(2, "0")}`;
|
|
834
|
+
return {
|
|
835
|
+
id: pageId,
|
|
836
|
+
page: page.page,
|
|
837
|
+
title: page.title,
|
|
838
|
+
subtitle: page.question,
|
|
839
|
+
claim: page.claim,
|
|
840
|
+
// F-108:正文既可能是 **正文** 字段行,也可能是标题行以下的散段;两处都取,
|
|
841
|
+
// 否则脚本页永远显示「(这页没有正文)」,用户在 UI 里看不到 Agent 写的内容。
|
|
842
|
+
content: [page.fields?.["正文"], page.content]
|
|
843
|
+
.map((part) => String(part ?? "").trim())
|
|
844
|
+
.filter(Boolean)
|
|
845
|
+
.join("\n\n"),
|
|
846
|
+
prompt: prompts[pageId] || page.fields?.["提示词"] || "",
|
|
847
|
+
source: page.source,
|
|
848
|
+
};
|
|
849
|
+
});
|
|
850
|
+
parsed.segments = doc.facts.segments ?? [];
|
|
851
|
+
} else if (docId === "design") {
|
|
852
|
+
parsed.rules = doc.facts.rules;
|
|
853
|
+
parsed.render = doc.facts.render;
|
|
854
|
+
} else if (docId === "changelog") {
|
|
855
|
+
parsed.entries = doc.facts.entries;
|
|
856
|
+
parsed.feed = doc.facts.feed ?? [];
|
|
857
|
+
} else if (docId === "feedback") {
|
|
858
|
+
parsed.rows = doc.facts.rows;
|
|
859
|
+
} else if (docId === "research") {
|
|
860
|
+
parsed.entries = doc.facts.entries;
|
|
861
|
+
parsed.archiveCount = doc.facts.archiveCount;
|
|
862
|
+
parsed.archive = (() => {
|
|
863
|
+
const dir = join(deckDir, "research", "files");
|
|
864
|
+
if (!existsSync(dir)) return [];
|
|
865
|
+
return readdirSync(dir).map((name) => ({ name, sizeBytes: sizeOf(join(dir, name)), path: `research/files/${name}` }));
|
|
866
|
+
})();
|
|
867
|
+
} else if (docId === "materials") {
|
|
868
|
+
parsed.items = doc.facts.items;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return { ...doc, content, parsed };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* ------------------------------------------------------------------ *
|
|
875
|
+
* 模板与脚手架(只写工作副本,不动源 deck)
|
|
876
|
+
* ------------------------------------------------------------------ */
|
|
877
|
+
|
|
878
|
+
export const DOC_TEMPLATES = {
|
|
879
|
+
intent: `---
|
|
880
|
+
title: (deck 标题)
|
|
881
|
+
owner: (负责人)
|
|
882
|
+
updated: (YYYY-MM-DD)
|
|
883
|
+
status: draft
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
# 意图
|
|
887
|
+
|
|
888
|
+
- **听众**:TODO 讲给谁(角色 / 部门 / 层级 / 对 AI 的熟悉度)
|
|
889
|
+
- **主题**:TODO 一句话说清这场讲什么
|
|
890
|
+
- **带走**:TODO 听完他们应该能带走什么(1–3 条,可验证)
|
|
891
|
+
- **场合 / 时长**:TODO(内部培训 / 客户评审 / 分享会;多少分钟)
|
|
892
|
+
- **成功标准**:TODO 怎么判断这场讲成了
|
|
893
|
+
- **不讲的**:TODO 明确排除什么(避免范围膨胀)
|
|
894
|
+
|
|
895
|
+
## 故事线(叙事结构)
|
|
896
|
+
|
|
897
|
+
> 这里写"为什么这个顺序",脚本页的叙事概览是从 script 自动派生的,不复制。
|
|
898
|
+
|
|
899
|
+
- **一句话主线**:TODO
|
|
900
|
+
- **分段**:
|
|
901
|
+
- 进入现场(01–03):TODO 这一段的任务
|
|
902
|
+
- 主体(04–08):TODO
|
|
903
|
+
- 收束(19–21):TODO
|
|
904
|
+
`,
|
|
905
|
+
script: `---
|
|
906
|
+
title: (deck 标题)· 逐页脚本
|
|
907
|
+
render: image
|
|
908
|
+
status: draft
|
|
909
|
+
---
|
|
910
|
+
|
|
911
|
+
# 逐页脚本
|
|
912
|
+
|
|
913
|
+
### P01 · 封面
|
|
914
|
+
|
|
915
|
+
**标题**:TODO
|
|
916
|
+
**副标题**:TODO
|
|
917
|
+
**页面内容**:
|
|
918
|
+
|
|
919
|
+
- TODO
|
|
920
|
+
|
|
921
|
+
**讲述意图**:TODO
|
|
922
|
+
**画面说明**:
|
|
923
|
+
|
|
924
|
+
- TODO
|
|
925
|
+
`,
|
|
926
|
+
design: `---
|
|
927
|
+
title: (deck 标题)· 画面规范
|
|
928
|
+
status: draft
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
# 画面规范
|
|
932
|
+
|
|
933
|
+
## 视觉定位
|
|
934
|
+
|
|
935
|
+
TODO 一句话说清这是什么风格的 deck。
|
|
936
|
+
|
|
937
|
+
## 画面约束
|
|
938
|
+
|
|
939
|
+
- 背景:TODO
|
|
940
|
+
- 线条 / 材质:TODO
|
|
941
|
+
- 主色:TODO
|
|
942
|
+
- 人物:TODO
|
|
943
|
+
- 构图:TODO
|
|
944
|
+
- 禁止:TODO
|
|
945
|
+
|
|
946
|
+
## 模板 / 版式
|
|
947
|
+
|
|
948
|
+
- 封面:TODO
|
|
949
|
+
- 信息页:TODO
|
|
950
|
+
- 案例页:TODO
|
|
951
|
+
|
|
952
|
+
## 出图参数
|
|
953
|
+
|
|
954
|
+
- 模型 / 节点 / 尺寸:TODO(与 profile 里的 render 配置一致)
|
|
955
|
+
`,
|
|
956
|
+
changelog: `---
|
|
957
|
+
title: (deck 标题)· 修改日志
|
|
958
|
+
status: active
|
|
959
|
+
---
|
|
960
|
+
|
|
961
|
+
# 修改日志
|
|
962
|
+
|
|
963
|
+
> 每次改动先写这里,再改文件。改动要能追溯到人 / 反馈 / 版本。
|
|
964
|
+
|
|
965
|
+
- YYYY-MM-DD · 初稿 · TODO · 确认人:TODO
|
|
966
|
+
`,
|
|
967
|
+
feedback: `---
|
|
968
|
+
title: (deck 标题)· 反馈
|
|
969
|
+
status: active
|
|
970
|
+
---
|
|
971
|
+
|
|
972
|
+
# 反馈
|
|
973
|
+
|
|
974
|
+
| 日期 | 对象 | 原话 | 处理 | 层 | 标签 |
|
|
975
|
+
|---|---|---|---|---|---|
|
|
976
|
+
| YYYY-MM-DD | TODO | TODO | TODO | A/B/C | TODO |
|
|
977
|
+
`,
|
|
978
|
+
research: `---
|
|
979
|
+
title: (deck 标题)· 调研台账
|
|
980
|
+
status: draft
|
|
981
|
+
---
|
|
982
|
+
|
|
983
|
+
# 调研台账
|
|
984
|
+
|
|
985
|
+
> 一条调研一行:出处 / 发表时间 / 原文链接 / 本地存档 / 总结。
|
|
986
|
+
> 原文下载到 \`research/files/\`,本地文件列填相对路径(如 \`research/files/anthropic-context-engineering.md\`)。
|
|
987
|
+
|
|
988
|
+
| 出处 | 标题 | 发表时间 | 原文链接 | 本地文件 | 总结 |
|
|
989
|
+
|---|---|---|---|---|---|
|
|
990
|
+
| TODO | TODO | YYYY-MM-DD | https:// | research/files/ | TODO |
|
|
991
|
+
`,
|
|
992
|
+
materials: `---
|
|
993
|
+
title: (deck 标题)· 支撑材料
|
|
994
|
+
status: draft
|
|
995
|
+
---
|
|
996
|
+
|
|
997
|
+
# 支撑材料
|
|
998
|
+
|
|
999
|
+
> 内部项目记录与素材:文件名 / 链接 / 总结。
|
|
1000
|
+
|
|
1001
|
+
| 文件名 | 链接 | 总结 |
|
|
1002
|
+
|---|---|---|
|
|
1003
|
+
| TODO | TODO | TODO |
|
|
1004
|
+
`,
|
|
1005
|
+
review: `---
|
|
1006
|
+
title: (deck 标题)· 审稿意见
|
|
1007
|
+
status: draft
|
|
1008
|
+
---
|
|
1009
|
+
|
|
1010
|
+
# 审稿意见
|
|
1011
|
+
|
|
1012
|
+
> 由 \`deck_review\` 生成:每条问题带严重度 / 位置 / 证据 / 修复建议;只审不改、不直接出图。
|
|
1013
|
+
|
|
1014
|
+
## 问题清单
|
|
1015
|
+
|
|
1016
|
+
| 严重度 | 类别 | 位置 | 证据 | 修复建议 |
|
|
1017
|
+
|---|---|---|---|---|
|
|
1018
|
+
| TODO | TODO | TODO | TODO | TODO |
|
|
1019
|
+
|
|
1020
|
+
## 结论
|
|
1021
|
+
|
|
1022
|
+
- TODO:可交付 / 需修改 / 不可交付 + 依据(审了哪些文档、哪些页)。
|
|
1023
|
+
`,
|
|
1024
|
+
factcheck: `---
|
|
1025
|
+
title: (deck 标题)· 事实核对
|
|
1026
|
+
status: draft
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
# 事实核对
|
|
1030
|
+
|
|
1031
|
+
> 每条断言一行:这句话是谁说/哪份材料说的,核对结论是否一致。
|
|
1032
|
+
|
|
1033
|
+
| 断言 | 来源 | 结论 | 说明 |
|
|
1034
|
+
|---|---|---|---|
|
|
1035
|
+
| TODO | TODO | 一致 / 冲突 / 无依据 | TODO |
|
|
1036
|
+
`,
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* 给缺失的文档打骨架(写到 `.deck/decks/<deckId>/docs/`,不碰源 deck)。
|
|
1041
|
+
* @returns {{created: string[], skipped: string[]}}
|
|
1042
|
+
*/
|
|
1043
|
+
export function scaffoldDeckDocs({ adapter, deckDir, docIds }) {
|
|
1044
|
+
const wanted = docIds?.length ? docIds : DOC_SPECS.map((spec) => spec.id);
|
|
1045
|
+
const created = [];
|
|
1046
|
+
const skipped = [];
|
|
1047
|
+
for (const id of wanted) {
|
|
1048
|
+
const spec = DOC_SPECS.find((item) => item.id === id);
|
|
1049
|
+
if (!spec) continue;
|
|
1050
|
+
const sourceExists = spec.candidates.some((name) => existsSync(join(adapter?.sourceRoot ?? "", name)));
|
|
1051
|
+
const target = draftPath(deckDir, id);
|
|
1052
|
+
if (sourceExists || existsSync(target)) {
|
|
1053
|
+
skipped.push(id);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
mkdirSync(join(target, ".."), { recursive: true });
|
|
1057
|
+
writeFileSync(target, DOC_TEMPLATES[id] ?? `# ${spec.label}\n`, "utf8");
|
|
1058
|
+
const fileName = id === "research" ? "research/index.md" : id === "factcheck" ? "fact-check.md" : `${id}.md`;
|
|
1059
|
+
created.push(`docs/${fileName}`);
|
|
1060
|
+
}
|
|
1061
|
+
return { created, skipped };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/* ------------------------------------------------------------------ *
|
|
1065
|
+
* 文档文件访问(工作副本里的草案 / 调研原文)
|
|
1066
|
+
* ------------------------------------------------------------------ */
|
|
1067
|
+
|
|
1068
|
+
const SAFE_REL = /^[A-Za-z0-9._\-\u4e00-\u9fa5/]+$/;
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 解析工作副本内的文档文件路径(只允许 docs/ 与 research/files/)。
|
|
1072
|
+
* 越界 / `..` / 绝对路径一律 null(路由层返回 404)。
|
|
1073
|
+
*/
|
|
1074
|
+
export function resolveDocFile({ deckDir, docId, name }) {
|
|
1075
|
+
if (!name || typeof name !== "string" || !SAFE_REL.test(name) || name.includes("..")) return null;
|
|
1076
|
+
const base = docId === "research" ? join(deckDir, "research") : join(deckDir, "docs");
|
|
1077
|
+
const file = join(base, name.replace(/^\//, ""));
|
|
1078
|
+
if (!file.startsWith(base)) return null;
|
|
1079
|
+
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
1080
|
+
return { file, name, sizeBytes: sizeOf(file) };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export const DOC_FILE_EXTS = {
|
|
1084
|
+
".md": "text/markdown; charset=utf-8",
|
|
1085
|
+
".txt": "text/plain; charset=utf-8",
|
|
1086
|
+
".json": "application/json; charset=utf-8",
|
|
1087
|
+
".png": "image/png",
|
|
1088
|
+
".jpg": "image/jpeg",
|
|
1089
|
+
".jpeg": "image/jpeg",
|
|
1090
|
+
".webp": "image/webp",
|
|
1091
|
+
".svg": "image/svg+xml",
|
|
1092
|
+
".pdf": "application/pdf",
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
export { slugOf };
|