dsh-novel-writer 0.6.0 → 0.7.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 CHANGED
@@ -1,11 +1,11 @@
1
- # dsh-novel-writer v0.6.0 — 安装指南
1
+ # dsh-novel-writer v0.7.0 — 安装指南
2
2
 
3
- 基于 v0.5.0 合并版的工程化升级。新增:分析缓存/报告导出、风格自检、伏笔登记表、关键词三字组与疑似人名、情感词典去噪、局域网开关可选放行。
3
+ 基于 v0.6.0 工程化版的 UI 升级:10 个工具全部带独立 UI 开关(侧边栏「写作助手功能」面板统一管理)。
4
4
 
5
5
  ```
6
6
  dsh-novel-writer-v0.6.0/
7
7
  ├── lib/
8
- │ ├── index.js # 宿主端(10 个工具 + state 路由 + 缓存)
8
+ │ ├── index.js # 宿主端(10 个工具全开关 + state 路由 + 缓存)
9
9
  │ ├── analysis.js # 句式模式分析引擎(含 fingerprintSimilarity / styleDiffs)
10
10
  │ └── client.js # 浏览器端(侧边栏面板 + 设置页状态卡片)
11
11
  ├── test/ # 引擎 + client + e2e 测试
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # dsh-novel-writer — 小说写作助手插件(v0.6.0 工程化版)
1
+ # dsh-novel-writer — 小说写作助手插件(v0.7.0:全工具 UI 开关)
2
2
 
3
3
  DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三方依赖(仅 Node 内置模块),
4
4
  浏览器端仅依赖 Web GUI 自带的 react。
5
- **v0.6.0 在 v0.5.0(合并版)基础上完成工程化升级**:分析缓存与报告导出、风格自检、伏笔登记表、
5
+ **v0.7.0 在 v0.6.0 基础上为全部 10 个工具增加独立 UI 开关(侧边栏「写作助手功能」面板统一管理)**:分析缓存与报告导出、风格自检、伏笔登记表、
6
6
  关键词三字组/疑似人名、情感词典去噪、祈使句与环境分类改进、局域网开关可选放行。
7
7
 
8
- ## 工具清单(10 个)
8
+ ## 工具清单(10 个,全部带独立 UI 开关)
9
9
 
10
10
  | 工具 | 说明 |
11
11
  |---|---|
@@ -33,9 +33,10 @@ DSH(DeepSeek Harness)小说写作助手 bundle 插件。宿主端零第三
33
33
  8. **novel_import 异名同书提示**:scan 结果中组名包含/高重合时列出 `maybe` 字段,提示用 `book` 合并。
34
34
  9. **局域网开关可选放行**:config `allowLanState: true` 时,state 路由对同源局域网请求放行(默认仍仅 loopback)。
35
35
 
36
- ## UI 开关(与 v0.5.0 相同,统一在一处)
36
+ ## UI 开关(v0.7.0:10 个工具全开关)
37
37
 
38
- - 唯一开关位置:侧边栏「句式分析」面板(总开关 enabled + 子开关 autoAnalyze);
38
+ - **唯一开关位置:侧边栏「写作助手功能」面板**——总开关(enabled,控制句式分析/风格自检)+ 自动分析(autoAnalyze)+ **10 个工具各自独立开关**(全部默认开启);
39
+ - 关闭的工具,AI 调用时会收到明确提示;novel_sentence_config 可查/改全部开关(含 tools 字段);
39
40
  - 设置 > 插件配置 卡片仅显示状态 + 「打开开关面板」跳转;
40
41
  - 持久化 `~/.dsh/dsh-novel-writer/state.json`;兼容 v0.4.0 的 novel-writer.json / config.stylePattern;
41
42
  - 默认开启;路由 `/api/dsh-novel-writer/state`(loopback 围栏,可选局域网放行)。
@@ -58,4 +59,11 @@ dsh plugin --profile web add D:/tools/dsh-novel-writer
58
59
  enabled: true
59
60
  autoAnalyze: true
60
61
  allowLanState: false # true=局域网访问 GUI 时也允许保存开关
62
+
63
+ ## v0.7.0 更新内容
64
+
65
+ - **10 个工具全部带独立 UI 开关**(侧边栏「写作助手功能」面板):novel_books / novel_chapters / novel_read / novel_keywords / novel_new_chapter / novel_import / novel_sentence_analysis / novel_sentence_config / novel_style_check / novel_plot,默认全开;
66
+ - UI 更名:侧边栏入口/面板/横幅由「句式分析」改为「写作助手功能」;设置页卡片同步;
67
+ - novel_sentence_config 升级:支持 tools 参数(如 `{ novel_plot: false }`)与返回各工具开关状态;
68
+ - 关闭的工具调用时返回明确提示(模型可见)。
61
69
  ```
package/lib/client.js CHANGED
@@ -60,6 +60,24 @@ window.__ModuleLoader__.load({
60
60
  ".nwBadge{flex:none;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:700;white-space:nowrap;letter-spacing:.5px}" +
61
61
  ".nwBadgeOn{background:#22c55e;color:#fff}" +
62
62
  ".nwBadgeOff{background:#e5e7eb;color:#6b7280}" +
63
+ ".nwSectionTitle{font-size:13px;font-weight:700;margin-top:6px}" +
64
+ ".nwToolsHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:2px 0 4px}" +
65
+ ".nwToolRow{display:flex;align-items:center;gap:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:8px 12px}" +
66
+ ".nwToolRowOn{border-color:rgba(34,197,94,.55);background:rgba(34,197,94,.05)}" +
67
+ ".nwToolLabel{flex:1;min-width:0;font-size:12px;line-height:1.4;color:var(--dsw-alias-label-primary)}" +
68
+ ".nwSwitchSmall{appearance:none;flex:none;width:40px;height:22px;border-radius:999px;border:2px solid #9ca3af;background:#9ca3af;cursor:pointer;position:relative;padding:0;transition:background .16s,border-color .16s}" +
69
+ ".nwSwitchSmallOn{background:#22c55e;border-color:#22c55e}" +
70
+ ".nwSwitchSmallKnob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .16s}" +
71
+ ".nwSwitchSmallOn .nwSwitchSmallKnob{left:20px}" +
72
+ ".nwToolDesc{font-size:11px;line-height:1.45;color:var(--dsw-alias-label-tertiary);margin-top:2px}" +
73
+ ".nwPlotBox{margin-top:6px;padding:6px 8px;background:rgba(127,127,127,.08);border-radius:8px}" +
74
+ ".nwPlotPath{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary);word-break:break-all}" +
75
+ ".nwBtnGroup{display:flex;gap:6px;margin-top:6px}" +
76
+ ".nwBtn{font-size:11px;line-height:1;padding:5px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer}" +
77
+ ".nwBtn:hover{background:var(--dsw-alias-bg-layer-2)}" +
78
+ ".nwBtn:disabled{opacity:.45;cursor:not-allowed}" +
79
+ ".nwPlotMsg{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary);margin-top:4px;word-break:break-all}" +
80
+ ".nwPlotErr{color:#ef4444}" +
63
81
  ".nwStatus{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;word-break:break-all}" +
64
82
  ".nwFoot{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;border-top:1px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:4px}";
65
83
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"dsh-novel-writer\"]") === null) {
@@ -72,61 +90,119 @@ window.__ModuleLoader__.load({
72
90
 
73
91
  // ---- 文案(zh / en)----
74
92
  var zh = {
75
- "entry.label": "句式分析",
76
- "entry.tooltip": "句式模式分析开关(dsh-novel-writer)",
77
- "panel.title": "句式模式分析",
78
- "panel.desc": "分析作品的句式分布(陈述/对话/心理/疑问/反问/感叹/祈使/省略留白)、排列规律、句长节奏、情感曲线与风格指纹,帮助 AI 快速掌握作者的写作习惯与主观情感。",
79
- "panel.enabled": "启用句式分析",
80
- "panel.enabledHint": "开启后,AI 可以调用 novel_sentence_analysis 提取句式模式报告。",
93
+ "entry.label": "写作助手功能",
94
+ "entry.tooltip": "写作助手功能开关(dsh-novel-writer):句式分析/风格自检/伏笔登记等",
95
+ "panel.title": "写作助手功能",
96
+ "panel.desc": "管理写作助手的功能开关:句式模式分析(九类句式/情感曲线/风格指纹)、风格自检、伏笔登记、稿件导入等。关闭的工具,AI 调用时会收到提示。",
97
+ "panel.enabled": "启用写作助手功能",
98
+ "panel.enabledHint": "总开关:控制句式模式分析与风格自检(novel_sentence_analysis / novel_style_check)。",
81
99
  "panel.autoAnalyze": "分析作品时自动使用",
82
100
  "panel.autoAnalyzeHint": "开启后,AI 在分析作品时会主动附带句式分析;关闭则仅在用户明确要求时使用。",
101
+ "panel.toolsTitle": "工具开关",
102
+ "panel.toolsHint": "全部默认开启。关闭后 AI 调用该工具会收到明确提示;可随时在此重新开启。",
83
103
  "panel.loading": "正在读取开关状态…",
84
104
  "panel.saved": "状态已保存到宿主端",
85
105
  "panel.localOnly": "宿主端不可达,开关仅保存在本浏览器(重启后可能恢复默认)。",
86
106
  "panel.saveFailed": "保存到宿主端失败,已降级保存在本浏览器。",
87
- "panel.foot": "提示:也可在对话中让 AI 用 novel_sentence_config 查看/修改这两个开关。重启 Web 应用后,AI 的工具列表中会出现 novel_sentence_analysis / novel_sentence_config。",
107
+ "panel.foot": "提示:也可在对话中让 AI 用 novel_sentence_config 查看/修改全部开关。",
88
108
  "panel.close": "关闭",
89
- "banner.on": "句式分析已启用",
90
- "banner.onSub": "AI 可以调用 novel_sentence_analysis",
91
- "banner.off": "句式分析已关闭",
92
- "banner.offSub": "AI 不会执行句式分析",
109
+ "banner.on": "写作助手功能已启用",
110
+ "banner.onSub": "句式分析 / 风格自检可用",
111
+ "banner.off": "写作助手功能已关闭",
112
+ "banner.offSub": "句式分析 / 风格自检不可用",
93
113
  "badge.on": "已开启",
94
114
  "badge.off": "已关闭",
115
+ "tool.novel_books": "作品列表 novel_books",
116
+ "tool.novel_chapters": "章节清单 novel_chapters",
117
+ "tool.novel_read": "阅读章节 novel_read",
118
+ "tool.novel_keywords": "关键词分析 novel_keywords",
119
+ "tool.novel_new_chapter": "新建章节 novel_new_chapter",
120
+ "tool.novel_import": "稿件导入 novel_import",
121
+ "tool.novel_sentence_analysis": "句式模式分析 novel_sentence_analysis",
122
+ "tool.novel_sentence_config": "开关配置 novel_sentence_config",
123
+ "tool.novel_style_check": "风格自检 novel_style_check",
124
+ "tool.novel_plot": "伏笔登记 novel_plot",
125
+ "tool.novel_books.desc": "列出书库中的全部作品(章节数/总字数)。",
126
+ "tool.novel_chapters.desc": "查看某本书的章节清单与字数。",
127
+ "tool.novel_read.desc": "阅读章节正文(支持分页与编码自动识别)。",
128
+ "tool.novel_keywords.desc": "提取高频关键词(词组/三字组/疑似人名)。",
129
+ "tool.novel_new_chapter.desc": "创建新章节文件(自动取下一章号)。",
130
+ "tool.novel_import.desc": "批量导入原稿件并自动分类到书库。",
131
+ "tool.novel_sentence_analysis.desc": "句式分布/排列规律/情感曲线/风格指纹。",
132
+ "tool.novel_sentence_config.desc": "查看/修改全部开关(AI 通道)。",
133
+ "tool.novel_style_check.desc": "本章文风与全书其余章节的相似度检查。",
134
+ "tool.novel_plot.desc": "登记/回收剧情伏笔,续写前查看未回收项。",
135
+ "plot.pathLabel": "存储位置",
136
+ "plot.pathUnknown": "位于书库根的 .novel-writer 文件夹(每本书一个 JSON)。先让 AI 调用一次 novel_plot,此处会显示真实路径。",
137
+ "plot.open": "打开文件夹",
138
+ "plot.copy": "复制路径",
139
+ "plot.copied": "已复制",
140
+ "plot.revealOk": "已打开文件夹",
141
+ "plot.revealErr": "打开失败",
95
142
  "card.title": "小说写作助手 novel-writer",
96
143
  "card.desc": "句式模式分析:分析原文陈述/环境/心理/对话/疑问/反问/感叹等句子的排列节奏来辅助模仿文风。⚠️ 若机械套用导致文风僵硬,模型会优先回归自然表达。",
97
- "card.status": "句式模式分析:已启用 · 自动分析:开",
98
- "card.statusOff": "句式模式分析:已关闭",
99
- "card.statusAutoOff": "句式模式分析:已启用 · 自动分析:关",
100
- "card.hint": "开关统一在侧边栏「句式分析」面板(也可让 AI 用 novel_sentence_config 调整),本卡片仅显示状态。",
144
+ "card.status": "写作助手功能:已启用 · 自动分析:开",
145
+ "card.statusOff": "写作助手功能:已关闭",
146
+ "card.statusAutoOff": "写作助手功能:已启用 · 自动分析:关",
147
+ "card.hint": "全部开关统一在侧边栏「写作助手功能」面板(也可让 AI 用 novel_sentence_config 调整),本卡片仅显示状态。",
101
148
  "card.open": "打开开关面板"
102
149
  };
103
150
  var en = {
104
- "entry.label": "Sentence Analysis",
105
- "entry.tooltip": "Sentence-pattern analysis switch (dsh-novel-writer)",
106
- "panel.title": "Sentence Pattern Analysis",
107
- "panel.desc": "Analyzes sentence-type distribution (statement/dialogue/inner-thought/question/rhetoric/exclamation/imperative/ellipsis), arrangement patterns, rhythm, emotion curve and a style fingerprint — so the AI can quickly learn an author's habits and subjective tone.",
108
- "panel.enabled": "Enable sentence analysis",
109
- "panel.enabledHint": "When on, the AI may call novel_sentence_analysis for pattern reports.",
151
+ "entry.label": "Writing Assistant",
152
+ "entry.tooltip": "Writing assistant switches (dsh-novel-writer): analysis / style check / plot tracking etc.",
153
+ "panel.title": "Writing Assistant",
154
+ "panel.desc": "Manage the writing-assistant features: sentence-pattern analysis, style check, plot tracking, import, etc. Disabled tools return a clear notice when called.",
155
+ "panel.enabled": "Enable writing assistant",
156
+ "panel.enabledHint": "Master switch: controls novel_sentence_analysis and novel_style_check.",
110
157
  "panel.autoAnalyze": "Auto-use when analyzing works",
111
158
  "panel.autoAnalyzeHint": "When on, the AI proactively includes sentence analysis; otherwise only on explicit request.",
159
+ "panel.toolsTitle": "Tool switches",
160
+ "panel.toolsHint": "All on by default. Disabled tools return a clear notice; re-enable anytime here.",
112
161
  "panel.loading": "Loading switch state…",
113
162
  "panel.saved": "Saved to the host state file",
114
163
  "panel.localOnly": "Host unreachable; kept in this browser only (defaults may return after restart).",
115
164
  "panel.saveFailed": "Host save failed; fell back to this browser.",
116
- "panel.foot": "Tip: ask the AI to run novel_sentence_config to view/change these switches. After restarting the Web app, novel_sentence_analysis / novel_sentence_config appear in the tool list.",
165
+ "panel.foot": "Tip: ask the AI to run novel_sentence_config to view/change all switches.",
117
166
  "panel.close": "Close",
118
- "banner.on": "Sentence analysis is ON",
119
- "banner.onSub": "novel_sentence_analysis is available",
120
- "banner.off": "Sentence analysis is OFF",
121
- "banner.offSub": "the AI will not run it",
167
+ "banner.on": "Writing assistant is ON",
168
+ "banner.onSub": "analysis / style check available",
169
+ "banner.off": "Writing assistant is OFF",
170
+ "banner.offSub": "analysis / style check unavailable",
122
171
  "badge.on": "ON",
123
172
  "badge.off": "OFF",
173
+ "tool.novel_books": "Books novel_books",
174
+ "tool.novel_chapters": "Chapters novel_chapters",
175
+ "tool.novel_read": "Read novel_read",
176
+ "tool.novel_keywords": "Keywords novel_keywords",
177
+ "tool.novel_new_chapter": "New chapter novel_new_chapter",
178
+ "tool.novel_import": "Import novel_import",
179
+ "tool.novel_sentence_analysis": "Pattern analysis novel_sentence_analysis",
180
+ "tool.novel_sentence_config": "Config novel_sentence_config",
181
+ "tool.novel_style_check": "Style check novel_style_check",
182
+ "tool.novel_plot": "Plot tracking novel_plot",
183
+ "tool.novel_books.desc": "List all books (chapters / total chars).",
184
+ "tool.novel_chapters.desc": "List chapters of a book with sizes.",
185
+ "tool.novel_read.desc": "Read chapter text (paged, auto-encoding).",
186
+ "tool.novel_keywords.desc": "Extract keywords (bigrams / trigrams / name candidates).",
187
+ "tool.novel_new_chapter.desc": "Create a new chapter file (next number auto).",
188
+ "tool.novel_import.desc": "Import raw drafts and auto-classify into the library.",
189
+ "tool.novel_sentence_analysis.desc": "Pattern distribution / rhythm / emotion curve / fingerprint.",
190
+ "tool.novel_sentence_config.desc": "View / change all switches (AI channel).",
191
+ "tool.novel_style_check.desc": "Style similarity of this chapter vs the rest of the book.",
192
+ "tool.novel_plot.desc": "Track / close plot hooks; check before continuing.",
193
+ "plot.pathLabel": "Storage",
194
+ "plot.pathUnknown": "Located in .novel-writer under the library root (one JSON per book). Ask the AI to run novel_plot once to show the real path.",
195
+ "plot.open": "Open folder",
196
+ "plot.copy": "Copy path",
197
+ "plot.copied": "Copied",
198
+ "plot.revealOk": "Opened",
199
+ "plot.revealErr": "Open failed",
124
200
  "card.title": "Novel Writer (dsh-novel-writer)",
125
201
  "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.",
202
+ "card.status": "Writing assistant: ON · auto-analyze: ON",
203
+ "card.statusOff": "Writing assistant: OFF",
204
+ "card.statusAutoOff": "Writing assistant: ON · auto-analyze: OFF",
205
+ "card.hint": "All switches live in the sidebar 'Writing Assistant' panel (or ask the AI to run novel_sentence_config); this card only shows status.",
130
206
  "card.open": "Open the switch panel"
131
207
  };
132
208
  function dictionary() {
@@ -137,6 +213,9 @@ window.__ModuleLoader__.load({
137
213
  return text === void 0 ? key : text;
138
214
  }
139
215
 
216
+ // ---- 工具清单(与宿主端 ALL_TOOLS 一致)----
217
+ var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"];
218
+
140
219
  // ---- 与宿主端同步 ----
141
220
  var STATE_ROUTE = "/api/dsh-novel-writer/state";
142
221
  var STORAGE_KEY = "dsh.novelWriter.state.v1";
@@ -161,12 +240,17 @@ window.__ModuleLoader__.load({
161
240
  var state = {
162
241
  enabled: true,
163
242
  autoAnalyze: true,
243
+ tools: {},
164
244
  file: "",
165
245
  source: "",
166
246
  loading: true,
167
247
  hostOk: true,
168
248
  saveFailed: false,
169
- panelOpen: false
249
+ panelOpen: false,
250
+ plotsDir: "",
251
+ revealMsg: "",
252
+ revealErr: false,
253
+ revealing: false
170
254
  };
171
255
  return {
172
256
  getSnapshot: function () { return state; },
@@ -177,6 +261,36 @@ window.__ModuleLoader__.load({
177
261
  subscribe: function (fn) {
178
262
  listeners.add(fn);
179
263
  return function () { listeners.delete(fn); };
264
+ },
265
+ openPlotsDir: function () {
266
+ controller.set({ revealMsg: "", revealErr: false, revealing: true });
267
+ fetch("/api/dsh-novel-writer/reveal", {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({ target: "plots-dir" })
271
+ }).then(function (r) { return r.json(); }).then(function (data) {
272
+ if (data.ok === true) {
273
+ controller.set({ plotsDir: data.path || controller.getSnapshot().plotsDir, revealMsg: t("plot.revealOk") + ":" + (data.path || ""), revealErr: false });
274
+ } else {
275
+ controller.set({ revealMsg: data.error || t("plot.revealErr"), revealErr: true });
276
+ }
277
+ }).catch(function () {
278
+ controller.set({ revealMsg: t("plot.revealErr") + ":宿主端不可达", revealErr: true });
279
+ }).finally(function () {
280
+ controller.set({ revealing: false });
281
+ });
282
+ },
283
+ copyPlotsPath: function () {
284
+ var dir = controller.getSnapshot().plotsDir;
285
+ if (dir === "") return;
286
+ var done = function () {
287
+ controller.set({ revealMsg: t("plot.copied") + ":" + dir, revealErr: false });
288
+ };
289
+ if (navigator.clipboard && navigator.clipboard.writeText) {
290
+ navigator.clipboard.writeText(dir).then(done, done);
291
+ } else {
292
+ done();
293
+ }
180
294
  }
181
295
  };
182
296
  }
@@ -338,6 +452,57 @@ window.__ModuleLoader__.load({
338
452
  react.createElement("span", { className: "nwBadge " + (state.autoAnalyze ? "nwBadgeOn" : "nwBadgeOff") }, state.autoAnalyze ? t("badge.on") : t("badge.off"))
339
453
  )
340
454
  ),
455
+ react.createElement("div", { className: "nwSectionTitle" }, t("panel.toolsTitle")),
456
+ react.createElement("div", { className: "nwToolsHint" }, t("panel.toolsHint")),
457
+ ALL_TOOLS.map(function (name) {
458
+ var checked = (state.tools || {})[name] !== false;
459
+ var desc = t("tool." + name + ".desc");
460
+ var plotExtra = null;
461
+ if (name === "novel_plot") {
462
+ var pathText = state.plotsDir !== "" ? state.plotsDir : t("plot.pathUnknown");
463
+ plotExtra = react.createElement(
464
+ "div",
465
+ { className: "nwPlotBox" },
466
+ react.createElement("div", { className: "nwPlotPath", title: state.plotsDir !== "" ? state.plotsDir : "" }, t("plot.pathLabel") + ":" + pathText),
467
+ react.createElement(
468
+ "div",
469
+ { className: "nwBtnGroup" },
470
+ react.createElement("button", {
471
+ type: "button", className: "nwBtn", disabled: state.plotsDir === "" || state.revealing,
472
+ onClick: function () { props.controller.openPlotsDir(); }
473
+ }, t("plot.open")),
474
+ react.createElement("button", {
475
+ type: "button", className: "nwBtn", disabled: state.plotsDir === "",
476
+ onClick: function () { props.controller.copyPlotsPath(); }
477
+ }, t("plot.copy"))
478
+ ),
479
+ state.revealMsg !== "" ? react.createElement("div", { className: "nwPlotMsg " + (state.revealErr ? "nwPlotErr" : "") }, state.revealMsg) : null
480
+ );
481
+ }
482
+ return react.createElement(
483
+ "div",
484
+ { className: "nwToolRow" + (checked ? " nwToolRowOn" : ""), key: name },
485
+ react.createElement(
486
+ "div",
487
+ { className: "nwToolLabel", title: desc },
488
+ react.createElement("div", null, t("tool." + name)),
489
+ react.createElement("div", { className: "nwToolDesc" }, desc),
490
+ plotExtra
491
+ ),
492
+ react.createElement(
493
+ "button",
494
+ {
495
+ type: "button",
496
+ role: "switch",
497
+ "aria-checked": checked ? "true" : "false",
498
+ className: "nwSwitchSmall" + (checked ? " nwSwitchSmallOn" : ""),
499
+ disabled: state.loading,
500
+ onClick: function () { props.toggle({ tools: (function (patch) { patch[name] = !checked; return patch; })({}) }); }
501
+ },
502
+ react.createElement("span", { className: "nwSwitchSmallKnob" })
503
+ )
504
+ );
505
+ }),
341
506
  react.createElement(
342
507
  "div",
343
508
  { className: "nwStatus" },
@@ -480,6 +645,8 @@ window.__ModuleLoader__.load({
480
645
  controller.set({
481
646
  enabled: !!remote.enabled,
482
647
  autoAnalyze: !!remote.autoAnalyze,
648
+ tools: remote.tools || {},
649
+ plotsDir: remote.plotsDir || "",
483
650
  file: remote.file || "",
484
651
  source: "host",
485
652
  hostOk: true,
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { basename, dirname, extname, join, sep } from "node:path";
27
27
  import { homedir } from "node:os";
28
28
  import { createHash } from "node:crypto";
29
+ import { execFile } from "node:child_process";
29
30
  import { analyzeText, CATEGORY_LABELS, CATEGORY_ORDER, fingerprintSimilarity, styleDiffs } from "./analysis.js";
30
31
 
31
32
  const name = "novel-writer";
@@ -442,6 +443,7 @@ function registerNovelImport(ctx, config) {
442
443
  render: (_args, value) => [{ type: "text", text: formatImport(value) }]
443
444
  },
444
445
  async execute(args, exec) {
446
+ await assertToolEnabled(config, "novel_import");
445
447
  const src = requiredString(args, "src");
446
448
  const mode = args?.mode === "apply" ? "apply" : "scan";
447
449
  const forcedBook = optionalString(args, "book");
@@ -567,6 +569,27 @@ function registerNovelImport(ctx, config) {
567
569
  /** 句式分析默认配置。 */
568
570
  const SENTENCE_ANALYSIS_DEFAULTS = Object.freeze({ enabled: true, autoAnalyze: true });
569
571
 
572
+ /** v0.7.0:全部工具清单(每个工具都有独立 UI 开关,默认全开)。 */
573
+ const ALL_TOOLS = Object.freeze([
574
+ "novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter",
575
+ "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot"
576
+ ]);
577
+ const TOOL_LABELS = Object.freeze({
578
+ novel_books: "作品列表", novel_chapters: "章节清单", novel_read: "阅读章节", novel_keywords: "关键词分析",
579
+ novel_new_chapter: "新建章节", novel_import: "稿件导入", novel_sentence_analysis: "句式模式分析",
580
+ novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记"
581
+ });
582
+ /** 工具级开关是否开启(默认开;state.tools[name] === false 时关闭)。 */
583
+ function toolEnabled(state, name) {
584
+ const tools = state?.tools ?? {};
585
+ return tools[name] !== false;
586
+ }
587
+ /** 断言工具开关:关闭时抛出明确提示(模型可见)。 */
588
+ async function assertToolEnabled(config, name) {
589
+ const state = await readSentenceState();
590
+ assert(toolEnabled(state, name), `工具 ${name}(${TOOL_LABELS[name] ?? name})当前已在「写作助手功能」UI 中关闭。可用 novel_sentence_config 或侧边栏面板重新开启。`);
591
+ }
592
+
570
593
  /** 运行时状态文件路径(Web GUI 侧边栏「句式分析」开关落盘位置)。 */
571
594
  function stateFilePath() {
572
595
  return join(homedir(), ".dsh", "dsh-novel-writer", "state.json");
@@ -602,7 +625,9 @@ async function readSentenceState() {
602
625
  return {
603
626
  exists: true,
604
627
  enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : SENTENCE_ANALYSIS_DEFAULTS.enabled,
605
- autoAnalyze: typeof parsed.autoAnalyze === "boolean" ? parsed.autoAnalyze : SENTENCE_ANALYSIS_DEFAULTS.autoAnalyze
628
+ autoAnalyze: typeof parsed.autoAnalyze === "boolean" ? parsed.autoAnalyze : SENTENCE_ANALYSIS_DEFAULTS.autoAnalyze,
629
+ tools: parsed.tools !== null && typeof parsed.tools === "object" ? parsed.tools : {},
630
+ lastRoot: typeof parsed.lastRoot === "string" ? parsed.lastRoot : void 0
606
631
  };
607
632
  } catch {
608
633
  return { exists: false, ...SENTENCE_ANALYSIS_DEFAULTS };
@@ -615,7 +640,9 @@ async function writeSentenceState(patch) {
615
640
  const next = {
616
641
  ...current,
617
642
  ...(typeof patch?.enabled === "boolean" ? { enabled: patch.enabled } : {}),
618
- ...(typeof patch?.autoAnalyze === "boolean" ? { autoAnalyze: patch.autoAnalyze } : {})
643
+ ...(typeof patch?.autoAnalyze === "boolean" ? { autoAnalyze: patch.autoAnalyze } : {}),
644
+ ...(patch?.tools !== null && typeof patch?.tools === "object" ? { tools: { ...(current.tools ?? {}), ...patch.tools } } : {}),
645
+ ...(typeof patch?.lastRoot === "string" ? { lastRoot: patch.lastRoot } : {})
619
646
  };
620
647
  const file = stateFilePath();
621
648
  await mkdir(dirname(file), { recursive: true });
@@ -691,15 +718,34 @@ async function readJsonBody(req) {
691
718
  try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return void 0; }
692
719
  }
693
720
 
694
- /** /api/dsh-novel-writer/state 路由:GET 读开关,POST 写开关。 */
695
- function makeStateRoutes(allowLan) {
721
+ /** 打开系统文件管理器定位到某目录(Windows: explorer /select)。 */
722
+ function openInExplorer(dir) {
723
+ if (process.platform === "win32") {
724
+ execFile("explorer", ["/select,", dir], () => {});
725
+ } else if (process.platform === "darwin") {
726
+ execFile("open", [dir], () => {});
727
+ } else {
728
+ execFile("xdg-open", [dir], () => {});
729
+ }
730
+ }
731
+
732
+ /** /api/dsh-novel-writer/state 路由:GET 读开关,POST 写开关;/reveal 打开文件夹。 */
733
+ function makeStateRoutes(allowLan, config) {
696
734
  const routePath = "/api/dsh-novel-writer/state";
735
+ const revealPath = "/api/dsh-novel-writer/reveal";
697
736
  const handler = async (req, res) => {
698
737
  if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
699
738
  const method = req.method ?? "GET";
700
739
  if (method === "GET") {
701
740
  const state = await readSentenceState();
702
- return writeJson(res, 200, { enabled: state.enabled, autoAnalyze: state.autoAnalyze, file: stateFilePath() });
741
+ const plotsRoot = config?.root ?? state.lastRoot;
742
+ return writeJson(res, 200, {
743
+ enabled: state.enabled,
744
+ autoAnalyze: state.autoAnalyze,
745
+ tools: state.tools ?? {},
746
+ plotsDir: typeof plotsRoot === "string" && plotsRoot.length > 0 ? join(plotsRoot, ".novel-writer") : "",
747
+ file: stateFilePath()
748
+ });
703
749
  }
704
750
  if (method === "POST") {
705
751
  const body = await readJsonBody(req);
@@ -713,7 +759,32 @@ function makeStateRoutes(allowLan) {
713
759
  }
714
760
  return writeJson(res, 405, { error: `method not allowed: ${method}` });
715
761
  };
716
- return [{ kind: "exact", path: routePath, handler }];
762
+ const revealHandler = async (req, res) => {
763
+ if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
764
+ if ((req.method ?? "GET") !== "POST") return writeJson(res, 405, { error: "method not allowed" });
765
+ const body = await readJsonBody(req);
766
+ const target = body?.target;
767
+ if (target !== "plots-dir") return writeJson(res, 400, { error: 'unknown target (allowed: "plots-dir")' });
768
+ const state = await readSentenceState();
769
+ const plotsRoot = config?.root ?? state.lastRoot;
770
+ if (typeof plotsRoot !== "string" || plotsRoot.length === 0) {
771
+ return writeJson(res, 404, { error: "书库根未知:先在对话中调用一次 novel_plot(或配置插件 root),即可在 UI 中打开伏笔文件夹。" });
772
+ }
773
+ const dir = join(plotsRoot, ".novel-writer");
774
+ try {
775
+ await mkdir(dir, { recursive: true });
776
+ } catch { /* 目录已存在或创建失败都不阻塞打开 */ }
777
+ try {
778
+ openInExplorer(dir);
779
+ return writeJson(res, 200, { ok: true, path: dir });
780
+ } catch (error) {
781
+ return writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
782
+ }
783
+ };
784
+ return [
785
+ { kind: "exact", path: routePath, handler },
786
+ { kind: "exact", path: revealPath, handler: revealHandler }
787
+ ];
717
788
  }
718
789
 
719
790
  /**
@@ -723,7 +794,7 @@ function makeStateRoutes(allowLan) {
723
794
  function registerStyleConfigRoute(ctx, config) {
724
795
  try {
725
796
  ctx.inject(["webServer"], (wctx) => {
726
- const routes = makeStateRoutes(config?.allowLanState === true);
797
+ const routes = makeStateRoutes(config?.allowLanState === true, config);
727
798
  wctx.effect?.(() => {
728
799
  const disposers = routes.map((route) => wctx.webServer.register(route));
729
800
  return () => {
@@ -945,6 +1016,7 @@ function registerNovelSentenceAnalysis(ctx, config) {
945
1016
  render: (_args, value) => [{ type: "text", text: formatSentenceAnalysis(value) }]
946
1017
  },
947
1018
  async execute(args, exec) {
1019
+ await assertToolEnabled(config, "novel_sentence_analysis");
948
1020
  const book = sanitizeSegment(requiredString(args, "book"), "book");
949
1021
  const chapterArg = optionalString(args, "chapter");
950
1022
  const top = optionalInt(args, "top", 1, 50, 8);
@@ -1027,8 +1099,9 @@ function registerNovelSentenceConfig(ctx, config) {
1027
1099
  type: "object",
1028
1100
  properties: {
1029
1101
  action: { type: "string", enum: ["get", "set"], description: "get=查看(默认);set=修改。" },
1030
- enabled: { type: "boolean", description: "set 时指定:是否启用句式模式分析。" },
1031
- autoAnalyze: { type: "boolean", description: "set 时指定:分析作品时是否主动使用句式分析。" }
1102
+ enabled: { type: "boolean", description: "set 时指定:写作助手功能总开关。" },
1103
+ autoAnalyze: { type: "boolean", description: "set 时指定:分析作品时是否主动使用句式分析。" },
1104
+ tools: { type: "object", additionalProperties: true, description: "set 时指定:各工具开关(如 { novel_plot: false }),键必须是 novel_* 工具名。" }
1032
1105
  },
1033
1106
  additionalProperties: false
1034
1107
  },
@@ -1041,23 +1114,31 @@ function registerNovelSentenceConfig(ctx, config) {
1041
1114
  enabled: { type: "boolean" },
1042
1115
  autoAnalyze: { type: "boolean" },
1043
1116
  source: { type: "string" },
1044
- updated: { type: "boolean" }
1117
+ updated: { type: "boolean" },
1118
+ tools: { type: "object", additionalProperties: true, description: "各工具当前开关状态。" }
1045
1119
  },
1046
- required: ["file", "enabled", "autoAnalyze"]
1120
+ required: ["file", "enabled", "autoAnalyze", "tools"]
1047
1121
  },
1048
- render: (_args, value) => [{
1049
- type: "text",
1050
- text: `<path>${value.file}</path>
1122
+ render: (_args, value) => {
1123
+ const toolLines = ALL_TOOLS.map((name) => ` - ${TOOL_LABELS[name] ?? name} (${name}): ${value.tools?.[name] === false ? "关闭" : "开启"}`);
1124
+ return [{
1125
+ type: "text",
1126
+ text: `<path>${value.file}</path>
1051
1127
  <type>novel-sentence-config</type>
1052
1128
  <content>
1053
- 句式模式分析: ${value.enabled ? "已启用" : "已关闭"}
1129
+ 写作助手功能: ${value.enabled ? "已启用" : "已关闭"}
1054
1130
  自动分析(autoAnalyze): ${value.autoAnalyze ? "开" : "关"}
1055
1131
  来源: ${value.source}
1056
1132
  ${value.updated === true ? "(已保存到 state 文件)" : ""}
1133
+
1134
+ 各工具开关:
1135
+ ${toolLines.join("\n")}
1057
1136
  </content>`
1058
- }]
1137
+ }];
1138
+ }
1059
1139
  },
1060
1140
  async execute(args) {
1141
+ await assertToolEnabled(config, "novel_sentence_config");
1061
1142
  const action = args?.action === "set" ? "set" : "get";
1062
1143
  const state = await readSentenceState();
1063
1144
  const effective = effectiveSentenceAnalysis(config, state);
@@ -1067,6 +1148,13 @@ ${value.updated === true ? "(已保存到 state 文件)" : ""}
1067
1148
  const patch = {};
1068
1149
  if (typeof args?.enabled === "boolean") patch.enabled = args.enabled;
1069
1150
  if (typeof args?.autoAnalyze === "boolean") patch.autoAnalyze = args.autoAnalyze;
1151
+ if (args?.tools !== null && typeof args?.tools === "object") {
1152
+ const toolsPatch = {};
1153
+ for (const name of ALL_TOOLS) {
1154
+ if (typeof args.tools[name] === "boolean") toolsPatch[name] = args.tools[name];
1155
+ }
1156
+ if (Object.keys(toolsPatch).length > 0) patch.tools = toolsPatch;
1157
+ }
1070
1158
  if (Object.keys(patch).length > 0) {
1071
1159
  current = await writeSentenceState(patch);
1072
1160
  updated = true;
@@ -1077,7 +1165,9 @@ ${value.updated === true ? "(已保存到 state 文件)" : ""}
1077
1165
  const source = current?.exists === true
1078
1166
  ? "state 文件(GUI 开关)"
1079
1167
  : (styleFileOn ? "v0.4.0 兼容文件 novel-writer.json" : (config?.sentenceAnalysis || config?.stylePattern ? "插件 config" : "默认值"));
1080
- return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated };
1168
+ const tools = {};
1169
+ for (const name of ALL_TOOLS) tools[name] = toolEnabled(current, name);
1170
+ return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated, tools };
1081
1171
  }
1082
1172
  });
1083
1173
  }
@@ -1148,10 +1238,14 @@ function registerNovelStyleCheck(ctx, config) {
1148
1238
  }
1149
1239
  },
1150
1240
  async execute(args, exec) {
1241
+ await assertToolEnabled(config, "novel_style_check");
1151
1242
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1152
1243
  const chapterArg = requiredString(args, "chapter");
1153
1244
  const root = resolveRoot(config, args, exec);
1154
1245
  const dir = bookDir(root, book);
1246
+ const state = await readSentenceState();
1247
+ const effective = effectiveSentenceAnalysis(config, state);
1248
+ assert(effective.enabled, "写作助手功能(句式模式分析)当前已关闭,可在侧边栏「写作助手功能」面板开启后再做风格自检。");
1155
1249
  const chapters = await scanChapters(dir);
1156
1250
  assert(chapters.length > 1, `book "${book}" 只有 ${chapters.length} 章,无法做风格对比(至少需要 2 章)`);
1157
1251
  const target = findChapter(chapters, chapterArg);
@@ -1203,6 +1297,17 @@ async function writePlots(root, book, entries) {
1203
1297
  await mkdir(dirname(file), { recursive: true });
1204
1298
  await writeFile(file, JSON.stringify({ book, entries }, null, 2), "utf8");
1205
1299
  }
1300
+
1301
+ /** v0.7.0:清洗伏笔条目为 lossless JSON(剔除 undefined 字段,DSH 通道要求)。 */
1302
+ function normalizePlotEntry(entry) {
1303
+ const out = { id: entry.id, content: entry.content, status: entry.status };
1304
+ if (entry.chapter !== void 0 && entry.chapter !== null) out.chapter = String(entry.chapter);
1305
+ if (entry.note !== void 0 && entry.note !== null) out.note = String(entry.note);
1306
+ if (entry.createdAt !== void 0) out.createdAt = entry.createdAt;
1307
+ if (entry.updatedAt !== void 0) out.updatedAt = entry.updatedAt;
1308
+ return out;
1309
+ }
1310
+
1206
1311
  function registerNovelPlot(ctx, config) {
1207
1312
  ctx.tools.register({
1208
1313
  name: "novel_plot",
@@ -1266,6 +1371,7 @@ function registerNovelPlot(ctx, config) {
1266
1371
  }
1267
1372
  },
1268
1373
  async execute(args, exec) {
1374
+ await assertToolEnabled(config, "novel_plot");
1269
1375
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1270
1376
  const action = ["add", "update", "done", "delete"].includes(args?.action) ? args.action : "list";
1271
1377
  const root = resolveRoot(config, args, exec);
@@ -1299,8 +1405,9 @@ function registerNovelPlot(ctx, config) {
1299
1405
  }
1300
1406
  }
1301
1407
  await writePlots(root, book, entries);
1408
+ try { await writeSentenceState({ lastRoot: root }); } catch { /* 记录失败不影响伏笔功能 */ }
1302
1409
  entries.sort((a, b) => (a.status === b.status ? a.createdAt.localeCompare(b.createdAt) : a.status === "open" ? -1 : 1));
1303
- return { book, action, entries, ...message ? { message } : {} };
1410
+ return { book, action, entries: entries.map(normalizePlotEntry), ...message ? { message } : {} };
1304
1411
  }
1305
1412
  });
1306
1413
  }
@@ -1346,6 +1453,7 @@ function registerNovelBooks(ctx, config) {
1346
1453
  }]
1347
1454
  },
1348
1455
  async execute(args, exec) {
1456
+ await assertToolEnabled(config, "novel_books");
1349
1457
  const root = resolveRoot(config, args, exec);
1350
1458
  const dir = novelsDir(root);
1351
1459
  let entries;
@@ -1430,6 +1538,7 @@ function registerNovelChapters(ctx, config) {
1430
1538
  }]
1431
1539
  },
1432
1540
  async execute(args, exec) {
1541
+ await assertToolEnabled(config, "novel_chapters");
1433
1542
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1434
1543
  const root = resolveRoot(config, args, exec);
1435
1544
  const dir = bookDir(root, book);
@@ -1512,6 +1621,7 @@ function registerNovelRead(ctx, config) {
1512
1621
  }]
1513
1622
  },
1514
1623
  async execute(args, exec) {
1624
+ await assertToolEnabled(config, "novel_read");
1515
1625
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1516
1626
  const chapterArg = requiredString(args, "chapter");
1517
1627
  const offset = optionalInt(args, "offset", 1, Number.MAX_SAFE_INTEGER, 1);
@@ -1621,6 +1731,7 @@ function registerNovelKeywords(ctx, config) {
1621
1731
  }]
1622
1732
  },
1623
1733
  async execute(args, exec) {
1734
+ await assertToolEnabled(config, "novel_keywords");
1624
1735
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1625
1736
  const chapterArg = optionalString(args, "chapter");
1626
1737
  const top = optionalInt(args, "top", 1, 100, 20);
@@ -1702,6 +1813,7 @@ Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
1702
1813
  }]
1703
1814
  },
1704
1815
  async execute(args, exec) {
1816
+ await assertToolEnabled(config, "novel_new_chapter");
1705
1817
  const book = sanitizeSegment(requiredString(args, "book"), "book");
1706
1818
  const explicit = optionalInt(args, "chapter", 1, 99999, void 0);
1707
1819
  const title = optionalString(args, "title");
@@ -1742,8 +1854,9 @@ const WORKFLOW_TEXT = [
1742
1854
  " 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
1743
1855
  "4. 句式模式分析(增强,受 UI 开关控制):分析作品时可调用 novel_sentence_analysis 提取句式分布(陈述/环境/心理/对话/疑问/反问/感叹/祈使/省略留白九类)、",
1744
1856
  " 句式排列规律(转移、高频模板、段首段尾句式、按章节的压缩节奏序列)、句长节奏、情感曲线、风格指纹与节奏建议,用于快速掌握作者的写作习惯与主观情感。",
1745
- " 开关默认开启;统一在 Web GUI 侧边栏「句式分析」面板调整(设置 > 插件配置 仅显示状态),也可用 novel_sentence_config。动手前可先调用 novel_sentence_config 查看状态:",
1746
- " - enabled=false 时 novel_sentence_analysis 会返回关闭状态,不要强行分析;",
1857
+ " 开关默认开启;统一在 Web GUI 侧边栏「写作助手功能」面板调整(设置 > 插件配置 仅显示状态),也可用 novel_sentence_config。动手前可先调用 novel_sentence_config 查看状态:",
1858
+ " - 总开关 enabled=false 时 novel_sentence_analysis / novel_style_check 会拒绝执行,不要强行分析;",
1859
+ " - 每个工具都有独立开关(novel_sentence_config 返回 tools 字段):关闭的工具调用时会返回明确提示,按提示引导用户开启或改用其他方式;",
1747
1860
  " - autoAnalyze=true 时分析作品应主动附带句式分析;false 时仅在用户明确要求时调用。",
1748
1861
  " 重要:句式模式是「参考节奏」而非「模板套用」。若机械复刻导致句子僵硬、重复、模式化,必须优先回归自然表达。",
1749
1862
  "5. 导入/整理原稿件时:用 novel_import 扫描存放多本小说稿件的文件夹(src),先以 scan 模式查看分组建议,",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "0.6.0",
4
- "description": "小说写作助手插件(v0.6.0 工程化版):章节库管理、句式模式分析(九类/情感曲线/风格指纹/缓存报告)、风格自检、伏笔登记表、批量导入、续写辅助(关键词含三字组与疑似人名、情感词典去噪、支持任意位置章号、UTF-8/16/GBK 编码探测)",
3
+ "version": "0.7.0",
4
+ "description": "小说写作助手插件(v0.7.0):10 个工具全部带独立 UI 开关(侧边栏「写作助手功能」面板统一管理)、句式分析/风格自检/伏笔登记/批量导入、分析缓存与报告导出、情感词典去噪、编码探测",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/test/e2e-test.mjs CHANGED
@@ -25,6 +25,33 @@ if (names.length !== 10) throw new Error("expected 10 tools");
25
25
 
26
26
  const exec = { agent: { session: { header: { cwd: testRoot } } } };
27
27
 
28
+ // ---- DSH schema 规则检查(additionalProperties 必须布尔;禁用 pattern/format/数值边界关键字)----
29
+ const SCHEMA_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "items", "enum", "const", "oneOf"]);
30
+ const SCHEMA_BANNED = ["pattern", "format", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"];
31
+ function assertDshSchema(node, where) {
32
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return;
33
+ for (const key of Object.keys(node)) {
34
+ const v = node[key];
35
+ if (!SCHEMA_KEYWORDS.has(key)) continue;
36
+ if (key === "additionalProperties" && typeof v !== "boolean") throw new Error(where + ".additionalProperties must be boolean: " + JSON.stringify(v).slice(0, 60));
37
+ if (key === "type" && typeof v !== "string") throw new Error(where + ".type must be string");
38
+ if (key === "properties") {
39
+ if (v === null || typeof v !== "object") throw new Error(where + ".properties must be object");
40
+ for (const field of Object.keys(v)) assertDshSchema(v[field], where + ".properties." + field);
41
+ } else if (typeof v === "object" && v !== null) {
42
+ assertDshSchema(v, where + "." + key);
43
+ }
44
+ }
45
+ for (const banned of SCHEMA_BANNED) {
46
+ if (Object.prototype.hasOwnProperty.call(node, banned)) throw new Error(where + "." + banned + " not supported by DSH");
47
+ }
48
+ }
49
+ for (const d of registry) {
50
+ if (d.parameters) assertDshSchema(d.parameters, d.name + ".parameters");
51
+ if (d.output?.schema) assertDshSchema(d.output.schema, d.name + ".output");
52
+ }
53
+ console.log("DSH schema 规则检查: 全部通过");
54
+
28
55
  // 1) 分析 + 缓存(第一次 miss,第二次 hit)
29
56
  const first = await defs.novel_sentence_analysis.execute({ book: "测试" }, exec);
30
57
  console.log("首次分析 cache:", first.cache, "| reportFile:", first.reportFile?.includes("cache"));