@cloud411716/fancy-webnovel 1.0.45 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-analyze plugin entry
|
|
3
|
+
*
|
|
4
|
+
* 触发命令: /fancy-analyze [书名]
|
|
5
|
+
*
|
|
6
|
+
* 流程:
|
|
7
|
+
* 1. 检查 .fancy-deployed 存在(项目已初始化)
|
|
8
|
+
* 2. 取书名:参数 / 弹窗
|
|
9
|
+
* 3. 在 参考资料/ 下查找匹配的源文件
|
|
10
|
+
* 4. 调用 llm_wiki API 创建项目
|
|
11
|
+
* 5. 拷贝 schema-analyze.md → 对标/schema.md
|
|
12
|
+
* 6. 执行 split_source.js 拆书,结果存到 对标/raw/sources/<书名>/
|
|
13
|
+
* 7. 调用 llm_wiki sources/ingest 接口
|
|
14
|
+
* 8. 通知完成
|
|
15
|
+
*
|
|
16
|
+
* 不依赖 phase:这个 skill 可以独立于整条流水线使用
|
|
17
|
+
* (用户在写正文之前、中途、之后都可以调用来分析参考爆款)
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, statSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
|
|
20
|
+
import { join, dirname, basename } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { askUserQuestion, spawnScript } from '#infra';
|
|
23
|
+
import { notify, llmFollowup } from '#infra';
|
|
24
|
+
import { describeFetchError, llmWikiError, readJsonSafe } from '#llm-wiki';
|
|
25
|
+
import { readDeployedState, DEPLOYED_STATUS } from '#deployed-state';
|
|
26
|
+
import { analyze } from './templates.js';
|
|
27
|
+
import {
|
|
28
|
+
scanSummaryProgress,
|
|
29
|
+
dirsToDispatch,
|
|
30
|
+
listTechDirs,
|
|
31
|
+
} from './summary-progress.js';
|
|
32
|
+
import { normalizeCommandInput, matchSubcommand } from './command-input.js';
|
|
33
|
+
|
|
34
|
+
export const inject = ['commands', 'userQuestions'];
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const SPLIT_SCRIPT = join(__dirname, 'scripts', 'split_source.js');
|
|
38
|
+
const SCHEMA_SOURCE = join(__dirname, 'schema-analyze.md');
|
|
39
|
+
|
|
40
|
+
/** resummary 备份旧总结文件的目录(放在 对标/ 下,不在 wiki 树里,免得被当成技法目录)。 */
|
|
41
|
+
const SUMMARY_BACKUP_DIRNAME = '.summary-backup';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 把时间戳格式化成 "20261026-1930" 这样的目录名。
|
|
45
|
+
* @param {number} ts - 毫秒时间戳
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function backupStamp(ts) {
|
|
49
|
+
const d = new Date(ts);
|
|
50
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
51
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getProjectRoot(session) {
|
|
55
|
+
// 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
|
|
56
|
+
// 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
|
|
57
|
+
// 此时返回 null,由调用方给出明确报错。
|
|
58
|
+
const cwd = session.header?.cwd;
|
|
59
|
+
return cwd || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取 .fancy-deployed 配置
|
|
64
|
+
*
|
|
65
|
+
* 必须区分「读不到」和「读到了但坏了」:
|
|
66
|
+
* 之前一律返回 null,于是 .fancy-deployed 是坏 JSON 时也报
|
|
67
|
+
* "尚未初始化,请先运行 /fancy-bootstrap" —— 而 bootstrap 在这个
|
|
68
|
+
* 状态下多半也跑不通(文件已存在),用户会被这条错误指引带偏。
|
|
69
|
+
*
|
|
70
|
+
* @returns {{config: object|null, error: 'missing'|'corrupt'|'no_phase'|null, message?: string}}
|
|
71
|
+
*/
|
|
72
|
+
function readDeployedConfig(projectRoot) {
|
|
73
|
+
const state = readDeployedState(projectRoot);
|
|
74
|
+
if (state.status === DEPLOYED_STATUS.OK) {
|
|
75
|
+
return { config: state.config, error: null };
|
|
76
|
+
}
|
|
77
|
+
if (state.status === DEPLOYED_STATUS.MISSING) {
|
|
78
|
+
return { config: null, error: 'missing' };
|
|
79
|
+
}
|
|
80
|
+
return { config: null, error: 'corrupt', message: state.error || state.status };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 更新 .fancy-deployed 中的指定字段
|
|
85
|
+
*
|
|
86
|
+
* 必须原子写:直接 writeFileSync 在写到一半时断电/中断,
|
|
87
|
+
* 会把 .fancy-deployed 截断成半个 JSON —— 而这个文件是所有插件
|
|
88
|
+
* 的项目状态源,坏掉之后整个项目都进不去。
|
|
89
|
+
* 仓库里 fancy-topic / fancy-scan 都用 tmp + rename 的写法。
|
|
90
|
+
*/
|
|
91
|
+
function updateDeployedField(projectRoot, key, value) {
|
|
92
|
+
const deployedPath = join(projectRoot, '.fancy-deployed');
|
|
93
|
+
const { config } = readDeployedConfig(projectRoot);
|
|
94
|
+
if (!config) return false;
|
|
95
|
+
config[key] = value;
|
|
96
|
+
const tmpPath = join(dirname(deployedPath), `.${Date.now()}-${process.pid}.tmp`);
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
99
|
+
renameSync(tmpPath, deployedPath);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
try { unlinkSync(tmpPath); } catch { /* 清理失败无所谓 */ }
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 在 handler 里取 .fancy-deployed 配置。
|
|
109
|
+
*
|
|
110
|
+
* 三条路径(fix / status / 普通)都要用,所以收成一个入口,
|
|
111
|
+
* 免得"损坏 vs 未初始化"在某个分支上又被压成同一种错误。
|
|
112
|
+
*
|
|
113
|
+
* @returns {{config: object|null, errorText: string|null}}
|
|
114
|
+
*/
|
|
115
|
+
function loadDeployedConfig(projectRoot) {
|
|
116
|
+
const { config, error, message } = readDeployedConfig(projectRoot);
|
|
117
|
+
if (error === 'missing') {
|
|
118
|
+
return { config: null, errorText: analyze.notify({ type: 'not_initialized' }) };
|
|
119
|
+
}
|
|
120
|
+
if (error) {
|
|
121
|
+
return {
|
|
122
|
+
config: null,
|
|
123
|
+
errorText: analyze.notify({ type: 'deployed_unreadable', err: message }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { config, errorText: null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 调用 llm_wiki API
|
|
131
|
+
* @param {string} baseUrl - llm_wiki_url(已去除末尾斜杠)
|
|
132
|
+
* @param {string} token - llm_wiki_token
|
|
133
|
+
* @param {string} path - API 路径
|
|
134
|
+
* @param {string} method - HTTP 方法
|
|
135
|
+
* @param {object} body - 请求体
|
|
136
|
+
* @param {number} [timeoutMs] - 超时(默认 30s)
|
|
137
|
+
*/
|
|
138
|
+
async function callLlmWikiApi(baseUrl, token, path, method = 'POST', body = null, timeoutMs = 30000) {
|
|
139
|
+
const url = `${baseUrl}${path}`;
|
|
140
|
+
const headers = {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'Authorization': `Bearer ${token}`,
|
|
143
|
+
};
|
|
144
|
+
const options = {
|
|
145
|
+
method,
|
|
146
|
+
headers,
|
|
147
|
+
};
|
|
148
|
+
if (body) {
|
|
149
|
+
options.body = JSON.stringify(body);
|
|
150
|
+
}
|
|
151
|
+
// 必须有超时:fetch 默认永不超时,llm_wiki 挂住时命令会永远卡着,
|
|
152
|
+
// 用户既看不到提示也拿不回终端。
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
157
|
+
// 响应体可能不是 JSON(例如网关返回 HTML 错误页)——
|
|
158
|
+
// readJsonSafe 返回 null 而不是抛 SyntaxError,把真正的原因盖掉。
|
|
159
|
+
const data = await readJsonSafe(response);
|
|
160
|
+
return { status: response.status, data };
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 转义正则元字符(用于把用户/文件里的字符串安全地拼进正则)。
|
|
168
|
+
*/
|
|
169
|
+
function escapeRegExp(s) {
|
|
170
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 查询 ingest 状态
|
|
175
|
+
* @param {string} baseUrl - llm_wiki_url
|
|
176
|
+
* @param {string} token - llm_wiki_token
|
|
177
|
+
* @param {string} projectId - reference_id
|
|
178
|
+
*/
|
|
179
|
+
async function queryIngestStatus(baseUrl, token, projectId) {
|
|
180
|
+
return callLlmWikiApi(baseUrl, token, `/projects/${projectId}/ingest/querystatus`, 'GET', null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 在 参考资料/ 下查找匹配的源文件
|
|
185
|
+
*
|
|
186
|
+
* 匹配规则:
|
|
187
|
+
* - 接受文件名(不带后缀)或完整文件名(带后缀)
|
|
188
|
+
* - 忽略后缀比较(.txt / .md / .epub / .docx 都算同名的"诡秘之主")
|
|
189
|
+
* - 不区分大小写
|
|
190
|
+
*
|
|
191
|
+
* 支持目录结构:
|
|
192
|
+
* 参考资料/<书名>.txt (文件)
|
|
193
|
+
*
|
|
194
|
+
* 返回:
|
|
195
|
+
* { path } 找到唯一匹配
|
|
196
|
+
* { error: 'dir_missing' } 参考资料/ 不存在
|
|
197
|
+
* { error: 'not_found', matches: [...] } 无匹配,列出目录下所有条目供用户参考
|
|
198
|
+
* { error: 'ambiguous', matches: [...] } 多个匹配
|
|
199
|
+
*/
|
|
200
|
+
function findSourceFile(refDir, bookName) {
|
|
201
|
+
if (!existsSync(refDir)) {
|
|
202
|
+
return { error: 'dir_missing' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stripExt = (s) => s.toLowerCase().replace(/\.[^./\\]+$/g, '');
|
|
206
|
+
// 只取文件名部分,忽略用户可能输入的路径前缀
|
|
207
|
+
const targetBase = bookName.trim().split(/[\\/]/).pop();
|
|
208
|
+
const target = stripExt(targetBase);
|
|
209
|
+
|
|
210
|
+
const stat = statSync(refDir);
|
|
211
|
+
const entries = stat.isDirectory()
|
|
212
|
+
? readdirSync(refDir)
|
|
213
|
+
: [];
|
|
214
|
+
|
|
215
|
+
// 情况:参考资料/<书名>.txt(文件)
|
|
216
|
+
const directMatches = [];
|
|
217
|
+
for (const name of entries) {
|
|
218
|
+
if (stripExt(name) === target) {
|
|
219
|
+
const full = join(refDir, name);
|
|
220
|
+
const st = statSync(full);
|
|
221
|
+
if (st.isFile()) {
|
|
222
|
+
directMatches.push({ path: full, kind: 'file' });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (directMatches.length === 1) {
|
|
228
|
+
return { path: directMatches[0].path };
|
|
229
|
+
}
|
|
230
|
+
if (directMatches.length > 1) {
|
|
231
|
+
return { error: 'ambiguous', matches: directMatches.map(m => m.path) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 没有匹配 → 列出目录下所有可识别的条目
|
|
235
|
+
const allCandidates = entries
|
|
236
|
+
.map(name => ({ name, stripped: stripExt(name) }))
|
|
237
|
+
.filter(e => e.name !== '.DS_Store' && e.name !== 'Thumbs.db');
|
|
238
|
+
return { error: 'not_found', matches: allCandidates.map(c => c.name) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 拷贝 schema 文件到目标目录
|
|
243
|
+
*/
|
|
244
|
+
function copySchemaFile(targetDir) {
|
|
245
|
+
const targetPath = join(targetDir, 'schema.md');
|
|
246
|
+
mkdirSync(targetDir, { recursive: true });
|
|
247
|
+
copyFileSync(SCHEMA_SOURCE, targetPath);
|
|
248
|
+
return targetPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function apply(ctx) {
|
|
252
|
+
ctx.commands.register({
|
|
253
|
+
name: 'fancy-analyze',
|
|
254
|
+
description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] )',
|
|
255
|
+
input: {
|
|
256
|
+
hint: '.',
|
|
257
|
+
},
|
|
258
|
+
handler: async (invocation) => {
|
|
259
|
+
const session = invocation.agent.session;
|
|
260
|
+
const { signal } = invocation;
|
|
261
|
+
|
|
262
|
+
const projectRoot = getProjectRoot(session);
|
|
263
|
+
if (!projectRoot) {
|
|
264
|
+
return notify(session, 'error', analyze.notify({
|
|
265
|
+
type: 'no_project_root',
|
|
266
|
+
err: '会话未提供 cwd'
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 1. 检查 .fancy-deployed
|
|
271
|
+
if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
|
|
272
|
+
return notify(session, 'error', analyze.notify({ type: 'not_initialized' }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2. 解析命令类型
|
|
276
|
+
// 2. 解析命令类型
|
|
277
|
+
//
|
|
278
|
+
// 输入要先**剥掉不可见字符**再判断。
|
|
279
|
+
//
|
|
280
|
+
// ## 为什么
|
|
281
|
+
//
|
|
282
|
+
// 实测用户输入过 `summary-status\u200bsummary`(零宽空格 U+200B 粘在中间)。
|
|
283
|
+
// 旧代码直接 `lowerInput === 'summary-status'` 精确比较,于是这条命令
|
|
284
|
+
// **既不是** summary-status **也不是** summary —— 它掉进了"普通模式"
|
|
285
|
+
// (按书名拆书),而拆书会调 llm_wiki 的 sources/ingest 接口,
|
|
286
|
+
// 那条路**有破坏性**。
|
|
287
|
+
//
|
|
288
|
+
// 子命令判断失败时掉进一个有破坏性的分支,是很糟的默认行为。
|
|
289
|
+
// 所以:
|
|
290
|
+
// 1. 剥掉不可见字符(零宽空格/零宽连接符/BOM/不换行空格…);
|
|
291
|
+
// 2. 用**前缀匹配**认子命令 —— 用户手滑多打了几个字,
|
|
292
|
+
// 意图明显是那个子命令,不该被当成书名去拆书。
|
|
293
|
+
const rawInput = normalizeCommandInput(invocation.rawInput || '');
|
|
294
|
+
const lowerInput = rawInput.toLowerCase();
|
|
295
|
+
const sub = matchSubcommand(lowerInput);
|
|
296
|
+
const isStatus = sub === 'status';
|
|
297
|
+
const isFix = sub === 'fix';
|
|
298
|
+
const isSummary = sub === 'summary';
|
|
299
|
+
const isSummaryStatus = sub === 'summary-status';
|
|
300
|
+
const isResummary = sub === 'resummary';
|
|
301
|
+
const resummaryTarget = isResummary
|
|
302
|
+
? rawInput.slice('resummary'.length).trim()
|
|
303
|
+
: '';
|
|
304
|
+
|
|
305
|
+
// 2.1 fix 模式:重新 ingest 缺失的文件
|
|
306
|
+
if (isFix) {
|
|
307
|
+
const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
|
|
308
|
+
if (configError) {
|
|
309
|
+
return notify(session, 'error', configError);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
|
|
313
|
+
const llmWikiToken = deployedConfig.llm_wiki_token || '';
|
|
314
|
+
const referenceId = deployedConfig.reference_id || '';
|
|
315
|
+
|
|
316
|
+
if (!llmWikiUrl || !llmWikiToken) {
|
|
317
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!referenceId) {
|
|
321
|
+
return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 读取 .retry 文件
|
|
325
|
+
const retryFilePath = join(projectRoot, '.retry');
|
|
326
|
+
if (!existsSync(retryFilePath)) {
|
|
327
|
+
return notify(session, 'error', `没有找到 .retry 文件。
|
|
328
|
+
1. 如果已执行过 fancy-analyze status 子命令则代表已成功提取,可以继续执行 fancy-analyze summary
|
|
329
|
+
2. 如果未执行过 fancy-analyze status 子命令请先执行该命令`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let retryData;
|
|
333
|
+
try {
|
|
334
|
+
retryData = JSON.parse(readFileSync(retryFilePath, 'utf-8'));
|
|
335
|
+
} catch {
|
|
336
|
+
return notify(session, 'error', '.retry 文件格式错误');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!retryData || !Array.isArray(retryData.items) || retryData.items.length === 0) {
|
|
340
|
+
return notify(session, 'success', '没有需要重试的项目');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
|
|
344
|
+
const results = [];
|
|
345
|
+
const errors = [];
|
|
346
|
+
|
|
347
|
+
for (const item of retryData.items) {
|
|
348
|
+
const { bookName, chapter } = item;
|
|
349
|
+
// 定位文件:书名/ 目录下查找文件名包含"第X章"的文件
|
|
350
|
+
const bookDir = join(rawSourcesDir, bookName);
|
|
351
|
+
if (!existsSync(bookDir)) {
|
|
352
|
+
errors.push(`未找到目录:${bookName}`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let matchedFile = null;
|
|
357
|
+
try {
|
|
358
|
+
const files = readdirSync(bookDir);
|
|
359
|
+
// 查找文件名中包含"第X章"的文件(如:第8章-心好痛.txt)
|
|
360
|
+
// chapter 来自 .retry(status 生成的"第N章"),但仍要转义:
|
|
361
|
+
// 直接拼进正则的话,一旦内容里出现正则元字符就会抛 SyntaxError,
|
|
362
|
+
// 而且 "." 未转义会让"第1章"匹配上"第1章X"这种文件名。
|
|
363
|
+
const pattern = new RegExp(`${escapeRegExp(chapter)}.*\\.txt$`);
|
|
364
|
+
matchedFile = files.find(f => pattern.test(f));
|
|
365
|
+
if (!matchedFile) {
|
|
366
|
+
errors.push(`未找到文件:${bookName} 下包含 ${chapter} 的文件`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 清除 ingest-cache 中对应条目的 hash
|
|
371
|
+
const ingestCachePath = join(projectRoot, '对标', '.llm-wiki', 'ingest-cache.json');
|
|
372
|
+
if (existsSync(ingestCachePath)) {
|
|
373
|
+
try {
|
|
374
|
+
const cacheContent = readFileSync(ingestCachePath, 'utf-8');
|
|
375
|
+
const cacheData = JSON.parse(cacheContent);
|
|
376
|
+
const cacheKey = `${bookName}/${matchedFile}`;
|
|
377
|
+
if (cacheData.entries && cacheData.entries[cacheKey]) {
|
|
378
|
+
cacheData.entries[cacheKey].hash = '';
|
|
379
|
+
writeFileSync(ingestCachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// 忽略缓存更新错误
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const filePath = `raw/sources/${bookName}/${matchedFile}`;
|
|
387
|
+
// 必须检查返回:ingest 返回 4xx/5xx 时 llm_wiki 根本没收到文件,
|
|
388
|
+
// 之前这里不检查,命令照报"## 重新 ingest 成功"(实测:两个 POST
|
|
389
|
+
// 都返回 500,用户看到的仍是"成功")。
|
|
390
|
+
const ingestResult = await callLlmWikiApi(
|
|
391
|
+
llmWikiUrl,
|
|
392
|
+
llmWikiToken,
|
|
393
|
+
`/projects/${referenceId}/sources/ingest`,
|
|
394
|
+
'POST',
|
|
395
|
+
{ paths: [filePath] }
|
|
396
|
+
);
|
|
397
|
+
const apiErr = llmWikiError(ingestResult);
|
|
398
|
+
if (apiErr) {
|
|
399
|
+
errors.push(`${bookName}/${matchedFile} - ${apiErr}`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
results.push(`${bookName}/${matchedFile}`);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
errors.push(`${bookName}/${chapter} - ${describeFetchError(err)}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let message = '';
|
|
409
|
+
if (results.length > 0) {
|
|
410
|
+
message += '## 重新 ingest 成功\n' + results.join('\n');
|
|
411
|
+
}
|
|
412
|
+
if (errors.length > 0) {
|
|
413
|
+
if (results.length > 0) {
|
|
414
|
+
message += '\n';
|
|
415
|
+
}
|
|
416
|
+
message += '## 错误\n' + errors.join('\n');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 全部失败时,标题要单独一行 —— 之前 "重新 ingest 失败:" 直接
|
|
420
|
+
// 粘在 "## 错误" 前面,渲染出来是一行乱糟糟的文本。
|
|
421
|
+
const headline = (errors.length > 0 && results.length === 0)
|
|
422
|
+
? '重新 ingest 失败:\n\n'
|
|
423
|
+
: '';
|
|
424
|
+
const finalMessage = headline + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
|
|
425
|
+
return notify(session, results.length > 0 ? 'success' : 'error', finalMessage);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 2.2 status 模式
|
|
429
|
+
if (isStatus) {
|
|
430
|
+
const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
|
|
431
|
+
if (configError) {
|
|
432
|
+
return notify(session, 'error', configError);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
|
|
436
|
+
const llmWikiToken = deployedConfig.llm_wiki_token || '';
|
|
437
|
+
const referenceId = deployedConfig.reference_id || '';
|
|
438
|
+
|
|
439
|
+
if (!llmWikiUrl || !llmWikiToken) {
|
|
440
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!referenceId) {
|
|
444
|
+
return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 检查来源目录下的文件,从"### 列表"表格中获取【有/无】信息
|
|
448
|
+
// 同时读取"来源"和"来源摘要"两个目录
|
|
449
|
+
// 映射格式:{ "书名": { "第X章": { "技法名": true/false, ... }, ... } }
|
|
450
|
+
const bookChapterTechHas = {};
|
|
451
|
+
// 记录缺少列表的章节
|
|
452
|
+
const missingListReports = [];
|
|
453
|
+
|
|
454
|
+
const sourceDirs = ['来源', '来源摘要'];
|
|
455
|
+
for (const sourceDir of sourceDirs) {
|
|
456
|
+
const duiBiaoWikiDir = join(projectRoot, '对标', 'wiki', sourceDir);
|
|
457
|
+
if (!existsSync(duiBiaoWikiDir)) continue;
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const sourceFiles = readdirSync(duiBiaoWikiDir);
|
|
461
|
+
for (const file of sourceFiles) {
|
|
462
|
+
if (!file.endsWith('.md')) continue;
|
|
463
|
+
// 提取书名和第X章(如:全球高武-第10章-气血检查.md → 书名:全球高武, 第X章:第10章)
|
|
464
|
+
// 格式:书名-第X章-xxx.md 或 书名-技法名-第X章.md
|
|
465
|
+
const fullMatch = file.match(/^(.+?)-(第\d+章)-/);
|
|
466
|
+
if (!fullMatch) continue;
|
|
467
|
+
const bookName = fullMatch[1];
|
|
468
|
+
const chapterKey = fullMatch[2];
|
|
469
|
+
|
|
470
|
+
if (!bookChapterTechHas[bookName]) {
|
|
471
|
+
bookChapterTechHas[bookName] = {};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const content = readFileSync(join(duiBiaoWikiDir, file), 'utf-8');
|
|
475
|
+
|
|
476
|
+
// 查找"### 列表"三级标题的位置
|
|
477
|
+
const listHeadingIndex = content.indexOf('### 列表');
|
|
478
|
+
if (listHeadingIndex === -1) {
|
|
479
|
+
// 记录缺少列表的章节
|
|
480
|
+
missingListReports.push(`${bookName}${chapterKey}的列表未找到`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 从"### 列表"之后提取表格内容(到下一个三级标题之前)
|
|
485
|
+
const afterList = content.substring(listHeadingIndex);
|
|
486
|
+
const nextHeadingMatch = afterList.match(/\n#{1,3}\s+\S/);
|
|
487
|
+
const tableEndIndex = nextHeadingMatch
|
|
488
|
+
? afterList.indexOf(nextHeadingMatch[0])
|
|
489
|
+
: afterList.length;
|
|
490
|
+
const tableContent = afterList.substring(0, tableEndIndex);
|
|
491
|
+
|
|
492
|
+
// 解析 markdown 表格
|
|
493
|
+
const tableLines = tableContent.split('\n');
|
|
494
|
+
const techHas = {}; // { "世界观构建": true, "伏笔回收": false, ... }
|
|
495
|
+
|
|
496
|
+
for (const line of tableLines) {
|
|
497
|
+
// 匹配表格行:| 技法名 | 有/无 |
|
|
498
|
+
const rowMatch = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
|
|
499
|
+
if (rowMatch) {
|
|
500
|
+
const techName = rowMatch[1].trim();
|
|
501
|
+
const hasValue = rowMatch[2] === '有';
|
|
502
|
+
techHas[techName] = hasValue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
bookChapterTechHas[bookName][chapterKey] = techHas;
|
|
507
|
+
}
|
|
508
|
+
} catch {
|
|
509
|
+
// 忽略读取错误
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 检查各技法目录下是否有对应的文件
|
|
514
|
+
// 映射格式:{ "书名": { "技法名": ["第X章", "第Y章", ...], ... }, ... }
|
|
515
|
+
const bookTechChapters = {};
|
|
516
|
+
|
|
517
|
+
const duiBiaoWikiRoot = join(projectRoot, '对标', 'wiki');
|
|
518
|
+
if (existsSync(duiBiaoWikiRoot)) {
|
|
519
|
+
const allDirs = readdirSync(duiBiaoWikiRoot);
|
|
520
|
+
for (const dir of allDirs) {
|
|
521
|
+
// 跳过来源相关目录
|
|
522
|
+
if (dir === '来源' || dir === '来源摘要') continue;
|
|
523
|
+
|
|
524
|
+
const dirPath = join(duiBiaoWikiRoot, dir);
|
|
525
|
+
if (!statSync(dirPath).isDirectory()) continue;
|
|
526
|
+
|
|
527
|
+
const files = readdirSync(dirPath);
|
|
528
|
+
for (const file of files) {
|
|
529
|
+
if (!file.endsWith('.md')) continue;
|
|
530
|
+
// 提取书名和第X章(如:全球高武-节奏韵律-第10章.md → 书名:全球高武, 第X章:第10章)
|
|
531
|
+
const fullMatch = file.match(/^(.+?)-.+?-(第\d+章)/);
|
|
532
|
+
if (fullMatch) {
|
|
533
|
+
const bookName = fullMatch[1];
|
|
534
|
+
const chapterKey = fullMatch[2];
|
|
535
|
+
const techName = dir; // 目录名就是技法名
|
|
536
|
+
|
|
537
|
+
if (!bookTechChapters[bookName]) {
|
|
538
|
+
bookTechChapters[bookName] = {};
|
|
539
|
+
}
|
|
540
|
+
if (!bookTechChapters[bookName][techName]) {
|
|
541
|
+
bookTechChapters[bookName][techName] = [];
|
|
542
|
+
}
|
|
543
|
+
bookTechChapters[bookName][techName].push(chapterKey);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 比对结果:找出"标记为有、但技法目录里没有对应文件"的章节。
|
|
550
|
+
// 之前同一段逻辑算了两遍(一次出报告、一次出 .retry),
|
|
551
|
+
// 两份循环还得保持一致,改一处漏一处就会让报告和 .retry 对不上。
|
|
552
|
+
const missingReports = [];
|
|
553
|
+
const retryItems = [];
|
|
554
|
+
for (const [bookName, chapterTechHas] of Object.entries(bookChapterTechHas)) {
|
|
555
|
+
for (const [chapter, techHas] of Object.entries(chapterTechHas)) {
|
|
556
|
+
const missingTechs = [];
|
|
557
|
+
for (const [tech, hasValue] of Object.entries(techHas)) {
|
|
558
|
+
// 只检查"有"的技法
|
|
559
|
+
if (hasValue !== true) continue;
|
|
560
|
+
// 检查该技法目录下是否有该章节的文件
|
|
561
|
+
const hasFile = bookTechChapters[bookName]?.[tech]?.includes(chapter);
|
|
562
|
+
if (!hasFile) {
|
|
563
|
+
missingTechs.push(tech);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (missingTechs.length > 0) {
|
|
567
|
+
missingReports.push(`${bookName}${chapter}的ingest有缺失,缺失的为${missingTechs.join('、')}`);
|
|
568
|
+
retryItems.push({ bookName, chapter });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// 添加缺少列表的章节到 retry
|
|
573
|
+
for (const report of missingListReports) {
|
|
574
|
+
const match = report.match(/^(.+?)(第\d+章)的列表未找到/);
|
|
575
|
+
if (match) {
|
|
576
|
+
const bookName = match[1];
|
|
577
|
+
const chapter = match[2];
|
|
578
|
+
const exists = retryItems.some(item => item.bookName === bookName && item.chapter === chapter);
|
|
579
|
+
if (!exists) {
|
|
580
|
+
retryItems.push({ bookName, chapter });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// 根据当前检查结果覆盖 .retry 文件
|
|
585
|
+
// 写失败必须说出来:写不进去的话,后面 /fancy-analyze fix 只会报
|
|
586
|
+
// "没有找到 .retry 文件",用户完全不知道真正的失败原因在这里。
|
|
587
|
+
let retryWriteError = null;
|
|
588
|
+
try {
|
|
589
|
+
if (retryItems.length > 0) {
|
|
590
|
+
writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items: retryItems }, null, 2), 'utf-8');
|
|
591
|
+
} else {
|
|
592
|
+
// 没有缺失项时删除 .retry 文件
|
|
593
|
+
const retryFilePath = join(projectRoot, '.retry');
|
|
594
|
+
if (existsSync(retryFilePath)) {
|
|
595
|
+
unlinkSync(retryFilePath);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} catch (err) {
|
|
599
|
+
retryWriteError = err instanceof Error ? err.message : String(err);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
|
|
604
|
+
|
|
605
|
+
// 既要查 data.ok,也要查 HTTP 状态码 —— llm_wiki 返回 502/401 时
|
|
606
|
+
// data 里可能没有 error 字段,只判 ok 会漏。
|
|
607
|
+
const queryErr = llmWikiError(result);
|
|
608
|
+
if (queryErr || !result.data || !result.data.ok) {
|
|
609
|
+
return notify(session, 'error', analyze.notify({
|
|
610
|
+
type: 'status_query_failed',
|
|
611
|
+
err: queryErr || result.data?.error || '查询失败'
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const missingFiles = result.data.missingFiles || [];
|
|
616
|
+
const queue = result.data.queue || {};
|
|
617
|
+
|
|
618
|
+
let message = '';
|
|
619
|
+
if (retryWriteError) {
|
|
620
|
+
message += `\n\n❌ 写入 .retry 文件失败:${retryWriteError}` +
|
|
621
|
+
'\n缺失清单没能保存,/fancy-analyze fix 将无法工作。';
|
|
622
|
+
}
|
|
623
|
+
if (missingReports.length > 0) {
|
|
624
|
+
message += '\n\n## 缺失检测\n' + missingReports.join('\n');
|
|
625
|
+
}
|
|
626
|
+
if (missingListReports.length > 0) {
|
|
627
|
+
message += '\n\n## 列表未找到\n' + missingListReports.join('\n');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return notify(session, 'success', analyze.notify({
|
|
631
|
+
type: 'status_result',
|
|
632
|
+
missingFiles,
|
|
633
|
+
queue,
|
|
634
|
+
extraMessage: message,
|
|
635
|
+
}));
|
|
636
|
+
} catch (err) {
|
|
637
|
+
const errMsg = describeFetchError(err);
|
|
638
|
+
return notify(session, 'error', analyze.notify({
|
|
639
|
+
type: 'status_query_failed',
|
|
640
|
+
err: errMsg
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 2.3 summary-status:查技法总结进度(纯本地,零网络、零 LLM)
|
|
646
|
+
//
|
|
647
|
+
// 这一支刻意不碰 llm_wiki:summary 的进度是**本地文件系统事实**
|
|
648
|
+
// (总结文件头部的「已总结章节」清单),跟 llm_wiki 的 ingest 队列
|
|
649
|
+
// 是两回事 —— ingest 队列是"拆解"进度,summary-status 是"总结"进度。
|
|
650
|
+
if (isSummaryStatus) {
|
|
651
|
+
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
652
|
+
if (!existsSync(wikiRoot)) {
|
|
653
|
+
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
654
|
+
}
|
|
655
|
+
const progress = scanSummaryProgress(wikiRoot);
|
|
656
|
+
return notify(session, 'success', analyze.notify({
|
|
657
|
+
type: 'summary_status',
|
|
658
|
+
summary: progress,
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// 2.4 resummary [技法名]:强制重新总结
|
|
663
|
+
//
|
|
664
|
+
// 语义:把指定技法(或全部技法)的总结文件**移入备份目录**,
|
|
665
|
+
// 于是头部���「已总结章节」清单一起没了 —— 下次派发时所有源章节
|
|
666
|
+
// 都会被当成"未总结",模型从头再写一遍。
|
|
667
|
+
//
|
|
668
|
+
// 为什么不"就地清空头部":那样旧技法条目还留在文件里,模型会把
|
|
669
|
+
// 新写的和旧的混在一起,文件越滚越胖。移走是最干脆的"重来"。
|
|
670
|
+
// 备份而不是删除:旧总结可能还有参考价值。
|
|
671
|
+
if (isResummary) {
|
|
672
|
+
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
673
|
+
if (!existsSync(wikiRoot)) {
|
|
674
|
+
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const allDirs = listTechDirs(wikiRoot);
|
|
678
|
+
const targets = resummaryTarget
|
|
679
|
+
? allDirs.filter((name) => name === resummaryTarget)
|
|
680
|
+
: allDirs;
|
|
681
|
+
|
|
682
|
+
if (resummaryTarget && targets.length === 0) {
|
|
683
|
+
const known = allDirs.join('、');
|
|
684
|
+
return notify(session, 'error',
|
|
685
|
+
`❌ 没有名为「${resummaryTarget}」的技法目录` +
|
|
686
|
+
(known ? `。可用:${known}` : '(对标/wiki 下还没有技法目录)'));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const stamp = backupStamp(Date.now());
|
|
690
|
+
const backupDir = join(projectRoot, '对标', SUMMARY_BACKUP_DIRNAME, stamp);
|
|
691
|
+
const reset = [];
|
|
692
|
+
|
|
693
|
+
for (const name of targets) {
|
|
694
|
+
const summaryPath = join(wikiRoot, name, `${name}-总结.md`);
|
|
695
|
+
if (!existsSync(summaryPath)) continue;
|
|
696
|
+
try {
|
|
697
|
+
mkdirSync(backupDir, { recursive: true });
|
|
698
|
+
const backupPath = join(backupDir, `${name}-总结.md`);
|
|
699
|
+
renameSync(summaryPath, backupPath);
|
|
700
|
+
reset.push({ name, backup: backupPath });
|
|
701
|
+
} catch (err) {
|
|
702
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
703
|
+
return notify(session, 'error', `❌ 重置「${name}」失败:${msg}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const progress = scanSummaryProgress(wikiRoot);
|
|
708
|
+
// 重置完,头部清单没了 → 所有源章节都算"未总结"。
|
|
709
|
+
// 提示词同样只给目录:章节名让模型自己去目录里找。
|
|
710
|
+
const tasks = dirsToDispatch(progress).map((p) => ({
|
|
711
|
+
name: p.name,
|
|
712
|
+
sourceCount: p.sourceCount,
|
|
713
|
+
pendingCount: p.pendingFiles.length,
|
|
714
|
+
}));
|
|
715
|
+
const remainingChapters = tasks.reduce((n, t) => n + t.pendingCount, 0);
|
|
716
|
+
|
|
717
|
+
let dispatched = 0;
|
|
718
|
+
if (tasks.length > 0) {
|
|
719
|
+
const prompt = analyze.generateSummarySystemPrompt({ tasks, remainingChapters });
|
|
720
|
+
const follow = await llmFollowup(invocation, prompt);
|
|
721
|
+
if (!follow || follow.ok !== true) {
|
|
722
|
+
return notify(session, 'error',
|
|
723
|
+
'❌ 重置完成,但提示词投递失败:' + ((follow && follow.error) || '未知错误') +
|
|
724
|
+
'\n旧总结已备份,用 /fancy-analyze summary-status 确认当前状态');
|
|
725
|
+
}
|
|
726
|
+
dispatched = tasks.length;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return notify(session, 'success', analyze.notify({
|
|
730
|
+
type: 'summary_reset',
|
|
731
|
+
summary: { reset, dispatched: tasks.map((t) => t.name), remainingChapters },
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// 2.5 summary 模式:总结各技法目录(只给目录,章节让模型自己找)
|
|
736
|
+
//
|
|
737
|
+
// **只派发"还有章节没总结"的目录**:
|
|
738
|
+
// 头部「已总结章节」清单是唯一事实源,插件据此算出每个目录还差哪些章节,
|
|
739
|
+
// 只把这部分交给模型。这样反复执行 summary 不会重复总结已有章节。
|
|
740
|
+
//
|
|
741
|
+
// 报的是"已派发"而不是"已完成":
|
|
742
|
+
// llmFollowup 只代表提示词入队(lib/infra.js:329-332),真正的写作是
|
|
743
|
+
// 模型在后续 agent loop 里异步完成的。之前这里直接报"✅ 技法总结完成",
|
|
744
|
+
// 模型一个字都还没写 —— 那是假成功。
|
|
745
|
+
if (isSummary) {
|
|
746
|
+
try {
|
|
747
|
+
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
748
|
+
if (!existsSync(wikiRoot)) {
|
|
749
|
+
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const progress = scanSummaryProgress(wikiRoot);
|
|
753
|
+
const toDispatch = dirsToDispatch(progress);
|
|
754
|
+
|
|
755
|
+
const skippedDone = progress.done.map((p) => p.name);
|
|
756
|
+
const skippedNoInput = progress.noInput.map((p) => p.name);
|
|
757
|
+
|
|
758
|
+
if (toDispatch.length === 0) {
|
|
759
|
+
return notify(session, 'success', analyze.notify({
|
|
760
|
+
type: 'summary_nothing_to_do',
|
|
761
|
+
summary: { done: skippedDone, skippedNoInput },
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 提示词**只给目录**,不给章节名 —— 一本长篇可能几千章,
|
|
766
|
+
// 把章节名列进提示词就是几十 K token 的静态开销(实测 3200 章 → 71 KB)。
|
|
767
|
+
// 每个目录里写清楚"怎么判断哪些章节还没总结",模型自己去目录里找。
|
|
768
|
+
const tasks = toDispatch.map((p) => ({
|
|
769
|
+
name: p.name,
|
|
770
|
+
sourceCount: p.sourceCount,
|
|
771
|
+
pendingCount: p.pendingFiles.length,
|
|
772
|
+
}));
|
|
773
|
+
const remainingChapters = toDispatch.reduce((n, p) => n + p.pendingFiles.length, 0);
|
|
774
|
+
|
|
775
|
+
// 生成提示词
|
|
776
|
+
const prompt = analyze.generateSummarySystemPrompt({ tasks, remainingChapters });
|
|
777
|
+
|
|
778
|
+
// 调用 LLM 进行总结
|
|
779
|
+
const follow = await llmFollowup(invocation, prompt);
|
|
780
|
+
// llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
|
|
781
|
+
// 否则投递失败也会报"技法总结完成"
|
|
782
|
+
if (!follow || follow.ok !== true) {
|
|
783
|
+
return notify(session, 'error', '❌ 提示词投递失败:' + ((follow && follow.error) || '未知错误'));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return notify(session, 'success', analyze.notify({
|
|
787
|
+
type: 'summary_dispatched',
|
|
788
|
+
summary: { tasks, skippedDone, skippedNoInput, remainingChapters },
|
|
789
|
+
}));
|
|
790
|
+
} catch (err) {
|
|
791
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
792
|
+
return notify(session, 'error', '❌ summary 执行失败:' + errMsg);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 2.6 普通模式:取书名
|
|
797
|
+
let bookName = rawInput;
|
|
798
|
+
|
|
799
|
+
if (!bookName) {
|
|
800
|
+
// 弹窗
|
|
801
|
+
const askedresult = await askUserQuestion(ctx, invocation, [analyze.askBookName()]);
|
|
802
|
+
if (!askedresult.ok) {
|
|
803
|
+
return notify(session, 'error', analyze.notify({
|
|
804
|
+
type: askedresult.failure.type,
|
|
805
|
+
err: askedresult.failure.message
|
|
806
|
+
}));
|
|
807
|
+
}
|
|
808
|
+
const result = askedresult.answer;
|
|
809
|
+
if (!result?.answers?.[0]) {
|
|
810
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
811
|
+
}
|
|
812
|
+
bookName = (result.answers[0].custom || result.answers[0].selected?.[0] || '').trim();
|
|
813
|
+
if (!bookName) {
|
|
814
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// 2.6.1 清理书名(去除后缀,用于目录和路径)
|
|
819
|
+
// 注意:此时 bookNameClean 还未定义,下面会在 sourcePath 获取后重新计算
|
|
820
|
+
|
|
821
|
+
// 3. 在 参考资料/ 下查找匹配的源文件(忽略后缀)
|
|
822
|
+
const refDir = join(projectRoot, '参考资料');
|
|
823
|
+
const lookup = findSourceFile(refDir, bookName);
|
|
824
|
+
if (lookup.error === 'dir_missing') {
|
|
825
|
+
// 必须传 refDir 和 bookName —— 模板的"参考资料/ 目录不存在:${refDir}"
|
|
826
|
+
// 和"手动下载《${bookName}》"要用它们;之前只传了 projectRoot,
|
|
827
|
+
// 消息里路径和书名都是空的。
|
|
828
|
+
return notify(session, 'error', analyze.notify({
|
|
829
|
+
type: 'source_dir_missing',
|
|
830
|
+
bookName,
|
|
831
|
+
refDir,
|
|
832
|
+
projectRoot,
|
|
833
|
+
}));
|
|
834
|
+
}
|
|
835
|
+
if (lookup.error === 'not_found') {
|
|
836
|
+
return notify(session, 'error', analyze.notify({
|
|
837
|
+
type: 'source_missing',
|
|
838
|
+
bookName,
|
|
839
|
+
projectRoot,
|
|
840
|
+
refDir,
|
|
841
|
+
}));
|
|
842
|
+
}
|
|
843
|
+
if (lookup.error === 'ambiguous') {
|
|
844
|
+
return notify(session, 'error', analyze.notify({
|
|
845
|
+
type: 'source_ambiguous',
|
|
846
|
+
bookName,
|
|
847
|
+
matches: lookup.matches,
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
const sourcePath = lookup.path;
|
|
851
|
+
|
|
852
|
+
// 2.6.2 从实际文件路径获取真实文件名(去除后缀)
|
|
853
|
+
// 必须用 basename:split('/').pop() 在 Windows 上取不到文件名
|
|
854
|
+
// (路径分隔符是 '\'),会把整条路径当成书名,
|
|
855
|
+
// 后面拆书就会写到 对标/raw/sources/C:\Users\...\ 这种乱路径下。
|
|
856
|
+
const sourceFileName = sourcePath ? basename(sourcePath) : bookName;
|
|
857
|
+
const bookNameClean = sourceFileName.replace(/\.[^./\\]+$/, '');
|
|
858
|
+
|
|
859
|
+
// 读取 .fancy-deployed 配置
|
|
860
|
+
const { config: deployedConfig, errorText: configError } = loadDeployedConfig(projectRoot);
|
|
861
|
+
if (configError) {
|
|
862
|
+
return notify(session, 'error', configError);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
|
|
866
|
+
const llmWikiToken = deployedConfig.llm_wiki_token || '';
|
|
867
|
+
|
|
868
|
+
if (!llmWikiUrl || !llmWikiToken) {
|
|
869
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_not_configured' }));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 4. 调用 llm_wiki API 创建项目
|
|
873
|
+
let referenceId = deployedConfig.reference_id || '';
|
|
874
|
+
try {
|
|
875
|
+
const result = await callLlmWikiApi(
|
|
876
|
+
llmWikiUrl,
|
|
877
|
+
llmWikiToken,
|
|
878
|
+
'/projects/create',
|
|
879
|
+
'POST',
|
|
880
|
+
{
|
|
881
|
+
name: '对标',
|
|
882
|
+
path: projectRoot,
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
if (result.data && result.data.error) {
|
|
887
|
+
const errorMsg = String(result.data.error);
|
|
888
|
+
if (errorMsg.includes('Directory already exists')) {
|
|
889
|
+
// 目录已存在,继续下一步
|
|
890
|
+
} else {
|
|
891
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: errorMsg }));
|
|
892
|
+
}
|
|
893
|
+
} else if (result.data && result.data.ok === true && result.data.project && result.data.project.id) {
|
|
894
|
+
// 成功,保存 reference_id
|
|
895
|
+
referenceId = result.data.project.id;
|
|
896
|
+
updateDeployedField(projectRoot, 'reference_id', referenceId);
|
|
897
|
+
} else {
|
|
898
|
+
// 既不是成功也不是 Directory already exists 错误
|
|
899
|
+
const apiErr = llmWikiError(result);
|
|
900
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: apiErr || 'API 调用失败' }));
|
|
901
|
+
}
|
|
902
|
+
} catch (err) {
|
|
903
|
+
const errMsg = describeFetchError(err);
|
|
904
|
+
return notify(session, 'error', analyze.notify({ type: 'llm_wiki_failed', err: errMsg }));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// 5. 拷贝 schema-analyze.md → 对标/schema.md
|
|
908
|
+
const duiBiaoDir = join(projectRoot, '对标');
|
|
909
|
+
try {
|
|
910
|
+
copySchemaFile(duiBiaoDir);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
return notify(session, 'error', analyze.notify({ type: 'write_failed', err: err.message }));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// 6. 执行拆书脚本 → 对标/raw/sources/<书名>/
|
|
916
|
+
const splitDir = join(projectRoot, '对标', 'raw', 'sources', bookNameClean);
|
|
917
|
+
const splitResult = await spawnScript('node', [SPLIT_SCRIPT, '--source', sourcePath, '--output-dir', splitDir], { timeout: 60000 });
|
|
918
|
+
if (!splitResult.ok) {
|
|
919
|
+
return notify(session, 'error', analyze.notify({ type: 'split_failed', err: splitResult.error || '未知错误' }));
|
|
920
|
+
}
|
|
921
|
+
let splitData;
|
|
922
|
+
try {
|
|
923
|
+
splitData = JSON.parse(splitResult.output);
|
|
924
|
+
} catch {
|
|
925
|
+
return notify(session, 'error', analyze.notify({ type: 'split_failed', err: '拆书脚本返回格式错误' }));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 7. 调用 llm_wiki sources/ingest 接口
|
|
929
|
+
//
|
|
930
|
+
// 这里必须把失败**报给用户**。
|
|
931
|
+
// 之前只是 try/catch + console.error,可 HTTP 4xx/5xx 根本不抛异常,
|
|
932
|
+
// catch 压根不触发 —— 于是 ingest 返回 500 时命令照样返回
|
|
933
|
+
// kind=success + "✅ 正在后台拆解中",而 llm_wiki 那边一个文件都没收到,
|
|
934
|
+
// 用户等多久都不会有结果(实测复现)。
|
|
935
|
+
if (referenceId && splitData && splitData.split_files && splitData.split_files.length > 0) {
|
|
936
|
+
const paths = splitData.split_files.map(f => `raw/sources/${bookNameClean}/${f}`);
|
|
937
|
+
try {
|
|
938
|
+
const ingestResult = await callLlmWikiApi(
|
|
939
|
+
llmWikiUrl,
|
|
940
|
+
llmWikiToken,
|
|
941
|
+
`/projects/${referenceId}/sources/ingest`,
|
|
942
|
+
'POST',
|
|
943
|
+
{ paths }
|
|
944
|
+
);
|
|
945
|
+
const apiErr = llmWikiError(ingestResult);
|
|
946
|
+
if (apiErr) {
|
|
947
|
+
console.error(`[fancy-analyze] sources/ingest failed: ${apiErr}`);
|
|
948
|
+
return notify(session, 'error', analyze.notify({
|
|
949
|
+
type: 'ingest_failed',
|
|
950
|
+
err: apiErr,
|
|
951
|
+
bookNameClean,
|
|
952
|
+
}));
|
|
953
|
+
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
const errMsg = describeFetchError(err);
|
|
956
|
+
console.error(`[fancy-analyze] sources/ingest failed: ${errMsg}`);
|
|
957
|
+
return notify(session, 'error', analyze.notify({
|
|
958
|
+
type: 'ingest_failed',
|
|
959
|
+
err: errMsg,
|
|
960
|
+
bookNameClean,
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// 8. 通知用户
|
|
966
|
+
return notify(session, 'success', analyze.notify({
|
|
967
|
+
type: 'done',
|
|
968
|
+
bookNameClean,
|
|
969
|
+
projectRoot,
|
|
970
|
+
}));
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
}
|