dsh-novel-writer 3.1.0 → 3.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/README.en.md +18 -14
- package/README.md +20 -17
- package/lib/client.js +133 -18
- package/lib/index.js +323 -35
- package/package.json +2 -2
- package/skills/novel-writing/SKILL.md +6 -0
package/README.en.md
CHANGED
|
@@ -8,10 +8,11 @@ English | [**中文**](./README.md)
|
|
|
8
8
|
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
9
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
10
|
|
|
11
|
-
A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v3.1.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **
|
|
11
|
+
A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v3.1.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **12-axis vibe spectrum**, **style portrait report**, **six-dimension style baseline band**, plot & settings management, local semantic search (0 token), webnovel signal detection, **original mode with creation files (bible/dynamic outline/hook backfill)**, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host. **Requires Node ≥ 22.3.**
|
|
12
12
|
|
|
13
13
|
> **A note to non-Chinese users**: This plugin is designed specifically for Chinese-language novel analysis and writing — its core capabilities (sentence-pattern analysis, emotion quantification, imagery detection) and its built-in semantic model are all built and tuned for Chinese text. Fully supporting English or other languages alongside Chinese is beyond my current capability. I sincerely apologize for any inconvenience this may cause.
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
@@ -30,24 +31,25 @@ npm install dsh-novel-writer
|
|
|
30
31
|
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
After installing, **restart the web app** to activate (host registers
|
|
34
|
+
After installing, **restart the web app** to activate (host registers 15 tools + state/reveal routes; browser mounts the "Writing Assistant" sidebar toggle panel).
|
|
34
35
|
|
|
35
36
|
---
|
|
36
37
|
|
|
37
38
|
## Features
|
|
38
39
|
|
|
39
|
-
1. **
|
|
40
|
-
2. **
|
|
41
|
-
3. **
|
|
42
|
-
4. **
|
|
43
|
-
5. **
|
|
44
|
-
6. **
|
|
45
|
-
7. **Writing toolkit**: plot tracking / five settings tables / chapter summaries / continuity audit / batch import / style check /
|
|
46
|
-
8. **Per-tool UI toggles**: master + grouped tool toggles + feature toggles
|
|
40
|
+
1. **Style portrait report** (`novel_style_report`): 6-dimension measurement — style fingerprint / high-frequency lexicon / genre-theme / emotion quantification / 12-axis vibe spectrum / semantic style distance. **Measurement-judgment separation**: the plugin only reports numbers, never labels; AI judgment can be saved back to `.novel-writer/style-reports/` for consistent continuation writing.
|
|
41
|
+
2. **12-axis vibe spectrum**: nightmare / angst / heartwarming / fluff / tearjerker / dark / mystery / blaze / absurd / lonesome / aesthetic / sensual — with traceable evidence, 0 token.
|
|
42
|
+
3. **Local semantic engine**: bge-small-zh Chinese model (23MB, shipped with the plugin) local CPU inference — `novel_semantic_search` finds semantically related passages with natural language (with chapter location), semantic style comparison, semantic implicit emotion; lazy loading + graceful fallback.
|
|
43
|
+
4. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
|
|
44
|
+
5. **Emotion purification & quantification**: strong/weak emotion-word grading, pollution detection, caveat warning + AI re-verification; Valence sliding window → variance V / delta Δ / conflict index C + implicit imagery carriers.
|
|
45
|
+
6. **Worldview & pragmatics detection**: auto cultural-baseline detection with confidence; speechStyle title/honorifics/rituals/tone norms; genre & theme + webnovel signals.
|
|
46
|
+
7. **Writing toolkit**: plot tracking / five settings tables (characters·locations·items·timeline·worldview) / chapter summaries / continuity audit / batch import / style check / continuation writing.
|
|
47
|
+
8. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + grouped tool toggles + feature toggles), plain-language labels, data-dir usage & semantic-engine status display.
|
|
48
|
+
9. **Style Baseline (v3.1.0)**: Six writing metrics (syntactic complexity / modifier density / abstraction / action density / hedging / gap index) + per-chapter μ±σ baseline band; `novel_style_report` outputs the band, `novel_style_check` compares new chapters (in-band ✓ / out-of-band ⚠); per-metric ±% tolerance configurable in the sidebar (**recommended = 1.5× σ of the book's chapter variance**, rounded, clamped to ±10%~100%; leave blank to use recommended) — free theme, writing style kept inside the band.
|
|
47
49
|
|
|
48
50
|
---
|
|
49
51
|
|
|
50
|
-
## Provided Tools (
|
|
52
|
+
## Provided Tools (15)
|
|
51
53
|
|
|
52
54
|
| Tool | Description |
|
|
53
55
|
|------|-------------|
|
|
@@ -59,14 +61,13 @@ After installing, **restart the web app** to activate (host registers 16 tools +
|
|
|
59
61
|
| `novel_import` | Batch import manuscripts |
|
|
60
62
|
| `novel_sentence_analysis` | Sentence-pattern analysis |
|
|
61
63
|
| `novel_sentence_config` | View/set tool & feature toggles |
|
|
62
|
-
| `novel_style_check` | Style check (rule + semantic
|
|
63
|
-
| `novel_style_report` | Style portrait (6-dim measurement
|
|
64
|
+
| `novel_style_check` | Style check (rule + semantic) |
|
|
65
|
+
| `novel_style_report` | **Style portrait report** (6-dim measurement) |
|
|
64
66
|
| `novel_plot` | Plot/foreshadowing tracker |
|
|
65
67
|
| `novel_settings` | Settings management (+worldview) |
|
|
66
68
|
| `novel_summary` | Chapter summaries |
|
|
67
69
|
| `novel_continuity_check` | Continuity audit |
|
|
68
70
|
| `novel_semantic_search` | Semantic search (local embedding, 0 token) |
|
|
69
|
-
| `novel_outline` | **Creation-material management** (bible/characters/outline/hooks/status) |
|
|
70
71
|
|
|
71
72
|
---
|
|
72
73
|
|
|
@@ -90,3 +91,6 @@ Under `<library-root>/.novel-writer/`: `plots` / `settings` / `summaries` / `ana
|
|
|
90
91
|
## License
|
|
91
92
|
|
|
92
93
|
[MIT](./LICENSE)
|
|
94
|
+
|
|
95
|
+
- **Writing sentinels (v3.2.0)**: `novel_continuity_check` extended — ①**bridge check** (`chapter`: time jumps/semantic distance/character continuity/hook handoff, with quoted evidence) ②**OOC check** (`ooc`: per-character emotion baseline deviation) ③**outline drift** (`outline`: direction vs body keyword overlap); **brief mode** for report tools; main panel gains **library stats** (7-day active chars), **🎬 demo** (built-in sample, no files), **📊 report history**; actionable error hints; slimmer tool descriptions; unit tests + GitHub Actions packaging.
|
|
96
|
+
- **Original mode**: fill in creation settings in the sidebar (worldview/characters/forbidden/main conflict/genre/extras, blank = model decides); novel_outline maintains creation files (bible/characters/outline/hooks/status), enforcing the bible → outline → hook chain with dynamic batches (10→20→30 chapters) to prevent plot jumps and OOC.
|
package/README.md
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
9
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
10
|
|
|
11
|
-
为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v3.1.0
|
|
11
|
+
为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v3.1.0):章节库管理、句式分析、情感净化与量化、氛围光谱、**风格画像报告**、**文笔六维基线带**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、**原创模式与创作资料管理(设定书/动态大纲/钩子回填)**、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。**要求 Node ≥ 22.3。**
|
|
12
12
|
|
|
13
13
|
> **致非中文用户**:本插件为中文小说分析写作而设计——句式、情感、意象等核心能力以及内置的语义模型,全部针对中文语料构建与调优。在深耕中文的同时兼顾英文等其他语言,确实超出了我目前的能力范围。若因此给您带来不便,我深感抱歉,恳请谅解。
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
## 安装
|
|
@@ -30,24 +31,25 @@ npm install dsh-novel-writer
|
|
|
30
31
|
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
安装后**重启 web 应用**生效(宿主端注册
|
|
34
|
+
安装后**重启 web 应用**生效(宿主端注册 15 个工具与 state/reveal 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
|
|
34
35
|
|
|
35
36
|
---
|
|
36
37
|
|
|
37
38
|
## 功能
|
|
38
39
|
|
|
39
|
-
1.
|
|
40
|
-
2.
|
|
41
|
-
3.
|
|
42
|
-
4.
|
|
43
|
-
5.
|
|
44
|
-
6.
|
|
45
|
-
7.
|
|
46
|
-
8. **全工具 UI
|
|
40
|
+
1. **风格画像报告**(novel_style_report):6 维测量报告——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 轴 / 语义风格距离。**测量与判断分离**:插件只报数不贴标签,AI 判断可回传存盘(`.novel-writer/style-reports/`),续写保持风格一致。
|
|
41
|
+
2. **氛围光谱 12 轴**:噩梦感 / 焦虑压抑 / 温馨治愈 / 甜宠日常 / 催泪虐心 / 黑暗残酷 / 悬疑神秘 / 热血激昂 / 荒诞无厘头 / 孤独疏离 / 文艺唯美 / 情欲暧昧——证据链可追溯,0 token。
|
|
42
|
+
3. **本地语义引擎**:bge-small-zh 中文模型(23MB 随插件分发)本地 CPU 推理——`novel_semantic_search` 自然语言搜全书语义相关段落(带章节定位),语义级风格对比、语义隐性情感,懒加载 + 自动回退。
|
|
43
|
+
4. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹与节奏建议,带缓存与报告导出。
|
|
44
|
+
5. **情感净化 + 量化**:强/弱情绪词分级、污染源检测、caveat 预警 + AI 复核;Valence 滑动窗口 → 方差 V / 斜率 Δ / 矛盾指数 C + 隐性意象载体。
|
|
45
|
+
6. **世界观与语用检测**:文化基准自动判断(西/东/混合)+ 置信度;speechStyle 称谓/客套/仪式/语气规范;题材流派 + 网文信号。
|
|
46
|
+
7. **写作辅助全家桶**:伏笔登记表 / 设定五张表(人物·地点·道具·时间线·世界观)/ 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 续写辅助。
|
|
47
|
+
8. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 工具开关分组 + 功能开关),大白话文案,显示数据目录占用与语义引擎状态。
|
|
48
|
+
9. **风格基线(v3.1.0)**:文笔六维测量(句法复杂度/修饰密度/抽象度/动作密度/不确定性/留白指数)+ 按章节 μ±σ 基线带;`novel_style_report` 输出基线带,`novel_style_check` 对照新章偏差(带内 ✓ / 出带 ⚠);侧边栏可自定义每维 ±% 容差(**推荐值 = 原著章节波动的 1.5 倍 σ**,自动取整、限 ±10%~100%;输入框留空即用推荐)——原创/续写时主题自由、写法保持在基线带内。
|
|
47
49
|
|
|
48
50
|
---
|
|
49
51
|
|
|
50
|
-
## 提供的工具(
|
|
52
|
+
## 提供的工具(15 个)
|
|
51
53
|
|
|
52
54
|
| 工具 | 说明 |
|
|
53
55
|
|------|------|
|
|
@@ -57,16 +59,15 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
|
57
59
|
| `novel_keywords` | 关键词:二字组/三字组/疑似人名 |
|
|
58
60
|
| `novel_new_chapter` | 创建新章节文件 |
|
|
59
61
|
| `novel_import` | 原稿件批量导入/分类 |
|
|
60
|
-
| `novel_sentence_analysis` |
|
|
62
|
+
| `novel_sentence_analysis` | 句式模式分析(九类/情感净化/量化/曲线/指纹) |
|
|
61
63
|
| `novel_sentence_config` | 查看/修改工具与功能开关 |
|
|
62
|
-
| `novel_style_check` |
|
|
63
|
-
| `novel_style_report` |
|
|
64
|
+
| `novel_style_check` | 风格自检(规则+语义双维度) |
|
|
65
|
+
| `novel_style_report` | **风格画像报告**(6 维测量 + AI 判断分离) |
|
|
64
66
|
| `novel_plot` | 伏笔/剧情线登记表 |
|
|
65
67
|
| `novel_settings` | 设定管理(人物/地点/道具/时间线/世界观) |
|
|
66
68
|
| `novel_summary` | 章节摘要(长书续写辅助) |
|
|
67
69
|
| `novel_continuity_check` | 连贯性审计(设定矛盾+语用冲突候选) |
|
|
68
70
|
| `novel_semantic_search` | 语义检索(本地 embedding,0 token) |
|
|
69
|
-
| `novel_outline` | **创作资料管理**(设定/人物/大纲/钩子/状态卡) |
|
|
70
71
|
|
|
71
72
|
---
|
|
72
73
|
|
|
@@ -83,10 +84,12 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
|
83
84
|
|
|
84
85
|
## 数据目录
|
|
85
86
|
|
|
86
|
-
`<书库根>/.novel-writer/`:`plots
|
|
87
|
+
`<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
|
|
87
88
|
|
|
88
89
|
---
|
|
89
90
|
|
|
90
91
|
## 许可证
|
|
91
92
|
|
|
92
|
-
[MIT](./LICENSE)
|
|
93
|
+
[MIT](./LICENSE)- **写作哨兵三件套(v3.2.0)**:`novel_continuity_check` 扩展——①**衔接检查**(chapter 参数:时间硬跳/语义距离/人物延续/钩子承接四路检测,带原文引用)②**OOC 检测**(ooc 参数:角色情绪基线偏离)③**大纲走偏**(outline 参数:方向行 vs 正文关键词重合);报告工具支持 **brief 精简模式**;主面板新增**书库统计卡**(近 7 天活跃字数)、**🎬 体验演示**(内置示例不落盘)、**📊 报告历史**(analysis/style-reports 列表浏览);错误提示带解决步骤;工具说明压缩省 token;单元测试 + GitHub Actions 自动打包。
|
|
94
|
+
- **原创模式**:侧边栏填写创作设定(世界观/角色/禁忌/主线/题材/额外要求,留空=模型自定);novel_outline 维护创作资料(创作设定/人物/剧情大纲/钩子记录/创作状态卡),原创强制「设定书→大纲→钩子」链,动态批次(10→20→30 章)防剧情跳跃与角色 OOC。
|
|
95
|
+
|
package/lib/client.js
CHANGED
|
@@ -333,6 +333,20 @@ var css = `
|
|
|
333
333
|
"creation.deleted": "已删除该书设定",
|
|
334
334
|
"creation.newed": "已创建设定,开始填写",
|
|
335
335
|
"creation.needName": "请先输入新书书名",
|
|
336
|
+
"stats.empty": "书库为空——在 novels/ 放一本小说,或让 AI 原创一本",
|
|
337
|
+
"stats.title": "书库统计",
|
|
338
|
+
"stats.week": "7天",
|
|
339
|
+
"stats.idle": "7天未动",
|
|
340
|
+
"stats.chapters": "章",
|
|
341
|
+
"stats.chars": "字",
|
|
342
|
+
"demo.run": "🎬 体验演示",
|
|
343
|
+
"demo.hint": "不落盘,5 秒看六维风格基线效果",
|
|
344
|
+
"demo.loading": "演示中…",
|
|
345
|
+
"reports.title": "报告历史",
|
|
346
|
+
"reports.hint": "句式分析与风格判断的已存报告(.novel-writer/analysis 与 style-reports)",
|
|
347
|
+
"reports.empty": "暂无报告——跑一次句式分析或风格画像后这里会出现",
|
|
348
|
+
"reports.back": "‹ 返回",
|
|
349
|
+
"reports.open": "打开",
|
|
336
350
|
"creation.dirtyWarn": "有未保存的修改,切换将丢弃。继续?",
|
|
337
351
|
"creation.formTitle": "编辑设定",
|
|
338
352
|
"creation.bookFor": "设定应用书",
|
|
@@ -499,6 +513,20 @@ var css = `
|
|
|
499
513
|
"creation.deleted": "Profile deleted",
|
|
500
514
|
"creation.newed": "Profile created, start filling",
|
|
501
515
|
"creation.needName": "Please enter a book name first",
|
|
516
|
+
"stats.empty": "Library empty — put a novel in novels/, or ask AI to create one",
|
|
517
|
+
"stats.title": "Library stats",
|
|
518
|
+
"stats.week": "/7d",
|
|
519
|
+
"stats.idle": "idle 7d",
|
|
520
|
+
"stats.chapters": " ch",
|
|
521
|
+
"stats.chars": " chars",
|
|
522
|
+
"demo.run": "🎬 Try demo",
|
|
523
|
+
"demo.hint": "No files written — see the 6-dimension baseline in 5s",
|
|
524
|
+
"demo.loading": "Running…",
|
|
525
|
+
"reports.title": "Report history",
|
|
526
|
+
"reports.hint": "Saved sentence & style reports (.novel-writer/analysis and style-reports)",
|
|
527
|
+
"reports.empty": "No reports yet — run an analysis first",
|
|
528
|
+
"reports.back": "‹ Back",
|
|
529
|
+
"reports.open": "Open",
|
|
502
530
|
"creation.dirtyWarn": "Unsaved changes will be lost. Continue?",
|
|
503
531
|
"creation.formTitle": "Edit profile",
|
|
504
532
|
"creation.bookFor": "Applied book",
|
|
@@ -565,6 +593,12 @@ var css = `
|
|
|
565
593
|
creationBook: "",
|
|
566
594
|
creationNewBook: "",
|
|
567
595
|
books: [],
|
|
596
|
+
booksStats: null, // null=未就绪(宿主未提供),[]=真空
|
|
597
|
+
demoReport: null,
|
|
598
|
+
demoLoading: false,
|
|
599
|
+
reportsGroups: null,
|
|
600
|
+
reportContent: null,
|
|
601
|
+
reportLoading: false,
|
|
568
602
|
features: {},
|
|
569
603
|
dirs: null,
|
|
570
604
|
view: "main",
|
|
@@ -606,6 +640,8 @@ var css = `
|
|
|
606
640
|
creationProfile: remote.creationProfile || null,
|
|
607
641
|
creationProfiles: remote.creationProfiles || {},
|
|
608
642
|
books: remote.books || [],
|
|
643
|
+
booksStats: remote.booksStats || [],
|
|
644
|
+
booksStats: remote.booksStats || [],
|
|
609
645
|
dirs: remote.dirs || null,
|
|
610
646
|
file: remote.file || "",
|
|
611
647
|
hostOk: true,
|
|
@@ -813,7 +849,7 @@ var css = `
|
|
|
813
849
|
);
|
|
814
850
|
};
|
|
815
851
|
var backBtn = function () {
|
|
816
|
-
return el("button", { type: "button", className: "nwBackBtn", onClick: function () { props.controller.openView(view === "
|
|
852
|
+
return el("button", { type: "button", className: "nwBackBtn", onClick: function () { props.controller.openView(view === "reports" || view === "creation" ? "main" : view === "baseline" ? "main" : view === "creation-form" ? "creation" : view === "model" ? "features" : "main"); } }, "‹ " + t("panel.back"));
|
|
817
853
|
};
|
|
818
854
|
var switchRow = function (name, fOn, onToggle, extra) {
|
|
819
855
|
return el("div", { className: "nwToolRow" + (fOn ? " nwToolRowOn" : ""), key: name },
|
|
@@ -968,19 +1004,7 @@ var TOOL_GROUPS = [
|
|
|
968
1004
|
extra = el("button", { type: "button", className: "nwModelBtn", title: t("model.manage"), onClick: function (ev) { ev.stopPropagation(); props.controller.openView("model"); } }, "⚙ " + t("model.manage"));
|
|
969
1005
|
}
|
|
970
1006
|
return switchRow(name, f[name] !== false, function (n, v) { props.toggle({ features: (function (patch) { patch[n] = v; return patch; })({}) }); }, extra);
|
|
971
|
-
})
|
|
972
|
-
// v3.1.0:原创模式入口
|
|
973
|
-
el("div", { key: "creationEntry", style: { margin: "8px 0 4px", padding: "10px 12px", borderRadius: "10px", background: "linear-gradient(135deg, rgba(99,102,241,.08), rgba(139,92,246,.08))", border: "1px solid rgba(99,102,241,.25)" } },
|
|
974
|
-
el("button", { type: "button", className: "nwNavEntry", style: { width: "100%", background: "transparent", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }, onClick: function () { props.controller.openView("creation"); } },
|
|
975
|
-
el("div", { className: "nwNavEntryText" },
|
|
976
|
-
el("div", { className: "nwNavEntryTitle" }, "🎨 " + t("panel.creationTitle")),
|
|
977
|
-
el("div", { className: "nwNavEntryHint" }, t("panel.creationHint"))
|
|
978
|
-
),
|
|
979
|
-
el("div", { className: "nwNavEntryRight" },
|
|
980
|
-
el("span", { className: "nwNavEntryArrow" }, "›")
|
|
981
|
-
)
|
|
982
|
-
)
|
|
983
|
-
)
|
|
1007
|
+
})
|
|
984
1008
|
);
|
|
985
1009
|
} else if (view === "tools") {
|
|
986
1010
|
body = el("div", null,
|
|
@@ -1097,7 +1121,42 @@ var TOOL_GROUPS = [
|
|
|
1097
1121
|
el("button", { type: "button", className: "nwBtn nwBtnGhost", onClick: resetTol, style: { background: "transparent", border: "1px solid #cbd5e1", color: "#475569", fontSize: "13px", padding: "9px 18px", borderRadius: "10px", cursor: "pointer" } }, "↺ " + t("baseline.reset"))
|
|
1098
1122
|
)
|
|
1099
1123
|
);
|
|
1100
|
-
} else if (view === "
|
|
1124
|
+
} else if (view === "reports") {
|
|
1125
|
+
// v3.2.0:报告历史(加载在入口 onClick 触发,此处纯渲染防 React 345 循环)
|
|
1126
|
+
var openReport = function (file) {
|
|
1127
|
+
props.controller.set({ reportContent: "读取中…" });
|
|
1128
|
+
fetch("/api/dsh-novel-writer/reports?read=" + encodeURIComponent(file), { headers: { accept: "application/json" } }).then(function (r2) { return r2.json(); }).then(function (d) {
|
|
1129
|
+
props.controller.set({ reportContent: d.ok ? JSON.stringify(d.content, null, 2) : ("读取失败:" + (d.error || "")) });
|
|
1130
|
+
}).catch(function () { props.controller.set({ reportContent: "读取失败(网络/路由不可用)" }); });
|
|
1131
|
+
};
|
|
1132
|
+
var groups = state.reportsGroups || [];
|
|
1133
|
+
var totalFiles = groups.reduce(function (a, g) { return a + (g.files || []).length; }, 0);
|
|
1134
|
+
var reportsLoading = state.reportsGroups === null;
|
|
1135
|
+
body = el("div", null,
|
|
1136
|
+
backBtn(),
|
|
1137
|
+
el("div", { className: "nwSectionTitle" }, "📊 " + t("reports.title")),
|
|
1138
|
+
el("div", { className: "nwToolsHint" }, t("reports.hint")),
|
|
1139
|
+
state.reportContent ?
|
|
1140
|
+
el("div", null,
|
|
1141
|
+
el("button", { type: "button", style: { margin: "6px 0", padding: "4px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", background: "#fff", fontSize: "12px", cursor: "pointer", color: "#475569" }, onClick: function () { props.controller.set({ reportContent: null }); } }, "‹ " + t("reports.back")),
|
|
1142
|
+
el("pre", { style: { margin: "4px 0 0", padding: "10px", borderRadius: "8px", background: "#fff", border: "1px solid #e2e8f0", fontSize: "11px", lineHeight: "1.7", color: "#334155", whiteSpace: "pre-wrap", wordBreak: "break-all", maxHeight: "70vh", overflow: "auto" } }, state.reportContent)
|
|
1143
|
+
)
|
|
1144
|
+
: el("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
1145
|
+
reportsLoading ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, "加载中…") : totalFiles === 0 ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, t("reports.empty")) : null,
|
|
1146
|
+
groups.map(function (g) {
|
|
1147
|
+
return el("div", { key: g.name },
|
|
1148
|
+
el("div", { style: { fontSize: "12px", fontWeight: "600", color: "#475569", margin: "6px 0 4px" } }, g.name + "(" + (g.files || []).length + ")"),
|
|
1149
|
+
(g.files || []).map(function (f) {
|
|
1150
|
+
return el("button", { key: f.file, type: "button", style: { display: "flex", alignItems: "center", width: "100%", padding: "7px 10px", marginBottom: "4px", borderRadius: "8px", border: "1px solid #e2e8f0", background: "#fff", cursor: "pointer", textAlign: "left" }, onClick: function () { openReport(f.file); } },
|
|
1151
|
+
el("span", { style: { flex: "1", fontSize: "12px", color: "#334155", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, f.label),
|
|
1152
|
+
el("span", { style: { fontSize: "10px", color: "#94a3b8" } }, (f.time || "").slice(0, 16).replace("T", " "))
|
|
1153
|
+
);
|
|
1154
|
+
})
|
|
1155
|
+
);
|
|
1156
|
+
})
|
|
1157
|
+
)
|
|
1158
|
+
);
|
|
1159
|
+
} else if (view === "creation" || view === "creation-form") {
|
|
1101
1160
|
// v3.1.0:原创模式设定库(方案 C:列表页 + 表单页 + 快速切换)
|
|
1102
1161
|
var CREATION_FIELDS = [
|
|
1103
1162
|
["worldview", "🌍", t("creation.worldview"), "例如:末日后异能世界…"],
|
|
@@ -1161,7 +1220,7 @@ var TOOL_GROUPS = [
|
|
|
1161
1220
|
isDefault ? el("div", { style: { fontSize: "10px", color: "#cbd5e1", marginTop: "1px" } }, t("creation.defaultHint")) : null
|
|
1162
1221
|
),
|
|
1163
1222
|
el("button", { type: "button", onClick: function () { props.controller.set({ creationBook: name, creationDraft: null, creationDirty: false }); props.controller.openView("creation-form"); } }, t("creation.edit")),
|
|
1164
|
-
isDefault ? null : el("button", { type: "button", onClick: function () { var patch = {}; patch[name] = null; props.toggle({ creationProfiles: patch }); props.controller.set({ revealMsg: t("creation.deleted"), revealErr: false }); } }, t("creation.delete"))
|
|
1223
|
+
isDefault ? null : el("button", { type: "button", onClick: function () { var patch = {}; patch[name] = null; props.toggle({ creationProfiles: patch }); var nb = (props.controller.getSnapshot().books || []).filter(function (b) { return b !== name; }); props.controller.set({ books: nb, revealMsg: t("creation.deleted"), revealErr: false }); } }, t("creation.delete"))
|
|
1165
1224
|
);
|
|
1166
1225
|
}
|
|
1167
1226
|
body = el("div", null,
|
|
@@ -1297,6 +1356,8 @@ var TOOL_GROUPS = [
|
|
|
1297
1356
|
})(),
|
|
1298
1357
|
entry(t("panel.featuresTitle"), t("panel.featuresHint"), onFeatures, "features"),
|
|
1299
1358
|
entry(t("panel.toolsTitle"), t("panel.toolsHint"), onTools, "tools"),
|
|
1359
|
+
// v3.2.0:原创模式(设定库)移到主面板
|
|
1360
|
+
entry(t("panel.creationTitle"), t("panel.creationHint"), null, "creation"),
|
|
1300
1361
|
// v3.0.0:风格基线(六维 ±% 容差带)
|
|
1301
1362
|
entry(t("panel.baselineTitle"), t("panel.baselineHint"), null, "baseline"),
|
|
1302
1363
|
// v2.6.0:数据目录占用 + 语义引擎状态(一目了然)
|
|
@@ -1306,7 +1367,52 @@ var TOOL_GROUPS = [
|
|
|
1306
1367
|
el("div", { className: "nwRowHint" }, formatSize(state.dataDirSize) + " · " + engineStatusText(state.embeddingStatus, t))
|
|
1307
1368
|
)
|
|
1308
1369
|
),
|
|
1309
|
-
pathBox(t("dir.data"), dirs.dataDir || state.dataDir || "", "data-dir")
|
|
1370
|
+
pathBox(t("dir.data"), dirs.dataDir || state.dataDir || "", "data-dir"),
|
|
1371
|
+
// v3.2.0:书库统计(写作打卡);null=未就绪不渲染,[]=真空提示
|
|
1372
|
+
state.booksStats === null ? null :
|
|
1373
|
+
el("div", { key: "statsCard", style: { marginTop: "10px", padding: "10px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff" } },
|
|
1374
|
+
el("div", { style: { fontSize: "12px", fontWeight: "600", color: "#475569", marginBottom: "6px" } }, "📚 " + t("stats.title")),
|
|
1375
|
+
el("div", { style: { display: "flex", flexDirection: "column", gap: "5px" } },
|
|
1376
|
+
(state.booksStats || []).length === 0
|
|
1377
|
+
? el("div", { style: { fontSize: "11px", color: "#94a3b8" } }, t("stats.empty"))
|
|
1378
|
+
: (state.booksStats || []).slice(0, 6).map(function (bs) {
|
|
1379
|
+
var charsText = bs.chars >= 10000 ? (bs.chars / 10000).toFixed(1) + "万" : String(bs.chars || 0);
|
|
1380
|
+
return el("div", { key: bs.name, style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "11px" } },
|
|
1381
|
+
el("span", { style: { flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "#334155" } }, "📕 " + bs.name),
|
|
1382
|
+
el("span", { style: { color: "#94a3b8" } }, bs.chapters + " " + t("stats.chapters") + " · " + charsText + t("stats.chars")),
|
|
1383
|
+
bs.recent7Chars > 0
|
|
1384
|
+
? el("span", { style: { color: "#10b981", fontWeight: "600" } }, "🔥 " + (bs.recent7Chars >= 10000 ? (bs.recent7Chars / 10000).toFixed(1) + "万" : String(bs.recent7Chars)) + " " + t("stats.week"))
|
|
1385
|
+
: el("span", { style: { color: "#cbd5e1" } }, t("stats.idle"))
|
|
1386
|
+
);
|
|
1387
|
+
})
|
|
1388
|
+
)
|
|
1389
|
+
),
|
|
1390
|
+
// v3.2.0:报告历史入口
|
|
1391
|
+
el("button", { key: "reportsEntry", type: "button", style: { display: "flex", alignItems: "center", width: "100%", marginTop: "8px", padding: "9px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff", cursor: "pointer", textAlign: "left" }, onClick: function () {
|
|
1392
|
+
props.controller.set({ reportsGroups: null, reportContent: null, reportLoading: true });
|
|
1393
|
+
fetch("/api/dsh-novel-writer/reports", { headers: { accept: "application/json" } }).then(function (r2) { return r2.json(); }).then(function (d) {
|
|
1394
|
+
props.controller.set({ reportsGroups: d.groups || [], reportLoading: false });
|
|
1395
|
+
}).catch(function () { props.controller.set({ reportsGroups: [], reportLoading: false }); });
|
|
1396
|
+
props.controller.openView("reports");
|
|
1397
|
+
} },
|
|
1398
|
+
el("span", { style: { flex: "1", fontSize: "13px", fontWeight: "600", color: "#334155" } }, "📊 " + t("reports.title")),
|
|
1399
|
+
el("span", { style: { fontSize: "11px", color: "#94a3b8" } }, t("reports.hint")),
|
|
1400
|
+
el("span", { style: { fontSize: "13px", color: "#94a3b8" } }, "›")
|
|
1401
|
+
),
|
|
1402
|
+
// v3.2.0:体验演示
|
|
1403
|
+
el("div", { key: "demoCard", style: { marginTop: "10px", padding: "10px 12px", borderRadius: "10px", border: "1px dashed #c7d2fe", background: "#f8f9ff" } },
|
|
1404
|
+
el("button", { type: "button", disabled: state.demoLoading, style: { display: "flex", alignItems: "center", gap: "8px", width: "100%", background: "transparent", border: "none", padding: 0, cursor: state.demoLoading ? "default" : "pointer", textAlign: "left" }, onClick: function () {
|
|
1405
|
+
if (state.demoLoading) return;
|
|
1406
|
+
props.controller.set({ demoLoading: true });
|
|
1407
|
+
fetch("/api/dsh-novel-writer/demo", { headers: { accept: "application/json" } }).then(function (r2) { return r2.json(); }).then(function (d) {
|
|
1408
|
+
props.controller.set({ demoReport: d.ok ? d.report : ("演示失败:" + (d.error || "")), demoLoading: false });
|
|
1409
|
+
}).catch(function () { props.controller.set({ demoReport: "演示失败(网络/路由不可用)", demoLoading: false }); });
|
|
1410
|
+
} },
|
|
1411
|
+
el("span", { style: { fontSize: "13px", fontWeight: "600", color: "#6366f1" } }, state.demoLoading ? t("demo.loading") : t("demo.run")),
|
|
1412
|
+
el("span", { style: { fontSize: "11px", color: "#94a3b8" } }, t("demo.hint"))
|
|
1413
|
+
),
|
|
1414
|
+
state.demoReport ? el("pre", { style: { margin: "8px 0 0", padding: "10px", borderRadius: "8px", background: "#fff", border: "1px solid #e2e8f0", fontSize: "11px", lineHeight: "1.7", color: "#334155", whiteSpace: "pre-wrap", wordBreak: "break-all", maxHeight: "260px", overflow: "auto" } }, state.demoReport) : null
|
|
1415
|
+
)
|
|
1310
1416
|
);
|
|
1311
1417
|
}
|
|
1312
1418
|
// 非净化模式弹窗(确认 + 倒计时 / 承诺输入)
|
|
@@ -1337,7 +1443,7 @@ var TOOL_GROUPS = [
|
|
|
1337
1443
|
}
|
|
1338
1444
|
return el("div", { className: "nwPanel" },
|
|
1339
1445
|
el("div", { className: "nwPanelHeader" },
|
|
1340
|
-
el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : view === "baseline" ? t("panel.baselineTitle") : view === "creation" ? t("panel.creationTitle") : view === "creation-form" ? t("panel.creationTitle") + " · " + t("creation.formTitle") : t("model.title")) : "")),
|
|
1446
|
+
el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : view === "baseline" ? t("panel.baselineTitle") : view === "creation" ? t("panel.creationTitle") : view === "creation-form" ? t("panel.creationTitle") + " · " + t("creation.formTitle") : view === "reports" ? t("reports.title") : t("model.title")) : "")),
|
|
1341
1447
|
el("button", { type: "button", className: "nwRefresh" + (state.refreshing ? " nwRefreshSpin" : ""), onClick: function () { props.controller.refresh(); } }, state.refreshing ? "⟳" : t("panel.refresh")),
|
|
1342
1448
|
el("button", { type: "button", className: "nwClose", "aria-label": t("panel.close"), onClick: props.onClose }, "×")
|
|
1343
1449
|
),
|
|
@@ -1532,6 +1638,15 @@ var TOOL_GROUPS = [
|
|
|
1532
1638
|
if (patch.tools && typeof patch.tools === "object") {
|
|
1533
1639
|
merged.tools = Object.assign({}, cur.tools || {}, patch.tools);
|
|
1534
1640
|
}
|
|
1641
|
+
// v3.1.1:creationProfiles 深合并(null 键=删除该书的设定,本地立即生效)
|
|
1642
|
+
if (patch.creationProfiles && typeof patch.creationProfiles === "object") {
|
|
1643
|
+
var mergedCp = Object.assign({}, cur.creationProfiles || {});
|
|
1644
|
+
for (var cpk in patch.creationProfiles) {
|
|
1645
|
+
if (patch.creationProfiles[cpk] === null) delete mergedCp[cpk];
|
|
1646
|
+
else mergedCp[cpk] = patch.creationProfiles[cpk];
|
|
1647
|
+
}
|
|
1648
|
+
merged.creationProfiles = mergedCp;
|
|
1649
|
+
}
|
|
1535
1650
|
controller.set(merged);
|
|
1536
1651
|
try {
|
|
1537
1652
|
var remote = await saveState(patch);
|
package/lib/index.js
CHANGED
|
@@ -451,7 +451,7 @@ function registerNovelImport(ctx, config) {
|
|
|
451
451
|
files: { type: "array", items: { type: "string" }, description: "apply 时可选:只处理这些文件(相对 src 的路径)。省略则处理全部扫描到的文件。" },
|
|
452
452
|
move: { type: "boolean", description: "apply 时是否移动原文件(默认 false=复制,源文件保留)。" },
|
|
453
453
|
recursive: { type: "boolean", description: "是否递归扫描子文件夹。默认 true。" },
|
|
454
|
-
root: { type: "string", description: "章节库根目录(含 novels
|
|
454
|
+
root: { type: "string", description: "章节库根目录(含 novels 子目录)。" }
|
|
455
455
|
},
|
|
456
456
|
required: ["src"],
|
|
457
457
|
additionalProperties: false
|
|
@@ -1024,6 +1024,8 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1024
1024
|
creationProfiles: state.creationProfiles ?? {},
|
|
1025
1025
|
// v3.1.0:书列表(原创模式设定库用)——novels 目录 ∪ 已预配置设定的书(含未建目录的新书)
|
|
1026
1026
|
books: [...new Set([...(await listBookNames(root)), ...Object.keys(state.creationProfiles ?? {})])].sort(function (a, b) { return a.localeCompare(b, "zh"); }),
|
|
1027
|
+
// v3.2.0:书库统计(写作打卡:章数/总字数/近 7 天活跃字数)
|
|
1028
|
+
booksStats: await bookStats(root),
|
|
1027
1029
|
dataDirSize,
|
|
1028
1030
|
embeddingStatus,
|
|
1029
1031
|
dataDir,
|
|
@@ -1056,6 +1058,30 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1056
1058
|
}
|
|
1057
1059
|
return writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
1058
1060
|
};
|
|
1061
|
+
// v3.2.0:体验演示——内置自创示例文本(不落盘),跑六维测量展示效果
|
|
1062
|
+
const demoHandler = async (req, res) => {
|
|
1063
|
+
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1064
|
+
if ((req.method ?? "GET") !== "GET") return writeJson(res, 405, { error: "method not allowed" });
|
|
1065
|
+
try {
|
|
1066
|
+
const { measureStyleMetrics, computeBaselineFromPerChapter, METRIC_LABELS, METRIC_ORDER } = await import("./style-metrics.js");
|
|
1067
|
+
const demoTexts = [
|
|
1068
|
+
"雨从傍晚下到深夜。阿澈把油布伞靠在门边,靴子上的泥在门槛前蹭了又蹭。\n\n「坐吧。」掌柜掀开帘子,端出一碗热汤。\n\n他接过碗,指尖还冻得发白,汤面的热气扑在脸上。\n",
|
|
1069
|
+
"天没亮,阿澈就醒了。他把昨晚的汤钱放在桌上,推门出去,雾把整条街都吞了。\n\n有人站在雾里,背对着他,手里提着一盏没点的灯。\n",
|
|
1070
|
+
"灯在雾里亮起来的时候,阿澈看清了那张脸——是他找了三年的人。\n\n「你迟到了。」那人说。\n\n阿澈站在原地,半天没动。\n"
|
|
1071
|
+
];
|
|
1072
|
+
const per = demoTexts.map(function (t, i) { return { file: "示例第" + (i + 1) + "章", metrics: measureStyleMetrics(t).metrics }; });
|
|
1073
|
+
const baseline = computeBaselineFromPerChapter(per);
|
|
1074
|
+
const lines = ["# 🎬 体验演示:六维风格基线(内置示例 3 章)", "", "| 维度 | μ | 推荐容差 |", "|---|---|---|"];
|
|
1075
|
+
for (const k of METRIC_ORDER) {
|
|
1076
|
+
const b = baseline[k];
|
|
1077
|
+
lines.push("| " + (METRIC_LABELS[k] || k) + " | " + b.mu.toFixed(2) + " | ±" + b.recTol + "% |");
|
|
1078
|
+
}
|
|
1079
|
+
lines.push("", "> 示例为插件内置文本(不落盘、不占书库)。想真正体验:在 novels/ 放一本小说,或让 AI 原创一本。", "");
|
|
1080
|
+
return writeJson(res, 200, { ok: true, chars: demoTexts.join("").length, report: lines.join("\n"), baseline });
|
|
1081
|
+
} catch (e) {
|
|
1082
|
+
return writeJson(res, 500, { ok: false, error: String(e).slice(0, 200) });
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1059
1085
|
const revealHandler = async (req, res) => {
|
|
1060
1086
|
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1061
1087
|
if ((req.method ?? "GET") !== "POST") return writeJson(res, 405, { error: "method not allowed" });
|
|
@@ -1091,6 +1117,45 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1091
1117
|
return writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1092
1118
|
}
|
|
1093
1119
|
};
|
|
1120
|
+
// v3.2.0:报告历史——列出 .novel-writer/analysis 与 style-reports 的已存报告,?read= 读取内容
|
|
1121
|
+
const reportsHandler = async (req, res) => {
|
|
1122
|
+
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1123
|
+
try {
|
|
1124
|
+
const state = await readSentenceState();
|
|
1125
|
+
const root = (typeof config?.root === "string" && config.root.length > 0) ? config.root : state.lastRoot;
|
|
1126
|
+
const dataDir = typeof root === "string" && root.length > 0 ? novelDataDir(root) : "";
|
|
1127
|
+
const qs = String(req.url || "").split("?")[1] || "";
|
|
1128
|
+
const params = new URLSearchParams(qs);
|
|
1129
|
+
const read = params.get("read");
|
|
1130
|
+
if (read) {
|
|
1131
|
+
const safe = String(read).replace(/[\\/]/g, "");
|
|
1132
|
+
const candidates = [join(dataDir, "analysis", safe), join(dataDir, "style-reports", safe)];
|
|
1133
|
+
const found = candidates.find(function (c) { return existsSync(c) && c.endsWith(".json"); });
|
|
1134
|
+
if (!found) return writeJson(res, 404, { error: "报告不存在" });
|
|
1135
|
+
const content = JSON.parse(await readFile(found, "utf8"));
|
|
1136
|
+
return writeJson(res, 200, { ok: true, file: safe, content });
|
|
1137
|
+
}
|
|
1138
|
+
const groups = [];
|
|
1139
|
+
const scanDir = async (dir, name, labelFn) => {
|
|
1140
|
+
try {
|
|
1141
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1142
|
+
const files = [];
|
|
1143
|
+
for (const e of entries) {
|
|
1144
|
+
if (!e.isFile() || !e.name.endsWith(".json")) continue;
|
|
1145
|
+
const st = await stat(join(dir, e.name));
|
|
1146
|
+
files.push({ file: e.name, label: labelFn(e.name), time: st.mtime.toISOString() });
|
|
1147
|
+
}
|
|
1148
|
+
files.sort(function (a, b) { return b.time.localeCompare(a.time); });
|
|
1149
|
+
groups.push({ name, files });
|
|
1150
|
+
} catch { /* 目录不存在 */ }
|
|
1151
|
+
};
|
|
1152
|
+
await scanDir(join(dataDir, "analysis"), "句式分析", function (n) { return n.replace(/\.json$/, ""); });
|
|
1153
|
+
await scanDir(join(dataDir, "style-reports"), "风格判断", function (n) { return n.replace(/\.json$/, ""); });
|
|
1154
|
+
return writeJson(res, 200, { ok: true, groups });
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
return writeJson(res, 500, { ok: false, error: String(e).slice(0, 200) });
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1094
1159
|
// v2.6.5:更新检查路由(GitHub Releases API + 24h 缓存 + 3s 超时,全程静默)
|
|
1095
1160
|
const updateCheckHandler = async (req, res) => {
|
|
1096
1161
|
if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
@@ -1100,7 +1165,9 @@ function makeStateRoutes(allowLan, config) {
|
|
|
1100
1165
|
return [
|
|
1101
1166
|
{ kind: "exact", path: routePath, handler },
|
|
1102
1167
|
{ kind: "exact", path: revealPath, handler: revealHandler },
|
|
1103
|
-
{ kind: "exact", path: "/api/dsh-novel-writer/update-check", handler: updateCheckHandler }
|
|
1168
|
+
{ kind: "exact", path: "/api/dsh-novel-writer/update-check", handler: updateCheckHandler },
|
|
1169
|
+
{ kind: "exact", path: "/api/dsh-novel-writer/demo", handler: demoHandler },
|
|
1170
|
+
{ kind: "exact", path: "/api/dsh-novel-writer/reports", handler: reportsHandler }
|
|
1104
1171
|
];
|
|
1105
1172
|
}
|
|
1106
1173
|
|
|
@@ -1193,7 +1260,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1193
1260
|
maxSentences: { type: "integer", description: "采样句数上限(超长文本保护,默认 20000)。" },
|
|
1194
1261
|
curveSegments: { type: "integer", description: "情感曲线分段数(1-50,默认 20)。" },
|
|
1195
1262
|
fresh: { type: "boolean", description: "true=强制重新分析(忽略缓存)。默认 false。" },
|
|
1196
|
-
|
|
1263
|
+
brief: { type: "boolean", description: "可选。true=返回精简摘要(brief 字段)。" },
|
|
1264
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
1197
1265
|
},
|
|
1198
1266
|
required: ["book"],
|
|
1199
1267
|
additionalProperties: false
|
|
@@ -1318,6 +1386,8 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1318
1386
|
required: ["dominant", "scores", "intensity", "topWords", "curve"]
|
|
1319
1387
|
},
|
|
1320
1388
|
reportFile: { type: "string" },
|
|
1389
|
+
// v3.2.0:精简摘要(brief 模式)
|
|
1390
|
+
brief: { type: "string" },
|
|
1321
1391
|
cache: { type: "string", enum: ["hit", "miss"] },
|
|
1322
1392
|
cachedAt: { type: "string" },
|
|
1323
1393
|
chapterPatterns: {
|
|
@@ -1413,7 +1483,11 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1413
1483
|
delete cached.emotion.quantification;
|
|
1414
1484
|
}
|
|
1415
1485
|
await enrichSemanticImplicit(state, root, book, cached, exec);
|
|
1416
|
-
|
|
1486
|
+
if (args?.brief === true && cached.categories && cached.lengths && cached.emotion) {
|
|
1487
|
+
const top3b = cached.categories.slice().sort(function (a, b) { return b.count - a.count; }).slice(0, 3).map(function (c) { return c.label + " " + Math.round(c.ratio * 100) + "%"; });
|
|
1488
|
+
cached.brief = "全书 " + cached.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cached.lengths.avg.toFixed(1) + ";主导情绪 " + (cached.emotion.dominant || "-") + "。";
|
|
1489
|
+
}
|
|
1490
|
+
return { ...cached, book, scope, cache: "hit", reportFile, cachedAt: cached.cachedAt ?? new Date().toISOString() };
|
|
1417
1491
|
} catch { /* 缓存缺失/损坏,重新分析 */ }
|
|
1418
1492
|
}
|
|
1419
1493
|
const options = { top, maxSentences, curveSegments };
|
|
@@ -1444,6 +1518,10 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1444
1518
|
delete result.emotion.quantification;
|
|
1445
1519
|
}
|
|
1446
1520
|
await enrichSemanticImplicit(state, root, book, result, exec);
|
|
1521
|
+
if (args?.brief === true && result.categories && result.lengths && result.emotion) {
|
|
1522
|
+
const top3 = result.categories.slice().sort(function (a, b) { return b.count - a.count; }).slice(0, 3).map(function (c) { return c.label + " " + Math.round(c.ratio * 100) + "%"; });
|
|
1523
|
+
result.brief = "全书 " + result.totalSentences + " 句:句式以 " + top3.join("、") + " 为主;均句长 " + result.lengths.avg.toFixed(1) + ";主导情绪 " + (result.emotion.dominant || "-") + "。" + (result.categories.find(function (c) { return c.type === "ellipsis"; }) && result.categories.find(function (c) { return c.type === "ellipsis"; }).ratio > 0.2 ? "留白较多,注意节奏" : "");
|
|
1524
|
+
}
|
|
1447
1525
|
return { ...result, cache: "miss", reportFile, cachedAt: new Date().toISOString() };
|
|
1448
1526
|
}
|
|
1449
1527
|
});
|
|
@@ -1452,15 +1530,15 @@ function registerNovelSentenceAnalysis(ctx, config) {
|
|
|
1452
1530
|
function registerNovelSentenceConfig(ctx, config) {
|
|
1453
1531
|
ctx.tools.register({
|
|
1454
1532
|
name: "novel_sentence_config",
|
|
1455
|
-
description: "查看或修改句式模式分析的开关状态:enabled(分析功能是否可用)、autoAnalyze(分析作品时是否主动使用)。
|
|
1533
|
+
description: "查看或修改句式模式分析的开关状态:enabled(分析功能是否可用)、autoAnalyze(分析作品时是否主动使用)。",
|
|
1456
1534
|
parameters: {
|
|
1457
1535
|
type: "object",
|
|
1458
1536
|
properties: {
|
|
1459
|
-
action: { type: "string", enum: ["get", "set"], description: "get
|
|
1460
|
-
enabled: { type: "boolean", description: "
|
|
1461
|
-
autoAnalyze: { type: "boolean", description: "
|
|
1462
|
-
tools: { type: "object", additionalProperties: true, description: "
|
|
1463
|
-
features: { type: "object", additionalProperties: true, description: "
|
|
1537
|
+
action: { type: "string", enum: ["get", "set"], description: "get=查看;set=修改。" },
|
|
1538
|
+
enabled: { type: "boolean", description: "写作助手功能总开关。" },
|
|
1539
|
+
autoAnalyze: { type: "boolean", description: "分析作品时是否主动使用句式分析。" },
|
|
1540
|
+
tools: { type: "object", additionalProperties: true, description: "各工具开关(如 { novel_plot: false }),键必须是 novel_* 工具名。" },
|
|
1541
|
+
features: { type: "object", additionalProperties: true, description: "功能开关(emotionCaveat=情感净化预警 / genreTheme=题材与流派检测),如 { emotionCaveat: false }。" },
|
|
1464
1542
|
styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(每维 { low: -20, high: 20 },low 为负/高为正;传空对象 {} 清除恢复推荐)。" },
|
|
1465
1543
|
creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(worldview/characters/forbidden/mainConflict/genre/extra 字符串键,留空项省略;传空对象 {} 清除全部交给模型)。" },
|
|
1466
1544
|
creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=同上结构;传空对象 {} 清除全部书的专属设定)。" }
|
|
@@ -1481,7 +1559,8 @@ function registerNovelSentenceConfig(ctx, config) {
|
|
|
1481
1559
|
features: { type: "object", additionalProperties: true, description: "各功能开关状态(emotionCaveat/genreTheme/emotionComplexity/semanticEmbedding)。" },
|
|
1482
1560
|
embedding: { type: "object", additionalProperties: true, description: "语义嵌入引擎状态(available/error)。" },
|
|
1483
1561
|
styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(用户自定义 ±%;空对象=使用推荐)。" },
|
|
1484
|
-
creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(用户在侧边栏填写的创作意图;空对象=未设置)。" }
|
|
1562
|
+
creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(用户在侧边栏填写的创作意图;空对象=未设置)。" },
|
|
1563
|
+
creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=设定对象;空对象=未设置)。" }
|
|
1485
1564
|
},
|
|
1486
1565
|
required: ["file", "enabled", "autoAnalyze", "tools", "features", "embedding", "styleTolerance", "creationProfile"]
|
|
1487
1566
|
},
|
|
@@ -1583,13 +1662,13 @@ ${featLines.join("\n")}
|
|
|
1583
1662
|
function registerNovelStyleCheck(ctx, config) {
|
|
1584
1663
|
ctx.tools.register({
|
|
1585
1664
|
name: "novel_style_check",
|
|
1586
|
-
description: "
|
|
1665
|
+
description: "风格自检:对比某章节与全书其他章节的风格指纹(句式分布/句长/情绪),输出相似度与偏差清单。",
|
|
1587
1666
|
parameters: {
|
|
1588
1667
|
type: "object",
|
|
1589
1668
|
properties: {
|
|
1590
1669
|
book: { type: "string", description: "书名。" },
|
|
1591
1670
|
chapter: { type: "string", description: "要检查的章节(章号/文件名/标题)。" },
|
|
1592
|
-
root: { type: "string", description: "
|
|
1671
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
1593
1672
|
},
|
|
1594
1673
|
required: ["book", "chapter"],
|
|
1595
1674
|
additionalProperties: false
|
|
@@ -1803,6 +1882,20 @@ async function syncCreationProfileFiles(root, patchProfiles, nextProfiles) {
|
|
|
1803
1882
|
const cnext = upsertCreationSection(ccur, "", "【用户角色设定】");
|
|
1804
1883
|
if (cnext !== ccur) await writeFile(cf, cnext, "utf8");
|
|
1805
1884
|
}
|
|
1885
|
+
// v3.1.1:空壳文件夹清理——所有文件只剩模板骨架(无模型内容)时整目录删除,避免空文件夹堆积
|
|
1886
|
+
const dir3 = join(base, sanitizeSegment(book, "book"));
|
|
1887
|
+
if (existsSync(dir3)) {
|
|
1888
|
+
let anyContent = false;
|
|
1889
|
+
for (const fname of Object.values(CREATION_FILES)) {
|
|
1890
|
+
const fp = join(dir3, fname);
|
|
1891
|
+
if (existsSync(fp)) {
|
|
1892
|
+
const c = await readFile(fp, "utf8");
|
|
1893
|
+
const meaningful = c.split("\n").map(function (l) { return l.trim(); }).filter(Boolean).filter(function (l) { return !l.startsWith("#") && !l.startsWith("(") && !l.startsWith("【"); });
|
|
1894
|
+
if (meaningful.length > 0) { anyContent = true; break; }
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (!anyContent) await rm(dir3, { recursive: true, force: true });
|
|
1898
|
+
}
|
|
1806
1899
|
} catch { /* 忽略 */ }
|
|
1807
1900
|
}
|
|
1808
1901
|
}
|
|
@@ -1841,6 +1934,40 @@ async function syncCreationProfileFiles(root, patchProfiles, nextProfiles) {
|
|
|
1841
1934
|
}
|
|
1842
1935
|
|
|
1843
1936
|
/** v3.1.0:列出书库中的作品名(novels 下目录,跳过创作资料;原创模式书选择器用)。 */
|
|
1937
|
+
/** v3.2.0:书库统计(每本:章数/总字数/近 7 天活跃字数,按活跃度排序)——写作打卡卡。 */
|
|
1938
|
+
async function bookStats(root) {
|
|
1939
|
+
try {
|
|
1940
|
+
const dir = join(root, "novels");
|
|
1941
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1942
|
+
const out = [];
|
|
1943
|
+
const weekAgo = Date.now() - 7 * 86400000;
|
|
1944
|
+
for (const entry of entries) {
|
|
1945
|
+
if (!entry.isDirectory() || entry.name === "创作资料" || entry.name.startsWith(".")) continue;
|
|
1946
|
+
const bookPath = join(dir, entry.name);
|
|
1947
|
+
let chapters = 0, chars = 0, recent7 = 0;
|
|
1948
|
+
try {
|
|
1949
|
+
const files = await readdir(bookPath, { withFileTypes: true });
|
|
1950
|
+
for (const f of files) {
|
|
1951
|
+
if (!f.isFile()) continue;
|
|
1952
|
+
const st = await stat(join(bookPath, f.name));
|
|
1953
|
+
chapters += 1;
|
|
1954
|
+
if (/\.(md|txt)$/i.test(f.name)) {
|
|
1955
|
+
const txt = await readFile(join(bookPath, f.name), "utf8");
|
|
1956
|
+
chars += txt.length;
|
|
1957
|
+
if (st.mtimeMs >= weekAgo) recent7 += txt.length;
|
|
1958
|
+
} else {
|
|
1959
|
+
chars += st.size;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
} catch { /* 跳过不可读 */ }
|
|
1963
|
+
out.push({ name: entry.name, chapters, chars, recent7Chars: recent7 });
|
|
1964
|
+
}
|
|
1965
|
+
return out.sort(function (a, b) { return b.recent7Chars - a.recent7Chars; });
|
|
1966
|
+
} catch {
|
|
1967
|
+
return [];
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1844
1971
|
async function listBookNames(root) {
|
|
1845
1972
|
try {
|
|
1846
1973
|
const dir = join(root, "novels");
|
|
@@ -1940,7 +2067,7 @@ async function readCreationStatus(root, book, exec) {
|
|
|
1940
2067
|
function createOutlineTool(config) {
|
|
1941
2068
|
return {
|
|
1942
2069
|
name: "novel_outline",
|
|
1943
|
-
description: "维护原创小说的创作资料(novels
|
|
2070
|
+
description: "维护原创小说的创作资料(novels/创作资料/<书名>/):创作设定/主要人物/次要人物/剧情大纲/钩子记录/创作状态卡的初始化、读取与更新。",
|
|
1944
2071
|
parameters: {
|
|
1945
2072
|
type: "object",
|
|
1946
2073
|
additionalProperties: false,
|
|
@@ -2270,7 +2397,7 @@ function normalizeSettingEntry(entry) {
|
|
|
2270
2397
|
}
|
|
2271
2398
|
async function readBookAllText(root, book, exec) {
|
|
2272
2399
|
const dir = join(root, "novels", sanitizeSegment(book, "book"));
|
|
2273
|
-
if (!existsSync(dir)) throw new Error("书库中未找到作品:" + book);
|
|
2400
|
+
if (!existsSync(dir)) throw new Error("书库中未找到作品:" + book + " 【解决:用 novel_books 查看可用作品名,或确认 novels/ 目录下已创建对应文件夹】");
|
|
2274
2401
|
const chapters = await scanChapters(dir);
|
|
2275
2402
|
let text = "";
|
|
2276
2403
|
for (const chapter of chapters) text += await readTextFile(join(dir, chapter.file), exec);
|
|
@@ -2282,15 +2409,15 @@ async function readBookAllText(root, book, exec) {
|
|
|
2282
2409
|
function registerNovelStyleReport(ctx, config) {
|
|
2283
2410
|
ctx.tools.register({
|
|
2284
2411
|
name: "novel_style_report",
|
|
2285
|
-
description: "风格画像报告:聚合 6 维测量数据(指纹/词汇/题材/情感/氛围 12 轴/语义距离)供 AI
|
|
2412
|
+
description: "风格画像报告:聚合 6 维测量数据(指纹/词汇/题材/情感/氛围 12 轴/语义距离)供 AI 判断风格。",
|
|
2286
2413
|
parameters: {
|
|
2287
2414
|
type: "object",
|
|
2288
2415
|
additionalProperties: true,
|
|
2289
2416
|
properties: {
|
|
2290
2417
|
book: { type: "string", description: "书名(novels 下的子目录名)" },
|
|
2291
|
-
root: { type: "string", description: "章节库根目录(含 novels
|
|
2418
|
+
root: { type: "string", description: "章节库根目录(含 novels 子目录)。" },
|
|
2292
2419
|
action: { type: "string", description: "report=生成测量报告(默认);get=读取已保存的 AI 风格判断" },
|
|
2293
|
-
aiJudgment: { type: "string", description: "AI
|
|
2420
|
+
aiJudgment: { type: "string", description: "AI 的风格气质判断结论:后插件将测量数据与判断存入 style-reports 供后续使用" }
|
|
2294
2421
|
},
|
|
2295
2422
|
required: ["book"]
|
|
2296
2423
|
},
|
|
@@ -2461,9 +2588,14 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
2461
2588
|
const promptLine = judgment
|
|
2462
2589
|
? "\n✅ 风格判断已保存:" + (saved.savedAt || "(存盘失败)")
|
|
2463
2590
|
: "\n📌 请结合上述六个维度数据判断本书的风格气质、读者感受与写法特征,";
|
|
2591
|
+
if (args?.brief === true && result.baseline) {
|
|
2592
|
+
const dims = Object.keys(result.baseline).filter(function (k) { return result.baseline[k] && typeof result.baseline[k].mu === "number"; });
|
|
2593
|
+
result.brief = "全书 " + result.chars + " 字:六维基线 μ=" + dims.map(function (k) { return k + ":" + result.baseline[k].mu.toFixed(1); }).join(" ") + ";推荐容差 " + dims.map(function (k) { return result.baseline[k].recTol + "%"; }).slice(0, 3).join("/") + "…。";
|
|
2594
|
+
}
|
|
2464
2595
|
return {
|
|
2465
2596
|
book,
|
|
2466
2597
|
chars: result.chars,
|
|
2598
|
+
brief: result.brief ?? void 0,
|
|
2467
2599
|
// v3.0.0:文笔六维基线带(novel_style_check 对照用)
|
|
2468
2600
|
baseline: result.baseline ?? null,
|
|
2469
2601
|
baselineChapterCount: result.baselineChapterCount ?? 0,
|
|
@@ -2517,7 +2649,7 @@ function registerNovelSettings(ctx, config) {
|
|
|
2517
2649
|
properties: {
|
|
2518
2650
|
book: { type: "string", description: "书名。" },
|
|
2519
2651
|
category: { type: "string", enum: ["character", "location", "item", "timeline", "worldview"], description: "表类别(character/location/item/timeline/worldview;list/add/update/delete/scan 需要;detect 固定 worldview)。" },
|
|
2520
|
-
action: { type: "string", enum: ["list", "add", "update", "delete", "scan", "detect"], description: "list
|
|
2652
|
+
action: { type: "string", enum: ["list", "add", "update", "delete", "scan", "detect"], description: "list=查看;add=登记;update=修改;delete=删除;scan=扫描章节提取候选;detect=自动判断世界观文化基准(worldview 专用)。" },
|
|
2521
2653
|
name: { type: "string", description: "条目名(人物名/地名/道具名;timeline 用 day 字段)。" },
|
|
2522
2654
|
description: { type: "string", description: "可选:描述。" },
|
|
2523
2655
|
traits: { type: "string", description: "character 专用:性格/外貌特征。" },
|
|
@@ -2534,7 +2666,7 @@ function registerNovelSettings(ctx, config) {
|
|
|
2534
2666
|
basis: { type: "string", description: "worldview 专用:判断依据/文化基准说明。" },
|
|
2535
2667
|
ritual: { type: "string", description: "worldview 专用:仪式规范(如'点烛不烧香')。" },
|
|
2536
2668
|
speechStyle: { type: "object", additionalProperties: true, description: "worldview 专用:说话方式规范(title 称谓/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气·整体为 JSON 对象)。" },
|
|
2537
|
-
root: { type: "string", description: "
|
|
2669
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
2538
2670
|
},
|
|
2539
2671
|
required: ["book"],
|
|
2540
2672
|
additionalProperties: false
|
|
@@ -2774,7 +2906,7 @@ async function readSummaries(root, book) {
|
|
|
2774
2906
|
function registerNovelSummary(ctx, config) {
|
|
2775
2907
|
ctx.tools.register({
|
|
2776
2908
|
name: "novel_summary",
|
|
2777
|
-
description: "
|
|
2909
|
+
description: "章节摘要(模型生成、插件存储):读摘要回忆剧情、避免重读。add/update/get/list/delete 按章节管理。",
|
|
2778
2910
|
parameters: {
|
|
2779
2911
|
type: "object",
|
|
2780
2912
|
properties: {
|
|
@@ -2784,7 +2916,7 @@ function registerNovelSummary(ctx, config) {
|
|
|
2784
2916
|
summary: { type: "string", description: "add/update 时:本章摘要(200-500 字,覆盖剧情走向/关键事件/结尾状态)。" },
|
|
2785
2917
|
keyEvents: { type: "array", items: { type: "string" }, description: "可选:关键事件列表。" },
|
|
2786
2918
|
keySettings: { type: "array", items: { type: "string" }, description: "可选:本章出现的关键设定/信息。" },
|
|
2787
|
-
root: { type: "string", description: "
|
|
2919
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
2788
2920
|
},
|
|
2789
2921
|
required: ["book"],
|
|
2790
2922
|
additionalProperties: false
|
|
@@ -2890,6 +3022,75 @@ function registerNovelSummary(ctx, config) {
|
|
|
2890
3022
|
}
|
|
2891
3023
|
|
|
2892
3024
|
/** v0.8.0 连贯性审计:设定表 + 全书扫描 → 矛盾候选清单。 */
|
|
3025
|
+
/** v3.2.0:衔接检查——硬跳时间词表(直接跳 vs 软跳)。 */
|
|
3026
|
+
const TIME_JUMP_WORDS = {
|
|
3027
|
+
hard: ["第二天", "第三天", "第四天", "第五天", "三天后", "四天后", "五天后", "六天后", "七天后", "十天后", "数天后", "一周后", "两周后", "半个月后", "一个月后", "两个月后", "三个月后", "半年后", "一年后", "两年后", "三年后", "多年后", "数日后", "几天后", "几天前", "几年后", "不久后", "很久以后"],
|
|
3028
|
+
soft: ["次日", "翌日", "次日清晨", "次日黄昏", "隔天", "深夜", "黎明", "清晨", "黄昏", "午夜", "当天晚上", "当晚"]
|
|
3029
|
+
};
|
|
3030
|
+
/** v3.2.0:章节衔接检测(规则 + 语义)——上章结尾 vs 本章开头。 */
|
|
3031
|
+
async function detectChapterBridge(root, book, prevFile, currFile, exec, config) {
|
|
3032
|
+
const out = [];
|
|
3033
|
+
try {
|
|
3034
|
+
const prevText = await readTextFile(join(root, "novels", book, prevFile), exec);
|
|
3035
|
+
const currText = await readTextFile(join(root, "novels", book, currFile), exec);
|
|
3036
|
+
const prevTail = prevText.slice(-600);
|
|
3037
|
+
const currHead = currText.slice(0, 800);
|
|
3038
|
+
if (!prevTail.trim() || !currHead.trim()) return out;
|
|
3039
|
+
// ① 时间跳跃
|
|
3040
|
+
const hardHit = TIME_JUMP_WORDS.hard.filter((w) => currHead.includes(w));
|
|
3041
|
+
const softHit = TIME_JUMP_WORDS.soft.filter((w) => currHead.includes(w));
|
|
3042
|
+
const prevHasTime = TIME_JUMP_WORDS.hard.some((w) => prevTail.includes(w)) || TIME_JUMP_WORDS.soft.some((w) => prevTail.includes(w));
|
|
3043
|
+
// v3.2.0:硬跳词(三天后/一周后…)直接报——跳跃本身就需要过渡确认;软跳词(次日/清晨…)仅在上章无时间锚时提示
|
|
3044
|
+
if (hardHit.length > 0) {
|
|
3045
|
+
out.push({ type: "衔接·时间跳跃", detail: "本章开头出现「" + hardHit.slice(0, 2).join("/") + "」(时间硬跳)——建议确认开头有过渡句(如“日子一天天过去”),否则补 1 句时间流逝", chapters: [prevFile, currFile] });
|
|
3046
|
+
} else if (softHit.length > 0 && !prevHasTime) {
|
|
3047
|
+
out.push({ type: "衔接·时间过渡", detail: "本章开头出现「" + softHit.slice(0, 2).join("/") + "」(软跳),可接受但建议确认时间线连贯", chapters: [prevFile, currFile] });
|
|
3048
|
+
}
|
|
3049
|
+
// ② 语义相似度(本地 embedding,0 token;不可用跳过)
|
|
3050
|
+
try {
|
|
3051
|
+
const engine = getEmbeddingEngine(config);
|
|
3052
|
+
if (engine) {
|
|
3053
|
+
const [vPrev, vCurr] = await Promise.all([embedText(engine, prevTail), embedText(engine, currHead)]);
|
|
3054
|
+
const sim = cosineSimilarity(vPrev, vCurr);
|
|
3055
|
+
if (sim < 0.3) out.push({ type: "衔接·语义距离", detail: "上章结尾与本章开头语义相似度 " + sim.toFixed(2) + "(偏低)——开头可能未承接上章场景,建议加承接句", chapters: [prevFile, currFile] });
|
|
3056
|
+
else if (sim < 0.45) out.push({ type: "衔接·语义距离", detail: "语义相似度 " + sim.toFixed(2) + "(一般)——建议确认本章开头与上章结尾的承接", chapters: [prevFile, currFile] });
|
|
3057
|
+
}
|
|
3058
|
+
} catch { /* 语义不可用跳过 */ }
|
|
3059
|
+
// ③ 人物延续(settings character 表)
|
|
3060
|
+
try {
|
|
3061
|
+
const st = await readSentenceState();
|
|
3062
|
+
const chars = (st.characters || []).filter((c) => typeof c.name === "string" && c.name.length >= 2);
|
|
3063
|
+
const prevNames = chars.filter((c) => prevTail.includes(c.name)).map((c) => c.name);
|
|
3064
|
+
const currNames = chars.filter((c) => currHead.includes(c.name)).map((c) => c.name);
|
|
3065
|
+
if (prevNames.length > 0 && currNames.length === 0) {
|
|
3066
|
+
out.push({ type: "衔接·人物断线", detail: "上章结尾出场人物(" + prevNames.slice(0, 3).join("/") + ")本章开头 800 字均未出现——角色线可能断掉,建议让至少一人承接", chapters: [prevFile, currFile] });
|
|
3067
|
+
}
|
|
3068
|
+
} catch { /* 设定表未登记跳过 */ }
|
|
3069
|
+
// ④ 承接卡(上一章钩子文本 vs 本章开头 bigram 重叠)
|
|
3070
|
+
try {
|
|
3071
|
+
const hooksPath = join(root, "novels", "创作资料", book, "钩子记录.md");
|
|
3072
|
+
const hooks = await readTextFile(hooksPath, exec);
|
|
3073
|
+
const hookLines = hooks.split("\n").filter((l) => /^- \d+ /.test(l));
|
|
3074
|
+
// v3.2.0:取上一章(prevFile)对应的钩子,不取最后一行(钩子记录乱序时防比对错章)
|
|
3075
|
+
const prevNum = parseInt((prevFile.match(/\d+/) || ["0"])[0], 10);
|
|
3076
|
+
const prevHookLine = hookLines.find((l) => parseInt((l.match(/^- (\d+)/) || [])[1], 10) === prevNum);
|
|
3077
|
+
if (prevHookLine) {
|
|
3078
|
+
const lastHook = prevHookLine.replace(/^- \d+ /, "");
|
|
3079
|
+
// v3.2.0:去重字符命中率 <25% 才报(单字级,短钩子比 bigram 稳;语义接续但字面弱的不误报)
|
|
3080
|
+
const hookChars = new Set(lastHook.replace(/[,。!?、\s]/g, "").split(""));
|
|
3081
|
+
const headChars = new Set(currHead.slice(0, 300).split(""));
|
|
3082
|
+
let hit = 0;
|
|
3083
|
+
for (const c of hookChars) if (headChars.has(c)) hit += 1;
|
|
3084
|
+
const ratio = hookChars.size > 0 ? hit / hookChars.size : 1;
|
|
3085
|
+
if (ratio < 0.25 && lastHook.length > 4) {
|
|
3086
|
+
out.push({ type: "衔接·钩子未接", detail: "上一章钩子「" + lastHook.slice(0, 24) + "…」与本章开头字符命中率仅 " + Math.round(ratio * 100) + "%——开头可能没接上钩子(若本章确已接续请忽略此提示)", chapters: [prevFile, currFile] });
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
} catch { /* 无创作资料跳过 */ }
|
|
3090
|
+
} catch { /* 读取失败返回空 */ }
|
|
3091
|
+
return out;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
2893
3094
|
function registerNovelContinuityCheck(ctx, config) {
|
|
2894
3095
|
ctx.tools.register({
|
|
2895
3096
|
name: "novel_continuity_check",
|
|
@@ -2898,7 +3099,7 @@ function registerNovelContinuityCheck(ctx, config) {
|
|
|
2898
3099
|
type: "object",
|
|
2899
3100
|
properties: {
|
|
2900
3101
|
book: { type: "string", description: "书名。" },
|
|
2901
|
-
root: { type: "string", description: "
|
|
3102
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
2902
3103
|
},
|
|
2903
3104
|
required: ["book"],
|
|
2904
3105
|
additionalProperties: false
|
|
@@ -2909,6 +3110,9 @@ function registerNovelContinuityCheck(ctx, config) {
|
|
|
2909
3110
|
additionalProperties: false,
|
|
2910
3111
|
properties: {
|
|
2911
3112
|
book: { type: "string" },
|
|
3113
|
+
// v3.2.0:衔接/OOC/大纲对照模式返回值
|
|
3114
|
+
action: { type: "string" },
|
|
3115
|
+
chapter: { type: "string" },
|
|
2912
3116
|
candidates: {
|
|
2913
3117
|
type: "array",
|
|
2914
3118
|
items: {
|
|
@@ -2947,12 +3151,96 @@ function registerNovelContinuityCheck(ctx, config) {
|
|
|
2947
3151
|
const dir = bookDir(root, book);
|
|
2948
3152
|
const chapters = await scanChapters(dir);
|
|
2949
3153
|
assert(chapters.length > 0, `作品 "${book}" 下没有章节文件`);
|
|
3154
|
+
// v3.2.0:单章衔接检查模式
|
|
3155
|
+
const bridgeChapter = args?.chapter !== void 0 ? String(args.chapter) : "";
|
|
3156
|
+
if (bridgeChapter !== "") {
|
|
3157
|
+
const target = findChapter(chapters, bridgeChapter);
|
|
3158
|
+
assert(target !== void 0, `在作品 "${book}" 中找不到章节 "${bridgeChapter}"(可用 novel_chapters 查看章节列表)`);
|
|
3159
|
+
const ti = chapters.findIndex((c) => c.file === target.file);
|
|
3160
|
+
if (ti <= 0) return { book, action: "衔接", candidates: [], advice: "第一章无上一章可比对——衔接检查从第二章起可用。" };
|
|
3161
|
+
const prev = chapters[ti - 1];
|
|
3162
|
+
const bridge = await detectChapterBridge(root, book, prev.file, target.file, exec, config);
|
|
3163
|
+
return {
|
|
3164
|
+
book,
|
|
3165
|
+
action: "衔接",
|
|
3166
|
+
chapter: target.file,
|
|
3167
|
+
candidates: bridge,
|
|
3168
|
+
advice: bridge.length === 0
|
|
3169
|
+
? "未发现明显衔接问题:时间线、人物延续与钩子承接均正常。"
|
|
3170
|
+
: "发现 " + bridge.length + " 处衔接风险点——按提示补过渡/承接后重查。"
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
2950
3173
|
const settings = await readSettings(root, book);
|
|
2951
3174
|
const chapterTexts = [];
|
|
2952
3175
|
for (const chapter of chapters) {
|
|
2953
3176
|
chapterTexts.push({ file: chapter.file, text: await readTextFile(join(dir, chapter.file), exec) });
|
|
2954
3177
|
}
|
|
2955
3178
|
const candidates = [];
|
|
3179
|
+
// v3.2.0:大纲 vs 实际偏离检查
|
|
3180
|
+
if (args?.outline === true) {
|
|
3181
|
+
const outlinePath = join(root, "novels", "创作资料", book, "剧情大纲.md");
|
|
3182
|
+
try {
|
|
3183
|
+
const outlineText = await readTextFile(outlinePath, exec);
|
|
3184
|
+
const directionLines = outlineText.split("\n").filter(function (l) { return /^- \d+ /.test(l); });
|
|
3185
|
+
if (directionLines.length === 0) return { book, action: "大纲对照", candidates: [], advice: "创作资料大纲没有方向行——先 novel_outline chapter 补每章方向" };
|
|
3186
|
+
const out = [];
|
|
3187
|
+
for (const l of directionLines) {
|
|
3188
|
+
const num = parseInt(l.match(/^- (\d+)/)[1], 10);
|
|
3189
|
+
const direction = l.replace(/^- \d+ /, "").trim();
|
|
3190
|
+
if (direction.length < 4) continue;
|
|
3191
|
+
const ch = chapters.find(function (c) { return parseInt((c.file.match(/\d+/) || ["0"])[0], 10) === num; });
|
|
3192
|
+
if (!ch) continue;
|
|
3193
|
+
const text = (await readTextFile(join(dir, ch.file), exec)).slice(0, 600);
|
|
3194
|
+
const grams = new Set();
|
|
3195
|
+
for (let i = 0; i < direction.length - 1; i += 1) grams.add(direction.slice(i, i + 2));
|
|
3196
|
+
let hit = 0;
|
|
3197
|
+
for (const g of grams) if (text.includes(g)) hit += 1;
|
|
3198
|
+
const ratio = grams.size > 0 ? hit / grams.size : 0;
|
|
3199
|
+
if (ratio < 0.12) out.push({ type: "大纲·可能偏离", detail: "第 " + num + " 章方向「" + direction.slice(0, 20) + "…」与正文开头关键词重合率仅 " + Math.round(ratio * 100) + "%——该章可能偏离大纲,建议核对", chapters: [ch.file] });
|
|
3200
|
+
}
|
|
3201
|
+
return { book, action: "大纲对照", candidates: out, advice: out.length === 0 ? "各章与大纲方向基本一致。" : "发现 " + out.length + " 章可能与大纲方向偏离——逐条核对(偏离不一定是错,但要有意为之)。" };
|
|
3202
|
+
} catch {
|
|
3203
|
+
return { book, action: "大纲对照", candidates: [], advice: "该书无创作资料(novels/创作资料/<书>/剧情大纲.md)——先 novel_outline init + chapter 补大纲方向" };
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
// v3.2.0:角色 OOC 哨兵(情绪基线偏离)
|
|
3207
|
+
if (args?.ooc === true) {
|
|
3208
|
+
const settings2 = await readSettings(root, book);
|
|
3209
|
+
const characters = (settings2.characters || []).filter(function (c) { return typeof c.name === "string" && c.name.length >= 2; });
|
|
3210
|
+
if (characters.length === 0) return { book, action: "OOC", candidates: [], advice: "未登记角色(novel_settings character)——先登记角色才能检测情绪偏离" };
|
|
3211
|
+
const { valenceStats } = await import("./analysis.js");
|
|
3212
|
+
const per = {};
|
|
3213
|
+
for (const ch of chapterTexts) {
|
|
3214
|
+
for (const c of characters) {
|
|
3215
|
+
const name = c.name;
|
|
3216
|
+
const around = [];
|
|
3217
|
+
let pos = -1;
|
|
3218
|
+
while ((pos = ch.text.indexOf(name, pos + 1)) !== -1 && around.length < 5) {
|
|
3219
|
+
around.push(ch.text.slice(Math.max(0, pos - 120), pos + 120));
|
|
3220
|
+
}
|
|
3221
|
+
if (around.length === 0) continue;
|
|
3222
|
+
const vs = valenceStats(around.join(" "));
|
|
3223
|
+
(per[name] = per[name] || []).push({ file: ch.file, val: vs.meanValence ?? 0 });
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
const out = [];
|
|
3227
|
+
for (const name of Object.keys(per)) {
|
|
3228
|
+
const list = per[name];
|
|
3229
|
+
if (list.length < 2) continue;
|
|
3230
|
+
const vals = list.map(function (x) { return x.val; });
|
|
3231
|
+
const mu = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
|
|
3232
|
+
const variance = vals.reduce(function (s, x) { return s + (x - mu) * (x - mu); }, 0) / vals.length;
|
|
3233
|
+
const sigma = Math.sqrt(variance) || 0.05;
|
|
3234
|
+
// v3.2.0:小样本(≤4 章)σ 会被离群值撑大,改用固定阈值 0.3
|
|
3235
|
+
const thr = list.length <= 4 ? 0.3 : 2 * sigma;
|
|
3236
|
+
for (const item of list) {
|
|
3237
|
+
if (Math.abs(item.val - mu) > thr) {
|
|
3238
|
+
out.push({ type: "OOC·情绪偏离", detail: "角色「" + name + "」在 " + item.file + " 的情绪值 " + item.val.toFixed(2) + " 偏离其全书均值 " + mu.toFixed(2) + "(>2σ)——若有重大剧情触发情绪变化可忽略,否则可能 OOC", chapters: [item.file] });
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
return { book, action: "OOC", candidates: out, advice: out.length === 0 ? "登记角色情绪基线未见异常。" : "发现 " + out.length + " 处角色情绪偏离——先确认是否有剧情触发,无触发则可能 OOC。" };
|
|
3243
|
+
}
|
|
2956
3244
|
// 1) 数字写法差异(亿/千万/百万 口径)
|
|
2957
3245
|
const numberForms = new Map();
|
|
2958
3246
|
for (const { file, text } of chapterTexts) {
|
|
@@ -3080,12 +3368,12 @@ function registerNovelContinuityCheck(ctx, config) {
|
|
|
3080
3368
|
function registerNovelPlot(ctx, config) {
|
|
3081
3369
|
ctx.tools.register({
|
|
3082
3370
|
name: "novel_plot",
|
|
3083
|
-
description: "伏笔/剧情线登记表:维护某部作品的伏笔与剧情钩子(open 待回收 / done
|
|
3371
|
+
description: "伏笔/剧情线登记表:维护某部作品的伏笔与剧情钩子(open 待回收 / done 已回收)。",
|
|
3084
3372
|
parameters: {
|
|
3085
3373
|
type: "object",
|
|
3086
3374
|
properties: {
|
|
3087
3375
|
book: { type: "string", description: "书名。" },
|
|
3088
|
-
action: { type: "string", enum: ["list", "add", "update", "done", "delete", "scan"], description: "list
|
|
3376
|
+
action: { type: "string", enum: ["list", "add", "update", "done", "delete", "scan"], description: "list=查看;add=登记新伏笔;update=修改;done=标记已回收;delete=删除;scan=扫描章节文本,自动更新伏笔提及章节。" },
|
|
3089
3377
|
id: { type: "string", description: "伏笔 id(update/done/delete 时需要)。" },
|
|
3090
3378
|
content: { type: "string", description: "add/update 时:伏笔内容描述。" },
|
|
3091
3379
|
chapter: { type: "string", description: "可选:伏笔出现的章节。" },
|
|
@@ -3095,7 +3383,7 @@ function registerNovelPlot(ctx, config) {
|
|
|
3095
3383
|
relatedCharacters: { type: "array", items: { type: "string" }, description: "可选:关联人物(add/update)。" },
|
|
3096
3384
|
locations: { type: "array", items: { type: "string" }, description: "可选:关联地点(add/update)。" },
|
|
3097
3385
|
payoffCondition: { type: "string", description: "可选:回收条件(add/update)。" },
|
|
3098
|
-
root: { type: "string", description: "
|
|
3386
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
3099
3387
|
},
|
|
3100
3388
|
required: ["book"],
|
|
3101
3389
|
additionalProperties: false
|
|
@@ -3255,7 +3543,7 @@ function registerNovelPlot(ctx, config) {
|
|
|
3255
3543
|
function registerNovelSemanticSearch(ctx, config) {
|
|
3256
3544
|
ctx.tools.register({
|
|
3257
3545
|
name: "novel_semantic_search",
|
|
3258
|
-
description: "语义检索(本地 embedding,0 token
|
|
3546
|
+
description: "语义检索(本地 embedding,0 token):自然语言检索全书语义相关段落,无关键词也能命中。",
|
|
3259
3547
|
parameters: {
|
|
3260
3548
|
type: "object",
|
|
3261
3549
|
additionalProperties: false,
|
|
@@ -3263,7 +3551,7 @@ function registerNovelSemanticSearch(ctx, config) {
|
|
|
3263
3551
|
book: { type: "string", description: "书名。" },
|
|
3264
3552
|
query: { type: "string", description: "要检索的语义描述,如「与血统秘密相关的段落」「女主压抑克制的时刻」。自然语言越具体越好。" },
|
|
3265
3553
|
top: { type: "integer", description: "返回条数(默认 5,最大 10)。" },
|
|
3266
|
-
root: { type: "string", description: "章节库根目录(含 novels
|
|
3554
|
+
root: { type: "string", description: "章节库根目录(含 novels 子目录)。" }
|
|
3267
3555
|
},
|
|
3268
3556
|
required: ["book", "query"]
|
|
3269
3557
|
},
|
|
@@ -3317,7 +3605,7 @@ function registerNovelSemanticSearch(ctx, config) {
|
|
|
3317
3605
|
}
|
|
3318
3606
|
const ready = await embedding.isAvailable();
|
|
3319
3607
|
if (!ready) {
|
|
3320
|
-
return { book, query, available: false, results: [], message: "语义引擎不可用:" + (embedding.engine?.error ?? "模型加载失败") + "。插件已回退纯规则模式,不影响其他功能。" };
|
|
3608
|
+
return { book, query, available: false, results: [], message: "语义引擎不可用:" + (embedding.engine?.error ?? "模型加载失败") + "。插件已回退纯规则模式,不影响其他功能。 【解决:确认 lib/models/ 含 bge-small-zh 模型文件;首次使用需自动加载,稍后重试】" };
|
|
3321
3609
|
}
|
|
3322
3610
|
const dir = bookDir(root, book);
|
|
3323
3611
|
const chapters = await scanChapters(dir);
|
|
@@ -3359,7 +3647,7 @@ function registerNovelBooks(ctx, config) {
|
|
|
3359
3647
|
parameters: {
|
|
3360
3648
|
type: "object",
|
|
3361
3649
|
properties: {
|
|
3362
|
-
root: { type: "string", description: "章节库根目录(含 novels
|
|
3650
|
+
root: { type: "string", description: "章节库根目录(含 novels 子目录)。" }
|
|
3363
3651
|
},
|
|
3364
3652
|
additionalProperties: false
|
|
3365
3653
|
},
|
|
@@ -3442,7 +3730,7 @@ function registerNovelChapters(ctx, config) {
|
|
|
3442
3730
|
type: "object",
|
|
3443
3731
|
properties: {
|
|
3444
3732
|
book: { type: "string", description: "书名(novels 下的子目录名)。" },
|
|
3445
|
-
root: { type: "string", description: "
|
|
3733
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
3446
3734
|
},
|
|
3447
3735
|
required: ["book"],
|
|
3448
3736
|
additionalProperties: false
|
|
@@ -3522,7 +3810,7 @@ function registerNovelRead(ctx, config) {
|
|
|
3522
3810
|
chapter: { type: "string", description: "章节标识:章号(如 1 或 01)、文件名(第01章.md)或标题子串。" },
|
|
3523
3811
|
offset: { type: "integer", description: "起始行号,从 1 开始。默认 1。" },
|
|
3524
3812
|
limit: { type: "integer", description: `最多返回行数。默认 ${READ_LIMIT}。` },
|
|
3525
|
-
root: { type: "string", description: "
|
|
3813
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
3526
3814
|
},
|
|
3527
3815
|
required: ["book", "chapter"],
|
|
3528
3816
|
additionalProperties: false
|
|
@@ -3629,14 +3917,14 @@ ${footer}
|
|
|
3629
3917
|
function registerNovelKeywords(ctx, config) {
|
|
3630
3918
|
ctx.tools.register({
|
|
3631
3919
|
name: "novel_keywords",
|
|
3632
|
-
description: "
|
|
3920
|
+
description: "统计某部作品(或单个章节)中出现频率较高的关键词:中文相邻二字词组、高频单字与英文词。",
|
|
3633
3921
|
parameters: {
|
|
3634
3922
|
type: "object",
|
|
3635
3923
|
properties: {
|
|
3636
3924
|
book: { type: "string", description: "书名。" },
|
|
3637
3925
|
chapter: { type: "string", description: "可选。只统计该章节;省略则统计全书。" },
|
|
3638
3926
|
top: { type: "integer", description: "返回的关键词数量。默认 20。" },
|
|
3639
|
-
root: { type: "string", description: "
|
|
3927
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
3640
3928
|
},
|
|
3641
3929
|
required: ["book"],
|
|
3642
3930
|
additionalProperties: false
|
|
@@ -3739,7 +4027,7 @@ function registerNovelNewChapter(ctx, config) {
|
|
|
3739
4027
|
chapter: { type: "integer", description: "可选。显式指定章号;省略则取现有最大章号 + 1。" },
|
|
3740
4028
|
title: { type: "string", description: "可选。章节标题,会写入 Markdown 一级标题。" },
|
|
3741
4029
|
content: { type: "string", description: "可选。章节初始正文。" },
|
|
3742
|
-
root: { type: "string", description: "
|
|
4030
|
+
root: { type: "string", description: "章节库根目录。" }
|
|
3743
4031
|
},
|
|
3744
4032
|
required: ["book"],
|
|
3745
4033
|
additionalProperties: false
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "小说写作助手 / Novel Writing Assistant for DeepSeek Harness — sentence/emotion/imagery analysis, 6-dim writing baseline band, 12-axis vibe spectrum, style report, original-creation mode
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "小说写作助手 / Novel Writing Assistant for DeepSeek Harness — sentence/emotion/imagery analysis, 6-dim writing baseline band, 12-axis vibe spectrum, style report, continuity sentinels (bridge/OOC/outline), original-creation mode, local semantic search (0 token), plot & settings management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
- 读完每章后调用 §BT§novel_summary§BT§ add 保存 200-500 字摘要 + 关键事件 + 关键设定;
|
|
41
41
|
- 续写长书时先 §BT§novel_summary§BT§ list 回忆剧情,再按需 §BT§novel_read§BT§ 细读。
|
|
42
42
|
|
|
43
|
+
## 写作哨兵(v3.2.0,novel_continuity_check 扩展)
|
|
44
|
+
- **衔接检查**(chapter 参数):写完一章跑一次——四路检测(时间硬跳词表 / 上章结尾与本章开头语义距离(0 token embedding)/ 人物延续 / 上一章钩子承接),命中带原文引用;硬跳(三天后/一周后)必须补过渡,软跳(次日清晨)可接受;
|
|
45
|
+
- **OOC 检测**(ooc 参数):登记角色后对比每章该角色附近文本的情绪值 vs 全书基线(>2σ 或小样本 0.3),偏离先确认是否有剧情触发;
|
|
46
|
+
- **大纲走偏**(outline 参数):对照创作资料大纲方向行,重合率 <25% 提示可能偏离;
|
|
47
|
+
- **报告精简**:novel_sentence_analysis / novel_style_report 传 brief:true 返回一句话结论;
|
|
48
|
+
|
|
43
49
|
## 伏笔登记表(v0.6.0 新增)
|
|
44
50
|
|
|
45
51
|
- `novel_plot`:list / add / update / done / delete 管理伏笔与剧情钩子;
|