dsh-novel-writer 0.3.0 → 0.5.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/INSTALL.md +28 -112
- package/README.md +55 -51
- package/lib/analysis.js +86 -17
- package/lib/client.js +81 -2
- package/lib/index.js +370 -39
- package/package.json +11 -30
- package/skills/novel-writing/SKILL.md +17 -11
- package/test/client-test.mjs +34 -0
- package/test/pattern-test.mjs +52 -0
package/INSTALL.md
CHANGED
|
@@ -1,123 +1,39 @@
|
|
|
1
|
-
# dsh-novel-writer v0.
|
|
1
|
+
# dsh-novel-writer v0.5.0 — 安装指南(v0.3.0 × v0.4.0 合并版)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
宿主端零第三方依赖;浏览器端依赖 Web GUI 自带的 react。
|
|
4
|
+
本包 UI:侧边栏「句式分析」大开关面板(统一开关位置);设置 > 插件配置 卡片仅显示状态 + 跳转按钮。
|
|
5
5
|
|
|
6
6
|
```
|
|
7
|
-
dsh-novel-writer-v0.
|
|
7
|
+
dsh-novel-writer-v0.5.0/
|
|
8
8
|
├── lib/
|
|
9
|
-
│ ├── index.js #
|
|
10
|
-
│ ├── analysis.js #
|
|
11
|
-
│ └── client.js #
|
|
12
|
-
├──
|
|
13
|
-
├──
|
|
14
|
-
├──
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
│ ├── index.js # 宿主端(8 个 novel_* 工具 + state 路由)
|
|
10
|
+
│ ├── analysis.js # 句式模式分析引擎(九类/情感/节奏,零依赖)
|
|
11
|
+
│ └── client.js # 浏览器端(侧边栏面板 + 设置页卡片)
|
|
12
|
+
├── test/ # 引擎与 client 契约测试
|
|
13
|
+
├── package.json
|
|
14
|
+
├── cordis.patch.yml
|
|
15
|
+
├── README.md
|
|
16
|
+
├── INSTALL.md
|
|
17
|
+
└── skills/novel-writing/SKILL.md
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## 安装
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
1. 把本文件夹放入 profile 的 node_modules(或 `dsh plugin --profile web add <路径>`);
|
|
23
|
+
2. profile `package.json` 的 `dsh.profile.bundles` 加入 `dsh-novel-writer`;
|
|
24
|
+
3. (可选)把 `skills/novel-writing` 复制到工作区 `.dsh/skills/`;
|
|
25
|
+
4. **重启 web 应用**。
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
## 验证
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
### 方式 B:手动等价操作
|
|
31
|
-
|
|
32
|
-
1. 把本文件夹(或链接)放入 profile 的 `node_modules`:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
# 在 <DSH_HOME>/profiles/<profile名> 目录下
|
|
36
|
-
npm install D:/tools/dsh-novel-writer
|
|
37
|
-
# 或手动建立 junction 链接(Windows):
|
|
38
|
-
# mklink /J <profile>\node_modules\dsh-novel-writer D:/tools/dsh-novel-writer
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
2. 编辑该 profile 的 `package.json`,在 `dsh.profile.bundles` 数组中加入:
|
|
42
|
-
|
|
43
|
-
```json
|
|
44
|
-
"dsh-novel-writer"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
3. **重启 web 应用**。宿主端注册 `novel_*` 系列工具(含新增的 novel_sentence_analysis / novel_sentence_config)
|
|
48
|
-
与 `/api/dsh-novel-writer/state` 路由;浏览器端加载 client half,侧边栏出现「句式分析」入口。
|
|
49
|
-
|
|
50
|
-
> 插件的 `cordis.patch.yml` 由 bundle 自动应用(`package.json` 中 `dsh.bundle.patch` 已声明),无需手动往 profile 的 patch 文件里加条目。
|
|
51
|
-
|
|
52
|
-
## 二、(可选)安装技能 SKILL.md
|
|
53
|
-
|
|
54
|
-
把 `skills/novel-writing` 整个文件夹复制到工作区的 `.dsh/skills/` 下:
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
<工作区>/.dsh/skills/novel-writing/SKILL.md
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
新会话中即会出现 `novel-writing` 技能(含句式模式分析工作流)。
|
|
61
|
-
|
|
62
|
-
## 三、UI 开关(v0.3.0)
|
|
63
|
-
|
|
64
|
-
Web GUI 侧边栏会出现「句式分析」入口,点击打开面板:
|
|
65
|
-
|
|
66
|
-
- **启用句式分析(enabled)**:关掉后 `novel_sentence_analysis` 返回"已关闭"状态;
|
|
67
|
-
- **自动分析(autoAnalyze)**:关掉后 AI 仅在用户明确要求时使用句式分析。
|
|
68
|
-
|
|
69
|
-
状态实时写入宿主端 `~/.dsh/dsh-novel-writer/state.json`;
|
|
70
|
-
也可在对话中让 AI 执行 `novel_sentence_config` 查看/修改。若浏览器无法访问宿主端路由
|
|
71
|
-
(例如未重启/非 web 环境),开关降级保存在浏览器 localStorage,并以提示文字告知。
|
|
72
|
-
|
|
73
|
-
## 四、准备章节库
|
|
74
|
-
|
|
75
|
-
章节库存放于**会话工作区**(或插件 config `root` 指定的目录)下的 `novels/` 文件夹:
|
|
76
|
-
|
|
77
|
-
```
|
|
78
|
-
<工作区>/novels/<书名>/
|
|
79
|
-
├── 第01章.md # 或 .txt / .markdown
|
|
80
|
-
└── 原稿件-单章-…第一章.txt # 序号在任意位置、中文数字均可识别
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
支持的文件命名与编码:
|
|
84
|
-
|
|
85
|
-
- 章号任意位置:`第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
|
|
86
|
-
- 阿拉伯数字 + 中文数字(第一章/第十四章/第三十章),自动按章号排序
|
|
87
|
-
- 编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)
|
|
88
|
-
|
|
89
|
-
## 五、验证安装
|
|
90
|
-
|
|
91
|
-
安装并重启后,在会话中应能看到以下工具:`novel_books`、`novel_chapters`、
|
|
92
|
-
`novel_read`、`novel_keywords`、`novel_new_chapter`、`novel_sentence_analysis`、
|
|
93
|
-
`novel_sentence_config`。可先执行 `novel_books` 确认能列出书库,再执行
|
|
94
|
-
`novel_sentence_analysis` 验证句式分析。
|
|
95
|
-
|
|
96
|
-
若 `root` 需要指向其他目录(默认会话工作区),在 profile 的 `cordis.patch.yml` 中覆盖:
|
|
97
|
-
|
|
98
|
-
```yaml
|
|
99
|
-
- patch:
|
|
100
|
-
- id: novel-writer
|
|
101
|
-
config:
|
|
102
|
-
root: 'D:/我的小说库'
|
|
103
|
-
sentenceAnalysis:
|
|
104
|
-
enabled: true
|
|
105
|
-
autoAnalyze: true
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## 版本记录
|
|
111
|
-
|
|
112
|
-
### v0.3.0(本分发件)
|
|
113
|
-
- **新增**:句式模式分析引擎(lib/analysis.js):陈述/对话/心理/疑问/反问/感叹/祈使/省略留白 的分布、
|
|
114
|
-
排列规律(转移/模板/段首段尾)、句长节奏、情感曲线(喜/怒/哀/惧/惊)与风格指纹;
|
|
115
|
-
- **新增**:novel_sentence_analysis / novel_sentence_config 工具;
|
|
116
|
-
- **新增**:Web UI 开关(侧边栏「句式分析」面板,client half)+ 宿主端 /api/dsh-novel-writer/state 路由;
|
|
117
|
-
- 更新系统提示词与 SKILL.md 的句式分析工作流。
|
|
29
|
+
- 工具列表出现:novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter /
|
|
30
|
+
novel_import / novel_sentence_analysis / novel_sentence_config;
|
|
31
|
+
- 侧边栏出现「句式分析」入口;设置 > 插件配置 出现 novel-writer 状态卡片;
|
|
32
|
+
- `GET /api/dsh-novel-writer/state` 返回 200 JSON。
|
|
118
33
|
|
|
119
|
-
|
|
120
|
-
- 修复章节序号提取(任意位置、中文数字);UTF-8/UTF-16/GBK 编码自动探测;37 章全量压力测试通过。
|
|
34
|
+
## 开关
|
|
121
35
|
|
|
122
|
-
|
|
123
|
-
-
|
|
36
|
+
- 默认开启;**唯一开关位置:侧边栏「句式分析」面板**(总开关 enabled + 子开关 autoAnalyze);
|
|
37
|
+
- 设置 > 插件配置 卡片仅显示状态并提供「打开开关面板」跳转;
|
|
38
|
+
- 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容读取 `~/.dsh/novel-writer.json`(v0.4.0 stylePattern);
|
|
39
|
+
- config 支持 `sentenceAnalysis.enabled / autoAnalyze` 与 `stylePattern`(v0.4.0 兼容)。
|
package/README.md
CHANGED
|
@@ -1,79 +1,83 @@
|
|
|
1
|
-
# dsh-novel-writer — 小说写作助手插件(v0.
|
|
1
|
+
# dsh-novel-writer — 小说写作助手插件(v0.5.0 合并版)
|
|
2
2
|
|
|
3
3
|
DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
|
|
4
|
-
浏览器端仅依赖 Web GUI 自带的 react
|
|
5
|
-
v0.3.0
|
|
4
|
+
浏览器端仅依赖 Web GUI 自带的 react。
|
|
5
|
+
**v0.5.0 = v0.3.0(深度句式分析 + 侧边栏开关)与 v0.4.0(批量导入 + 设置页卡片)的合并版**。
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
9
|
1. **章节库**:章节存于 `novels/<书名>/第N章.md`(或 `.txt`/`.markdown`),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
2
|
|
16
|
-
3. **句式模式分析(v0.3.0 新增)**:`novel_sentence_analysis` 对全书/单章输出——
|
|
17
|
-
- 句式分布:陈述 / 对话 / 心理 / 疑问 / 反问 / 感叹 / 祈使 / 省略留白(比例、句数、均长、例句);
|
|
18
|
-
- 排列规律:句式转移、2/3 连句高频模板、段首/段尾句式、纯对话段/纯心理段/混合段统计;
|
|
10
|
+
`novel_books` / `novel_chapters` / `novel_read` 浏览与读取。
|
|
11
|
+
章号任意位置 + 中文数字识别;UTF-8 / UTF-16 LE/BE / GBK 编码自动探测。
|
|
12
|
+
2. **分析**:`novel_keywords` 提取全书/单章高频关键词(中文二字词组 + 英文词)。
|
|
13
|
+
3. **句式模式分析**:`novel_sentence_analysis` 对全书/单章输出——
|
|
14
|
+
- 九类句式:陈述 / 环境 / 心理 / 对话 / 疑问 / 反问 / 感叹 / 祈使 / 省略留白(比例、句数、均长、例句);
|
|
15
|
+
- 排列规律:句式转移、2/3 连句高频模板、段首/段尾句式、**按章节的压缩节奏序列**(S×8 ENV×2 PSY×3…);
|
|
19
16
|
- 句长节奏:均值/中位数、短句/长句占比;
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
5. **续写**:系统提示词规定续写工作流(先读后写、保持文风与伏笔一致),
|
|
28
|
-
`novel_new_chapter` 创建新章节文件。
|
|
17
|
+
- 主观情感:情感词典(喜/怒/哀/惧/惊)+ 强度副词加权 → 主导情绪、情绪分布、情感曲线、主观性指数;
|
|
18
|
+
- 风格指纹 + 给模型的**节奏建议**(guidance)。
|
|
19
|
+
⚠️ 风险约束:句式模式是"参考节奏"而非"模板套用",模型在机械复刻导致僵硬时会优先回归自然表达。
|
|
20
|
+
4. **原稿件批量导入**:`novel_import` 扫描存放多本小说的文件夹(支持子文件夹、多编码、任意位置章号),
|
|
21
|
+
从文件名与文件头双通道提取书名候选并分组(scan 预览),AI 确认后一键复制/移动到 `novels/<书名>/`(apply);
|
|
22
|
+
支持 `book` 强制归并异名同书、`files` 精确指定、`move` 移动模式。
|
|
23
|
+
5. **续写**:先读后写、保持文风与伏笔一致,`novel_new_chapter` 创建新章节文件。
|
|
29
24
|
|
|
30
25
|
## 提供的工具
|
|
31
26
|
|
|
32
27
|
| 工具 | 说明 |
|
|
33
28
|
|---|---|
|
|
34
|
-
| `novel_books` |
|
|
35
|
-
| `novel_chapters` |
|
|
36
|
-
| `novel_read` |
|
|
37
|
-
| `novel_keywords` |
|
|
38
|
-
| `novel_new_chapter` |
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
29
|
+
| `novel_books` | 列出章节库全部作品 |
|
|
30
|
+
| `novel_chapters` | 列出某作品章节清单 |
|
|
31
|
+
| `novel_read` | 阅读某章正文(offset/limit 分段) |
|
|
32
|
+
| `novel_keywords` | 提取高频关键词 |
|
|
33
|
+
| `novel_new_chapter` | 创建新章节文件 |
|
|
34
|
+
| `novel_import` | **原稿件批量导入/自动分类(v0.4.0)** |
|
|
35
|
+
| `novel_sentence_analysis` | **句式模式分析(v0.3.0 深度 + v0.4.0 节奏)** |
|
|
36
|
+
| `novel_sentence_config` | 查看/修改句式分析开关 |
|
|
37
|
+
|
|
38
|
+
## UI 开关(统一在一处)
|
|
39
|
+
|
|
40
|
+
- **唯一开关位置:侧边栏「句式分析」面板**——状态横幅 + **总开关「启用句式分析」**(enabled)+ 子开关「分析作品时自动使用」(autoAnalyze);
|
|
41
|
+
- **设置 > 插件配置** 的 novel-writer 卡片仅**显示状态**(已启用/已关闭、自动分析开/关),并提供「打开开关面板」按钮跳转;
|
|
42
|
+
- 也可让 AI 用 `novel_sentence_config` 查看/修改。
|
|
43
|
+
- 状态写入 `~/.dsh/dsh-novel-writer/state.json`;同时**只读兼容** v0.4.0 的 `~/.dsh/novel-writer.json`(stylePattern)与 config.stylePattern。
|
|
44
|
+
- 开关默认**开启**;宿主端路由 `/api/dsh-novel-writer/state`(loopback 围栏 + 可选注入,headless 自动跳过)。
|
|
41
45
|
|
|
42
46
|
## 安装(web profile)
|
|
43
47
|
|
|
44
48
|
```sh
|
|
45
|
-
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
49
|
+
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
46
50
|
```
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
`dsh.profile.bundles` 中加入 `dsh-novel-writer`。安装后**重启 web
|
|
50
|
-
宿主端注册新工具与 `/api/dsh-novel-writer/state` 路由,浏览器端挂载侧边栏「句式分析」开关。
|
|
52
|
+
或手动:把本文件夹放入 profile 的 `node_modules`,并在 profile `package.json` 的
|
|
53
|
+
`dsh.profile.bundles` 中加入 `dsh-novel-writer`。安装后**重启 web 应用**生效。
|
|
51
54
|
|
|
52
55
|
## 配置
|
|
53
56
|
|
|
54
|
-
插件 config 支持 `root`(章节库根目录)与 `sentenceAnalysis`(默认开关):
|
|
55
|
-
|
|
56
57
|
```yaml
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
- id: novel-writer
|
|
59
|
+
config:
|
|
60
|
+
root: 'D:/我的小说库'
|
|
61
|
+
sentenceAnalysis:
|
|
62
|
+
enabled: true
|
|
63
|
+
autoAnalyze: true
|
|
64
|
+
# stylePattern: true # v0.4.0 兼容写法(任一生效即开启)
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
## 版本记录
|
|
67
68
|
|
|
68
|
-
### v0.
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
|
|
69
|
+
### v0.5.0(合并版)
|
|
70
|
+
- 合并 v0.3.0 与 v0.4.0:九类句式(新增"环境")、章节压缩节奏序列、guidance 节奏建议、采样上限;
|
|
71
|
+
- 新增 novel_import 批量导入(v0.4.0);开关同时出现在侧边栏面板与官方设置页;
|
|
72
|
+
- 路由改可选注入 ctx.inject(["webServer"]),headless 兼容;保留 loopback 围栏;
|
|
73
|
+
- 兼容读取 v0.4.0 的 novel-writer.json / config.stylePattern;提示词内置"僵硬时优先自然表达"约束。
|
|
74
|
+
|
|
75
|
+
### v0.4.0(他人改版,已并入)
|
|
76
|
+
- novel_import 批量导入自动分类;novel_style_pattern 轻量句式分析(环境类/压缩序列/guidance);
|
|
77
|
+
- 官方设置页插件配置卡片;ctx.inject 可选注入;开关默认关闭 + 风险提示。
|
|
74
78
|
|
|
75
|
-
### v0.
|
|
76
|
-
-
|
|
79
|
+
### v0.3.0(本系列)
|
|
80
|
+
- 句式模式分析深度版(情感曲线/句长/段落/指纹);侧边栏开关面板;state 路由。
|
|
77
81
|
|
|
78
|
-
### v0.1.0
|
|
79
|
-
-
|
|
82
|
+
### v0.2.0 / v0.1.0
|
|
83
|
+
- 章节库、关键词、新建章节;编码探测。
|
package/lib/analysis.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-novel-writer — 句式模式分析引擎 (v0.
|
|
2
|
+
* dsh-novel-writer — 句式模式分析引擎 (v0.5.0 合并版)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
4
|
+
* 融合 v0.3.0(深度分析)与 v0.4.0(轻量节奏参考)两版引擎:
|
|
5
|
+
* - 九类句式:陈述 / 环境 / 心理 / 对话 / 疑问 / 反问 / 感叹 / 祈使 / 省略留白
|
|
6
|
+
* - 排列规律:句式转移、2/3 连句模板、段首段尾句式、按章节的压缩排列序列(跑长编码)
|
|
7
|
+
* - 风格特征:句长分布、短长句占比、对话/心理/环境密度、主观性指数、风格指纹
|
|
8
|
+
* - 主观情感:轻量情感词典(喜/怒/哀/惧/惊)+ 强度副词加权,输出情感曲线
|
|
9
|
+
* - 节奏建议:给模型的 guidance 文本(句式分布 + 高频组合 + 章节节奏序列)
|
|
10
|
+
* - 采样上限:maxSentences 保护超长文本(默认 20000 句)
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* 零依赖、确定性规则,不调用任何外部服务。
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
export const CATEGORY_LABELS = Object.freeze({
|
|
15
16
|
statement: "陈述",
|
|
16
|
-
|
|
17
|
+
environment: "环境",
|
|
17
18
|
psychology: "心理",
|
|
19
|
+
dialogue: "对话",
|
|
18
20
|
question: "疑问",
|
|
19
21
|
"rhetoric-question": "反问",
|
|
20
22
|
exclamation: "感叹",
|
|
@@ -24,10 +26,16 @@ export const CATEGORY_LABELS = Object.freeze({
|
|
|
24
26
|
|
|
25
27
|
/** 输出固定顺序。 */
|
|
26
28
|
export const CATEGORY_ORDER = Object.freeze([
|
|
27
|
-
"statement", "
|
|
29
|
+
"statement", "environment", "psychology", "dialogue", "question",
|
|
28
30
|
"rhetoric-question", "exclamation", "imperative", "ellipsis"
|
|
29
31
|
]);
|
|
30
32
|
|
|
33
|
+
/** 短码(压缩序列/指纹用)。 */
|
|
34
|
+
export const TYPE_CODE = Object.freeze({
|
|
35
|
+
statement: "S", environment: "ENV", psychology: "PSY", dialogue: "DLG", question: "Q",
|
|
36
|
+
"rhetoric-question": "RQ", exclamation: "EX", imperative: "IMP", ellipsis: "…"
|
|
37
|
+
});
|
|
38
|
+
|
|
31
39
|
const EMOTION_LABELS = Object.freeze({
|
|
32
40
|
joy: "喜", anger: "怒", sorrow: "哀", fear: "惧", surprise: "惊", neutral: "中性"
|
|
33
41
|
});
|
|
@@ -60,7 +68,7 @@ const PSYCH_MARKERS = [
|
|
|
60
68
|
/心里|内心|心底|心头/
|
|
61
69
|
];
|
|
62
70
|
|
|
63
|
-
/**
|
|
71
|
+
/** 反问强标记(与疑问区分)。 */
|
|
64
72
|
const RHETORIC_MARKERS = [
|
|
65
73
|
/难道|岂不|岂非|岂能|岂敢|岂止|何尝|何曾|何必|何须|何苦|何不|谈何|莫非|岂|不成/,
|
|
66
74
|
/哪(能|敢|会|曾|配|有|里)/,
|
|
@@ -77,6 +85,9 @@ const QUESTION_WORDS = ["谁", "什么", "为什么", "怎么", "怎样", "如
|
|
|
77
85
|
const IMPERATIVE_HARD = ["请", "快", "赶紧", "立刻", "马上", "别", "不要", "莫", "勿", "不许", "不准", "滚", "住手", "放手", "站住", "听我说", "给我", "让", "快点儿", "快点"];
|
|
78
86
|
const IMPERATIVE_START = ["走", "来", "去", "坐", "站", "停", "开始", "出发", "算了吧", "算了", "吃", "喝", "看", "听", "说", "做", "干", "起来", "坐下", "住口"];
|
|
79
87
|
|
|
88
|
+
/** 环境描写标记词(天色/天气/景物类名词,来自 v0.4.0)。 */
|
|
89
|
+
const ENV_MARKERS = /夜色|月光|晨光|暮色|夕阳|黄昏|清晨|深夜|夜空|天边|星光|灯火|细雨|微风|大雨|暴雨|风雪|薄雾|浓雾|云层|远山|山影|河面|湖水|街道|巷子|烛火|窗|雨声|风声|树影|落叶|残雪|霜|天色/;
|
|
90
|
+
|
|
80
91
|
/** 句尾结束符。 */
|
|
81
92
|
const TERMINATORS = "。!?…";
|
|
82
93
|
|
|
@@ -202,7 +213,10 @@ function isImperative(text) {
|
|
|
202
213
|
return new RegExp("^(你|您|我们|咱们|大家)?(" + IMPERATIVE_START.join("|") + ")").test(text);
|
|
203
214
|
}
|
|
204
215
|
|
|
205
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* 单句分类(优先级:对话 > 心理 > 反问 > 疑问 > 感叹 > 祈使 > 环境 > 省略 > 陈述)。
|
|
218
|
+
* 引语优先:引号内的直接引语算对话;引号内含心理标记(内心独白)算心理。
|
|
219
|
+
*/
|
|
206
220
|
export function classifySentence(raw) {
|
|
207
221
|
const text = String(raw).trim();
|
|
208
222
|
if (text === "") return { type: "statement", text, quoted: false };
|
|
@@ -217,11 +231,12 @@ export function classifySentence(raw) {
|
|
|
217
231
|
if (isQuestion(text)) return { type: "question", text, quoted: false };
|
|
218
232
|
if (isExclamation(text)) return { type: "exclamation", text, quoted: false };
|
|
219
233
|
if (isImperative(text)) return { type: "imperative", text, quoted: false };
|
|
234
|
+
if (ENV_MARKERS.test(text)) return { type: "environment", text, quoted: false };
|
|
220
235
|
if (/…+$/.test(text)) return { type: "ellipsis", text, quoted: false };
|
|
221
236
|
return { type: "statement", text, quoted: false };
|
|
222
237
|
}
|
|
223
238
|
|
|
224
|
-
/**
|
|
239
|
+
/** 情感词计数(含强度副词加权)。 */
|
|
225
240
|
function emotionOf(text) {
|
|
226
241
|
const scores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
|
|
227
242
|
const words = { joy: [], anger: [], sorrow: [], fear: [], surprise: [] };
|
|
@@ -264,21 +279,57 @@ function median(values) {
|
|
|
264
279
|
return sorted.length % 2 === 1 ? sorted[mid] : round((sorted[mid - 1] + sorted[mid]) / 2, 2);
|
|
265
280
|
}
|
|
266
281
|
|
|
282
|
+
/** 跑长编码压缩排列序列(来自 v0.4.0):S×8 ENV×2 PSY×3 … */
|
|
283
|
+
export function compressSequence(codes, maxRuns = 48) {
|
|
284
|
+
const runs = [];
|
|
285
|
+
for (const code of codes) {
|
|
286
|
+
const last = runs[runs.length - 1];
|
|
287
|
+
if (last !== void 0 && last.code === code) last.n += 1;
|
|
288
|
+
else runs.push({ code, n: 1 });
|
|
289
|
+
}
|
|
290
|
+
const parts = runs.slice(0, maxRuns).map((run) => run.code + "×" + run.n);
|
|
291
|
+
if (runs.length > maxRuns) parts.push("…(共" + runs.length + "段)");
|
|
292
|
+
return parts.join(" ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 生成给模型的节奏建议文本(来自 v0.4.0 buildGuidance,适配九类)。 */
|
|
296
|
+
export function buildGuidance(ratios, topPatterns, chapterSequences) {
|
|
297
|
+
const ratioText = CATEGORY_ORDER
|
|
298
|
+
.filter((code) => (ratios[code] ?? 0) > 0)
|
|
299
|
+
.map((code) => TYPE_CODE[code] + " " + Math.round((ratios[code] ?? 0) * 100) + "%")
|
|
300
|
+
.join(" · ");
|
|
301
|
+
const patternText = topPatterns.length > 0
|
|
302
|
+
? topPatterns.slice(0, 5).map((p) => p.pattern + " ×" + p.count).join("、")
|
|
303
|
+
: "(样本过短,未形成高频组合)";
|
|
304
|
+
const lines = [
|
|
305
|
+
"句式分布:" + ratioText,
|
|
306
|
+
"高频句式组合:" + patternText
|
|
307
|
+
];
|
|
308
|
+
if (chapterSequences.length > 0) {
|
|
309
|
+
lines.push("章节节奏(压缩序列," + CATEGORY_ORDER.map((code) => TYPE_CODE[code] + "=" + CATEGORY_LABELS[code]).join(" ") + "):");
|
|
310
|
+
for (const item of chapterSequences.slice(0, 12)) lines.push(" " + item.chapter + ": " + item.sequence);
|
|
311
|
+
}
|
|
312
|
+
return lines.join("\n");
|
|
313
|
+
}
|
|
314
|
+
|
|
267
315
|
/**
|
|
268
|
-
*
|
|
316
|
+
* 全书/单章句式模式分析主入口(v0.5.0 合并版)。
|
|
269
317
|
* @param text 正文文本。
|
|
270
|
-
* @param options { top:
|
|
318
|
+
* @param options { top: 句式模板条数(默认 8), maxSentences: 采样上限(默认 20000), chapterTexts: [{chapter, text}] 可选分章输入 }
|
|
271
319
|
* @returns 结构化分析结果(与 novel_sentence_analysis 输出 schema 一致)。
|
|
272
320
|
*/
|
|
273
321
|
export function analyzeText(text, options = {}) {
|
|
274
322
|
const top = Number.isInteger(options.top) ? options.top : 8;
|
|
323
|
+
const maxSentences = Number.isInteger(options.maxSentences) ? options.maxSentences : 20000;
|
|
275
324
|
const blocks = splitBlocks(text);
|
|
276
325
|
const sentences = [];
|
|
277
326
|
const blockMeta = [];
|
|
278
327
|
for (let b = 0; b < blocks.length; b += 1) {
|
|
328
|
+
if (sentences.length >= maxSentences) break;
|
|
279
329
|
const parts = splitSentences(blocks[b]);
|
|
280
330
|
const meta = { sentences: [], opening: void 0, closing: void 0 };
|
|
281
331
|
for (const part of parts) {
|
|
332
|
+
if (sentences.length >= maxSentences) break;
|
|
282
333
|
const classified = classifySentence(part);
|
|
283
334
|
const record = {
|
|
284
335
|
text: part,
|
|
@@ -472,10 +523,12 @@ export function analyzeText(text, options = {}) {
|
|
|
472
523
|
|
|
473
524
|
const dialogueRatio = totalSentences === 0 ? 0 : counts.dialogue / totalSentences;
|
|
474
525
|
const psychologyRatio = psychRatio;
|
|
526
|
+
const environmentRatio = totalSentences === 0 ? 0 : counts.environment / totalSentences;
|
|
475
527
|
const questionRatio = totalSentences === 0 ? 0 : (counts.question + counts["rhetoric-question"]) / totalSentences;
|
|
476
528
|
const style = {
|
|
477
529
|
dialogueRatio: round(dialogueRatio, 4),
|
|
478
530
|
psychologyRatio: round(psychologyRatio, 4),
|
|
531
|
+
environmentRatio: round(environmentRatio, 4),
|
|
479
532
|
questionRatio: round(questionRatio, 4),
|
|
480
533
|
exclamationRatio: round(exclaimRatio, 4),
|
|
481
534
|
shortSentenceRatio: lengths.shortRatio,
|
|
@@ -486,6 +539,21 @@ export function analyzeText(text, options = {}) {
|
|
|
486
539
|
firstPersonDensity: round(firstPersonDensity, 2)
|
|
487
540
|
};
|
|
488
541
|
|
|
542
|
+
// 分章节奏序列(可选:调用方传入各章文本)
|
|
543
|
+
const chapterPatterns = [];
|
|
544
|
+
if (Array.isArray(options.chapterTexts)) {
|
|
545
|
+
for (const item of options.chapterTexts) {
|
|
546
|
+
const chapterCodes = splitSentences(String(item.text)).slice(0, maxSentences).map((s) => classifySentence(s).type);
|
|
547
|
+
chapterPatterns.push({
|
|
548
|
+
chapter: String(item.chapter),
|
|
549
|
+
sequence: compressSequence(chapterCodes.map((type) => TYPE_CODE[type]))
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const ratios = Object.fromEntries(CATEGORY_ORDER.map((type) => [type, totalSentences === 0 ? 0 : round(counts[type] / totalSentences, 4)]));
|
|
555
|
+
const guidance = buildGuidance(ratios, motifs, chapterPatterns);
|
|
556
|
+
|
|
489
557
|
const fingerprint = buildFingerprint(categories, lengths, style, emotion, motifs);
|
|
490
558
|
|
|
491
559
|
return {
|
|
@@ -498,6 +566,8 @@ export function analyzeText(text, options = {}) {
|
|
|
498
566
|
lengths,
|
|
499
567
|
style,
|
|
500
568
|
emotion,
|
|
569
|
+
chapterPatterns,
|
|
570
|
+
guidance,
|
|
501
571
|
fingerprint
|
|
502
572
|
};
|
|
503
573
|
}
|
|
@@ -542,8 +612,7 @@ function emotionCurve(blockMeta, maxSegments) {
|
|
|
542
612
|
|
|
543
613
|
/** 一维风格指纹。 */
|
|
544
614
|
function buildFingerprint(categories, lengths, style, emotion, motifs) {
|
|
545
|
-
const
|
|
546
|
-
const parts = categories.map((category) => codeOf[category.type] + ":" + Math.round(category.ratio * 1000) / 10);
|
|
615
|
+
const parts = categories.map((category) => TYPE_CODE[category.type] + ":" + Math.round(category.ratio * 1000) / 10);
|
|
547
616
|
const motif = motifs.length > 0 ? motifs[0].pattern : "-";
|
|
548
617
|
return parts.join(" ") + " | len:" + lengths.avg + " subj:" + style.subjectivityIndex + " emo:" + emotion.dominant + " | motif:" + motif;
|
|
549
618
|
}
|
package/lib/client.js
CHANGED
|
@@ -91,7 +91,14 @@ window.__ModuleLoader__.load({
|
|
|
91
91
|
"banner.off": "句式分析已关闭",
|
|
92
92
|
"banner.offSub": "AI 不会执行句式分析",
|
|
93
93
|
"badge.on": "已开启",
|
|
94
|
-
"badge.off": "已关闭"
|
|
94
|
+
"badge.off": "已关闭",
|
|
95
|
+
"card.title": "小说写作助手 novel-writer",
|
|
96
|
+
"card.desc": "句式模式分析:分析原文陈述/环境/心理/对话/疑问/反问/感叹等句子的排列节奏来辅助模仿文风。⚠️ 若机械套用导致文风僵硬,模型会优先回归自然表达。",
|
|
97
|
+
"card.status": "句式模式分析:已启用 · 自动分析:开",
|
|
98
|
+
"card.statusOff": "句式模式分析:已关闭",
|
|
99
|
+
"card.statusAutoOff": "句式模式分析:已启用 · 自动分析:关",
|
|
100
|
+
"card.hint": "开关统一在侧边栏「句式分析」面板(也可让 AI 用 novel_sentence_config 调整),本卡片仅显示状态。",
|
|
101
|
+
"card.open": "打开开关面板"
|
|
95
102
|
};
|
|
96
103
|
var en = {
|
|
97
104
|
"entry.label": "Sentence Analysis",
|
|
@@ -113,7 +120,14 @@ window.__ModuleLoader__.load({
|
|
|
113
120
|
"banner.off": "Sentence analysis is OFF",
|
|
114
121
|
"banner.offSub": "the AI will not run it",
|
|
115
122
|
"badge.on": "ON",
|
|
116
|
-
"badge.off": "OFF"
|
|
123
|
+
"badge.off": "OFF",
|
|
124
|
+
"card.title": "Novel Writer (dsh-novel-writer)",
|
|
125
|
+
"card.desc": "Sentence-pattern analysis: reads the rhythm of statements/environment/inner-thought/dialogue/questions etc. to help mimic the author's style. ⚠️ If mechanical imitation stiffens the prose, natural expression wins.",
|
|
126
|
+
"card.status": "Sentence analysis: ON · auto-analyze: ON",
|
|
127
|
+
"card.statusOff": "Sentence analysis: OFF",
|
|
128
|
+
"card.statusAutoOff": "Sentence analysis: ON · auto-analyze: OFF",
|
|
129
|
+
"card.hint": "Switches live in the sidebar 'Sentence Analysis' panel (or ask the AI to run novel_sentence_config); this card only shows status.",
|
|
130
|
+
"card.open": "Open the switch panel"
|
|
117
131
|
};
|
|
118
132
|
function dictionary() {
|
|
119
133
|
return (typeof document !== "undefined" && (document.documentElement.lang || "zh").toLowerCase().startsWith("en")) ? en : zh;
|
|
@@ -332,6 +346,56 @@ window.__ModuleLoader__.load({
|
|
|
332
346
|
react.createElement("div", { className: "nwFoot" }, t("panel.foot"))
|
|
333
347
|
);
|
|
334
348
|
}
|
|
349
|
+
/** 官方设置页「插件配置」槽位卡片(v0.5.0 统一版):状态只读 + 跳转按钮,开关只在侧边栏面板。 */
|
|
350
|
+
function NovelWriterSettingsCard(props) {
|
|
351
|
+
var state = props.controller.getSnapshot();
|
|
352
|
+
var force = react.useState(0)[1];
|
|
353
|
+
react.useEffect(function () {
|
|
354
|
+
return props.controller.subscribe(function () { force(function (n) { return n + 1; }); });
|
|
355
|
+
}, []);
|
|
356
|
+
state = props.controller.getSnapshot();
|
|
357
|
+
var statusKey = state.enabled ? (state.autoAnalyze ? "card.status" : "card.statusAutoOff") : "card.statusOff";
|
|
358
|
+
return react.createElement(
|
|
359
|
+
"div",
|
|
360
|
+
{ style: { padding: "12px 0", borderBottom: "1px solid var(--dsw-alias-border-l2, #e5e7eb)" } },
|
|
361
|
+
react.createElement("div", { style: { fontWeight: 600 } }, t("card.title")),
|
|
362
|
+
react.createElement("div", { style: { color: "var(--dsw-alias-text-l2, #888)", fontSize: 12, margin: "4px 0 8px" } }, t("card.desc")),
|
|
363
|
+
react.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, marginTop: 4 } },
|
|
364
|
+
react.createElement(
|
|
365
|
+
"span",
|
|
366
|
+
{
|
|
367
|
+
style: {
|
|
368
|
+
display: "inline-flex", alignItems: "center", gap: 6,
|
|
369
|
+
fontSize: 12, fontWeight: 700, lineHeight: 1,
|
|
370
|
+
padding: "4px 10px", borderRadius: 999,
|
|
371
|
+
background: state.enabled ? "rgba(34,197,94,.14)" : "rgba(239,68,68,.10)",
|
|
372
|
+
color: state.enabled ? "#15803d" : "#b91c1c",
|
|
373
|
+
border: "1px solid " + (state.enabled ? "#22c55e" : "#ef4444")
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
react.createElement("span", null, state.enabled ? "●" : "○"),
|
|
377
|
+
react.createElement("span", null, t(statusKey))
|
|
378
|
+
),
|
|
379
|
+
react.createElement(
|
|
380
|
+
"button",
|
|
381
|
+
{
|
|
382
|
+
type: "button",
|
|
383
|
+
style: {
|
|
384
|
+
appearance: "none", font: "inherit", cursor: "pointer",
|
|
385
|
+
border: "1px solid var(--dsw-alias-border-l2, #d1d5db)", borderRadius: 8,
|
|
386
|
+
background: "var(--dsw-alias-bg-layer-3, #fff)", color: "inherit",
|
|
387
|
+
padding: "5px 12px", fontSize: 12
|
|
388
|
+
},
|
|
389
|
+
onClick: function () {
|
|
390
|
+
if (typeof props.onOpenPanel === "function") props.onOpenPanel();
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
t("card.open")
|
|
394
|
+
)
|
|
395
|
+
),
|
|
396
|
+
react.createElement("div", { style: { color: "var(--dsw-alias-text-l2, #888)", fontSize: 12, marginTop: 8 } }, t("card.hint"))
|
|
397
|
+
);
|
|
398
|
+
}
|
|
335
399
|
function mountPanel(controller, toggle) {
|
|
336
400
|
var container;
|
|
337
401
|
var root;
|
|
@@ -467,6 +531,21 @@ window.__ModuleLoader__.load({
|
|
|
467
531
|
} catch (error) {
|
|
468
532
|
console.warn("[dsh-novel-writer] UI mount failed:", error);
|
|
469
533
|
}
|
|
534
|
+
// 官方设置页「插件配置」卡片(v0.4.0 合并;slots 服务由 inject 声明)
|
|
535
|
+
try {
|
|
536
|
+
ctx.slots.inject("settings.plugin.item", function () {
|
|
537
|
+
return ctx.slots.register({
|
|
538
|
+
name: "settings.plugin.item",
|
|
539
|
+
id: "novel-writer-config",
|
|
540
|
+
order: 105,
|
|
541
|
+
inject: function () { return {}; }
|
|
542
|
+
}, function () {
|
|
543
|
+
return react.createElement(NovelWriterSettingsCard, { controller: controller, toggle: toggle, onOpenPanel: openPanel });
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
} catch (error) {
|
|
547
|
+
console.warn("[dsh-novel-writer] settings card mount failed:", error);
|
|
548
|
+
}
|
|
470
549
|
ctx.effect?.(function () {
|
|
471
550
|
return function () {
|
|
472
551
|
for (var i = 0; i < disposers.length; i += 1) {
|