dsh-novel-writer 2.5.0 → 2.5.5
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 +83 -20
- package/README.md +76 -60
- package/lib/embedding.js +40 -9
- package/lib/index.js +24 -15
- package/lib/vibe.js +80 -26
- package/package.json +3 -3
- package/skills/novel-writing/SKILL.md +1 -1
package/README.en.md
CHANGED
|
@@ -1,26 +1,89 @@
|
|
|
1
|
-
# dsh-novel-writer — Novel Writing Assistant
|
|
1
|
+
# 📚 dsh-novel-writer — Novel Writing Assistant
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [**中文**](./README.md)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/siweina/dsh-novel-writer/stargazers)
|
|
8
|
+
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
- **Local engine** (lib/embedding.js): Xenova/bge-small-zh-v1.5 (quantized), 512-dim Chinese vectors, ~23MB, bundled; @huggingface/tokenizers + onnxruntime-web (WASM), local CPU, 0 token / 0 API cost;
|
|
10
|
-
- **Lazy load** (~0.3s first call); automatic fallback to pure-rule mode if unavailable;
|
|
11
|
-
- **Index cache**: per-book vectors at `.novel-writer/embedding/<book>.json` — repeated searches are instant;
|
|
12
|
-
- **novel_semantic_search**: natural-language search across the whole book (foreshadowing clues, emotional scenes, setting mentions) — matches by meaning even when keywords differ;
|
|
13
|
-
- **novel_style_check upgrade**: adds semantic similarity (target chapter vs rest of book) alongside rule-based fingerprint similarity;
|
|
14
|
-
- **Switch: semanticEmbedding** — default ON (probe-based: auto-enabled when the model is present); turn it off in the sidebar「写作助手功能」panel to fully return to rule-only mode.
|
|
11
|
+
A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v2.5.5): chapter library management, sentence-pattern analysis, emotion purification & quantification, **12-axis vibe spectrum**, **style portrait report**, plot & settings management, local semantic search (0 token), webnovel signal detection, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host.
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
Sentence-pattern analysis; emotion purification + AI review (strong/weak lexicon, pollution caveat); emotion quantification (Valence sliding window: variance/slope/conflict + implicit imagery + complexity score); worldview/genre/theme detection (modern/western/eastern + 15 genres + 35 themes); pragmatic-style review; settings tables (5) + foreshadowing registry + summaries + continuity audit + style check; analysis cache & report export (`.novel-writer/analysis/`).
|
|
13
|
+
---
|
|
18
14
|
|
|
19
|
-
##
|
|
20
|
-
- **novel_style_report** (15th tool): aggregates 6 measurement dimensions (style fingerprint / keywords / theme / emotion quantification / 12-axis vibe / semantic style distances);
|
|
21
|
-
- Plugin only reports numbers, never labels — style judgment is left to the LLM; AI judgment can be saved via `aiJudgment` (stored in .novel-writer/style-reports/) and read with `action=get`;
|
|
22
|
-
- Vibe spectrum: 12 axes (added 文艺唯美 Aesthetic / 情欲暧昧 Sensual), rule-based conclusions removed;
|
|
23
|
-
- Semantic style distances: 12 style prototype sets × book vector (local embedding, 0 token).
|
|
15
|
+
## Installation
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
**Option 1: npm (recommended)**
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
dsh plugin --profile web add dsh-novel-writer
|
|
21
|
+
# or
|
|
22
|
+
npm install dsh-novel-writer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Option 2: From GitHub**
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
After installing, **restart the web app** to activate (host registers 15 tools + state/reveal routes; browser mounts the "Writing Assistant" sidebar toggle panel).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
1. **Style portrait report** (`novel_style_report`, v2.5.0): 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.
|
|
38
|
+
2. **12-axis vibe spectrum** (v2.5.0): nightmare / angst / heartwarming / fluff / tearjerker / dark / mystery / blaze / absurd / lonesome / aesthetic / sensual — with traceable evidence, 0 token.
|
|
39
|
+
3. **Local semantic engine** (v2.0.0): bge-small-zh Chinese model (23MB, shipped with the plugin) local CPU inference — `novel_semantic_search` finds semantically related passages with natural language, semantic style comparison, semantic implicit emotion; lazy loading + graceful fallback.
|
|
40
|
+
4. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
|
|
41
|
+
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.
|
|
42
|
+
6. **Worldview & pragmatics detection**: auto cultural-baseline detection with confidence; speechStyle title/honorifics/rituals/tone norms; genre & theme + webnovel signals (v2.2).
|
|
43
|
+
7. **Writing toolkit**: plot tracking / five settings tables (characters·locations·items·timeline·worldview) / chapter summaries / continuity audit / batch import / style check / continuation writing.
|
|
44
|
+
8. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + grouped tool toggles + feature toggles), plain-language labels.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Provided Tools (15)
|
|
49
|
+
|
|
50
|
+
| Tool | Description |
|
|
51
|
+
|------|-------------|
|
|
52
|
+
| `novel_books` | List all books in library |
|
|
53
|
+
| `novel_chapters` | List a book's chapters |
|
|
54
|
+
| `novel_read` | Read a chapter (paginated) |
|
|
55
|
+
| `novel_keywords` | Keywords: bigram/trigram/name candidates |
|
|
56
|
+
| `novel_new_chapter` | Create new chapter file |
|
|
57
|
+
| `novel_import` | Batch import manuscripts |
|
|
58
|
+
| `novel_sentence_analysis` | Sentence-pattern analysis |
|
|
59
|
+
| `novel_sentence_config` | View/set tool & feature toggles |
|
|
60
|
+
| `novel_style_check` | Style check (rule + semantic) |
|
|
61
|
+
| `novel_style_report` | **Style portrait report** (6-dim measurement) |
|
|
62
|
+
| `novel_plot` | Plot/foreshadowing tracker |
|
|
63
|
+
| `novel_settings` | Settings management (+worldview) |
|
|
64
|
+
| `novel_summary` | Chapter summaries |
|
|
65
|
+
| `novel_continuity_check` | Continuity audit |
|
|
66
|
+
| `novel_semantic_search` | Semantic search (local embedding, 0 token) |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
- id: novel-writer
|
|
74
|
+
config:
|
|
75
|
+
root: 'D:/my-novel-library'
|
|
76
|
+
allowLanState: false # true = allow state save from LAN GUI access
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Data Directory
|
|
82
|
+
|
|
83
|
+
Under `<library-root>/.novel-writer/`: `plots` / `settings` / `summaries` / `analysis` / `audits` / `embedding` / `style-reports`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
[MIT](./LICENSE)
|
package/README.md
CHANGED
|
@@ -1,73 +1,89 @@
|
|
|
1
|
-
# dsh-novel-writer —
|
|
1
|
+
# 📚 dsh-novel-writer — 小说写作助手
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[**English**](./README.en.md) | 中文
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/siweina/dsh-novel-writer/stargazers)
|
|
8
|
+
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v2.5.5):章节库管理、句式分析、情感净化与量化、氛围光谱、**风格画像报告**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
---
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
- 模型:Xenova/bge-small-zh-v1.5(512 维中文向量,约 91MB),随插件分发,开箱即用;
|
|
13
|
-
- 运行时:**@huggingface/tokenizers**(官方轻量分词,294KB 零依赖)+ onnxruntime-web(WASM 推理),本地 CPU,0 token、0 API 费用;
|
|
14
|
-
- 懒加载:首次调用才加载(约 0.3s);加载失败自动回退纯规则,不影响任何既有功能;
|
|
15
|
-
- 索引缓存:每书向量落盘 `.novel-writer/embedding/<书>.json`,重复检索秒开(47 万字约 3100 段,首次建索引约 20-30s,之后命中缓存)。
|
|
15
|
+
## 安装
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
用自然语言描述在全书中检索语义相关段落——找伏笔线索、情感场景、设定提及,**即使原文没有相同关键词也能命中**(如搜「压抑克制的时刻」能命中没有"压抑"二字的段落)。
|
|
17
|
+
**方式一:npm(推荐)**
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
```sh
|
|
20
|
+
dsh plugin --profile web add dsh-novel-writer
|
|
21
|
+
# 或
|
|
22
|
+
npm install dsh-novel-writer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**方式二:从 GitHub 安装**
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
29
|
+
```
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
- 句式模式分析(九类句式分布/排列/段落/句长/情感曲线/风格指纹/密度);
|
|
29
|
-
- 情感净化 + AI 复核(强/弱情绪词分级、污染预警 caveat、强制抽查);
|
|
30
|
-
- 情感量化(Valence 滑动窗口:方差/斜率/矛盾指数 + 隐性意象 + 复杂度评分);
|
|
31
|
-
- 世界观/流派/题材检测(modern/western/eastern + 15 流派 + 35 题材,主副题材);
|
|
32
|
-
- 语用级审查(称谓/客套/仪式/语气,中西/现代禁用词);
|
|
33
|
-
- 设定管理五张表 + 伏笔登记 + 章节摘要 + 连贯性审计 + 风格自检;
|
|
34
|
-
- 分析缓存与报告导出(`.novel-writer/analysis/`)。
|
|
31
|
+
安装后**重启 web 应用**生效(宿主端注册 15 个工具与 state/reveal 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
- 总开关 enabled;autoAnalyze;14 个工具级开关;
|
|
38
|
-
- 功能级:emotionCaveat(情感净化预警)/ genreTheme(题材与流派检测)/ emotionComplexity(情感量化)/ **semanticEmbedding(语义增强,v2.0.0)**。
|
|
33
|
+
---
|
|
39
34
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
## 功能
|
|
36
|
+
|
|
37
|
+
1. **风格画像报告**(novel_style_report,v2.5.0):6 维测量报告——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 轴 / 语义风格距离。**测量与判断分离**:插件只报数不贴标签,AI 判断可回传存盘(`.novel-writer/style-reports/`),续写保持风格一致。
|
|
38
|
+
2. **氛围光谱 12 轴**(v2.5.0):噩梦感 / 焦虑压抑 / 温馨治愈 / 甜宠日常 / 催泪虐心 / 黑暗残酷 / 悬疑神秘 / 热血激昂 / 荒诞无厘头 / 孤独疏离 / 文艺唯美 / 情欲暧昧——证据链可追溯,0 token。
|
|
39
|
+
3. **本地语义引擎**(v2.0.0):bge-small-zh 中文模型(23MB 随插件分发)本地 CPU 推理——`novel_semantic_search` 自然语言搜全书语义相关段落,语义级风格对比、语义隐性情感,懒加载 + 自动回退。
|
|
40
|
+
4. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹与节奏建议,带缓存与报告导出。
|
|
41
|
+
5. **情感净化 + 量化**:强/弱情绪词分级、污染源检测、caveat 预警 + AI 复核;Valence 滑动窗口 → 方差 V / 斜率 Δ / 矛盾指数 C + 隐性意象载体。
|
|
42
|
+
6. **世界观与语用检测**:文化基准自动判断(西/东/混合)+ 置信度;speechStyle 称谓/客套/仪式/语气规范;题材流派 + 网文信号(v2.2)。
|
|
43
|
+
7. **写作辅助全家桶**:伏笔登记表 / 设定五张表(人物·地点·道具·时间线·世界观)/ 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 续写辅助。
|
|
44
|
+
8. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 工具开关分组 + 功能开关),大白话文案。
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 提供的工具(15 个)
|
|
49
|
+
|
|
50
|
+
| 工具 | 说明 |
|
|
51
|
+
|------|------|
|
|
52
|
+
| `novel_books` | 列出章节库全部作品 |
|
|
53
|
+
| `novel_chapters` | 列出某作品章节清单 |
|
|
54
|
+
| `novel_read` | 阅读某章正文(分段) |
|
|
55
|
+
| `novel_keywords` | 关键词:二字组/三字组/疑似人名 |
|
|
56
|
+
| `novel_new_chapter` | 创建新章节文件 |
|
|
57
|
+
| `novel_import` | 原稿件批量导入/分类 |
|
|
58
|
+
| `novel_sentence_analysis` | 句式模式分析(九类/情感净化/量化/曲线/指纹) |
|
|
59
|
+
| `novel_sentence_config` | 查看/修改工具与功能开关 |
|
|
60
|
+
| `novel_style_check` | 风格自检(规则+语义双维度) |
|
|
61
|
+
| `novel_style_report` | **风格画像报告**(6 维测量 + AI 判断分离) |
|
|
62
|
+
| `novel_plot` | 伏笔/剧情线登记表 |
|
|
63
|
+
| `novel_settings` | 设定管理(人物/地点/道具/时间线/世界观) |
|
|
64
|
+
| `novel_summary` | 章节摘要(长书续写辅助) |
|
|
65
|
+
| `novel_continuity_check` | 连贯性审计(设定矛盾+语用冲突候选) |
|
|
66
|
+
| `novel_semantic_search` | 语义检索(本地 embedding,0 token) |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 配置
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
- id: novel-writer
|
|
74
|
+
config:
|
|
75
|
+
root: 'D:/我的小说库'
|
|
76
|
+
allowLanState: false # true=局域网访问 GUI 时也允许保存开关
|
|
52
77
|
```
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## v2.5.0 新增:风格画像报告(测量与判断分离)
|
|
67
|
-
- **novel_style_report(第 15 个工具)**:聚合 6 维测量数据(文风指纹/高频词汇/题材流派/情感量化/氛围光谱 12 轴/语义风格距离)输出结构化报告;
|
|
68
|
-
- **插件只报数不贴标签**:氛围光谱 12 轴(新增 文艺唯美/情欲暧昧),砍掉规则结论文案——判断交给大模型;
|
|
69
|
-
- **AI 判断回传存盘**:AI 结合报告给出风格气质判断 → aiJudgment 参数回传 → 存入 .novel-writer/style-reports/<书>.json,后续 action=get 读取,续写保持风格一致;
|
|
70
|
-
- **语义风格距离**:12 组风格原型句 × 全书向量(本地 embedding,0 token)——实测示例书:克苏鲁诡异 0.79/压抑致郁 0.79。
|
|
71
|
-
|
|
72
|
-
## 体积说明
|
|
73
|
-
**zip 约 31MB**(含本地语义模型与 WASM 运行时,解压后代码+模型约 130MB);已移除 transformers.js/onnxruntime-node/sharp 全家桶(省 85%)。如磁盘紧张可在「语义增强」开关关闭后删除 `lib/models/` 与 `node_modules/`,插件回到纯规则模式。
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 数据目录
|
|
82
|
+
|
|
83
|
+
`<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 许可证
|
|
88
|
+
|
|
89
|
+
[MIT](./LICENSE)
|
package/lib/embedding.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
import { createRequire } from "node:module";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
12
|
|
|
@@ -74,9 +75,14 @@ async function isAvailable() {
|
|
|
74
75
|
async function embed(text) {
|
|
75
76
|
const s = await initEngine();
|
|
76
77
|
if (!s.available) return null;
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
78
|
+
// v2.5.0 修复轮 6:空/纯空白输入防护(空输入会产生无意义向量)
|
|
79
|
+
const raw = String(text ?? "");
|
|
80
|
+
if (raw.trim() === "") return null;
|
|
81
|
+
const enc = s.tokenizer.encode(raw.slice(0, 4000));
|
|
82
|
+
// v2.5.0 修复轮 6:token 级截断(bge 上限 512 token)——字符截断下 4000 个中文字符≈2000+ token
|
|
83
|
+
// 远超模型上限会导致 OrtRun() 报错(超长 query 首次必现"语义引擎不可用")
|
|
84
|
+
const ids = Array.from(enc.ids).slice(0, 512);
|
|
85
|
+
const mask = Array.from(enc.attention_mask).slice(0, 512);
|
|
80
86
|
const ort = s.ort;
|
|
81
87
|
const iids = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
|
|
82
88
|
const attn = new ort.Tensor("int64", BigInt64Array.from(mask, BigInt), [1, mask.length]);
|
|
@@ -114,11 +120,14 @@ function cosine(a, b) {
|
|
|
114
120
|
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
/**
|
|
123
|
+
/** 语义检索(query 维度跟随索引:首次直出 512 维 / 缓存命中 128 维)。 */
|
|
118
124
|
async function search(query, index, k = 5) {
|
|
119
125
|
const qv = await embed(query);
|
|
120
126
|
if (!qv || !index || index.length === 0) return [];
|
|
121
|
-
|
|
127
|
+
// v2.5.0 修复轮 5:首次 built 的索引为 512 维(embedMany 直出),缓存命中为 128 维(saveIndex 降采样)——
|
|
128
|
+
// query 必须与 index 同维度,否则 cosine(128,512) 长度不等恒返回 0(首次检索全 0 分、排序无意义)
|
|
129
|
+
const dim = index[0]?.vec?.length ?? 0;
|
|
130
|
+
const qvCache = dim === qv.length ? qv : qv.filter((_, i) => i % 4 === 0);
|
|
122
131
|
return index
|
|
123
132
|
.map((item) => ({ ...item, score: cosine(qvCache, item.vec) }))
|
|
124
133
|
.sort((x, y) => y.score - x.score)
|
|
@@ -205,13 +214,25 @@ function cachePath(root, book) {
|
|
|
205
214
|
return path.join(root, EMBEDDING_CACHE_ROOT, "embedding", safe + ".json");
|
|
206
215
|
}
|
|
207
216
|
|
|
208
|
-
/**
|
|
209
|
-
function
|
|
217
|
+
/** 内容指纹(sha1,id+text):章节内容/章节集变化 → 指纹变化 → 索引自动重建(不依赖版本号)。 */
|
|
218
|
+
function fingerprint(chunks) {
|
|
219
|
+
const h = createHash("sha1");
|
|
220
|
+
for (const c of chunks || []) {
|
|
221
|
+
h.update(String(c.id));
|
|
222
|
+
h.update("\u0000");
|
|
223
|
+
h.update(String(c.text));
|
|
224
|
+
h.update("\u0001");
|
|
225
|
+
}
|
|
226
|
+
return h.digest("hex");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 保存索引(128 维降采样压缩;fp=构建输入内容指纹,用于章节更新后失效重建)。 */
|
|
230
|
+
function saveIndex(root, book, index, fp = "") {
|
|
210
231
|
try {
|
|
211
232
|
const dir = path.dirname(cachePath(root, book));
|
|
212
233
|
fs.mkdirSync(dir, { recursive: true });
|
|
213
234
|
const compact = index.map((item) => ({ id: item.id, t: item.text.slice(0, 120), v: item.vec.filter((_, i) => i % 4 === 0) }));
|
|
214
|
-
fs.writeFileSync(cachePath(root, book), JSON.stringify({ model: "bge-small-zh-v1.5", dim: 128, items: compact }), "utf8");
|
|
235
|
+
fs.writeFileSync(cachePath(root, book), JSON.stringify({ model: "bge-small-zh-v1.5", dim: 128, fp, items: compact }), "utf8");
|
|
215
236
|
return true;
|
|
216
237
|
} catch { return false; }
|
|
217
238
|
}
|
|
@@ -226,6 +247,16 @@ function loadIndex(root, book) {
|
|
|
226
247
|
} catch { return null; }
|
|
227
248
|
}
|
|
228
249
|
|
|
250
|
+
/** 读取索引缓存 + 内容指纹(旧格式无 fp 时 fp=null,视为失效需重建;不靠版本号失效)。 */
|
|
251
|
+
function loadIndexMeta(root, book) {
|
|
252
|
+
try {
|
|
253
|
+
const p = cachePath(root, book);
|
|
254
|
+
if (!fs.existsSync(p)) return { items: null, fp: null };
|
|
255
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
256
|
+
return { items: data.items.map((item) => ({ id: item.id, text: item.t, vec: item.v })), fp: data.fp ?? null };
|
|
257
|
+
} catch { return { items: null, fp: null }; }
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
/** 全文 → 语义段落(按空行/150 字切块,带章节标记)。 */
|
|
230
261
|
function chunkText(text) {
|
|
231
262
|
const raw = String(text);
|
|
@@ -249,4 +280,4 @@ function chunkText(text) {
|
|
|
249
280
|
/** 显式释放(仅重置单例,WASM 内存由 GC 回收)。 */
|
|
250
281
|
function reset() { enginePromise = null; engine = { available: false, error: null, tokenizer: null, session: null, ort: null, wasmPaths: null }; }
|
|
251
282
|
|
|
252
|
-
export { status, isAvailable, embed, embedMany, cosine, search, saveIndex, loadIndex, chunkText, reset, detectImplicitEmotions, IMPLICIT_EMOTION_PROTOTYPES, MODEL_DIR, cachePath };
|
|
283
|
+
export { status, isAvailable, embed, embedMany, cosine, search, saveIndex, loadIndex, loadIndexMeta, fingerprint, chunkText, reset, detectImplicitEmotions, IMPLICIT_EMOTION_PROTOTYPES, MODEL_DIR, cachePath };
|
package/lib/index.js
CHANGED
|
@@ -855,10 +855,13 @@ async function enrichSemanticImplicit(state, root, book, result, exec) {
|
|
|
855
855
|
const text = await readTextFile(join(dir, chapter.file), exec);
|
|
856
856
|
for (const p of embedding.chunkText(text)) chunks.push({ ...p, id: chapter.file + "|" + p.id });
|
|
857
857
|
}
|
|
858
|
-
|
|
859
|
-
|
|
858
|
+
// v2.5.0 修复轮 4:内容指纹失效重建——章节更新后旧缓存不再命中(不靠版本号)
|
|
859
|
+
const { items: cachedIndex, fp: cachedFp } = embedding.loadIndexMeta(root, book);
|
|
860
|
+
const indexFp = embedding.fingerprint(chunks);
|
|
861
|
+
let index = cachedIndex;
|
|
862
|
+
if (!index || index.length === 0 || cachedFp !== indexFp) {
|
|
860
863
|
index = await embedding.embedMany(chunks);
|
|
861
|
-
embedding.saveIndex(root, book, index);
|
|
864
|
+
embedding.saveIndex(root, book, index, indexFp);
|
|
862
865
|
}
|
|
863
866
|
const implicit = await embedding.detectImplicitEmotions(index);
|
|
864
867
|
if (result.emotion?.quantification) {
|
|
@@ -1707,10 +1710,13 @@ function registerNovelStyleCheck(ctx, config) {
|
|
|
1707
1710
|
for (const p of embedding.chunkText(allTexts[i])) allChunks.push({ ...p, id: String(i) + "|" + p.id });
|
|
1708
1711
|
}
|
|
1709
1712
|
const styleCacheKey = book + "__style_" + String(chapterArg).replace(/[\\/:*?"<>|]/g, "_");
|
|
1710
|
-
|
|
1711
|
-
|
|
1713
|
+
// v2.5.0 修复轮 4:内容指纹失效重建——章节更新后旧缓存不再命中,避免语义对比静默失效(不靠版本号)
|
|
1714
|
+
const { items: cachedIndex, fp: cachedFp } = embedding.loadIndexMeta(root, styleCacheKey);
|
|
1715
|
+
const indexFp = embedding.fingerprint(allChunks);
|
|
1716
|
+
let index = cachedIndex;
|
|
1717
|
+
if (!index || index.length === 0 || cachedFp !== indexFp) {
|
|
1712
1718
|
index = await embedding.embedMany(allChunks);
|
|
1713
|
-
embedding.saveIndex(root, styleCacheKey, index);
|
|
1719
|
+
embedding.saveIndex(root, styleCacheKey, index, indexFp);
|
|
1714
1720
|
}
|
|
1715
1721
|
// 目标章段落向量 vs 其他章段落向量,平均余弦作为语义风格相似度
|
|
1716
1722
|
const targetChunks = allChunks.filter((ch) => ch.id.startsWith("0|"));
|
|
@@ -1818,7 +1824,7 @@ function normalizePlotEntry(entry) {
|
|
|
1818
1824
|
}
|
|
1819
1825
|
|
|
1820
1826
|
|
|
1821
|
-
/** v0.8.0
|
|
1827
|
+
/** v0.8.0 设定管理(五张表:人物/地点/道具/时间线/世界观用语规范,worldview 于 v0.9.0 加入)。 */
|
|
1822
1828
|
function settingsFile(root, book) {
|
|
1823
1829
|
return join(novelDataDir(root), "settings", sanitizeSegment(book, "book") + ".json");
|
|
1824
1830
|
}
|
|
@@ -1960,7 +1966,7 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
1960
1966
|
try {
|
|
1961
1967
|
if ((await embedding.isAvailable())) {
|
|
1962
1968
|
const emb = await import("./embedding.js");
|
|
1963
|
-
semantic = await semanticStyleDistances(text, emb);
|
|
1969
|
+
semantic = await semanticStyleDistances(text, emb, { root, book });
|
|
1964
1970
|
}
|
|
1965
1971
|
} catch { /* 语义距离失败不影响报告 */ }
|
|
1966
1972
|
// 组装报告文本
|
|
@@ -1991,8 +1997,8 @@ function registerNovelStyleReport(ctx, config) {
|
|
|
1991
1997
|
lines.push(" 意象:负 " + Math.round((implicit.negative ?? 0) * 100) + "% / 正 " + Math.round((implicit.positive ?? 0) * 100) + "% / 歧义 " + Math.round((implicit.ambiguousRatio ?? 0) * 100) + "%");
|
|
1992
1998
|
lines.push(" 隐性情绪:" + (Object.keys(q.semanticImplicit?.distribution || {}).map((k) => k + "×" + q.semanticImplicit.distribution[k]).join(" ") || "(无)"));
|
|
1993
1999
|
lines.push("");
|
|
1994
|
-
lines.push("五、氛围光谱(什么味道,0~1
|
|
1995
|
-
for (const ax of vibe.axes
|
|
2000
|
+
lines.push("五、氛围光谱(什么味道,0~1,共 12 轴)");
|
|
2001
|
+
for (const ax of vibe.axes) {
|
|
1996
2002
|
const bar = "█".repeat(Math.round(ax.score * 12)).padEnd(12, "░");
|
|
1997
2003
|
lines.push(" " + ax.name.padEnd(5) + " " + bar + " " + ax.score.toFixed(2));
|
|
1998
2004
|
}
|
|
@@ -2068,7 +2074,7 @@ function extractTopKeywords(text, topN) {
|
|
|
2068
2074
|
function registerNovelSettings(ctx, config) {
|
|
2069
2075
|
ctx.tools.register({
|
|
2070
2076
|
name: "novel_settings",
|
|
2071
|
-
description: "
|
|
2077
|
+
description: "设定管理(五张表):人物卡/地点卡/道具清单/时间线/世界观用语规范。list/add/update/delete 按 category 维护;scan 用规则扫描章节提取人物/道具候选供登记;detect 自动判断世界观文化基准。用于续写时保持人物性格、环境、道具、时间线与用语风格一致。",
|
|
2072
2078
|
parameters: {
|
|
2073
2079
|
type: "object",
|
|
2074
2080
|
properties: {
|
|
@@ -2883,12 +2889,15 @@ function registerNovelSemanticSearch(ctx, config) {
|
|
|
2883
2889
|
const text = await readTextFile(join(dir, chapter.file), exec);
|
|
2884
2890
|
for (const p of embedding.chunkText(text)) chunks.push({ ...p, id: chapter.file + "|" + p.id });
|
|
2885
2891
|
}
|
|
2886
|
-
|
|
2892
|
+
// v2.5.0 修复轮 4:内容指纹失效重建——章节更新后旧缓存不再命中(不靠版本号)
|
|
2893
|
+
const { items: cachedIndex, fp: cachedFp } = embedding.loadIndexMeta(root, book);
|
|
2894
|
+
const indexFp = embedding.fingerprint(chunks);
|
|
2895
|
+
let index = cachedIndex;
|
|
2887
2896
|
let cache = "hit";
|
|
2888
|
-
if (!index || index.length === 0) {
|
|
2897
|
+
if (!index || index.length === 0 || cachedFp !== indexFp) {
|
|
2889
2898
|
index = await embedding.embedMany(chunks);
|
|
2890
2899
|
cache = "built";
|
|
2891
|
-
embedding.saveIndex(root, book, index);
|
|
2900
|
+
embedding.saveIndex(root, book, index, indexFp);
|
|
2892
2901
|
}
|
|
2893
2902
|
const results = await embedding.search(query, index, top);
|
|
2894
2903
|
return {
|
|
@@ -3378,7 +3387,7 @@ const WORKFLOW_TEXT = [
|
|
|
3378
3387
|
"8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
|
|
3379
3388
|
" 世界观一致性:续写前先确认文化基准——novel_settings category=worldview action=detect 自动判断(或人工 add/update),动笔时对照其 bannedWords/recommended 用词,避免中西意象混搭(如欧式背景不写老夫/上香/时辰);novel_continuity_check 会扫描用语冲突候选;",
|
|
3380
3389
|
" 语用一致性:不只管词,还要管'怎么说话'——对照 worldview 的 speechStyle(title 称谓规范/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气),人物开口前检查称谓是否欧式(Miss+名)、客套是否避免'提点/承蒙/在下'、宗教仪式是否点烛而非烧香/上X柱香、对话是否口语化不文言;",
|
|
3381
|
-
" novel_settings
|
|
3390
|
+
" novel_settings 维护五张设定表(人物/地点/道具/时间线/世界观用语规范),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
|
|
3382
3391
|
" novel_sentence_analysis 结果自动缓存到书库数据目录 <root>/.novel-writer/analysis/(reportFile 字段),novel_keywords 结果同样落盘,需要重算时传 fresh=true。"
|
|
3383
3392
|
].join("\n");
|
|
3384
3393
|
|
package/lib/vibe.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* 纯规则加权 0 token;不做"贴标签",只输出数值坐标 + 组合结论 + 证据链。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
7
10
|
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
8
11
|
|
|
9
12
|
/** 解析 evidence 词频数组 → { 词: 次数 } */
|
|
@@ -47,15 +50,16 @@ function semSignals(dist) {
|
|
|
47
50
|
// v2.2.0 网文动作/对话/套路词群(参考 webnovel-writer genre-tropes 精选,封顶权重防污染)
|
|
48
51
|
const WEB_NOVEL_SIGNALS = {
|
|
49
52
|
// 词源标注:★=genre-tropes.md 现成套路词(lib/lexicons/webnovel-tropes.md),无标注=补充词
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
// v2.5.0 修复轮 7:单字词全部改双字("战/杀/血/死/慌/怕/疼/腻/查"等命中普通词会误判)
|
|
54
|
+
fluff: ["宝贝", "老婆", "亲爱的", "宠溺", "宠", "哄", "撒娇", "搂", "吻", "抱紧", "摸头", "甜", "心头一软", "小傻瓜", "乖乖", "甜宠★", "拉扯★", "白月光★"],
|
|
55
|
+
tearjerker: ["心碎", "对不起", "眼泪", "分手", "绝望", "崩溃★", "哽咽", "追妻★", "火葬场★", "求你", "别走", "心疼", "悔婚★", "虐心★", "决绝★", "卑微★", "挽回★", "拒绝★", "替身★"],
|
|
56
|
+
blaze: ["冲锋", "斩杀", "轰鸣", "燃烧", "震碎", "战意", "怒吼", "碾压", "横扫", "秒杀★", "一招", "吞噬★", "越级★", "击杀★", "暴涨★", "大比★", "秘境★", "夺宝★", "宗门★", "练气★", "筑基★", "突破", "绝技★", "凶兽★", "宝物★", "金手指★", "天才★", "觉醒★", "传承★", "武道★", "念力★", "雷电★", "火焰★", "超能力★", "医术★", "体质★", "戒指★", "老爷爷★", "重生★", "签到★"],
|
|
53
57
|
absurd: ["离谱", "无语", "笑死", "尴尬", "吐槽", "什么鬼", "疯了", "惊了", "搞什么", "还有这种", "打脸★", "装逼★", "扮猪吃虎★", "嘲讽★", "震惊★", "下跪★", "跪地★", "退婚★", "废物★", "赌石★", "透视★", "隐藏身份★", "高攀不起", "悔婚★", "看不起★"],
|
|
54
58
|
nightmare: ["诡异", "毛骨悚然", "不祥", "低语", "畸形", "腐烂", "扭曲", "窒息", "心悸", "不对劲", "渗人", "爬行", "凶兽★"],
|
|
55
|
-
angst: ["完了", "完了完了", "怎么办", "发抖", "紧张", "不安", "
|
|
59
|
+
angst: ["完了", "完了完了", "怎么办", "发抖", "紧张", "不安", "慌张", "慌乱", "害怕", "惧怕", "救命"],
|
|
56
60
|
heartwarming: ["温柔", "安心", "踏实", "轻声", "轻轻", "抚", "哄睡", "暖意", "治愈"],
|
|
57
|
-
mystery: ["线索", "真相", "谜", "疑点", "秘密", "调查", "
|
|
58
|
-
dark: ["
|
|
61
|
+
mystery: ["线索", "真相", "谜", "疑点", "秘密", "调查", "发现", "蛛丝马迹★", "身份反转★", "震撼★"],
|
|
62
|
+
dark: ["鲜血", "尸体", "死亡", "屠杀", "血泊", "杀戮", "折磨", "地狱", "残忍", "冷血"],
|
|
59
63
|
lonesome: ["一个人", "独自", "没人", "寂寞", "想家", "陌生", "空荡荡", "孤零零"],
|
|
60
64
|
aesthetic: ["月色", "清辉", "烟雨", "荷塘", "落英", "余韵", "浮光", "静默", "素净", "微凉", "风过", "细碎"],
|
|
61
65
|
sensual: ["呼吸", "发烫", "贴近", "肌肤", "颤栗", "酥麻", "灼热", "喘息", "缠绕", "柔软", "耳畔", "温存"]
|
|
@@ -140,7 +144,8 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
140
144
|
const linkThemes = (axis, base) => {
|
|
141
145
|
for (const l of THEME_AXIS_LINK) {
|
|
142
146
|
if (l.axis === axis && l.themes.some((t) => themes.includes(t) || theme.includes(t))) {
|
|
143
|
-
|
|
147
|
+
// v2.5.0 修复轮 7:题材联动必须已有基础信号(去掉 bonus>=0.25 绕过,"提了一句恐怖"不再强拉噩梦感)
|
|
148
|
+
if ((base ?? 0) >= l.needBase) push(axis, l.bonus, 1.2, "题材联动:" + l.themes[0]);
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
146
151
|
}
|
|
@@ -213,7 +218,8 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
213
218
|
linkThemes("mystery", axes.mystery?.score || 0);
|
|
214
219
|
|
|
215
220
|
// ⑧ 热血激昂
|
|
216
|
-
|
|
221
|
+
// v2.5.0 修复轮 7:战斗词改双字(单字"战/杀"命中"战战兢兢/抹杀"误判热血)
|
|
222
|
+
const fightWords = freqIn([...(ev.modern || []), ...(ev.western || []), ...(ev.eastern || [])], ["战斗", "冲锋", "斩杀", "刀光", "剑影", "铁拳", "战意", "厮杀", "刀剑", "拳风", "刀锋", "剑锋", "热血", "烈焰", "战鼓", "号角", "拔剑", "挥剑", "挥刀", "杀伐"]);
|
|
217
223
|
push("blaze", clean === "anger" ? 0.8 : 0.15, 2.5, "clean=anger");
|
|
218
224
|
push("blaze", Math.min(1, fightWords * 0.3), 1.5, "战斗词群");
|
|
219
225
|
push("blaze", pos * 0.4, 1, "意象正向");
|
|
@@ -223,7 +229,8 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
223
229
|
|
|
224
230
|
// ⑨ 荒诞无厘头(absurdWords 已在顶部定义)
|
|
225
231
|
push("absurd", Math.min(1, absurdWords * 0.12), 1.2, "网络生活词");
|
|
226
|
-
|
|
232
|
+
// v2.5.0 修复轮 7:反差信号需净化确有动作(caveat)才计,且降权(原来有污染时几乎必触发、权重偏大)
|
|
233
|
+
push("absurd", (emotion.dominant !== emotion.cleanDominant && emotion.caveat) ? 0.3 : 0.08, 1, "情绪反差(表象≠内核)");
|
|
227
234
|
if (jocular) push("absurd", 0.4, 1.2, "戏谑语气");
|
|
228
235
|
wnv("absurd", 1.1);
|
|
229
236
|
linkThemes("absurd", axes.absurd?.score || 0);
|
|
@@ -267,7 +274,6 @@ export function computeVibe(detect, emotion, text = "") {
|
|
|
267
274
|
const evidenceCount = (emotion.caveat ? 1 : 0) + (implicit.totalHits > 0 ? 1 : 0) + (Object.keys(dist).length > 0 ? 1 : 0) + (westernCount > 0 ? 1 : 0) + (modernCount > 0 ? 1 : 0) + (horror || mystery ? 1 : 0);
|
|
268
275
|
const confidence = clamp01(topScore * 0.55 + evidenceCount * 0.07);
|
|
269
276
|
|
|
270
|
-
const combo = top.map((t) => t.name).join("+");
|
|
271
277
|
// v2.5.0:不再由规则贴结论——判断交给大模型读报告
|
|
272
278
|
const conclusion = "(测量数据已输出,请由大模型结合全部维度判断风格气质)";
|
|
273
279
|
const evidence = [
|
|
@@ -297,24 +303,68 @@ export const STYLE_PROTOTYPES = {
|
|
|
297
303
|
"怀旧乡愁": ["巷口的桂花还是那个味道", "老屋的木门吱呀作响,像在说别走", "她想起很多年前的黄昏,也是这样下雨", "照片泛黄了,可那时候的笑还是真的", "故乡的月亮,总是比别处圆"],
|
|
298
304
|
"孤独疏离": ["她一个人坐在角落里,看着热气升起来", "没有人叫她的名字", "这座城很大,可没有一盏灯是为她亮的", "隔着一层雾,什么都够不着", "她习惯了把话咽回去"]
|
|
299
305
|
};
|
|
306
|
+
/** 等距抽样:超过 max 段时全书均匀取 max 段(首尾保留),避免只取开头序章失真。 */
|
|
307
|
+
function sampleEvenly(arr, max) {
|
|
308
|
+
if (arr.length <= max) return arr;
|
|
309
|
+
const out = [];
|
|
310
|
+
const step = (arr.length - 1) / (max - 1);
|
|
311
|
+
for (let i = 0; i < max; i++) out.push(arr[Math.round(i * step)]);
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 风格原型句向量内存缓存(STYLE_PROTOTYPES 固定 12 类×5 句,跨书复用,进程内一次)。 */
|
|
316
|
+
const PROTO_VEC_CACHE = new Map();
|
|
317
|
+
|
|
318
|
+
/** 语义距离按书缓存路径:<root>/.novel-writer/embedding/<书>__styleproto.json */
|
|
319
|
+
function styleProtoCachePath(root, book) {
|
|
320
|
+
const safe = String(book).replace(/[\\/:*?"<>|]/g, "_");
|
|
321
|
+
return path.join(root, ".novel-writer", "embedding", safe + "__styleproto.json");
|
|
322
|
+
}
|
|
323
|
+
|
|
300
324
|
/**
|
|
301
325
|
* 计算全书与各风格原型的语义距离表。
|
|
302
326
|
* @param {string} text 全书文本
|
|
303
|
-
* @param {object} emb embedding 模块(embed/cosine/chunkText)
|
|
327
|
+
* @param {object} emb embedding 模块(embed/cosine/chunkText/fingerprint)
|
|
328
|
+
* @param {object} [opts] 可选 { root, book }——提供后段落向量按书缓存(内容指纹失效重建,不靠版本号)
|
|
304
329
|
* @returns {Array<{name:string,score:number}>} 按相似度降序
|
|
305
330
|
*/
|
|
306
|
-
export async function semanticStyleDistances(text, emb) {
|
|
331
|
+
export async function semanticStyleDistances(text, emb, opts = {}) {
|
|
307
332
|
const results = [];
|
|
308
333
|
try {
|
|
309
334
|
if (!emb || typeof emb.embed !== "function") return results;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
335
|
+
// v2.5.0 修复轮 7:全书等距抽样 ≤60 段(原 slice(0,60) 只取开头,序章/引子会失真)
|
|
336
|
+
const allChunks = emb.chunkText ? emb.chunkText(text) : [{ text: text.slice(0, 2000) }];
|
|
337
|
+
if (allChunks.length === 0) return results;
|
|
338
|
+
const chunks = sampleEvenly(allChunks, 60);
|
|
339
|
+
const fp = typeof emb.fingerprint === "function" ? emb.fingerprint(chunks) : null;
|
|
340
|
+
// 段落向量:优先复用按书缓存(fp 匹配才命中;不匹配/缺失/损坏则重建)
|
|
341
|
+
let bookVecs = null;
|
|
342
|
+
let cacheFile = null;
|
|
343
|
+
if (opts?.root && fp) {
|
|
314
344
|
try {
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
345
|
+
cacheFile = styleProtoCachePath(opts.root, opts.book);
|
|
346
|
+
if (fs.existsSync(cacheFile)) {
|
|
347
|
+
const data = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
348
|
+
if (data.fp === fp && Array.isArray(data.bookVecs) && data.bookVecs.length === chunks.length) {
|
|
349
|
+
bookVecs = data.bookVecs;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch { /* 缓存损坏 → 重建 */ }
|
|
353
|
+
}
|
|
354
|
+
if (!bookVecs) {
|
|
355
|
+
bookVecs = [];
|
|
356
|
+
for (const ch of chunks) {
|
|
357
|
+
try {
|
|
358
|
+
const vec = await emb.embed(ch.text);
|
|
359
|
+
if (vec && vec.length > 0) bookVecs.push(vec);
|
|
360
|
+
} catch { /* 单段失败跳过 */ }
|
|
361
|
+
}
|
|
362
|
+
if (cacheFile && fp && bookVecs.length > 0) {
|
|
363
|
+
try {
|
|
364
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
365
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ fp, bookVecs }), "utf8");
|
|
366
|
+
} catch { /* 写缓存失败不影响结果 */ }
|
|
367
|
+
}
|
|
318
368
|
}
|
|
319
369
|
if (bookVecs.length === 0) return results;
|
|
320
370
|
const dim = bookVecs[0].length;
|
|
@@ -322,14 +372,18 @@ export async function semanticStyleDistances(text, emb) {
|
|
|
322
372
|
for (const vec of bookVecs) for (let i = 0; i < dim; i++) bookMean[i] += vec[i] / bookVecs.length;
|
|
323
373
|
for (const [name, sentences] of Object.entries(STYLE_PROTOTYPES)) {
|
|
324
374
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
375
|
+
let pMean = PROTO_VEC_CACHE.get(name);
|
|
376
|
+
if (!pMean || pMean.length !== dim) {
|
|
377
|
+
const pVecs = [];
|
|
378
|
+
for (const s of sentences) {
|
|
379
|
+
const vec = await emb.embed(s);
|
|
380
|
+
if (vec && vec.length === dim) pVecs.push(vec);
|
|
381
|
+
}
|
|
382
|
+
if (pVecs.length === 0) continue;
|
|
383
|
+
pMean = new Array(dim).fill(0);
|
|
384
|
+
for (const vec of pVecs) for (let i = 0; i < dim; i++) pMean[i] += vec[i] / pVecs.length;
|
|
385
|
+
PROTO_VEC_CACHE.set(name, pMean);
|
|
329
386
|
}
|
|
330
|
-
if (pVecs.length === 0) continue;
|
|
331
|
-
const pMean = new Array(dim).fill(0);
|
|
332
|
-
for (const vec of pVecs) for (let i = 0; i < dim; i++) pMean[i] += vec[i] / pVecs.length;
|
|
333
387
|
const score = emb.cosine(bookMean, pMean);
|
|
334
388
|
if (Number.isFinite(score)) results.push({ name, score: Math.round(score * 1000) / 1000 });
|
|
335
389
|
} catch { /* 单原型失败跳过 */ }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "2.5.
|
|
4
|
-
"description": "小说写作助手插件(v2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
|
+
"description": "小说写作助手插件(v2.5.5):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告(测量与判断分离)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -38,4 +38,4 @@
|
|
|
38
38
|
"onnxruntime-web": "^1.24.3"
|
|
39
39
|
},
|
|
40
40
|
"license": "MIT"
|
|
41
|
-
}
|
|
41
|
+
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
## 设定管理(v0.8.0 新增)
|
|
33
33
|
|
|
34
|
-
- §BT§novel_settings§BT
|
|
34
|
+
- §BT§novel_settings§BT§:五张表(人物卡/地点卡/道具清单/时间线/世界观用语规范),list/add/update/delete/scan/detect;
|
|
35
35
|
- 登记新人物/新地点/道具去向,时间线记录"第几天/倒计时";
|
|
36
36
|
- §BT§novel_continuity_check§BT§:续写前跑一次,输出设定矛盾候选(数字口径/人物缺场/别名/重复)。
|
|
37
37
|
|