dsh-novel-writer 0.2.0 → 0.4.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.md CHANGED
@@ -1,132 +1,78 @@
1
- # 📚 dsh-novel-writer — 小说写作助手 / Novel Writing Assistant
1
+ # dsh-novel-writer — 小说写作助手插件
2
2
 
3
- **中文** | [**English**](#english)
3
+ DSH(DeepSeek Harness)小说写作助手 bundle 插件。零第三方依赖(仅 Node 内置模块),
4
+ 为 AI 提供小说章节库管理、分析与续写辅助能力。
4
5
 
5
- 一个零第三方依赖(仅 Node 内置模块)的 **DeepSeek Harness (DSH / Cordis) 插件**,为 AI 提供小说章节库管理、剧情分析与续写辅助的能力。
6
+ ## 功能(对应需求)
6
7
 
7
- A **zero-dependency** (Node built-ins only) **DeepSeek Harness (DSH / Cordis) plugin** that gives the AI novel-chapter-library management, plot analysis, and continuation-writing assistance.
8
-
9
- ---
10
-
11
- ## 功能 / Features
12
-
13
- ### 🇨🇳 中文
14
-
15
- 1. **章节库管理**:章节存放于 `novels/<书名>/第N章.md`(也支持 `.txt` / `.markdown`),
16
- 插件提供 `novel_books` / `novel_chapters` / `novel_read` 让你(AI)浏览库、列出章节并逐章阅读。
17
- 2. **智能章号识别**:章节文件名中的序号支持**任意位置**与多种写法,
18
- 例如 `第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`,
19
- 阿拉伯数字与中文数字(第一章/第十四章/第三十章)均可识别,并自动按章号排序。
20
- 3. **编码自动探测**:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)均可直接读取,无需手动转码(如记事本另存的 Unicode 文本)。
21
- 4. **关键词分析**:`novel_keywords` 确定性提取全书/单章高频关键词(中文相邻二字词组 + 高频单字 + 英文词),
8
+ 1. **章节库**:章节存于 `novels/<书名>/第N章.md`(或 `.txt`/`.markdown`),
9
+ 插件提供 `novel_books` / `novel_chapters` / `novel_read` 让 AI 浏览并读取每一章内容。
10
+ 章节文件名中的序号支持任意位置与多种写法,例如:
11
+ `第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
12
+ (阿拉伯数字与中文数字"第一章/第十四章/第三十章"均可识别,自动按章号排序)。
13
+ 文件编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式识别)、GBK/GB18030(无 BOM),
14
+ 无需手动转码即可读取(如记事本另存的 Unicode 文本)。
15
+ 2. **分析**:`novel_keywords` 确定性提取全书/单章高频关键词(中文相邻二字词组 + 高频单字 + 英文词),
22
16
  配合系统提示词引导 AI 分析剧情脉络、写作手法、词汇偏好与意象母题。
23
- 5. **续写辅助**:系统提示词规定续写工作流(先读后写、保持文风与伏笔一致),
24
- `novel_new_chapter` 自动创建下一章文件。
25
-
26
- ### 🇬🇧 English
27
-
28
- 1. **Chapter-library management**: chapters live under `novels/<book>/第N章.md` (also `.txt` / `.markdown`).
29
- `novel_books` / `novel_chapters` / `novel_read` let the AI browse, list, and read chapters.
30
- 2. **Smart chapter numbering**: chapter numbers may appear **anywhere** in the filename and in many forms,
31
- e.g. `第01章.md`, `01-title.md`, `raw-…Chapter-1.txt`. Both Arabic and **Chinese numerals**
32
- (第一章 / 第十四章 / 第三十章) are recognized and chapters are sorted by number automatically.
33
- 3. **Automatic encoding detection**: UTF-8 (with/without BOM), UTF-16 LE/BE (BOM or heuristic), and
34
- GBK/GB18030 (no BOM) are decoded on the fly — no manual transcoding needed.
35
- 4. **Keyword analysis**: `novel_keywords` deterministically extracts high-frequency words from a whole book
36
- or one chapter (Chinese bigrams + characters + English words), guiding the AI to analyze plot,
37
- writing style, vocabulary preferences, and imagery motifs.
38
- 5. **Continuation writing**: a system prompt enforces the workflow (read first, then write; keep style and
39
- foreshadowing consistent). `novel_new_chapter` creates the next chapter file automatically.
40
-
41
- ---
42
-
43
- ## 提供的工具 / Provided Tools
44
-
45
- | Tool | 中文说明 | English |
46
- |------|----------|---------|
47
- | `novel_books` | 列出章节库全部作品(章节数、总字数) | List all books in the library (chapter count, total chars) |
48
- | `novel_chapters` | 列出某作品章节清单(章号/标题/字数/行数/更新时间) | List a book's chapters (number/title/chars/lines/updated) |
49
- | `novel_read` | 阅读某章正文(行号 + 字数统计,offset/limit 分段) | Read a chapter's body (line numbers + char count, paginated) |
50
- | `novel_keywords` | 提取高频关键词(可单章或全书) | Extract high-frequency keywords (chapter or whole book) |
51
- | `novel_new_chapter` | 创建新章节文件(自动取下一个章号) | Create a new chapter file (auto-next number) |
52
-
53
- ---
54
-
55
- ## 安装 / Installation
56
-
57
- > 现代安装请优先参考 `INSTALL.md`,或使用 DSH CLI:
58
-
59
- > Preferred install via DSH CLI, see also `INSTALL.md`:
17
+ 3. **续写**:系统提示词规定续写工作流(先读后写、保持文风与伏笔一致),
18
+ `novel_new_chapter` 创建新章节文件。
19
+ 4. **原稿件批量导入/自动分类**:`novel_import` 扫描存放多本小说稿件的文件夹
20
+ (支持子文件夹、UTF-16/GBK 等编码、任意位置章号),从文件名与文件头内容双通道
21
+ 提取书名候选并聚合成分组建议(scan 模式),AI 确认后可一键按书分类复制/移动到
22
+ `novels/<书名>/`(apply 模式);支持 `book` 强制归并异名同书、`files` 精确指定文件。
23
+
24
+ ## 提供的工具
25
+
26
+ | 工具 | 说明 |
27
+ |---|---|
28
+ | `novel_books` | 列出章节库全部作品(章节数、总字数) |
29
+ | `novel_chapters` | 列出某作品章节清单(章号/标题/字数/行数/更新时间) |
30
+ | `novel_read` | 阅读某章正文(行号 + 字数统计,offset/limit 分段) |
31
+ | `novel_keywords` | 提取高频关键词(可单章或全书) |
32
+ | `novel_new_chapter` | 创建新章节文件(自动取下一个章号) |
33
+ | `novel_import` | 扫描原稿件文件夹,自动识别/分类多本小说章节到 `novels/<书名>/`(scan 预览 / apply 执行,支持 book 强制归并与 files 精确导入) |
34
+ | `novel_style_pattern` | **(受 stylePattern 开关控制)** 句式模式分析:把句子分为陈述/环境/心理/对话/疑问/反问/感叹七类,输出占比、高频组合与按章节的压缩排列序列,用于模仿原文叙事节奏 |
35
+
36
+ ## 安装(web profile)
60
37
 
61
38
  ```sh
62
- dsh plugin --profile web add D:/path/to/dsh-novel-writer
63
- ```
64
-
65
- 或手动等价操作:在 profile 的 `package.json` 的 `dsh.profile.bundles` 中加入 `dsh-novel-writer`,
66
- 并在 profile `node_modules` 中链接本包(Windows 可建 junction:`mklink /J`)。安装后重启 web 应用生效。
67
-
68
- Or the manual equivalent: add `dsh-novel-writer` to the `dsh.profile.bundles` array in the profile's
69
- `package.json`, and link this package into the profile `node_modules` (on Windows, `mklink /J`).
70
- Restart the web app after installing.
71
-
72
- ### (可选)安装技能 / (Optional) Install the Skill
73
-
74
- 把 `skills/novel-writing` 整个文件夹复制到工作区 `.dsh/skills/` 下,新会话即出现 `novel-writing` 技能:
75
-
76
- Copy the whole `skills/novel-writing` folder into your workspace `.dsh/skills/`; a new session will surface the `novel-writing` skill:
77
-
39
+ dsh plugin --profile web add D:/Deep\ Seek插件库/novel-writer
78
40
  ```
79
- <workspace>/.dsh/skills/novel-writing/SKILL.md
80
- ```
81
-
82
- ---
83
41
 
84
- ## 配置 / Configuration
42
+ 或手动等价操作:在 profile `package.json` 的 `dsh.profile.bundles` 中加入
43
+ `dsh-novel-writer`,并在 profile `node_modules` 中链接本包。安装后重启 web 应用生效。
85
44
 
86
- 插件 `config` 支持 `root`:章节库根目录(默认取**会话工作区**,即 `novels/` 所在位置)。
87
- 在 profile 的 `cordis.patch.yml` 中可覆盖:
45
+ ## 配置
88
46
 
89
- The plugin `config` supports `root`: the library root directory (defaults to the **session workspace** where `novels/` lives). Override it in the profile's `cordis.patch.yml`:
47
+ 插件 config 支持 `root`:章节库根目录(默认取会话工作区,即 `novels/` 所在位置)。
48
+ 在 profile 的 `cordis.patch.yml` 中可覆盖(顶层数组元素直接是条目,无需 `patch:` 包装):
90
49
 
91
50
  ```yaml
92
- - patch:
93
- - id: novel-writer
94
- config:
95
- root: 'D:/我的小说库'
96
- ```
97
-
98
- ---
99
-
100
- ## 章节库约定 / Library Convention
101
-
51
+ - id: novel-writer
52
+ config:
53
+ root: 'D:/我的小说库'
102
54
  ```
103
- <workspace or root>/novels/<书名>/
104
- ├── 第01章.md # 或 .txt / .markdown
105
- └── 原稿件-单章-…第一章.txt # 序号在任意位置、中文数字均可识别
106
- ```
107
-
108
- 支持的命名与编码 / Supported naming & encoding:
109
55
 
110
- - 章号任意位置:`第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
111
- - 阿拉伯数字 + 中文数字(第一章/第十四章/第三十章),自动按章号排序
112
- - 编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)
56
+ ### stylePattern 开关(句式模式仿写拓展)
113
57
 
114
- ---
58
+ `stylePattern`(布尔,**默认 false 关闭**):控制 `novel_style_pattern` 工具与
59
+ "句式模式仿写"提示词——分析原文陈述/反问/心理/对话等句子的排列节奏,引导续写时保持相近
60
+ 叙事节奏。
115
61
 
116
- ## 安全说明 / Security Notes
62
+ > ⚠️ **已知风险**:该功能可能让整体文风变得更僵硬(机械套用句式模板)。默认关闭;
63
+ > 开启后模型侧也内置了"模式是参考、僵硬时优先自然表达"的约束,但仍建议按需使用。
117
64
 
118
- - 仅使用 Node 内置模块,无第三方运行时依赖、无网络请求、无 shell 执行。
119
- - 所有文件读写均限定在章节库根目录下,并通过 `sanitizeSegment` 阻止路径穿越(拒绝 `.`/`..` 与 `\`/`/`)。
120
- - 读取设有上限(单次最多 400 行 / 2 万字符),保护模型上下文。
65
+ **Web 设置开关(推荐)**:重启 web 后,打开 **设置 > 插件配置**,会看到
66
+ 「小说写作助手 novel-writer」卡片,勾选「开启句式模式仿写(stylePattern)」即可——
67
+ 改动写入 `$DSH_HOME/novel-writer.json`,**即时生效,无需重启**(工具未开启时调用会返回明确提示)。
121
68
 
122
- - Uses only Node built-in modules: no third-party runtime deps, no network calls, no shell execution.
123
- - All file reads/writes are confined to the library root, with `sanitizeSegment` blocking path traversal (rejects `.`/`..` and `\`/`/`).
124
- - Reads are capped (400 lines / 20k chars per call) to protect the model's context window.
69
+ 也可在 profile `cordis.patch.yml` 中配置(顶层数组元素直接是条目):
125
70
 
126
- ---
127
-
128
- <!-- ENGLISH -->
129
-
130
- ## English
71
+ ```yaml
72
+ - id: novel-writer
73
+ config:
74
+ root: ''
75
+ stylePattern: true
76
+ ```
131
77
 
132
- `dsh-novel-writer` is a self-contained **DSH (Cordis) bundle plugin** for novel-writing assistance, with **no third-party dependencies** (Node built-ins only). See the sections above for features, tools, install, config, and security notes. MIT licensed.
78
+ 开关判定优先级:`$DSH_HOME/novel-writer.json` 中的持久化值 loader 配置的 `stylePattern`,任一为 true 即开启。
package/client.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * dsh-novel-writer 客户端设置卡片。
3
+ *
4
+ * 注册进官方设置页的「插件配置」槽位(settings.plugin.item),提供
5
+ * stylePattern(句式模式仿写)开关。读写走宿主端的 /novel-writer-config
6
+ * 同源路由,绕开 rc.6 的 settings namespace 白名单限制;改动即时生效。
7
+ *
8
+ * 格式说明(与官方客户端插件一致):
9
+ * - window.__ModuleLoader__.load({ id: <包名>, factory })
10
+ * - factory 返回 { name, inject, apply } —— inject 声明本插件使用的服务名
11
+ * (缺少 inject 声明会导致 ctx.locale / ctx.slots 等被服务门禁拦截)。
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: "dsh-novel-writer",
15
+ factory: (require) => {
16
+ const React = require("react");
17
+ const { createElement: h, useEffect, useState } = React;
18
+
19
+ const name = "novel-writer-config";
20
+ const inject = ["slots"];
21
+
22
+ function NovelWriterConfigCard() {
23
+ const [enabled, setEnabled] = useState(null);
24
+ const [busy, setBusy] = useState(false);
25
+ useEffect(() => {
26
+ let alive = true;
27
+ fetch("/novel-writer-config", { method: "GET" })
28
+ .then((r) => r.json())
29
+ .then((j) => { if (alive) setEnabled(j.stylePattern === true); })
30
+ .catch(() => { if (alive) setEnabled(false); });
31
+ return () => { alive = false; };
32
+ }, []);
33
+ const toggle = (next) => {
34
+ setBusy(true);
35
+ fetch("/novel-writer-config", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ stylePattern: next })
39
+ })
40
+ .then((r) => r.json())
41
+ .then((j) => setEnabled(j.stylePattern === true))
42
+ .catch(() => {})
43
+ .finally(() => setBusy(false));
44
+ };
45
+ return h("div", { style: { padding: "12px 0", borderBottom: "1px solid var(--dsw-alias-border-l2, #e5e7eb)" } },
46
+ h("div", { style: { fontWeight: 600 } }, "小说写作助手 novel-writer"),
47
+ h("div", { style: { color: "var(--dsw-alias-text-l2, #888)", fontSize: 12, margin: "4px 0 8px" } },
48
+ "句式模式仿写:分析原文陈述/反问/心理/对话等句子的排列节奏来模仿文风。⚠️ 可能让文风变僵硬,默认关闭;改动即时生效。"),
49
+ h("label", { style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer" } },
50
+ h("input", {
51
+ type: "checkbox",
52
+ checked: enabled === true,
53
+ disabled: busy || enabled === null,
54
+ onChange: (e) => toggle(e.target.checked)
55
+ }),
56
+ h("span", null, "开启句式模式仿写(stylePattern)")
57
+ )
58
+ );
59
+ }
60
+
61
+ function apply(ctx) {
62
+ ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
63
+ name: "settings.plugin.item",
64
+ id: "novel-writer-config",
65
+ order: 105,
66
+ inject: () => ({})
67
+ }, NovelWriterConfigCard));
68
+ }
69
+
70
+ return { name, inject, apply };
71
+ }
72
+ });
package/lib/index.js CHANGED
@@ -17,8 +17,11 @@
17
17
  * 注意:output.schema 必须使用标准 JSON Schema 子集(required 是对象层的
18
18
  * 字符串数组),与 tools.register() 的 assertSupportedJsonSchema 一致。
19
19
  */
20
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
20
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
22
  import { basename, extname, join, sep } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { TYPE_CODES, analyzePattern, buildGuidance, classifySentence, compressSequence, ratioMap, splitSentences } from "./pattern.js";
22
25
 
23
26
  const name = "novel-writer";
24
27
  const inject = ["tools", "systemPrompt"];
@@ -85,6 +88,277 @@ function cleanChapterTitle(stem) {
85
88
  .trim();
86
89
  }
87
90
 
91
+ // ---- 原稿件导入 / 自动分类 ----
92
+
93
+ /** 文件名中的噪音词(前缀/独立成段,不构成书名)。 */
94
+ const IMPORT_NOISE = new Set(["原稿件", "单章", "调教计划", "未命名", "新建文档", "草稿", "无题", "正文", "手稿"]);
95
+
96
+ /** 从文件名提取书名候选:去扩展名、章号标记、噪音词与分隔符。失败返回 void 0。 */
97
+ function bookNameFromFileName(fileName) {
98
+ const stem = fileName.replace(/\.[^.]+$/, "");
99
+ const parts = stem
100
+ .replace(/第?(\d{1,4})[章回话]/g, " ")
101
+ .replace(/第[零一二三四五六七八九十百两]+[章回话]/g, " ")
102
+ .replace(LEADING_NUMBER, " ")
103
+ .split(/[\s._\-—~]+/)
104
+ .map((p) => p.trim())
105
+ .filter((p) => p !== "" && !IMPORT_NOISE.has(p));
106
+ const name = parts.join(" ").trim();
107
+ return name === "" ? void 0 : name;
108
+ }
109
+
110
+ /** 从文件头内容提取书名候选:跳过噪音行,只认"含章号标记的标题行"(如"吸血鬼萝莉的魅魔系统 第一章"),
111
+ * 清理章号后作为书名候选;找不到则返回 void 0(该文件将落入"未分类"组,由 AI 判断归属)。 */
112
+ function bookNameFromContent(text) {
113
+ const lines = String(text).split(/\r?\n/).slice(0, 12);
114
+ for (const raw of lines) {
115
+ const line = raw.replace(/^#+\s*/, "").replace(/^\uFEFF/, "").trim();
116
+ if (line === "") continue;
117
+ if (IMPORT_NOISE.has(line)) continue;
118
+ const hasChapterMark = /第?(\d{1,4})[章回话]|第[零一二三四五六七八九十百两]+[章回话]/.test(line);
119
+ if (hasChapterMark) {
120
+ const cleaned = cleanChapterTitle(line);
121
+ if (cleaned !== "") return cleaned;
122
+ }
123
+ // 无章号标记的行不是章节标题行(可能是正文/随笔),跳过继续找
124
+ }
125
+ return void 0;
126
+ }
127
+
128
+ /** 扫描一个目录(递归)下的章节类文本文件,返回相对路径列表。 */
129
+ async function collectTextFiles(dir, recursive, out = [], prefix = "") {
130
+ let entries;
131
+ try {
132
+ entries = await readdir(dir, { withFileTypes: true });
133
+ } catch {
134
+ return out;
135
+ }
136
+ entries.sort((a, b) => a.name.localeCompare(b.name));
137
+ for (const entry of entries) {
138
+ const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
139
+ if (entry.isDirectory()) {
140
+ if (recursive) await collectTextFiles(join(dir, entry.name), recursive, out, rel);
141
+ } else if (entry.isFile() && CHAPTER_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
142
+ out.push(rel);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function registerNovelImport(ctx, config) {
149
+ ctx.tools.register({
150
+ name: "novel_import",
151
+ description: "扫描一个存放大量原稿件文本的文件夹,自动识别并分类其中包含的小说章节:从文件名与文件头内容双通道提取书名候选与章号,聚合成分组建议;可执行复制/移动到章节库 novels/<书名>/ 分类存放。用于把混杂的多本小说稿件一键整理进书库。",
152
+ parameters: {
153
+ type: "object",
154
+ properties: {
155
+ src: { type: "string", description: "待导入的原稿件文件夹路径(可含多本小说的章节文本,支持子文件夹)。" },
156
+ mode: { type: "string", enum: ["scan", "apply"], description: "scan=只分析并返回分组建议(默认,不写盘);apply=按分组执行导入。" },
157
+ book: { type: "string", description: "apply 时可选:强制把所有(或 files 指定的)文件归入该书名,用于合并异名同书(如\"萝莉/幼女\"两个书名候选实为同一本),或把\"未分类\"中的文件指定归属。" },
158
+ files: {
159
+ type: "array",
160
+ items: { type: "string" },
161
+ description: "apply 时可选:只处理这些文件(相对 src 的路径,如\"旧稿/第一章.txt\")。省略则处理全部扫描到的文件。"
162
+ },
163
+ move: { type: "boolean", description: "apply 时是否移动原文件(默认 false=复制,源文件保留)。" },
164
+ recursive: { type: "boolean", description: "是否递归扫描子文件夹。默认 true。" },
165
+ root: { type: "string", description: "章节库根目录(含 novels 子目录)。默认取会话工作区。" }
166
+ },
167
+ required: ["src"],
168
+ additionalProperties: false
169
+ },
170
+ output: {
171
+ schema: {
172
+ type: "object",
173
+ additionalProperties: false,
174
+ properties: {
175
+ src: { type: "string" },
176
+ mode: { type: "string" },
177
+ groups: {
178
+ type: "array",
179
+ items: {
180
+ type: "object",
181
+ additionalProperties: false,
182
+ properties: {
183
+ book: { type: "string" },
184
+ from: { type: "string", enum: ["file", "content", "forced"] },
185
+ files: {
186
+ type: "array",
187
+ items: {
188
+ type: "object",
189
+ additionalProperties: false,
190
+ properties: {
191
+ file: { type: "string" },
192
+ chapter: { type: "integer" },
193
+ title: { type: "string" }
194
+ },
195
+ required: ["file"]
196
+ }
197
+ }
198
+ },
199
+ required: ["book", "files"]
200
+ }
201
+ },
202
+ skipped: {
203
+ type: "array",
204
+ items: { type: "string" }
205
+ },
206
+ imported: {
207
+ type: "array",
208
+ items: {
209
+ type: "object",
210
+ additionalProperties: false,
211
+ properties: {
212
+ book: { type: "string" },
213
+ file: { type: "string" },
214
+ path: { type: "string" }
215
+ },
216
+ required: ["book", "file", "path"]
217
+ }
218
+ }
219
+ },
220
+ required: ["src", "mode", "groups", "skipped", "imported"]
221
+ },
222
+ render: (_args, value) => [{
223
+ type: "text",
224
+ text: formatImport(value)
225
+ }]
226
+ },
227
+ async execute(args, exec) {
228
+ const src = requiredString(args, "src");
229
+ const mode = args?.mode === "apply" ? "apply" : "scan";
230
+ const forcedBook = optionalString(args, "book");
231
+ const onlyFiles = Array.isArray(args?.files) && args.files.length > 0
232
+ ? new Set(args.files.map((f) => String(f).replace(/\\/g, "/").replace(/^\.?\//, "")))
233
+ : null;
234
+ const move = args?.move === true;
235
+ const recursive = args?.recursive !== false;
236
+ const root = resolveRoot(config, args, exec);
237
+ const files = await collectTextFiles(src, recursive);
238
+ const skipped = [];
239
+ const rows = [];
240
+ for (const rel of files) {
241
+ const norm = rel.replace(/\\/g, "/");
242
+ if (onlyFiles !== null && !onlyFiles.has(norm)) continue;
243
+ const full = join(src, rel);
244
+ let info;
245
+ try {
246
+ info = await stat(full);
247
+ } catch {
248
+ continue;
249
+ }
250
+ if (!info.isFile()) continue;
251
+ const fileName = basename(rel);
252
+ let text = "";
253
+ try {
254
+ text = await readTextFile(full, exec);
255
+ } catch {
256
+ skipped.push(rel);
257
+ continue;
258
+ }
259
+ if (text.trim() === "") {
260
+ skipped.push(rel);
261
+ continue;
262
+ }
263
+ const nameFromFile = bookNameFromFileName(fileName);
264
+ const nameFromContent = bookNameFromContent(text);
265
+ rows.push({
266
+ file: rel,
267
+ chapter: parseChapterNumber(fileName),
268
+ nameFromFile,
269
+ nameFromContent
270
+ });
271
+ }
272
+ // 分组:forcedBook > 文件名候选 > 内容候选 > 未分类。
273
+ // 关键规则:只有"疑似章节文件"(文件名或内容含章号标记)才参与正常分组;
274
+ // 无章号标记的文件(随笔/损坏/无关文档)一律归入"未分类",由 AI 判断是否导入。
275
+ const groupMap = new Map();
276
+ const groupFrom = new Map();
277
+ for (const row of rows) {
278
+ const isChapterFile = row.chapter !== void 0 || row.nameFromContent !== void 0;
279
+ let book;
280
+ let from;
281
+ if (forcedBook !== void 0) {
282
+ book = forcedBook;
283
+ from = "forced";
284
+ } else if (isChapterFile && row.nameFromFile !== void 0) {
285
+ book = row.nameFromFile;
286
+ from = "file";
287
+ } else if (isChapterFile && row.nameFromContent !== void 0) {
288
+ book = row.nameFromContent;
289
+ from = "content";
290
+ } else {
291
+ book = "未分类";
292
+ from = "content";
293
+ }
294
+ if (!groupMap.has(book)) {
295
+ groupMap.set(book, []);
296
+ groupFrom.set(book, from);
297
+ }
298
+ groupMap.get(book).push(row);
299
+ }
300
+ const groups = [];
301
+ for (const [book, rows2] of groupMap) {
302
+ rows2.sort((a, b) => (a.chapter ?? Number.MAX_SAFE_INTEGER) - (b.chapter ?? Number.MAX_SAFE_INTEGER) || a.file.localeCompare(b.file));
303
+ groups.push({
304
+ book,
305
+ from: groupFrom.get(book),
306
+ files: rows2.map((r) => ({
307
+ file: r.file,
308
+ ...r.chapter === void 0 ? {} : { chapter: r.chapter },
309
+ ...r.nameFromContent === void 0 ? {} : { title: r.nameFromContent }
310
+ }))
311
+ });
312
+ }
313
+ groups.sort((a, b) => b.files.length - a.files.length || a.book.localeCompare(b.book));
314
+ // apply:复制/移动到 novels/<book>/
315
+ const imported = [];
316
+ if (mode === "apply") {
317
+ for (const group of groups) {
318
+ if (group.book === "未分类") continue;
319
+ const safeBook = sanitizeSegment(group.book, "book");
320
+ const destDir = join(novelsDir(root), safeBook);
321
+ await mkdir(destDir, { recursive: true });
322
+ for (const row of group.files) {
323
+ const srcFull = join(src, row.file);
324
+ const destFull = join(destDir, basename(row.file));
325
+ if (move) {
326
+ await copyFile(srcFull, destFull);
327
+ await rm(srcFull, { force: true });
328
+ } else {
329
+ await copyFile(srcFull, destFull);
330
+ }
331
+ imported.push({ book: safeBook, file: basename(row.file), path: destFull });
332
+ }
333
+ }
334
+ }
335
+ return { src, mode, groups, skipped, imported };
336
+ }
337
+ });
338
+ }
339
+
340
+ function formatImport(value) {
341
+ const lines = [`<path>${value.src}</path>`, `<type>novel-import-${value.mode}</type>`, `<content>`, ""];
342
+ lines.push(`扫描结果:${value.groups.reduce((n, g) => n + g.files.length, 0)} 个文件 → ${value.groups.length} 组` +
343
+ (value.skipped.length > 0 ? `(跳过 ${value.skipped.length} 个无法读取的文件)` : ""));
344
+ for (const group of value.groups) {
345
+ const from = group.from === "file" ? "文件名" : group.from === "content" ? "文件头内容" : "强制指定";
346
+ lines.push("");
347
+ lines.push(`[${group.book}] (来自${from}, ${group.files.length} 个文件)`);
348
+ for (const f of group.files) {
349
+ const ch = f.chapter === void 0 ? "?" : `第${f.chapter}章`;
350
+ lines.push(` - ${ch} ${f.file}`);
351
+ }
352
+ }
353
+ if (value.imported.length > 0) {
354
+ lines.push("");
355
+ lines.push(`已导入 ${value.imported.length} 个文件:`);
356
+ for (const item of value.imported) lines.push(` - novels/${item.book}/${item.file}`);
357
+ }
358
+ lines.push("", "</content>");
359
+ return lines.join("\n");
360
+ }
361
+
88
362
  /** 中文虚词/功能字黑名单:不参与关键词统计。 */
89
363
  const CJK_STOP_CHARS = new Set(
90
364
  "的了是在有和就都而于及与或这那之其以被把让向着过也还又但更最很从对为等啊吧呢吗嗯哦呀哈啦么个中上下前后左右来去出进到说想看要会能可没有不".split("")
@@ -685,6 +959,162 @@ Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
685
959
  });
686
960
  }
687
961
 
962
+ /** 句式模式分析工具(stylePattern 开关开启时注册)。 */
963
+ function registerNovelStylePattern(ctx, config) {
964
+ ctx.tools.register({
965
+ name: "novel_style_pattern",
966
+ description: "分析一部作品(或单章)的句式排列模式:把句子分为陈述/环境/心理/对话/疑问/反问/感叹七类,输出各类占比、高频句式组合与按章节的压缩排列序列,用于模仿原文的叙事节奏。",
967
+ parameters: {
968
+ type: "object",
969
+ properties: {
970
+ book: { type: "string", description: "书名。" },
971
+ chapter: { type: "string", description: "可选。只分析该章节;省略则分析全书。" },
972
+ top: { type: "integer", description: "返回的高频句式组合数量。默认 10。" },
973
+ root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
974
+ },
975
+ required: ["book"],
976
+ additionalProperties: false
977
+ },
978
+ output: {
979
+ schema: {
980
+ type: "object",
981
+ additionalProperties: false,
982
+ properties: {
983
+ book: { type: "string" },
984
+ scope: { type: "string" },
985
+ totalSentences: { type: "integer" },
986
+ counts: {
987
+ type: "object",
988
+ additionalProperties: false,
989
+ properties: {
990
+ S: { type: "integer" }, ENV: { type: "integer" }, PSY: { type: "integer" },
991
+ DLG: { type: "integer" }, Q: { type: "integer" }, RQ: { type: "integer" }, EX: { type: "integer" }
992
+ },
993
+ required: ["S", "ENV", "PSY", "DLG", "Q", "RQ", "EX"]
994
+ },
995
+ ratios: {
996
+ type: "object",
997
+ additionalProperties: false,
998
+ properties: {
999
+ S: { type: "integer" }, ENV: { type: "integer" }, PSY: { type: "integer" },
1000
+ DLG: { type: "integer" }, Q: { type: "integer" }, RQ: { type: "integer" }, EX: { type: "integer" }
1001
+ },
1002
+ required: ["S", "ENV", "PSY", "DLG", "Q", "RQ", "EX"]
1003
+ },
1004
+ topPatterns: {
1005
+ type: "array",
1006
+ items: {
1007
+ type: "object",
1008
+ additionalProperties: false,
1009
+ properties: {
1010
+ pattern: { type: "string" },
1011
+ count: { type: "integer" }
1012
+ },
1013
+ required: ["pattern", "count"]
1014
+ }
1015
+ },
1016
+ chapterPatterns: {
1017
+ type: "array",
1018
+ items: {
1019
+ type: "object",
1020
+ additionalProperties: false,
1021
+ properties: {
1022
+ chapter: { type: "string" },
1023
+ sequence: { type: "string" }
1024
+ },
1025
+ required: ["chapter", "sequence"]
1026
+ }
1027
+ },
1028
+ guidance: { type: "string" }
1029
+ },
1030
+ required: ["book", "scope", "totalSentences", "counts", "ratios", "topPatterns", "chapterPatterns", "guidance"]
1031
+ },
1032
+ render: (_args, value) => [{
1033
+ type: "text",
1034
+ text: formatStylePattern(value)
1035
+ }]
1036
+ },
1037
+ async execute(args, exec) {
1038
+ const styleOn = config?.stylePattern === true || readStyleEnabled();
1039
+ assert(styleOn, "句式模式仿写(novel_style_pattern)当前未开启:可在 Web 设置 > 插件配置 中打开 novel-writer 的 stylePattern 开关后重试。");
1040
+ const book = sanitizeSegment(requiredString(args, "book"), "book");
1041
+ const chapterArg = optionalString(args, "chapter");
1042
+ const top = optionalInt(args, "top", 1, 50, 10);
1043
+ const root = resolveRoot(config, args, exec);
1044
+ const dir = bookDir(root, book);
1045
+ const chapters = await scanChapters(dir);
1046
+ assert(chapters.length > 0, `book "${book}" has no chapter files`);
1047
+ const selected = chapterArg !== void 0 ? [findChapter(chapters, chapterArg)] : chapters;
1048
+ assert(selected[0] !== void 0, `cannot find chapter "${chapterArg}" in book "${book}"`);
1049
+ const scope = chapterArg !== void 0 ? selected[0].file : `全书 ${chapters.length} 章`;
1050
+ const chapterPatterns = [];
1051
+ const allCodes = [];
1052
+ const allCounts = {};
1053
+ for (const code of TYPE_CODES) allCounts[code] = 0;
1054
+ for (const chapter of selected) {
1055
+ const text = await readFile(join(dir, chapter.file), { encoding: "utf8", signal: exec.signal });
1056
+ const local = analyzePattern(text, { top });
1057
+ for (const code of TYPE_CODES) allCounts[code] += local.counts[code];
1058
+ allCodes.push(...local.codes);
1059
+ chapterPatterns.push({
1060
+ chapter: chapter.file,
1061
+ sequence: local.sequence
1062
+ });
1063
+ }
1064
+ const topPatterns = (() => {
1065
+ const grams = new Map();
1066
+ for (let i = 0; i + 2 < allCodes.length; i += 1) {
1067
+ const key = allCodes.slice(i, i + 3).join("→");
1068
+ grams.set(key, (grams.get(key) ?? 0) + 1);
1069
+ }
1070
+ return [...grams.entries()]
1071
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1072
+ .slice(0, top)
1073
+ .map(([pattern, count]) => ({ pattern, count }));
1074
+ })();
1075
+ const ratios = ratioMap(allCounts, allCodes.length);
1076
+ const guidance = buildGuidance(ratios, topPatterns, chapterPatterns);
1077
+ return {
1078
+ book,
1079
+ scope,
1080
+ totalSentences: allCodes.length,
1081
+ counts: allCounts,
1082
+ ratios,
1083
+ topPatterns,
1084
+ chapterPatterns,
1085
+ guidance
1086
+ };
1087
+ }
1088
+ });
1089
+ }
1090
+
1091
+ function formatStylePattern(value) {
1092
+ const typeName = { S: "陈述", ENV: "环境", PSY: "心理", DLG: "对话", Q: "疑问", RQ: "反问", EX: "感叹" };
1093
+ const lines = [
1094
+ `<path>novels/${value.book}</path>`,
1095
+ `<type>novel-style-pattern</type>`,
1096
+ `<content>`,
1097
+ `统计范围: ${value.scope}(共 ${value.totalSentences} 句)`,
1098
+ "",
1099
+ "句式分布: " + Object.entries(value.ratios)
1100
+ .filter(([, r]) => r > 0)
1101
+ .map(([code, r]) => `${typeName[code]}( ${code} ) ${r}%`)
1102
+ .join(" · "),
1103
+ "高频句式组合: " + (value.topPatterns.length > 0
1104
+ ? value.topPatterns.slice(0, 5).map((p) => `${p.pattern} ×${p.count}`).join("、")
1105
+ : "(样本过短)"),
1106
+ "",
1107
+ "章节节奏(S=陈述 ENV=环境 PSY=心理 DLG=对话 Q=疑问 RQ=反问 EX=感叹):",
1108
+ ...value.chapterPatterns.slice(0, 12).map((c) => ` ${c.chapter}: ${c.sequence}`),
1109
+ "",
1110
+ "仿写建议:",
1111
+ ...value.guidance.split("\n").map((line) => ` ${line}`),
1112
+ "",
1113
+ "</content>"
1114
+ ];
1115
+ return lines.join("\n");
1116
+ }
1117
+
688
1118
  /** 系统提示词:小说写作工作流(需求 1-3 的模型侧约定)。 */
689
1119
  const WORKFLOW_TEXT = [
690
1120
  "【小说写作助手 novel-writer】",
@@ -695,7 +1125,89 @@ const WORKFLOW_TEXT = [
695
1125
  " 以及用 novel_keywords 提取的高频关键词(作者词汇偏好与意象母题),并说明这些手法/关键词在具体章节中的例证。",
696
1126
  "3. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
697
1127
  " 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
698
- "4. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。"
1128
+ "4. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
1129
+ "5. 导入/整理原稿件时:用 novel_import 扫描存放多本小说稿件的文件夹(src),先以 scan 模式查看分组建议,",
1130
+ " 若发现异名同书(如\"萝莉/幼女\"实为同一本),用 book 参数强制合并后以 apply 模式导入到 novels/<书名>/ 分类存放。"
1131
+ ].join("\n");
1132
+
1133
+ /**
1134
+ * stylePattern 开关的持久化:独立的配置文件($DSH_HOME/novel-writer.json)。
1135
+ * Web 设置卡片通过 /novel-writer-config 路由读写它;工具执行时实时读取,
1136
+ * 因此 Web 里开关后立即生效(无需重启)。
1137
+ */
1138
+ function styleConfigPath() {
1139
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
1140
+ return join(home, "novel-writer.json");
1141
+ }
1142
+ /** 读取持久化的 stylePattern 开关(true = 开启)。文件缺失/损坏一律视为关。 */
1143
+ function readStyleEnabled() {
1144
+ try {
1145
+ if (!existsSync(styleConfigPath())) return false;
1146
+ const parsed = JSON.parse(readFileSync(styleConfigPath(), "utf8"));
1147
+ return parsed?.stylePattern === true;
1148
+ } catch {
1149
+ return false;
1150
+ }
1151
+ }
1152
+ /** 写入持久化的 stylePattern 开关。 */
1153
+ function writeStyleEnabled(enabled) {
1154
+ const path = styleConfigPath();
1155
+ writeFileSync(path, JSON.stringify({ stylePattern: enabled === true }, null, 2) + "\n", "utf8");
1156
+ }
1157
+ /**
1158
+ * 在 web profile 注册 /novel-writer-config 配置路由(设置卡片的读写后端)。
1159
+ * 用可选注入:headless 等无 webServer 的 profile 直接跳过。
1160
+ */
1161
+ function registerStyleConfigRoute(ctx) {
1162
+ ctx.inject(["webServer"], (wctx) => {
1163
+ wctx.webServer.register({
1164
+ kind: "exact",
1165
+ path: "/novel-writer-config",
1166
+ handler: (req, res) => {
1167
+ const send = (status, body) => {
1168
+ const payload = JSON.stringify(body);
1169
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
1170
+ res.end(payload);
1171
+ };
1172
+ if (req.method === "GET") {
1173
+ send(200, { stylePattern: readStyleEnabled() });
1174
+ return;
1175
+ }
1176
+ if (req.method === "POST") {
1177
+ let raw = "";
1178
+ req.on("data", (chunk) => {
1179
+ raw += chunk;
1180
+ if (raw.length > 4096) req.destroy();
1181
+ });
1182
+ req.on("end", () => {
1183
+ try {
1184
+ const body = JSON.parse(raw || "{}");
1185
+ if (typeof body.stylePattern !== "boolean") {
1186
+ send(400, { error: "stylePattern must be a boolean" });
1187
+ return;
1188
+ }
1189
+ writeStyleEnabled(body.stylePattern);
1190
+ send(200, { stylePattern: body.stylePattern });
1191
+ } catch {
1192
+ send(400, { error: "invalid JSON body" });
1193
+ }
1194
+ });
1195
+ return;
1196
+ }
1197
+ send(405, { error: "method not allowed" });
1198
+ }
1199
+ });
1200
+ });
1201
+ }
1202
+
1203
+ /** 句式模式仿写提示词(stylePattern 开启时注入;含风险说明)。 */
1204
+ const STYLE_GUIDANCE_TEXT = [
1205
+ "【句式模式仿写 novel_style_pattern(受 stylePattern 开关控制)】",
1206
+ "1. 该功能默认关闭;若调用 novel_style_pattern 返回「未开启」提示,说明用户未在 Web 设置 > 插件配置 中开启 stylePattern,此时不要强行模仿句式,按普通流程写作即可。",
1207
+ "2. 开启时:仿写/续写前,先用 novel_style_pattern 分析原文的句式排列:陈述/环境/心理/对话/疑问/反问/感叹七类句子的占比、高频组合与按章节的节奏序列。",
1208
+ "3. 写作时尽量贴近原文的句式节奏:陈述句密度、心理描写出现的时机与位置、反问句的使用频率、对话与叙事的穿插比例、环境描写的点缀节奏。",
1209
+ "4. 重要风险提示:句式模式是「参考节奏」,不是「模板套用」。若机械复刻导致句子僵硬、重复、模式化或失去自然感,必须优先回归自然表达;",
1210
+ " 适度偏离原文模式是允许且更优的选择。分析报告中的模式代码(S/ENV/PSY/DLG/Q/RQ/EX)只用来指导句式选择,不要直接出现在正文或报告里。"
699
1211
  ].join("\n");
700
1212
 
701
1213
  function apply(ctx, config = {}) {
@@ -704,11 +1216,22 @@ function apply(ctx, config = {}) {
704
1216
  registerNovelRead(ctx, config);
705
1217
  registerNovelKeywords(ctx, config);
706
1218
  registerNovelNewChapter(ctx, config);
1219
+ registerNovelImport(ctx, config);
707
1220
  ctx.systemPrompt.section({
708
1221
  name: "novel-writing",
709
1222
  order: 150,
710
1223
  text: WORKFLOW_TEXT
711
1224
  });
1225
+ // stylePattern 开关:默认关闭(规避机械套用导致文风僵硬的已知风险)。
1226
+ // 工具常注册,执行时实时读取开关状态(config.stylePattern 或 $DSH_HOME/novel-writer.json),
1227
+ // 因此 Web 设置 > 插件配置 里的开关改动即时生效,无需重启。
1228
+ registerNovelStylePattern(ctx, config);
1229
+ registerStyleConfigRoute(ctx);
1230
+ ctx.systemPrompt.section({
1231
+ name: "novel-writing-style",
1232
+ order: 151,
1233
+ text: STYLE_GUIDANCE_TEXT
1234
+ });
712
1235
  }
713
1236
 
714
1237
  export { apply, inject, name };
package/lib/pattern.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 句式模式分析引擎 — 纯函数、零依赖。
3
+ *
4
+ * 把正文拆成句子,按启发式规则分类:
5
+ * S 陈述/叙事 ENV 环境描写 PSY 心理描写 DLG 对话
6
+ * Q 疑问句 RQ 反问句 EX 感叹句
7
+ * 然后输出:各类占比、高频句式组合(三元组)、按章节的压缩排列序列。
8
+ *
9
+ * 这是给模型做"风格节奏参考"的软信号,不是精确语法标注:启发式分类
10
+ * 允许有误差,模型应把它当节奏参考而非硬约束(见插件提示词中的风险说明)。
11
+ */
12
+
13
+ /** 全部句式代码(顺序即展示顺序)。 */
14
+ export const TYPE_CODES = ["S", "ENV", "PSY", "DLG", "Q", "RQ", "EX"];
15
+
16
+ /** 对话:句子含中文/英文引号即视为对话(优先于其它类型)。 */
17
+ const DIALOGUE_RE = /[“”"「」『』‘’]/;
18
+ /** 疑问句:以问号结尾。 */
19
+ const INTERROGATIVE_RE = /[??]$/;
20
+ /** 反问句:疑问句且含典型反问标记词。 */
21
+ const RHETORICAL_RE = /难道|岂|何尝|焉能|怎能|怎么会|哪里|哪儿|哪有|不是吗|莫非|不成|何必|未必|何苦|奈何/;
22
+ /** 感叹句:以感叹号结尾。 */
23
+ const EXCLAMATION_RE = /[!!]$/;
24
+ /** 心理描写标记词(较保守,避免把常见动词"想/知道"误判为心理)。 */
25
+ const PSY_RE = /心里|心中|心想|心道|暗自|暗暗|不禁|忍不住|觉得|感到|感觉|仿佛|似乎|好像|但愿|后悔|担心|害怕|怀疑|意识到|没想到|没料到|怔了怔|愣住|内心|回忆|想念|寒了|一沉|一紧|倒吸/;
26
+ /** 环境描写标记词(天色/天气/景物类名词)。 */
27
+ const ENV_RE = /夜色|月光|晨光|暮色|夕阳|黄昏|清晨|深夜|夜空|天边|星光|灯火|细雨|微风|大雨|暴雨|风雪|薄雾|浓雾|云层|远山|山影|河面|湖水|街道|巷子|烛火|窗|雨声|风声|树影|落叶|残雪|霜|天色/;
28
+
29
+ /** 把正文拆成句子(保留句末标点;无标点的尾部片段也算一句)。 */
30
+ export function splitSentences(text) {
31
+ const sentences = [];
32
+ const re = /[^。!?…!?]*[。!?…!?]/g;
33
+ let match;
34
+ let last = 0;
35
+ while ((match = re.exec(text)) !== null) {
36
+ const sentence = match[0].trim();
37
+ if (sentence.length > 0) sentences.push(sentence);
38
+ last = match.index + match[0].length;
39
+ }
40
+ const tail = text.slice(last).trim();
41
+ if (tail.length > 0) sentences.push(tail);
42
+ return sentences;
43
+ }
44
+
45
+ /** 单句分类(优先级:对话 > 疑问/反问 > 感叹 > 心理 > 环境 > 陈述)。 */
46
+ export function classifySentence(sentence) {
47
+ if (DIALOGUE_RE.test(sentence)) return "DLG";
48
+ if (INTERROGATIVE_RE.test(sentence)) return RHETORICAL_RE.test(sentence) ? "RQ" : "Q";
49
+ if (EXCLAMATION_RE.test(sentence)) return "EX";
50
+ if (PSY_RE.test(sentence)) return "PSY";
51
+ if (ENV_RE.test(sentence)) return "ENV";
52
+ return "S";
53
+ }
54
+
55
+ /** 压缩排列序列:S×8 ENV×2 PSY×3 …(跑长编码,直观展示句式先后排列)。 */
56
+ export function compressSequence(codes, maxRuns = 48) {
57
+ const runs = [];
58
+ for (const code of codes) {
59
+ const last = runs[runs.length - 1];
60
+ if (last !== void 0 && last.code === code) last.n += 1;
61
+ else runs.push({ code, n: 1 });
62
+ }
63
+ const parts = runs.slice(0, maxRuns).map((run) => `${run.code}×${run.n}`);
64
+ if (runs.length > maxRuns) parts.push(`…(共${runs.length}段)`);
65
+ return parts.join(" ");
66
+ }
67
+
68
+ /** 各类占比(整数百分比)。 */
69
+ export function ratioMap(counts, total) {
70
+ const out = {};
71
+ for (const code of TYPE_CODES) out[code] = total > 0 ? Math.round((counts[code] / total) * 100) : 0;
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * 分析一段正文的句式模式。
77
+ * @param text - 正文。
78
+ * @param opts - { top: 高频组合条数(默认 10), maxSentences: 采样上限(默认 4000) }。
79
+ * @returns { codes, counts, topPatterns, sequence, ratios }
80
+ */
81
+ export function analyzePattern(text, opts = {}) {
82
+ const top = opts.top ?? 10;
83
+ const maxSentences = opts.maxSentences ?? 4000;
84
+ const sentences = splitSentences(text).slice(0, maxSentences);
85
+ const codes = sentences.map(classifySentence);
86
+ const counts = {};
87
+ for (const code of TYPE_CODES) counts[code] = 0;
88
+ for (const code of codes) counts[code] += 1;
89
+ const grams = new Map();
90
+ for (let i = 0; i + 2 < codes.length; i += 1) {
91
+ const key = codes.slice(i, i + 3).join("→");
92
+ grams.set(key, (grams.get(key) ?? 0) + 1);
93
+ }
94
+ const topPatterns = [...grams.entries()]
95
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
96
+ .slice(0, top)
97
+ .map(([pattern, count]) => ({ pattern, count }));
98
+ return {
99
+ codes,
100
+ counts,
101
+ topPatterns,
102
+ sequence: compressSequence(codes),
103
+ ratios: ratioMap(counts, codes.length)
104
+ };
105
+ }
106
+
107
+ /** 生成给模型的风格节奏建议(紧凑文本)。 */
108
+ export function buildGuidance(ratios, topPatterns, chapterSequences) {
109
+ const ratioText = TYPE_CODES
110
+ .filter((code) => ratios[code] > 0)
111
+ .map((code) => `${code} ${ratios[code]}%`)
112
+ .join(" · ");
113
+ const patternText = topPatterns.length > 0
114
+ ? topPatterns.slice(0, 5).map((p) => `${p.pattern} ×${p.count}`).join("、")
115
+ : "(样本过短,未形成高频组合)";
116
+ const lines = [
117
+ `句式分布:${ratioText}`,
118
+ `高频句式组合:${patternText}`
119
+ ];
120
+ if (chapterSequences.length > 0) {
121
+ lines.push("章节节奏(压缩序列,S=陈述 ENV=环境 PSY=心理 DLG=对话 Q=疑问 RQ=反问 EX=感叹):");
122
+ for (const item of chapterSequences.slice(0, 12)) lines.push(` ${item.chapter}: ${item.sequence}`);
123
+ }
124
+ return lines.join("\n");
125
+ }
package/package.json CHANGED
@@ -1,40 +1,32 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "0.2.0",
4
- "description": "小说写作助手插件:章节库管理、剧情/写作手法/高频关键词分析、续写辅助(支持任意位置章号、中文数字、UTF-8/UTF-16/GBK 编码自动探测)",
3
+ "version": "0.4.0",
4
+ "description": "小说写作助手插件:章节库管理、剧情/写作手法/高频关键词分析、续写辅助、原稿件批量导入自动分类、句式模式仿写(stylePattern 开关,Web 设置可调)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
- "dsh": {
8
- "bundle": {
9
- "patch": "./cordis.patch.yml"
10
- }
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json"
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
- "skills",
14
+ "client.js",
15
15
  "cordis.patch.yml",
16
- "INSTALL.md",
17
16
  "README.md"
18
17
  ],
19
- "keywords": [
20
- "dsh",
21
- "cordis",
22
- "deepseek-harness",
23
- "plugin",
24
- "novel",
25
- "writing",
26
- "fiction",
27
- "chapters"
28
- ],
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/siweina/dsh-novel-writer.git"
32
- },
33
- "homepage": "https://github.com/siweina/dsh-novel-writer",
34
- "bugs": {
35
- "url": "https://github.com/siweina/dsh-novel-writer/issues"
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime",
26
+ "@deepseek-ai/dsh-client-ui-slots"
27
+ ]
28
+ }
36
29
  },
37
- "author": "siweina",
38
- "license": "MIT",
39
- "dependencies": {}
30
+ "dependencies": {},
31
+ "license": "MIT"
40
32
  }
package/INSTALL.md DELETED
@@ -1,110 +0,0 @@
1
- # dsh-novel-writer v0.2.0 — 跨设备安装指南
2
-
3
- 本目录是一个**完整的、自包含的插件分发件**,零第三方依赖(仅 Node 内置模块)。
4
- 将其复制到任何安装了 DSH(DeepSeek Harness)的设备即可使用,无需联网下载任何依赖。
5
-
6
- ```
7
- dsh-novel-writer-v0.2.0/
8
- ├── lib/index.js # 插件本体(含全部修复,见下文"版本记录")
9
- ├── package.json # 包定义(bundle patch 指向 cordis.patch.yml)
10
- ├── cordis.patch.yml # 挂载插件到 loader 树
11
- ├── README.md # 功能与工具说明
12
- └── skills/
13
- └── novel-writing/
14
- └── SKILL.md # 可选:小说写作技能(增强 AI 工作流)
15
- ```
16
-
17
- ---
18
-
19
- ## 一、安装插件
20
-
21
- ### 方式 A:dsh CLI(推荐)
22
-
23
- 把整个文件夹放到目标设备的任意位置(例如 `D:/tools/dsh-novel-writer`),然后:
24
-
25
- ```sh
26
- dsh plugin --profile web add D:/tools/dsh-novel-writer
27
- ```
28
-
29
- ### 方式 B:手动等价操作
30
-
31
- 1. 把本文件夹(或链接)放入 profile 的 `node_modules`:
32
-
33
- ```sh
34
- # 在 <DSH_HOME>/profiles/<profile名> 目录下
35
- npm install D:/tools/dsh-novel-writer
36
- # 或手动建立 junction 链接(Windows):
37
- # mklink /J <profile>\node_modules\dsh-novel-writer D:/tools/dsh-novel-writer
38
- ```
39
-
40
- 2. 编辑该 profile 的 `package.json`,在 `dsh.profile.bundles` 数组中加入:
41
-
42
- ```json
43
- "dsh-novel-writer"
44
- ```
45
-
46
- 3. 重启 web 应用,`novel_*` 系列工具即会注册到会话。
47
-
48
- > 插件的 `cordis.patch.yml` 由 bundle 自动应用(`package.json` 中
49
- > `dsh.bundle.patch` 已声明),无需手动往 profile 的 patch 文件里加条目。
50
-
51
- ## 二、(可选)安装技能 SKILL.md
52
-
53
- 技能用于增强 AI 的小说写作工作流提示(与插件工具配合使用):
54
-
55
- - 把 `skills/novel-writing` 整个文件夹复制到工作区的 `.dsh/skills/` 下:
56
-
57
- ```
58
- <工作区>/.dsh/skills/novel-writing/SKILL.md
59
- ```
60
-
61
- - 新会话中即会出现 `novel-writing` 技能。
62
-
63
- ## 三、准备章节库
64
-
65
- 章节库存放于**会话工作区**(或插件 config `root` 指定的目录)下的 `novels/` 文件夹:
66
-
67
- ```
68
- <工作区>/novels/<书名>/
69
- ├── 第01章.md # 或 .txt / .markdown
70
- └── 原稿件-单章-…第一章.txt # 序号在任意位置、中文数字均可识别
71
- ```
72
-
73
- 支持的文件命名与编码:
74
-
75
- - 章号任意位置:`第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
76
- - 阿拉伯数字 + 中文数字(第一章/第十四章/第三十章),自动按章号排序
77
- - 编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)
78
-
79
- ## 四、验证安装
80
-
81
- 安装并重启后,在会话中应能看到以下工具:`novel_books`、`novel_chapters`、
82
- `novel_read`、`novel_keywords`、`novel_new_chapter`。可先执行 `novel_books`
83
- 确认能列出书库。
84
-
85
- 若 `root` 需要指向其他目录(默认会话工作区),在 profile 的
86
- `cordis.patch.yml` 中覆盖:
87
-
88
- ```yaml
89
- - patch:
90
- - id: novel-writer
91
- config:
92
- root: 'D:/我的小说库'
93
- ```
94
-
95
- ---
96
-
97
- ## 版本记录
98
-
99
- ### v0.2.0(本分发件)
100
- - **修复**:章节序号提取支持任意位置(含中文数字"第一章/第十四章/第三十章"),
101
- 修复 `原稿件-单章-…第N章` 命名下章号全部丢失、排序混乱、
102
- `novel_read(book,"1")` 误命中"第31章"、`novel_new_chapter` 自动取号错乱的问题。
103
- - **新增**:文件编码自动探测(UTF-8 / UTF-16 LE/BE / GBK),
104
- 修复 UTF-16/GBK 章节文件读取乱码与字数统计错误的问题。
105
- - 通过 37 章全量压力测试(463,475 字 / 13,231 行,全书读取+关键词统计 < 200ms)。
106
-
107
- ### v0.1.0(初始)
108
- - 章节库管理:novel_books / novel_chapters / novel_read
109
- - 分析:novel_keywords(中文二字词组 + 英文词)
110
- - 续写:novel_new_chapter(自动取下一个章号)
@@ -1,35 +0,0 @@
1
- ---
2
- name: novel-writing
3
- description: 小说写作助手工作流:管理章节库、分析剧情/写作手法/高频关键词、保持文风续写。当用户提到“小说/章节/续写/剧情分析/写作手法/关键词”时使用。
4
- user-invocable: true
5
- ---
6
-
7
- # 小说写作助手 (novel-writing)
8
-
9
- ## 章节库约定
10
-
11
- - 所有小说章节存放在工作区 `novels/<书名>/` 文件夹下,每章一个 Markdown 文件,命名如 `第01章.md`、`第02章.md`。
12
- - 优先使用专用工具 `novel_books` / `novel_chapters` / `novel_read` 浏览与阅读;也可用 `glob`/`read` 直接查看。
13
-
14
- ## 分析工作流
15
-
16
- 1. `novel_books` 找到用户的作品;
17
- 2. `novel_chapters` 查看章节结构与各章字数;
18
- 3. `novel_read` 逐章通读(长章节用 offset/limit 分段读完);
19
- 4. `novel_keywords` 提取高频关键词(中文词组/单字 + 英文词),作为词汇偏好与意象母题的证据;
20
- 5. 输出分析,至少覆盖三方面:
21
- - **剧情脉络**:冲突、转折、悬念、伏笔、人物关系与目标;
22
- - **写作手法**:叙事视角、节奏、对话、环境与细节描写、比喻意象、留白;
23
- - **关键词与意象**:结合 novel_keywords 的结果,指出作者反复使用的词汇与意象,并引用具体章节佐证。
24
-
25
- ## 续写工作流
26
-
27
- 1. 先通读最近的章节(必要时读完全书),保持:人物设定、时间线、情节伏笔、文风、已提炼的关键词一致;
28
- 2. 动笔前简述:上一章结尾状态 → 本章目标 → 写作计划;
29
- 3. 用 `novel_new_chapter` 创建新章节文件,或直接用 `write` 工具写入 `novels/<书名>/第NN章.md`;
30
- 4. 正文用中文,保持与原文一致的叙事风格与细节密度。
31
-
32
- ## 注意事项
33
-
34
- - 用户放入章节文件的方式不限:可以自己复制进文件夹,也可以让 AI 用 `write`/`edit` 工具创建。
35
- - 分析结果与续写正文都应使用中文呈现。