dsh-novel-writer 0.7.0 → 0.8.1
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/README.md +12 -53
- package/lib/analysis.js +47 -0
- package/lib/client.js +64 -15
- package/lib/index.js +566 -27
- package/package.json +3 -3
- package/skills/novel-writing/SKILL.md +11 -0
- package/test/e2e-test.mjs +3 -3
package/README.md
CHANGED
|
@@ -1,64 +1,23 @@
|
|
|
1
|
-
# dsh-novel-writer — 小说写作助手插件(v0.
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v0.8.1:记忆层)
|
|
2
2
|
|
|
3
3
|
DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
|
|
4
4
|
浏览器端仅依赖 Web GUI 自带的 react。
|
|
5
|
-
**v0.
|
|
5
|
+
**v0.8.1 在 v0.7.0 基础上补全「记忆层」:四张设定表、章节摘要、连贯性审计、伏笔字段化与提及追踪、细节密度指标、统一数据目录、UI 路径可视化修复**:分析缓存与报告导出、风格自检、伏笔登记表、
|
|
6
6
|
关键词三字组/疑似人名、情感词典去噪、祈使句与环境分类改进、局域网开关可选放行。
|
|
7
7
|
|
|
8
|
-
## 工具清单(
|
|
8
|
+
## 工具清单(13 个,全部带独立 UI 开关)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|---|---|
|
|
12
|
-
| `novel_books` | 列出章节库全部作品 |
|
|
13
|
-
| `novel_chapters` | 列出某作品章节清单 |
|
|
14
|
-
| `novel_read` | 阅读某章正文(offset/limit 分段,编码自动探测) |
|
|
15
|
-
| `novel_keywords` | 关键词:二字组 + **三字组** + **疑似人名** + 英文词 |
|
|
16
|
-
| `novel_new_chapter` | 创建新章节文件 |
|
|
17
|
-
| `novel_import` | 原稿件批量导入/自动分类(含**异名同书提示**) |
|
|
18
|
-
| `novel_sentence_analysis` | 句式模式分析(九类/情感曲线/章节节奏/指纹,**带缓存与报告文件**) |
|
|
19
|
-
| `novel_sentence_config` | 查看/修改句式分析开关 |
|
|
20
|
-
| `novel_style_check` | **风格自检(v0.6.0 新增)**:章节 vs 全书指纹对比 + 偏差清单 |
|
|
21
|
-
| `novel_plot` | **伏笔/剧情线登记表(v0.6.0 新增)**:list/add/update/done/delete |
|
|
10
|
+
novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot / **novel_settings(设定管理)** / **novel_summary(章节摘要)** / **novel_continuity_check(连贯性审计)**
|
|
22
11
|
|
|
23
|
-
## v0.
|
|
12
|
+
## v0.8.1 新增能力
|
|
24
13
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
7. **情感曲线粒度可调**:`curveSegments` 参数(1-50,默认 20)。
|
|
33
|
-
8. **novel_import 异名同书提示**:scan 结果中组名包含/高重合时列出 `maybe` 字段,提示用 `book` 合并。
|
|
34
|
-
9. **局域网开关可选放行**:config `allowLanState: true` 时,state 路由对同源局域网请求放行(默认仍仅 loopback)。
|
|
35
|
-
|
|
36
|
-
## UI 开关(v0.7.0:10 个工具全开关)
|
|
37
|
-
|
|
38
|
-
- **唯一开关位置:侧边栏「写作助手功能」面板**——总开关(enabled,控制句式分析/风格自检)+ 自动分析(autoAnalyze)+ **10 个工具各自独立开关**(全部默认开启);
|
|
39
|
-
- 关闭的工具,AI 调用时会收到明确提示;novel_sentence_config 可查/改全部开关(含 tools 字段);
|
|
40
|
-
- 设置 > 插件配置 卡片仅显示状态 + 「打开开关面板」跳转;
|
|
41
|
-
- 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容 v0.4.0 的 novel-writer.json / config.stylePattern;
|
|
42
|
-
- 默认开启;路由 `/api/dsh-novel-writer/state`(loopback 围栏,可选局域网放行)。
|
|
43
|
-
|
|
44
|
-
## 安装
|
|
45
|
-
|
|
46
|
-
```sh
|
|
47
|
-
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
或手动:放入 profile 的 node_modules + `dsh.profile.bundles` 加 `dsh-novel-writer`;重启 web 应用。
|
|
51
|
-
|
|
52
|
-
## 配置
|
|
53
|
-
|
|
54
|
-
```yaml
|
|
55
|
-
- id: novel-writer
|
|
56
|
-
config:
|
|
57
|
-
root: 'D:/我的小说库'
|
|
58
|
-
sentenceAnalysis:
|
|
59
|
-
enabled: true
|
|
60
|
-
autoAnalyze: true
|
|
61
|
-
allowLanState: false # true=局域网访问 GUI 时也允许保存开关
|
|
14
|
+
- **novel_settings(四张表)**:人物卡/地点卡/道具清单/时间线,list/add/update/delete + scan 候选提取;
|
|
15
|
+
- **novel_summary**:模型生成、插件存储的每章摘要(长书续写先读摘要);
|
|
16
|
+
- **novel_continuity_check**:对照设定表扫描全书 → 数字口径/人物缺场/别名/重复条目矛盾候选;
|
|
17
|
+
- **novel_plot 字段化**:type/priority/relatedCharacters/locations/payoffCondition/mentionedIn/lastMentioned + scan 自动提及追踪;
|
|
18
|
+
- **细节密度指标**:动作链密度/物件名词/感官词(千字归一)进 novel_sentence_analysis 与 novel_style_check;
|
|
19
|
+
- **统一数据目录**:§BT§<书库根>/.novel-writer/§BT§ 下 plots/ settings/ summaries/ analysis/ audits/(伏笔旧位置自动迁移;分析缓存与关键词报告落盘到 analysis/);
|
|
20
|
+
- **UI 路径修复**:cordis 空字符串 root 回退 lastRoot、面板刷新按钮、打开/复制按钮不再禁用、显示 dataDir。
|
|
62
21
|
|
|
63
22
|
## v0.7.0 更新内容
|
|
64
23
|
|
package/lib/analysis.js
CHANGED
|
@@ -599,6 +599,7 @@ export function analyzeText(text, options = {}) {
|
|
|
599
599
|
const guidance = buildGuidance(ratios, motifs, chapterPatterns);
|
|
600
600
|
|
|
601
601
|
const fingerprint = buildFingerprint(categories, lengths, style, emotion, motifs);
|
|
602
|
+
const density = densityOf(text);
|
|
602
603
|
|
|
603
604
|
return {
|
|
604
605
|
totalChars,
|
|
@@ -612,6 +613,7 @@ export function analyzeText(text, options = {}) {
|
|
|
612
613
|
emotion,
|
|
613
614
|
chapterPatterns,
|
|
614
615
|
guidance,
|
|
616
|
+
density,
|
|
615
617
|
fingerprint
|
|
616
618
|
};
|
|
617
619
|
}
|
|
@@ -714,3 +716,48 @@ export function styleDiffs(a, b, threshold = 0.06) {
|
|
|
714
716
|
}
|
|
715
717
|
return diffs;
|
|
716
718
|
}
|
|
719
|
+
|
|
720
|
+
/** v0.8.0 细节密度词表:动作动词/物件名词/感官词。 */
|
|
721
|
+
const DENSITY_ACTION_VERBS = /摸|拿|推|拉|走|跑|蹲|站|坐|转|伸|握|点|掀|翻|跪|爬|钻|抱|搂|抬|放|挂|摘|系|披|踩|迈|跨|扑|撞|躲|闪|看|盯|瞥|望|听|闻|尝|咬|喝|吃|敲|拍|捏|揉|擦|吹|按|拨|拧|捡|拾|丢|抛|接|递|塞|掏|捞|舀|倒|浇|洗|叠|铺|盖|关|开|掩|合/;
|
|
722
|
+
const DENSITY_OBJECT_WORDS = /油灯|灯|床|门|窗|桌|椅|凳|杯|碗|碟|盘|筷|勺|剑|刀|枪|书|本|纸|笔|衣|裙|帽|鞋|镜|烛台|蜡烛|铁罐|罐|盒|箱|马车|车|墙|柱|帘|毯|被|枕|簪|铃|钟|画|架|梯|绳|袋|篮|桶|壶|锅|铲|柴|炭|火|灰/;
|
|
723
|
+
const DENSITY_SENSE = {
|
|
724
|
+
visual: /看|见|映入|光|亮|暗|色|影|闪|照|映|目光|眼神|脸|面|状|样/,
|
|
725
|
+
auditory: /听|声|音|响|钟|脚步|敲|喊|叫|低语|呢喃|咕哝|咚|砰|咔/,
|
|
726
|
+
tactile: /凉|热|疼|痛|滑|糙|冷|暖|烫|冰|湿|干|硬|软|麻|痒/,
|
|
727
|
+
olfactory: /香|臭|味|气|腥|甜|苦|酸/,
|
|
728
|
+
temperature: /热|烫|凉|冷|暖|温/
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
/** 细节密度统计(v0.8.0):动作链/物件名词/感官词,按千字归一。 */
|
|
732
|
+
export function densityOf(text) {
|
|
733
|
+
const sentences = splitSentences(text);
|
|
734
|
+
const totalChars = text.replace(/\s/g, "").length;
|
|
735
|
+
let actionVerbs = 0;
|
|
736
|
+
let actionChainSentences = 0;
|
|
737
|
+
let objectHits = 0;
|
|
738
|
+
const sense = { visual: 0, auditory: 0, tactile: 0, olfactory: 0, temperature: 0 };
|
|
739
|
+
const actionChains = [];
|
|
740
|
+
for (const sentence of sentences) {
|
|
741
|
+
let verbs = 0;
|
|
742
|
+
for (const m of sentence.matchAll(new RegExp(DENSITY_ACTION_VERBS.source, "g"))) verbs += 1;
|
|
743
|
+
if (verbs >= 2) {
|
|
744
|
+
actionChainSentences += 1;
|
|
745
|
+
if (actionChains.length < 3) actionChains.push(sentence.slice(0, 40));
|
|
746
|
+
}
|
|
747
|
+
actionVerbs += verbs;
|
|
748
|
+
for (const m of sentence.matchAll(new RegExp(DENSITY_OBJECT_WORDS.source, "g"))) objectHits += 1;
|
|
749
|
+
for (const [kind, re] of Object.entries(DENSITY_SENSE)) {
|
|
750
|
+
for (const m of sentence.matchAll(new RegExp(re.source, "g"))) sense[kind] += 1;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const per1000 = (n) => Math.round((n / Math.max(totalChars, 1)) * 1000 * 10) / 10;
|
|
754
|
+
const ratio = sentences.length === 0 ? 0 : Math.round((actionChainSentences / sentences.length) * 1000) / 10;
|
|
755
|
+
return {
|
|
756
|
+
actionVerbsPer1000: per1000(actionVerbs),
|
|
757
|
+
actionChainRatio: ratio,
|
|
758
|
+
actionChainExamples: actionChains,
|
|
759
|
+
objectNounsPer1000: per1000(objectHits),
|
|
760
|
+
sensePer1000: per1000(Object.values(sense).reduce((x, y) => x + y, 0)),
|
|
761
|
+
sense: { ...sense }
|
|
762
|
+
};
|
|
763
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -36,6 +36,8 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
".nwPanelHeader{display:flex;align-items:center;gap:10px}" +
|
|
37
37
|
".nwPanelTitle{font-size:16px;font-weight:700;flex:1;min-width:0}" +
|
|
38
38
|
".nwClose{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);width:30px;height:30px;line-height:1}" +
|
|
39
|
+
".nwRefresh{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;padding:5px 9px}" +
|
|
40
|
+
".nwRefresh:hover{background:var(--dsw-alias-bg-layer-2)}" +
|
|
39
41
|
".nwDesc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.6;margin:0}" +
|
|
40
42
|
".nwBanner{display:flex;align-items:center;gap:10px;border-radius:12px;padding:14px 16px;font-size:15px;font-weight:700;border:1px solid}" +
|
|
41
43
|
".nwBannerOn{background:rgba(34,197,94,.12);border-color:#22c55e;color:#15803d}" +
|
|
@@ -132,13 +134,21 @@ window.__ModuleLoader__.load({
|
|
|
132
134
|
"tool.novel_sentence_config.desc": "查看/修改全部开关(AI 通道)。",
|
|
133
135
|
"tool.novel_style_check.desc": "本章文风与全书其余章节的相似度检查。",
|
|
134
136
|
"tool.novel_plot.desc": "登记/回收剧情伏笔,续写前查看未回收项。",
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
+
"tool.novel_settings": "设定管理 novel_settings",
|
|
138
|
+
"tool.novel_summary": "章节摘要 novel_summary",
|
|
139
|
+
"tool.novel_continuity_check": "连贯性审计 novel_continuity_check",
|
|
140
|
+
"tool.novel_settings.desc": "四张设定表:人物卡/地点卡/道具/时间线。",
|
|
141
|
+
"tool.novel_summary.desc": "保存/读取每章摘要,长书续写先读摘要。",
|
|
142
|
+
"tool.novel_continuity_check.desc": "对照设定表扫描全书,输出矛盾候选。",
|
|
143
|
+
"plot.pathLabel": "数据目录",
|
|
144
|
+
"plot.pathUnknown": "位于书库根的 .novel-writer 文件夹(plots 伏笔 / settings 设定 / summaries 摘要 / analysis 分析报告)。先让 AI 调用一次 novel_plot 或 novel_settings,此处会显示真实路径。",
|
|
137
145
|
"plot.open": "打开文件夹",
|
|
138
146
|
"plot.copy": "复制路径",
|
|
139
147
|
"plot.copied": "已复制",
|
|
140
148
|
"plot.revealOk": "已打开文件夹",
|
|
141
149
|
"plot.revealErr": "打开失败",
|
|
150
|
+
"panel.refresh": "刷新",
|
|
151
|
+
"panel.refreshed": "已刷新",
|
|
142
152
|
"card.title": "小说写作助手 novel-writer",
|
|
143
153
|
"card.desc": "句式模式分析:分析原文陈述/环境/心理/对话/疑问/反问/感叹等句子的排列节奏来辅助模仿文风。⚠️ 若机械套用导致文风僵硬,模型会优先回归自然表达。",
|
|
144
154
|
"card.status": "写作助手功能:已启用 · 自动分析:开",
|
|
@@ -190,13 +200,21 @@ window.__ModuleLoader__.load({
|
|
|
190
200
|
"tool.novel_sentence_config.desc": "View / change all switches (AI channel).",
|
|
191
201
|
"tool.novel_style_check.desc": "Style similarity of this chapter vs the rest of the book.",
|
|
192
202
|
"tool.novel_plot.desc": "Track / close plot hooks; check before continuing.",
|
|
193
|
-
"
|
|
194
|
-
"
|
|
203
|
+
"tool.novel_settings": "Settings novel_settings",
|
|
204
|
+
"tool.novel_summary": "Summaries novel_summary",
|
|
205
|
+
"tool.novel_continuity_check": "Continuity novel_continuity_check",
|
|
206
|
+
"tool.novel_settings.desc": "Four tables: characters / locations / items / timeline.",
|
|
207
|
+
"tool.novel_summary.desc": "Store / read per-chapter summaries.",
|
|
208
|
+
"tool.novel_continuity_check.desc": "Scan against settings, list contradiction candidates.",
|
|
209
|
+
"plot.pathLabel": "Data dir",
|
|
210
|
+
"plot.pathUnknown": "Located in .novel-writer under the library root (plots / settings / summaries / analysis). Ask the AI to run novel_plot or novel_settings once to show the real path.",
|
|
195
211
|
"plot.open": "Open folder",
|
|
196
212
|
"plot.copy": "Copy path",
|
|
197
213
|
"plot.copied": "Copied",
|
|
198
214
|
"plot.revealOk": "Opened",
|
|
199
215
|
"plot.revealErr": "Open failed",
|
|
216
|
+
"panel.refresh": "Refresh",
|
|
217
|
+
"panel.refreshed": "Refreshed",
|
|
200
218
|
"card.title": "Novel Writer (dsh-novel-writer)",
|
|
201
219
|
"card.desc": "Sentence-pattern analysis: reads the rhythm of statements/environment/inner-thought/dialogue/questions etc. to help mimic the author's style. ⚠️ If mechanical imitation stiffens the prose, natural expression wins.",
|
|
202
220
|
"card.status": "Writing assistant: ON · auto-analyze: ON",
|
|
@@ -214,7 +232,7 @@ window.__ModuleLoader__.load({
|
|
|
214
232
|
}
|
|
215
233
|
|
|
216
234
|
// ---- 工具清单(与宿主端 ALL_TOOLS 一致)----
|
|
217
|
-
var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"];
|
|
235
|
+
var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot", "novel_settings", "novel_summary", "novel_continuity_check"];
|
|
218
236
|
|
|
219
237
|
// ---- 与宿主端同步 ----
|
|
220
238
|
var STATE_ROUTE = "/api/dsh-novel-writer/state";
|
|
@@ -248,6 +266,7 @@ window.__ModuleLoader__.load({
|
|
|
248
266
|
saveFailed: false,
|
|
249
267
|
panelOpen: false,
|
|
250
268
|
plotsDir: "",
|
|
269
|
+
dataDir: "",
|
|
251
270
|
revealMsg: "",
|
|
252
271
|
revealErr: false,
|
|
253
272
|
revealing: false
|
|
@@ -262,15 +281,35 @@ window.__ModuleLoader__.load({
|
|
|
262
281
|
listeners.add(fn);
|
|
263
282
|
return function () { listeners.delete(fn); };
|
|
264
283
|
},
|
|
265
|
-
|
|
284
|
+
refresh: function () {
|
|
285
|
+
return fetchState().then(function (remote) {
|
|
286
|
+
controller.set({
|
|
287
|
+
enabled: !!remote.enabled,
|
|
288
|
+
autoAnalyze: !!remote.autoAnalyze,
|
|
289
|
+
tools: remote.tools || {},
|
|
290
|
+
plotsDir: remote.plotsDir || "",
|
|
291
|
+
dataDir: remote.dataDir || "",
|
|
292
|
+
file: remote.file || "",
|
|
293
|
+
hostOk: true,
|
|
294
|
+
loading: false,
|
|
295
|
+
revealMsg: t("panel.refreshed"),
|
|
296
|
+
revealErr: false
|
|
297
|
+
});
|
|
298
|
+
return remote;
|
|
299
|
+
}).catch(function () {
|
|
300
|
+
controller.set({ hostOk: false, loading: false, revealMsg: t("plot.revealErr") + ":宿主端不可达", revealErr: true });
|
|
301
|
+
return null;
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
openDataDir: function () {
|
|
266
305
|
controller.set({ revealMsg: "", revealErr: false, revealing: true });
|
|
267
306
|
fetch("/api/dsh-novel-writer/reveal", {
|
|
268
307
|
method: "POST",
|
|
269
308
|
headers: { "content-type": "application/json" },
|
|
270
|
-
body: JSON.stringify({ target: "
|
|
309
|
+
body: JSON.stringify({ target: "data-dir" })
|
|
271
310
|
}).then(function (r) { return r.json(); }).then(function (data) {
|
|
272
311
|
if (data.ok === true) {
|
|
273
|
-
controller.set({
|
|
312
|
+
controller.set({ dataDir: data.path || controller.getSnapshot().dataDir, revealMsg: t("plot.revealOk") + ":" + (data.path || ""), revealErr: false });
|
|
274
313
|
} else {
|
|
275
314
|
controller.set({ revealMsg: data.error || t("plot.revealErr"), revealErr: true });
|
|
276
315
|
}
|
|
@@ -281,8 +320,11 @@ window.__ModuleLoader__.load({
|
|
|
281
320
|
});
|
|
282
321
|
},
|
|
283
322
|
copyPlotsPath: function () {
|
|
284
|
-
var dir = controller.getSnapshot().plotsDir;
|
|
285
|
-
if (dir === "")
|
|
323
|
+
var dir = controller.getSnapshot().dataDir || controller.getSnapshot().plotsDir;
|
|
324
|
+
if (dir === "") {
|
|
325
|
+
controller.set({ revealMsg: t("plot.pathUnknown"), revealErr: true });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
286
328
|
var done = function () {
|
|
287
329
|
controller.set({ revealMsg: t("plot.copied") + ":" + dir, revealErr: false });
|
|
288
330
|
};
|
|
@@ -403,6 +445,11 @@ window.__ModuleLoader__.load({
|
|
|
403
445
|
"div",
|
|
404
446
|
{ className: "nwPanelHeader" },
|
|
405
447
|
react.createElement("div", { className: "nwPanelTitle" }, t("panel.title")),
|
|
448
|
+
react.createElement(
|
|
449
|
+
"button",
|
|
450
|
+
{ type: "button", className: "nwRefresh", onClick: function () { props.controller.refresh(); } },
|
|
451
|
+
t("panel.refresh")
|
|
452
|
+
),
|
|
406
453
|
react.createElement("button", { type: "button", className: "nwClose", "aria-label": t("panel.close"), onClick: props.onClose }, "×")
|
|
407
454
|
),
|
|
408
455
|
react.createElement(
|
|
@@ -459,20 +506,21 @@ window.__ModuleLoader__.load({
|
|
|
459
506
|
var desc = t("tool." + name + ".desc");
|
|
460
507
|
var plotExtra = null;
|
|
461
508
|
if (name === "novel_plot") {
|
|
462
|
-
var
|
|
509
|
+
var dir = state.dataDir || state.plotsDir;
|
|
510
|
+
var pathText = dir !== "" ? dir : t("plot.pathUnknown");
|
|
463
511
|
plotExtra = react.createElement(
|
|
464
512
|
"div",
|
|
465
513
|
{ className: "nwPlotBox" },
|
|
466
|
-
react.createElement("div", { className: "nwPlotPath", title:
|
|
514
|
+
react.createElement("div", { className: "nwPlotPath", title: dir !== "" ? dir : "" }, t("plot.pathLabel") + ":" + pathText),
|
|
467
515
|
react.createElement(
|
|
468
516
|
"div",
|
|
469
517
|
{ className: "nwBtnGroup" },
|
|
470
518
|
react.createElement("button", {
|
|
471
|
-
type: "button", className: "nwBtn", disabled: state.
|
|
472
|
-
onClick: function () { props.controller.
|
|
519
|
+
type: "button", className: "nwBtn", disabled: state.revealing,
|
|
520
|
+
onClick: function () { props.controller.openDataDir(); }
|
|
473
521
|
}, t("plot.open")),
|
|
474
522
|
react.createElement("button", {
|
|
475
|
-
type: "button", className: "nwBtn",
|
|
523
|
+
type: "button", className: "nwBtn",
|
|
476
524
|
onClick: function () { props.controller.copyPlotsPath(); }
|
|
477
525
|
}, t("plot.copy"))
|
|
478
526
|
),
|
|
@@ -647,6 +695,7 @@ window.__ModuleLoader__.load({
|
|
|
647
695
|
autoAnalyze: !!remote.autoAnalyze,
|
|
648
696
|
tools: remote.tools || {},
|
|
649
697
|
plotsDir: remote.plotsDir || "",
|
|
698
|
+
dataDir: remote.dataDir || "",
|
|
650
699
|
file: remote.file || "",
|
|
651
700
|
source: "host",
|
|
652
701
|
hostOk: true,
|
package/lib/index.js
CHANGED
|
@@ -572,12 +572,14 @@ const SENTENCE_ANALYSIS_DEFAULTS = Object.freeze({ enabled: true, autoAnalyze: t
|
|
|
572
572
|
/** v0.7.0:全部工具清单(每个工具都有独立 UI 开关,默认全开)。 */
|
|
573
573
|
const ALL_TOOLS = Object.freeze([
|
|
574
574
|
"novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter",
|
|
575
|
-
"novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"
|
|
575
|
+
"novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot",
|
|
576
|
+
"novel_settings", "novel_summary", "novel_continuity_check"
|
|
576
577
|
]);
|
|
577
578
|
const TOOL_LABELS = Object.freeze({
|
|
578
579
|
novel_books: "作品列表", novel_chapters: "章节清单", novel_read: "阅读章节", novel_keywords: "关键词分析",
|
|
579
580
|
novel_new_chapter: "新建章节", novel_import: "稿件导入", novel_sentence_analysis: "句式模式分析",
|
|
580
|
-
novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记"
|
|
581
|
+
novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记",
|
|
582
|
+
novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计"
|
|
581
583
|
});
|
|
582
584
|
/** 工具级开关是否开启(默认开;state.tools[name] === false 时关闭)。 */
|
|
583
585
|
function toolEnabled(state, name) {
|
|
@@ -738,12 +740,15 @@ function makeStateRoutes(allowLan, config) {
|
|
|
738
740
|
const method = req.method ?? "GET";
|
|
739
741
|
if (method === "GET") {
|
|
740
742
|
const state = await readSentenceState();
|
|
741
|
-
|
|
743
|
+
// v0.8.0:修复 cordis 注入空字符串 root 时 ?? 不生效的问题
|
|
744
|
+
const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
|
|
745
|
+
const dataDir = typeof root === "string" && root.length > 0 ? novelDataDir(root) : "";
|
|
742
746
|
return writeJson(res, 200, {
|
|
743
747
|
enabled: state.enabled,
|
|
744
748
|
autoAnalyze: state.autoAnalyze,
|
|
745
749
|
tools: state.tools ?? {},
|
|
746
|
-
|
|
750
|
+
dataDir,
|
|
751
|
+
plotsDir: dataDir !== "" ? join(dataDir, "plots") : "",
|
|
747
752
|
file: stateFilePath()
|
|
748
753
|
});
|
|
749
754
|
}
|
|
@@ -764,13 +769,14 @@ function makeStateRoutes(allowLan, config) {
|
|
|
764
769
|
if ((req.method ?? "GET") !== "POST") return writeJson(res, 405, { error: "method not allowed" });
|
|
765
770
|
const body = await readJsonBody(req);
|
|
766
771
|
const target = body?.target;
|
|
767
|
-
if (target !== "plots-dir") return writeJson(res, 400, { error: 'unknown target (allowed: "plots-dir")' });
|
|
772
|
+
if (target !== "data-dir" && target !== "plots-dir") return writeJson(res, 400, { error: 'unknown target (allowed: "data-dir", "plots-dir")' });
|
|
768
773
|
const state = await readSentenceState();
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
774
|
+
// v0.8.0:空字符串 root 回退 lastRoot
|
|
775
|
+
const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
|
|
776
|
+
if (typeof root !== "string" || root.length === 0) {
|
|
777
|
+
return writeJson(res, 404, { error: "书库根未知:先在对话中调用一次 novel_plot / novel_settings(或配置插件 root),即可在 UI 中打开数据文件夹。" });
|
|
772
778
|
}
|
|
773
|
-
const dir = join(
|
|
779
|
+
const dir = target === "data-dir" ? novelDataDir(root) : join(novelDataDir(root), "plots");
|
|
774
780
|
try {
|
|
775
781
|
await mkdir(dir, { recursive: true });
|
|
776
782
|
} catch { /* 目录已存在或创建失败都不阻塞打开 */ }
|
|
@@ -1009,7 +1015,19 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1009
1015
|
}
|
|
1010
1016
|
},
|
|
1011
1017
|
guidance: { type: "string" },
|
|
1012
|
-
fingerprint: { type: "string" }
|
|
1018
|
+
fingerprint: { type: "string" },
|
|
1019
|
+
density: {
|
|
1020
|
+
type: "object",
|
|
1021
|
+
additionalProperties: false,
|
|
1022
|
+
properties: {
|
|
1023
|
+
actionVerbsPer1000: { type: "number" },
|
|
1024
|
+
actionChainRatio: { type: "number" },
|
|
1025
|
+
actionChainExamples: { type: "array", items: { type: "string" } },
|
|
1026
|
+
objectNounsPer1000: { type: "number" },
|
|
1027
|
+
sensePer1000: { type: "number" },
|
|
1028
|
+
sense: { type: "object", additionalProperties: true }
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1013
1031
|
},
|
|
1014
1032
|
required: ["book", "scope", "enabled", "totalChars", "totalSentences"]
|
|
1015
1033
|
},
|
|
@@ -1063,8 +1081,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1063
1081
|
message: "句式模式分析当前已关闭。可在 Web GUI 侧边栏「句式分析」面板开启,或用 novel_sentence_config 设置 enabled=true。"
|
|
1064
1082
|
};
|
|
1065
1083
|
}
|
|
1066
|
-
// v0.
|
|
1067
|
-
const cacheDir = join(
|
|
1084
|
+
// v0.8.0:分析结果缓存 + 报告导出(书库统一数据目录 <root>/.novel-writer/analysis)
|
|
1085
|
+
const cacheDir = join(novelDataDir(root), "analysis");
|
|
1068
1086
|
const cacheKey = createHash("sha1").update(book + "|" + scope + "|" + scopeKeyParts.join(",")).digest("hex").slice(0, 20);
|
|
1069
1087
|
const reportFile = join(cacheDir, book + "-" + cacheKey + ".json");
|
|
1070
1088
|
if (!fresh) {
|
|
@@ -1281,16 +1299,33 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1281
1299
|
}
|
|
1282
1300
|
|
|
1283
1301
|
/** v0.6.0 伏笔/剧情线登记表。 */
|
|
1302
|
+
/** v0.8.0:书库统一数据目录(<root>/.novel-writer/),子目录:plots/settings/summaries/analysis/audits。 */
|
|
1303
|
+
function novelDataDir(root) {
|
|
1304
|
+
return join(root, ".novel-writer");
|
|
1305
|
+
}
|
|
1284
1306
|
function plotsFile(root, book) {
|
|
1285
|
-
return join(root, "
|
|
1307
|
+
return join(novelDataDir(root), "plots", sanitizeSegment(book, "book") + ".json");
|
|
1308
|
+
}
|
|
1309
|
+
function legacyPlotsFile(root, book) {
|
|
1310
|
+
return join(novelDataDir(root), sanitizeSegment(book, "book") + ".json");
|
|
1286
1311
|
}
|
|
1287
1312
|
async function readPlots(root, book) {
|
|
1313
|
+
const file = plotsFile(root, book);
|
|
1314
|
+
let parsed = null;
|
|
1288
1315
|
try {
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1316
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1317
|
+
} catch { /* 新位置不存在 */ }
|
|
1318
|
+
if (!parsed) {
|
|
1319
|
+
// v0.8.0 迁移:旧位置 <root>/.novel-writer/<书>.json → plots/<书>.json
|
|
1320
|
+
try {
|
|
1321
|
+
parsed = JSON.parse(await readFile(legacyPlotsFile(root, book), "utf8"));
|
|
1322
|
+
if (Array.isArray(parsed?.entries)) {
|
|
1323
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1324
|
+
await writeFile(file, JSON.stringify(parsed, null, 2), "utf8");
|
|
1325
|
+
}
|
|
1326
|
+
} catch { /* 无旧文件 */ }
|
|
1293
1327
|
}
|
|
1328
|
+
return Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
1294
1329
|
}
|
|
1295
1330
|
async function writePlots(root, book, entries) {
|
|
1296
1331
|
const file = plotsFile(root, book);
|
|
@@ -1298,16 +1333,446 @@ async function writePlots(root, book, entries) {
|
|
|
1298
1333
|
await writeFile(file, JSON.stringify({ book, entries }, null, 2), "utf8");
|
|
1299
1334
|
}
|
|
1300
1335
|
|
|
1336
|
+
/** v0.8.0:从伏笔内容提取 2-4 字中文关键词(用于 scan 提及检测),跳过停用字。 */
|
|
1337
|
+
function plotKeywords(content) {
|
|
1338
|
+
const text = String(content).replace(/[^\u4e00-\u9fff]/g, "");
|
|
1339
|
+
const stop = new Set("的了是在我有和就都不一一个这那与及或但是因为所以如果然后而且比如比如什么怎么自己她们他们你们我们咱们没有不是别莫未".split(""));
|
|
1340
|
+
const keywords = [];
|
|
1341
|
+
for (const length of [4, 3, 2]) {
|
|
1342
|
+
for (let i = 0; i + length <= text.length; i += 1) {
|
|
1343
|
+
const sub = text.slice(i, i + length);
|
|
1344
|
+
const nonStop = [...sub].filter((ch) => !stop.has(ch)).length;
|
|
1345
|
+
if (nonStop >= Math.ceil(length * 0.6)) keywords.push(sub);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// 去重并按长度降序,最多取 8 个
|
|
1349
|
+
return [...new Set(keywords)].sort((a, b) => b.length - a.length).slice(0, 8);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1301
1352
|
/** v0.7.0:清洗伏笔条目为 lossless JSON(剔除 undefined 字段,DSH 通道要求)。 */
|
|
1302
1353
|
function normalizePlotEntry(entry) {
|
|
1303
1354
|
const out = { id: entry.id, content: entry.content, status: entry.status };
|
|
1304
|
-
|
|
1305
|
-
|
|
1355
|
+
for (const key of ["chapter", "note", "payoffCondition", "lastMentioned", "type", "priority"]) {
|
|
1356
|
+
if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
|
|
1357
|
+
}
|
|
1358
|
+
for (const key of ["relatedCharacters", "locations", "mentionedIn"]) {
|
|
1359
|
+
if (Array.isArray(entry[key])) out[key] = entry[key].map((x) => String(x));
|
|
1360
|
+
}
|
|
1306
1361
|
if (entry.createdAt !== void 0) out.createdAt = entry.createdAt;
|
|
1307
1362
|
if (entry.updatedAt !== void 0) out.updatedAt = entry.updatedAt;
|
|
1308
1363
|
return out;
|
|
1309
1364
|
}
|
|
1310
1365
|
|
|
1366
|
+
|
|
1367
|
+
/** v0.8.0 设定管理(四张表:人物/地点/道具/时间线)。 */
|
|
1368
|
+
function settingsFile(root, book) {
|
|
1369
|
+
return join(novelDataDir(root), "settings", sanitizeSegment(book, "book") + ".json");
|
|
1370
|
+
}
|
|
1371
|
+
async function readSettings(root, book) {
|
|
1372
|
+
try {
|
|
1373
|
+
const parsed = JSON.parse(await readFile(settingsFile(root, book), "utf8"));
|
|
1374
|
+
return {
|
|
1375
|
+
characters: Array.isArray(parsed.characters) ? parsed.characters : [],
|
|
1376
|
+
locations: Array.isArray(parsed.locations) ? parsed.locations : [],
|
|
1377
|
+
items: Array.isArray(parsed.items) ? parsed.items : [],
|
|
1378
|
+
timeline: Array.isArray(parsed.timeline) ? parsed.timeline : []
|
|
1379
|
+
};
|
|
1380
|
+
} catch {
|
|
1381
|
+
return { characters: [], locations: [], items: [], timeline: [] };
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
async function writeSettings(root, book, data) {
|
|
1385
|
+
const file = settingsFile(root, book);
|
|
1386
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1387
|
+
await writeFile(file, JSON.stringify({ book, ...data }, null, 2), "utf8");
|
|
1388
|
+
}
|
|
1389
|
+
function normalizeSettingEntry(entry) {
|
|
1390
|
+
const out = { name: String(entry.name) };
|
|
1391
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "notes", "owner", "status", "lastSeen", "day", "event", "chapter"]) {
|
|
1392
|
+
if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
|
|
1393
|
+
}
|
|
1394
|
+
for (const key of ["alias"]) {
|
|
1395
|
+
if (Array.isArray(entry[key])) out[key] = entry[key].map(String);
|
|
1396
|
+
}
|
|
1397
|
+
return out;
|
|
1398
|
+
}
|
|
1399
|
+
function registerNovelSettings(ctx, config) {
|
|
1400
|
+
ctx.tools.register({
|
|
1401
|
+
name: "novel_settings",
|
|
1402
|
+
description: "设定管理(四张表):人物卡/地点卡/道具清单/时间线。list/add/update/delete 按 category 维护;scan 用规则扫描章节提取人物/道具候选供登记。用于续写时保持人物性格、环境、道具与时间线一致。",
|
|
1403
|
+
parameters: {
|
|
1404
|
+
type: "object",
|
|
1405
|
+
properties: {
|
|
1406
|
+
book: { type: "string", description: "书名。" },
|
|
1407
|
+
category: { type: "string", enum: ["character", "location", "item", "timeline"], description: "表类别(list/add/update/delete/scan 需要)。" },
|
|
1408
|
+
action: { type: "string", enum: ["list", "add", "update", "delete", "scan"], description: "list=查看(默认);add=登记;update=修改;delete=删除;scan=扫描章节提取候选。" },
|
|
1409
|
+
name: { type: "string", description: "条目名(人物名/地名/道具名;timeline 用 day 字段)。" },
|
|
1410
|
+
description: { type: "string", description: "可选:描述。" },
|
|
1411
|
+
traits: { type: "string", description: "character 专用:性格/外貌特征。" },
|
|
1412
|
+
relationships: { type: "string", description: "character 专用:人际关系。" },
|
|
1413
|
+
alias: { type: "array", items: { type: "string" }, description: "character 专用:别名。" },
|
|
1414
|
+
firstSeen: { type: "string", description: "可选:首次出现的章节。" },
|
|
1415
|
+
owner: { type: "string", description: "item 专用:当前持有者。" },
|
|
1416
|
+
status: { type: "string", description: "item 专用:状态(如 在琉璃处/已遗失)。" },
|
|
1417
|
+
lastSeen: { type: "string", description: "item 专用:最近出现的章节。" },
|
|
1418
|
+
day: { type: "string", description: "timeline 专用:时间点(如 穿越第1天/考核前10日)。" },
|
|
1419
|
+
event: { type: "string", description: "timeline 专用:事件。" },
|
|
1420
|
+
chapter: { type: "string", description: "timeline 专用:对应章节。" },
|
|
1421
|
+
notes: { type: "string", description: "可选:备注。" },
|
|
1422
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1423
|
+
},
|
|
1424
|
+
required: ["book"],
|
|
1425
|
+
additionalProperties: false
|
|
1426
|
+
},
|
|
1427
|
+
output: {
|
|
1428
|
+
schema: {
|
|
1429
|
+
type: "object",
|
|
1430
|
+
additionalProperties: false,
|
|
1431
|
+
properties: {
|
|
1432
|
+
book: { type: "string" },
|
|
1433
|
+
category: { type: "string" },
|
|
1434
|
+
action: { type: "string" },
|
|
1435
|
+
characters: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1436
|
+
locations: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1437
|
+
items: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1438
|
+
timeline: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1439
|
+
candidates: { type: "array", items: { type: "string" } },
|
|
1440
|
+
message: { type: "string" }
|
|
1441
|
+
},
|
|
1442
|
+
required: ["book", "category", "action"]
|
|
1443
|
+
},
|
|
1444
|
+
render: (_args, value) => {
|
|
1445
|
+
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-settings</type>", "<content>", ""];
|
|
1446
|
+
if (value.message) lines.push(value.message);
|
|
1447
|
+
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[value.category] ?? value.category;
|
|
1448
|
+
const list = value.characters ?? value.locations ?? value.items ?? value.timeline ?? [];
|
|
1449
|
+
lines.push(`${categoryLabel}(${list.length} 条):`);
|
|
1450
|
+
for (const e of list) {
|
|
1451
|
+
const extra = [e.traits, e.description, e.relationships, e.owner, e.status, e.lastSeen, e.day + (e.event ? " " + e.event : "")].filter(Boolean).join(" | ");
|
|
1452
|
+
lines.push(` - ${e.name}${extra ? ":" + extra : ""}${e.chapter ? "(" + e.chapter + ")" : ""}`);
|
|
1453
|
+
}
|
|
1454
|
+
if (Array.isArray(value.candidates) && value.candidates.length > 0) {
|
|
1455
|
+
lines.push("", "扫描候选(供登记):");
|
|
1456
|
+
for (const cand of value.candidates) lines.push(" ? " + cand);
|
|
1457
|
+
}
|
|
1458
|
+
lines.push("", "</content>");
|
|
1459
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1460
|
+
}
|
|
1461
|
+
},
|
|
1462
|
+
async execute(args, exec) {
|
|
1463
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1464
|
+
const category = ["character", "location", "item", "timeline"].includes(args?.category) ? args.category : "character";
|
|
1465
|
+
const action = ["add", "update", "delete", "scan"].includes(args?.action) ? args.action : "list";
|
|
1466
|
+
const root = resolveRoot(config, args, exec);
|
|
1467
|
+
const data = await readSettings(root, book);
|
|
1468
|
+
const now = new Date().toISOString();
|
|
1469
|
+
let message = "";
|
|
1470
|
+
const categoryLabel = { character: "人物卡", location: "地点卡", item: "道具清单", timeline: "时间线" }[category] ?? category;
|
|
1471
|
+
const listKey = { character: "characters", location: "locations", item: "items", timeline: "timeline" }[category];
|
|
1472
|
+
const list = data[listKey];
|
|
1473
|
+
if (action === "add") {
|
|
1474
|
+
const name = optionalString(args, "name") ?? optionalString(args, "day");
|
|
1475
|
+
assert(name !== void 0, "novel_settings add 需要 name(timeline 用 day)参数");
|
|
1476
|
+
const entry = { name };
|
|
1477
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
|
|
1478
|
+
if (args?.[key] !== void 0) entry[key] = String(args[key]);
|
|
1479
|
+
}
|
|
1480
|
+
if (Array.isArray(args?.alias)) entry.alias = args.alias.map(String);
|
|
1481
|
+
list.push(entry);
|
|
1482
|
+
message = `已登记 ${categoryLabel}:${name}`;
|
|
1483
|
+
await writeSettings(root, book, data);
|
|
1484
|
+
} else if (action === "update" || action === "delete") {
|
|
1485
|
+
const name = optionalString(args, "name") ?? optionalString(args, "day");
|
|
1486
|
+
assert(name !== void 0, `novel_settings ${action} 需要 name(timeline 用 day)参数`);
|
|
1487
|
+
const index = list.findIndex((e) => e.name === name);
|
|
1488
|
+
assert(index !== -1, `${categoryLabel}中不存在「${name}」`);
|
|
1489
|
+
if (action === "delete") {
|
|
1490
|
+
list.splice(index, 1);
|
|
1491
|
+
message = `已删除 ${categoryLabel}:${name}`;
|
|
1492
|
+
} else {
|
|
1493
|
+
for (const key of ["description", "traits", "relationships", "firstSeen", "owner", "status", "lastSeen", "event", "chapter", "notes"]) {
|
|
1494
|
+
if (args?.[key] !== void 0) list[index][key] = String(args[key]);
|
|
1495
|
+
}
|
|
1496
|
+
if (Array.isArray(args?.alias)) list[index].alias = args.alias.map(String);
|
|
1497
|
+
message = `已更新 ${categoryLabel}:${name}`;
|
|
1498
|
+
}
|
|
1499
|
+
await writeSettings(root, book, data);
|
|
1500
|
+
} else if (action === "scan") {
|
|
1501
|
+
// 规则候选:人物(XX说/道/问 高频 2-3 字),道具(含 owner 的常见物件词)
|
|
1502
|
+
const dir = bookDir(root, book);
|
|
1503
|
+
const chapters = await scanChapters(dir);
|
|
1504
|
+
let text = "";
|
|
1505
|
+
for (const chapter of chapters) text += await readTextFile(join(dir, chapter.file), exec);
|
|
1506
|
+
const nameCounts = new Map();
|
|
1507
|
+
for (const m of text.matchAll(/([\u4e00-\u9fff]{2,3})(?:说|道|问|喊|叫|笑|叹|点头|摇头)/g)) {
|
|
1508
|
+
const n = m[1];
|
|
1509
|
+
if (!["那个", "这个", "什么", "怎么", "自己", "她们", "他们", "你们", "我们"].includes(n)) {
|
|
1510
|
+
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const known = new Set(list.map((e) => e.name));
|
|
1514
|
+
const candidates = [...nameCounts.entries()].filter(([n, count]) => count >= 2 && !known.has(n)).map(([n, count]) => n + "(" + count + "次)").slice(0, 15);
|
|
1515
|
+
message = `扫描全书,提取 ${candidates.length} 个人物候选`;
|
|
1516
|
+
const result = { book, category, action, candidates, message };
|
|
1517
|
+
if (category === "character") result.characters = data.characters.map(normalizeSettingEntry);
|
|
1518
|
+
if (category === "location") result.locations = data.locations.map(normalizeSettingEntry);
|
|
1519
|
+
if (category === "item") result.items = data.items.map(normalizeSettingEntry);
|
|
1520
|
+
if (category === "timeline") result.timeline = data.timeline.map(normalizeSettingEntry);
|
|
1521
|
+
return result;
|
|
1522
|
+
}
|
|
1523
|
+
const result = { book, category, action };
|
|
1524
|
+
result.characters = data.characters.map(normalizeSettingEntry);
|
|
1525
|
+
result.locations = data.locations.map(normalizeSettingEntry);
|
|
1526
|
+
result.items = data.items.map(normalizeSettingEntry);
|
|
1527
|
+
result.timeline = data.timeline.map(normalizeSettingEntry);
|
|
1528
|
+
if (message) result.message = message;
|
|
1529
|
+
return result;
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** v0.8.0 章节摘要(模型生成内容,插件负责存储/读取)。 */
|
|
1535
|
+
function summariesFile(root, book) {
|
|
1536
|
+
return join(novelDataDir(root), "summaries", sanitizeSegment(book, "book") + ".json");
|
|
1537
|
+
}
|
|
1538
|
+
async function readSummaries(root, book) {
|
|
1539
|
+
try {
|
|
1540
|
+
const parsed = JSON.parse(await readFile(summariesFile(root, book), "utf8"));
|
|
1541
|
+
return Array.isArray(parsed.summaries) ? parsed.summaries : [];
|
|
1542
|
+
} catch {
|
|
1543
|
+
return [];
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function registerNovelSummary(ctx, config) {
|
|
1547
|
+
ctx.tools.register({
|
|
1548
|
+
name: "novel_summary",
|
|
1549
|
+
description: "章节摘要(模型生成、插件存储):阅读章节后把摘要存盘,续写长书时先读摘要回忆剧情,避免全文重读。add/update/get/list/delete 按章节管理。",
|
|
1550
|
+
parameters: {
|
|
1551
|
+
type: "object",
|
|
1552
|
+
properties: {
|
|
1553
|
+
book: { type: "string", description: "书名。" },
|
|
1554
|
+
action: { type: "string", enum: ["list", "get", "add", "update", "delete"], description: "list=全部摘要(默认);get=取某章;add=新增/覆盖摘要;update=修改;delete=删除。" },
|
|
1555
|
+
chapter: { type: "string", description: "章节标识(章号/文件名/标题)。" },
|
|
1556
|
+
summary: { type: "string", description: "add/update 时:本章摘要(200-500 字,覆盖剧情走向/关键事件/结尾状态)。" },
|
|
1557
|
+
keyEvents: { type: "array", items: { type: "string" }, description: "可选:关键事件列表。" },
|
|
1558
|
+
keySettings: { type: "array", items: { type: "string" }, description: "可选:本章出现的关键设定/信息。" },
|
|
1559
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1560
|
+
},
|
|
1561
|
+
required: ["book"],
|
|
1562
|
+
additionalProperties: false
|
|
1563
|
+
},
|
|
1564
|
+
output: {
|
|
1565
|
+
schema: {
|
|
1566
|
+
type: "object",
|
|
1567
|
+
additionalProperties: false,
|
|
1568
|
+
properties: {
|
|
1569
|
+
book: { type: "string" },
|
|
1570
|
+
action: { type: "string" },
|
|
1571
|
+
summaries: {
|
|
1572
|
+
type: "array",
|
|
1573
|
+
items: {
|
|
1574
|
+
type: "object",
|
|
1575
|
+
additionalProperties: false,
|
|
1576
|
+
properties: {
|
|
1577
|
+
chapter: { type: "string" },
|
|
1578
|
+
summary: { type: "string" },
|
|
1579
|
+
keyEvents: { type: "array", items: { type: "string" } },
|
|
1580
|
+
keySettings: { type: "array", items: { type: "string" } },
|
|
1581
|
+
updatedAt: { type: "string" }
|
|
1582
|
+
},
|
|
1583
|
+
required: ["chapter", "summary"]
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
message: { type: "string" }
|
|
1587
|
+
},
|
|
1588
|
+
required: ["book", "action", "summaries"]
|
|
1589
|
+
},
|
|
1590
|
+
render: (_args, value) => {
|
|
1591
|
+
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-summary</type>", "<content>", ""];
|
|
1592
|
+
if (value.message) lines.push(value.message);
|
|
1593
|
+
for (const s of value.summaries) {
|
|
1594
|
+
lines.push(`【${s.chapter}】`);
|
|
1595
|
+
lines.push(s.summary);
|
|
1596
|
+
if (Array.isArray(s.keyEvents) && s.keyEvents.length > 0) lines.push("关键事件:" + s.keyEvents.join(";"));
|
|
1597
|
+
if (Array.isArray(s.keySettings) && s.keySettings.length > 0) lines.push("关键设定:" + s.keySettings.join(";"));
|
|
1598
|
+
lines.push("");
|
|
1599
|
+
}
|
|
1600
|
+
lines.push("</content>");
|
|
1601
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
async execute(args, exec) {
|
|
1605
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1606
|
+
const action = ["get", "add", "update", "delete"].includes(args?.action) ? args.action : "list";
|
|
1607
|
+
const root = resolveRoot(config, args, exec);
|
|
1608
|
+
const summaries = await readSummaries(root, book);
|
|
1609
|
+
const now = new Date().toISOString();
|
|
1610
|
+
let message = "";
|
|
1611
|
+
if (action !== "list") {
|
|
1612
|
+
const chapterArg = optionalString(args, "chapter");
|
|
1613
|
+
assert(chapterArg !== void 0, `novel_summary ${action} 需要 chapter 参数`);
|
|
1614
|
+
const index = summaries.findIndex((s) => s.chapter === chapterArg);
|
|
1615
|
+
if (action === "get") {
|
|
1616
|
+
const found = index !== -1 ? [summaries[index]] : [];
|
|
1617
|
+
return { book, action, summaries: found.map((s) => ({ ...s })) };
|
|
1618
|
+
}
|
|
1619
|
+
if (action === "delete") {
|
|
1620
|
+
if (index !== -1) summaries.splice(index, 1);
|
|
1621
|
+
message = `已删除 ${chapterArg} 的摘要`;
|
|
1622
|
+
} else {
|
|
1623
|
+
const summary = optionalString(args, "summary");
|
|
1624
|
+
assert(summary !== void 0, `novel_summary ${action} 需要 summary 参数`);
|
|
1625
|
+
const entry = {
|
|
1626
|
+
chapter: chapterArg,
|
|
1627
|
+
summary,
|
|
1628
|
+
keyEvents: Array.isArray(args?.keyEvents) ? args.keyEvents.map(String) : void 0,
|
|
1629
|
+
keySettings: Array.isArray(args?.keySettings) ? args.keySettings.map(String) : void 0,
|
|
1630
|
+
updatedAt: now
|
|
1631
|
+
};
|
|
1632
|
+
if (index !== -1) summaries[index] = entry;
|
|
1633
|
+
else summaries.push(entry);
|
|
1634
|
+
message = `已保存 ${chapterArg} 的摘要`;
|
|
1635
|
+
}
|
|
1636
|
+
await mkdir(dirname(summariesFile(root, book)), { recursive: true });
|
|
1637
|
+
await writeFile(summariesFile(root, book), JSON.stringify({ book, summaries }, null, 2), "utf8");
|
|
1638
|
+
}
|
|
1639
|
+
summaries.sort((a, b) => String(a.chapter).localeCompare(String(b.chapter)));
|
|
1640
|
+
const clean = summaries.map((s) => {
|
|
1641
|
+
const out = { chapter: String(s.chapter), summary: String(s.summary) };
|
|
1642
|
+
if (Array.isArray(s.keyEvents)) out.keyEvents = s.keyEvents.map(String);
|
|
1643
|
+
if (Array.isArray(s.keySettings)) out.keySettings = s.keySettings.map(String);
|
|
1644
|
+
if (s.updatedAt !== void 0) out.updatedAt = s.updatedAt;
|
|
1645
|
+
return out;
|
|
1646
|
+
});
|
|
1647
|
+
return { book, action, summaries: clean, ...message ? { message } : {} };
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** v0.8.0 连贯性审计:设定表 + 全书扫描 → 矛盾候选清单。 */
|
|
1653
|
+
function registerNovelContinuityCheck(ctx, config) {
|
|
1654
|
+
ctx.tools.register({
|
|
1655
|
+
name: "novel_continuity_check",
|
|
1656
|
+
description: "连贯性审计:对照设定表扫描全书,输出矛盾候选(数字写法差异、登记人物在章节中的出现分布、设定别名变体),供模型判断并修正设定。",
|
|
1657
|
+
parameters: {
|
|
1658
|
+
type: "object",
|
|
1659
|
+
properties: {
|
|
1660
|
+
book: { type: "string", description: "书名。" },
|
|
1661
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1662
|
+
},
|
|
1663
|
+
required: ["book"],
|
|
1664
|
+
additionalProperties: false
|
|
1665
|
+
},
|
|
1666
|
+
output: {
|
|
1667
|
+
schema: {
|
|
1668
|
+
type: "object",
|
|
1669
|
+
additionalProperties: false,
|
|
1670
|
+
properties: {
|
|
1671
|
+
book: { type: "string" },
|
|
1672
|
+
candidates: {
|
|
1673
|
+
type: "array",
|
|
1674
|
+
items: {
|
|
1675
|
+
type: "object",
|
|
1676
|
+
additionalProperties: false,
|
|
1677
|
+
properties: {
|
|
1678
|
+
type: { type: "string" },
|
|
1679
|
+
detail: { type: "string" },
|
|
1680
|
+
chapters: { type: "array", items: { type: "string" } }
|
|
1681
|
+
},
|
|
1682
|
+
required: ["type", "detail"]
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
reportFile: { type: "string" },
|
|
1686
|
+
advice: { type: "string" }
|
|
1687
|
+
},
|
|
1688
|
+
required: ["book", "candidates", "advice"]
|
|
1689
|
+
},
|
|
1690
|
+
render: (_args, value) => {
|
|
1691
|
+
const lines = [`<path>novels/${value.book}</path>`, "<type>novel-continuity-check</type>", "<content>", ""];
|
|
1692
|
+
if (value.candidates.length === 0) {
|
|
1693
|
+
lines.push("未发现明显矛盾候选。");
|
|
1694
|
+
} else {
|
|
1695
|
+
lines.push(`矛盾候选 ${value.candidates.length} 条(供人工判断):`);
|
|
1696
|
+
for (const cand of value.candidates) {
|
|
1697
|
+
lines.push(` - [${cand.type}] ${cand.detail}${Array.isArray(cand.chapters) && cand.chapters.length > 0 ? "(涉及:" + cand.chapters.join("、") + ")" : ""}`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
lines.push("", "【建议】" + value.advice, "", "</content>");
|
|
1701
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1702
|
+
}
|
|
1703
|
+
},
|
|
1704
|
+
async execute(args, exec) {
|
|
1705
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1706
|
+
const root = resolveRoot(config, args, exec);
|
|
1707
|
+
const dir = bookDir(root, book);
|
|
1708
|
+
const chapters = await scanChapters(dir);
|
|
1709
|
+
assert(chapters.length > 0, `book "${book}" has no chapter files`);
|
|
1710
|
+
const settings = await readSettings(root, book);
|
|
1711
|
+
const chapterTexts = [];
|
|
1712
|
+
for (const chapter of chapters) {
|
|
1713
|
+
chapterTexts.push({ file: chapter.file, text: await readTextFile(join(dir, chapter.file), exec) });
|
|
1714
|
+
}
|
|
1715
|
+
const candidates = [];
|
|
1716
|
+
// 1) 数字写法差异(亿/千万/百万 口径)
|
|
1717
|
+
const numberForms = new Map();
|
|
1718
|
+
for (const { file, text } of chapterTexts) {
|
|
1719
|
+
for (const m of text.matchAll(/\d+\s*(?:万亿|千万|百万|十万|[万亿千百])|[一二三四五六七八九十]{1,2}(?:万亿|千万|百万|十万|[万亿千百])/g)) {
|
|
1720
|
+
const form = m[0].replace(/\s/g, "");
|
|
1721
|
+
// 只保留"带单位"的数量表达;过滤纯数字(五十/二十九)、连词(万一)、约数(七八)
|
|
1722
|
+
if (!/[万亿千百]$/.test(form)) continue;
|
|
1723
|
+
if (/^[十百千万亿]$/.test(form)) continue;
|
|
1724
|
+
if (!numberForms.has(form)) numberForms.set(form, []);
|
|
1725
|
+
if (!numberForms.get(form).includes(file)) numberForms.get(form).push(file);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (numberForms.size >= 2) {
|
|
1729
|
+
const forms = [...numberForms.entries()];
|
|
1730
|
+
for (let i = 0; i < forms.length; i += 1) {
|
|
1731
|
+
for (let j = i + 1; j < forms.length; j += 1) {
|
|
1732
|
+
const [a, ca] = forms[i];
|
|
1733
|
+
const [b, cb] = forms[j];
|
|
1734
|
+
if (a !== b) {
|
|
1735
|
+
candidates.push({ type: "数字口径", detail: `「${a}」与「${b}」写法并存(可能指同一数量),请确认口径`, chapters: [...new Set(ca.concat(cb))] });
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
// 2) 登记人物出现分布(缺失章节提示)
|
|
1741
|
+
for (const character of settings.characters) {
|
|
1742
|
+
const absent = chapterTexts.filter(({ file, text }) => !text.includes(character.name)).map(({ file }) => file);
|
|
1743
|
+
if (absent.length === chapterTexts.length) {
|
|
1744
|
+
candidates.push({ type: "人物缺失", detail: `设定表人物「${character.name}」在全书章节中均未出现,请确认是否已出场`, chapters: absent });
|
|
1745
|
+
} else if (absent.length > 0 && chapterTexts.length > 1) {
|
|
1746
|
+
candidates.push({ type: "人物缺场", detail: `设定表人物「${character.name}」未出现在 ${absent.length} 个章节中(如无出场必要可忽略)`, chapters: absent.slice(0, 5) });
|
|
1747
|
+
}
|
|
1748
|
+
if (Array.isArray(character.alias) && character.alias.length > 0) {
|
|
1749
|
+
const used = chapterTexts.filter(({ text }) => character.alias.some((al) => text.includes(al))).map(({ file }) => file);
|
|
1750
|
+
if (used.length === 0) {
|
|
1751
|
+
candidates.push({ type: "别名未用", detail: `人物「${character.name}」登记的别名 ${character.alias.join("、")} 在全书未出现`, chapters: [] });
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
// 3) 设定表重复条目
|
|
1756
|
+
for (const key of ["characters", "locations", "items"]) {
|
|
1757
|
+
const names = settings[key].map((e) => e.name);
|
|
1758
|
+
const dup = names.filter((n, i) => names.indexOf(n) !== i);
|
|
1759
|
+
if (dup.length > 0) candidates.push({ type: "设定重复", detail: `${key} 中存在重复条目:${[...new Set(dup)].join("、")}`, chapters: [] });
|
|
1760
|
+
}
|
|
1761
|
+
const advice = candidates.length === 0
|
|
1762
|
+
? "设定表与章节基本一致。建议继续登记新章节出现的新人物/新地点。"
|
|
1763
|
+
: "以上为规则候选,请逐条人工判断:确认为矛盾则更新设定表(novel_settings update)或修正正文;非矛盾(如回忆/刻意缺席)可忽略。";
|
|
1764
|
+
// 落盘审计报告
|
|
1765
|
+
const auditDir = join(novelDataDir(root), "audits");
|
|
1766
|
+
const reportFile = join(auditDir, book + "-" + createHash("sha1").update(book + "|" + chapters.map((x) => x.file).join(",")).digest("hex").slice(0, 16) + ".json");
|
|
1767
|
+
try {
|
|
1768
|
+
await mkdir(auditDir, { recursive: true });
|
|
1769
|
+
await writeFile(reportFile, JSON.stringify({ book, generatedAt: new Date().toISOString(), candidates }, null, 2), "utf8");
|
|
1770
|
+
} catch { /* 落盘失败不阻塞 */ }
|
|
1771
|
+
return { book, candidates, reportFile, advice };
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1311
1776
|
function registerNovelPlot(ctx, config) {
|
|
1312
1777
|
ctx.tools.register({
|
|
1313
1778
|
name: "novel_plot",
|
|
@@ -1316,11 +1781,16 @@ function registerNovelPlot(ctx, config) {
|
|
|
1316
1781
|
type: "object",
|
|
1317
1782
|
properties: {
|
|
1318
1783
|
book: { type: "string", description: "书名。" },
|
|
1319
|
-
action: { type: "string", enum: ["list", "add", "update", "done", "delete"], description: "list=查看(默认);add=登记新伏笔;update=修改;done=标记已回收;delete
|
|
1784
|
+
action: { type: "string", enum: ["list", "add", "update", "done", "delete", "scan"], description: "list=查看(默认);add=登记新伏笔;update=修改;done=标记已回收;delete=删除;scan=扫描章节文本,自动更新每条 open 伏笔的提及章节(mentionedIn/lastMentioned)。" },
|
|
1320
1785
|
id: { type: "string", description: "伏笔 id(update/done/delete 时需要)。" },
|
|
1321
1786
|
content: { type: "string", description: "add/update 时:伏笔内容描述。" },
|
|
1322
1787
|
chapter: { type: "string", description: "可选:伏笔出现的章节。" },
|
|
1323
1788
|
note: { type: "string", description: "可选:备注(如何回收/何时回收)。" },
|
|
1789
|
+
type: { type: "string", enum: ["剧情", "设定", "道具", "人物", "其他"], description: "可选:伏笔类型(add/update)。" },
|
|
1790
|
+
priority: { type: "string", enum: ["high", "medium", "low"], description: "可选:优先级(add/update)。" },
|
|
1791
|
+
relatedCharacters: { type: "array", items: { type: "string" }, description: "可选:关联人物(add/update)。" },
|
|
1792
|
+
locations: { type: "array", items: { type: "string" }, description: "可选:关联地点(add/update)。" },
|
|
1793
|
+
payoffCondition: { type: "string", description: "可选:回收条件(add/update)。" },
|
|
1324
1794
|
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
1325
1795
|
},
|
|
1326
1796
|
required: ["book"],
|
|
@@ -1343,6 +1813,13 @@ function registerNovelPlot(ctx, config) {
|
|
|
1343
1813
|
content: { type: "string" },
|
|
1344
1814
|
chapter: { type: "string" },
|
|
1345
1815
|
note: { type: "string" },
|
|
1816
|
+
type: { type: "string" },
|
|
1817
|
+
priority: { type: "string" },
|
|
1818
|
+
relatedCharacters: { type: "array", items: { type: "string" } },
|
|
1819
|
+
locations: { type: "array", items: { type: "string" } },
|
|
1820
|
+
payoffCondition: { type: "string" },
|
|
1821
|
+
mentionedIn: { type: "array", items: { type: "string" } },
|
|
1822
|
+
lastMentioned: { type: "string" },
|
|
1346
1823
|
status: { type: "string", enum: ["open", "done"] },
|
|
1347
1824
|
createdAt: { type: "string" },
|
|
1348
1825
|
updatedAt: { type: "string" }
|
|
@@ -1361,7 +1838,10 @@ function registerNovelPlot(ctx, config) {
|
|
|
1361
1838
|
const done = value.entries.filter((e) => e.status === "done");
|
|
1362
1839
|
lines.push(`未回收伏笔 ${open.length} 条:`);
|
|
1363
1840
|
if (open.length === 0) lines.push(" (无)");
|
|
1364
|
-
for (const e of open)
|
|
1841
|
+
for (const e of open) {
|
|
1842
|
+
const tags = [e.type, e.priority, e.chapter ? "出自 " + e.chapter : "", e.payoffCondition ? "回收:" + e.payoffCondition : "", e.lastMentioned ? "最近提及:" + e.lastMentioned : ""].filter(Boolean).join(" | ");
|
|
1843
|
+
lines.push(` - [${e.id}] ${e.content}${tags ? "(" + tags + ")" : ""}${e.note ? "|" + e.note : ""}`);
|
|
1844
|
+
}
|
|
1365
1845
|
if (done.length > 0) {
|
|
1366
1846
|
lines.push(`已回收 ${done.length} 条:`);
|
|
1367
1847
|
for (const e of done) lines.push(` - [${e.id}] ${e.content}`);
|
|
@@ -1373,7 +1853,7 @@ function registerNovelPlot(ctx, config) {
|
|
|
1373
1853
|
async execute(args, exec) {
|
|
1374
1854
|
await assertToolEnabled(config, "novel_plot");
|
|
1375
1855
|
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
1376
|
-
const action = ["add", "update", "done", "delete"].includes(args?.action) ? args.action : "list";
|
|
1856
|
+
const action = ["add", "update", "done", "delete", "scan"].includes(args?.action) ? args.action : "list";
|
|
1377
1857
|
const root = resolveRoot(config, args, exec);
|
|
1378
1858
|
const entries = await readPlots(root, book);
|
|
1379
1859
|
const now = new Date().toISOString();
|
|
@@ -1382,8 +1862,48 @@ function registerNovelPlot(ctx, config) {
|
|
|
1382
1862
|
const content = optionalString(args, "content");
|
|
1383
1863
|
assert(content !== void 0, "novel_plot add 需要 content 参数");
|
|
1384
1864
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1385
|
-
|
|
1865
|
+
const entry = {
|
|
1866
|
+
id,
|
|
1867
|
+
content,
|
|
1868
|
+
chapter: optionalString(args, "chapter"),
|
|
1869
|
+
note: optionalString(args, "note"),
|
|
1870
|
+
type: ["剧情", "设定", "道具", "人物", "其他"].includes(args?.type) ? args.type : void 0,
|
|
1871
|
+
priority: ["high", "medium", "low"].includes(args?.priority) ? args.priority : void 0,
|
|
1872
|
+
relatedCharacters: Array.isArray(args?.relatedCharacters) ? args.relatedCharacters.map(String) : void 0,
|
|
1873
|
+
locations: Array.isArray(args?.locations) ? args.locations.map(String) : void 0,
|
|
1874
|
+
payoffCondition: optionalString(args, "payoffCondition"),
|
|
1875
|
+
status: "open",
|
|
1876
|
+
createdAt: now,
|
|
1877
|
+
updatedAt: now
|
|
1878
|
+
};
|
|
1879
|
+
entries.push(entry);
|
|
1386
1880
|
message = `已登记伏笔 #${id}`;
|
|
1881
|
+
} else if (action === "scan") {
|
|
1882
|
+
// v0.8.0:扫描章节文本,更新 open 伏笔的提及章节
|
|
1883
|
+
const scanChapter = optionalString(args, "chapter");
|
|
1884
|
+
const dir = bookDir(root, book);
|
|
1885
|
+
const chapters = await scanChapters(dir);
|
|
1886
|
+
assert(chapters.length > 0, `book "${book}" has no chapter files`);
|
|
1887
|
+
const targets = scanChapter !== void 0 ? [findChapter(chapters, scanChapter)] : chapters;
|
|
1888
|
+
assert(targets[0] !== void 0, `cannot find chapter "${scanChapter}"`);
|
|
1889
|
+
let mentioned = 0;
|
|
1890
|
+
for (const chapter of targets) {
|
|
1891
|
+
const chapterText = await readTextFile(join(dir, chapter.file), exec);
|
|
1892
|
+
for (const entry of entries) {
|
|
1893
|
+
if (entry.status !== "open") continue;
|
|
1894
|
+
const keywords = plotKeywords(entry.content);
|
|
1895
|
+
if (keywords.length > 0 && keywords.some((k) => chapterText.includes(k))) {
|
|
1896
|
+
const mentionedIn = Array.isArray(entry.mentionedIn) ? entry.mentionedIn : [];
|
|
1897
|
+
if (!mentionedIn.includes(chapter.file)) {
|
|
1898
|
+
entry.mentionedIn = mentionedIn.concat(chapter.file);
|
|
1899
|
+
entry.lastMentioned = chapter.file;
|
|
1900
|
+
entry.updatedAt = now;
|
|
1901
|
+
mentioned += 1;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
message = `扫描 ${targets.length} 章,更新 ${mentioned} 条伏笔的提及记录`;
|
|
1387
1907
|
} else if (action === "update" || action === "done" || action === "delete") {
|
|
1388
1908
|
const id = optionalString(args, "id");
|
|
1389
1909
|
assert(id !== void 0, `novel_plot ${action} 需要 id 参数`);
|
|
@@ -1400,6 +1920,11 @@ function registerNovelPlot(ctx, config) {
|
|
|
1400
1920
|
if (args?.content !== void 0) entries[index].content = String(args.content);
|
|
1401
1921
|
if (args?.note !== void 0) entries[index].note = String(args.note);
|
|
1402
1922
|
if (args?.chapter !== void 0) entries[index].chapter = String(args.chapter);
|
|
1923
|
+
if (["剧情", "设定", "道具", "人物", "其他"].includes(args?.type)) entries[index].type = args.type;
|
|
1924
|
+
if (["high", "medium", "low"].includes(args?.priority)) entries[index].priority = args.priority;
|
|
1925
|
+
if (Array.isArray(args?.relatedCharacters)) entries[index].relatedCharacters = args.relatedCharacters.map(String);
|
|
1926
|
+
if (Array.isArray(args?.locations)) entries[index].locations = args.locations.map(String);
|
|
1927
|
+
if (args?.payoffCondition !== void 0) entries[index].payoffCondition = String(args.payoffCondition);
|
|
1403
1928
|
entries[index].updatedAt = now;
|
|
1404
1929
|
message = `已更新伏笔 #${id}`;
|
|
1405
1930
|
}
|
|
@@ -1721,7 +2246,8 @@ function registerNovelKeywords(ctx, config) {
|
|
|
1721
2246
|
},
|
|
1722
2247
|
required: ["word", "count", "kind"]
|
|
1723
2248
|
}
|
|
1724
|
-
}
|
|
2249
|
+
},
|
|
2250
|
+
reportFile: { type: "string" }
|
|
1725
2251
|
},
|
|
1726
2252
|
required: ["book", "scope", "totalChars", "keywords"]
|
|
1727
2253
|
},
|
|
@@ -1753,12 +2279,21 @@ function registerNovelKeywords(ctx, config) {
|
|
|
1753
2279
|
for (const chapter of selected) {
|
|
1754
2280
|
text += await readTextFile(join(dir, chapter.file), exec);
|
|
1755
2281
|
}
|
|
1756
|
-
|
|
2282
|
+
const result = {
|
|
1757
2283
|
book,
|
|
1758
2284
|
scope,
|
|
1759
2285
|
totalChars: text.length,
|
|
1760
2286
|
keywords: extractKeywords(text, top)
|
|
1761
2287
|
};
|
|
2288
|
+
// v0.8.0:关键词结果落盘到书库统一数据目录(与设定同文件夹)
|
|
2289
|
+
try {
|
|
2290
|
+
const reportDir = join(novelDataDir(root), "analysis");
|
|
2291
|
+
const reportFile = join(reportDir, book + "-keywords-" + createHash("sha1").update(book + "|" + scope + "|" + text.length).digest("hex").slice(0, 12) + ".json");
|
|
2292
|
+
await mkdir(reportDir, { recursive: true });
|
|
2293
|
+
await writeFile(reportFile, JSON.stringify({ ...result, generatedAt: new Date().toISOString() }, null, 2), "utf8");
|
|
2294
|
+
result.reportFile = reportFile;
|
|
2295
|
+
} catch { /* 落盘失败不阻塞 */ }
|
|
2296
|
+
return result;
|
|
1762
2297
|
}
|
|
1763
2298
|
});
|
|
1764
2299
|
}
|
|
@@ -1864,8 +2399,9 @@ const WORKFLOW_TEXT = [
|
|
|
1864
2399
|
"6. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
|
|
1865
2400
|
" 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
|
|
1866
2401
|
"7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
|
|
1867
|
-
"8. 工具链提示:续写/分析前可用 novel_plot
|
|
1868
|
-
"
|
|
2402
|
+
"8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
|
|
2403
|
+
" novel_settings 维护四张设定表(人物/地点/道具/时间线),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
|
|
2404
|
+
" novel_sentence_analysis 结果自动缓存到书库数据目录 <root>/.novel-writer/analysis/(reportFile 字段),novel_keywords 结果同样落盘,需要重算时传 fresh=true。"
|
|
1869
2405
|
].join("\n");
|
|
1870
2406
|
|
|
1871
2407
|
function apply(ctx, config = {}) {
|
|
@@ -1879,6 +2415,9 @@ function apply(ctx, config = {}) {
|
|
|
1879
2415
|
registerNovelSentenceConfig(ctx, config);
|
|
1880
2416
|
registerNovelStyleCheck(ctx, config);
|
|
1881
2417
|
registerNovelPlot(ctx, config);
|
|
2418
|
+
registerNovelSettings(ctx, config);
|
|
2419
|
+
registerNovelSummary(ctx, config);
|
|
2420
|
+
registerNovelContinuityCheck(ctx, config);
|
|
1882
2421
|
// 注册 UI 开关状态路由(可选注入,headless 自动跳过)
|
|
1883
2422
|
registerStyleConfigRoute(ctx, config);
|
|
1884
2423
|
ctx.systemPrompt.section({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "小说写作助手插件(v0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "小说写作助手插件(v0.8.1 记忆层):四张设定表(人物/地点/道具/时间线)、章节摘要、连贯性审计、伏笔字段化+提及追踪、细节密度指标、统一数据目录(plots/settings/summaries/analysis/audits)、UI 路径可视化修复",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -36,4 +36,4 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {},
|
|
38
38
|
"license": "MIT"
|
|
39
|
-
}
|
|
39
|
+
}
|
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
- 写完新章节后调用 `novel_style_check`:本章 vs 全书其余章节 → 相似度 + 偏差清单 + 建议;
|
|
30
30
|
- 偏差明显时主动调整文风(对话/心理占比、句长、情绪),防止漂移。
|
|
31
31
|
|
|
32
|
+
## 设定管理(v0.8.0 新增)
|
|
33
|
+
|
|
34
|
+
- §BT§novel_settings§BT§:四张表(人物卡/地点卡/道具清单/时间线),list/add/update/delete/scan;
|
|
35
|
+
- 登记新人物/新地点/道具去向,时间线记录"第几天/倒计时";
|
|
36
|
+
- §BT§novel_continuity_check§BT§:续写前跑一次,输出设定矛盾候选(数字口径/人物缺场/别名/重复)。
|
|
37
|
+
|
|
38
|
+
## 章节摘要(v0.8.0 新增)
|
|
39
|
+
|
|
40
|
+
- 读完每章后调用 §BT§novel_summary§BT§ add 保存 200-500 字摘要 + 关键事件 + 关键设定;
|
|
41
|
+
- 续写长书时先 §BT§novel_summary§BT§ list 回忆剧情,再按需 §BT§novel_read§BT§ 细读。
|
|
42
|
+
|
|
32
43
|
## 伏笔登记表(v0.6.0 新增)
|
|
33
44
|
|
|
34
45
|
- `novel_plot`:list / add / update / done / delete 管理伏笔与剧情钩子;
|
package/test/e2e-test.mjs
CHANGED
|
@@ -21,7 +21,7 @@ apply(ctx, { root: testRoot, sentenceAnalysis: { enabled: true, autoAnalyze: tru
|
|
|
21
21
|
const defs = Object.fromEntries(registry.map((d) => [d.name, d]));
|
|
22
22
|
const names = registry.map((d) => d.name);
|
|
23
23
|
console.log("工具数:", names.length, names.join(", "));
|
|
24
|
-
if (names.length !==
|
|
24
|
+
if (names.length !== 13) throw new Error("expected 13 tools (v0.8.0)");
|
|
25
25
|
|
|
26
26
|
const exec = { agent: { session: { header: { cwd: testRoot } } } };
|
|
27
27
|
|
|
@@ -54,7 +54,7 @@ console.log("DSH schema 规则检查: 全部通过");
|
|
|
54
54
|
|
|
55
55
|
// 1) 分析 + 缓存(第一次 miss,第二次 hit)
|
|
56
56
|
const first = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
57
|
-
console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("
|
|
57
|
+
console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("analysis") ? "✓ 新目录" : "✗ " + first.reportFile);
|
|
58
58
|
if (first.cache !== "miss") throw new Error("first should be miss");
|
|
59
59
|
const second = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
|
|
60
60
|
console.log("二次分析 cache:", second.cache, "| 指纹一致:", second.fingerprint === first.fingerprint);
|
|
@@ -79,7 +79,7 @@ await defs.novel_plot.execute({ book: "测试", action: "done", id }, exec);
|
|
|
79
79
|
const afterDone = await defs.novel_plot.execute({ book: "测试", action: "list" }, exec);
|
|
80
80
|
console.log("done 后:", afterDone.entries.map((e) => e.content + ":" + e.status).join(" "));
|
|
81
81
|
if (afterDone.entries.find((e) => e.id === id).status !== "done") throw new Error("plot done broken");
|
|
82
|
-
const plotFile = join(testRoot, ".novel-writer", "测试.json");
|
|
82
|
+
const plotFile = join(testRoot, ".novel-writer", "plots", "测试.json");
|
|
83
83
|
if (!existsSync(plotFile)) throw new Error("plot file not persisted");
|
|
84
84
|
console.log("伏笔文件:", plotFile, "存在 ✓");
|
|
85
85
|
|