dsh-novel-writer 2.6.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -8,11 +8,10 @@ English | [**中文**](./README.md)
8
8
  [![GitHub release](https://img.shields.io/github/v/release/siweina/dsh-novel-writer.svg?style=flat-square)](https://github.com/siweina/dsh-novel-writer/releases)
9
9
  [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4b8bbe.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
10
10
 
11
- A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v2.6.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **12-axis vibe spectrum**, **style portrait report**, plot & settings management, local semantic search (0 token), webnovel signal detection, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host. **Requires Node ≥ 22.3.**
11
+ A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v3.0.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **6-dimension writing-metric baseline band**, 12-axis vibe spectrum, **style portrait report**, plot & settings management, local semantic search (0 token), webnovel signal detection, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host. **Requires Node ≥ 22.3.**
12
12
 
13
13
  > **A note to non-Chinese users**: This plugin is designed specifically for Chinese-language novel analysis and writing — its core capabilities (sentence-pattern analysis, emotion quantification, imagery detection) and its built-in semantic model are all built and tuned for Chinese text. Fully supporting English or other languages alongside Chinese is beyond my current capability. I sincerely apologize for any inconvenience this may cause.
14
14
 
15
-
16
15
  ---
17
16
 
18
17
  ## Installation
@@ -31,20 +30,21 @@ npm install dsh-novel-writer
31
30
  dsh plugin --profile web add github:siweina/dsh-novel-writer#main
32
31
  ```
33
32
 
34
- After installing, **restart the web app** to activate (host registers 15 tools + state/reveal routes; browser mounts the "Writing Assistant" sidebar toggle panel).
33
+ After installing, **restart the web app** to activate (host registers 15 tools + state/reveal/update-check routes; browser mounts the "Writing Assistant" sidebar panel).
35
34
 
36
35
  ---
37
36
 
38
37
  ## Features
39
38
 
40
- 1. **Style portrait report** (`novel_style_report`): 6-dimension measurement — style fingerprint / high-frequency lexicon / genre-theme / emotion quantification / 12-axis vibe spectrum / semantic style distance. **Measurement-judgment separation**: the plugin only reports numbers, never labels; AI judgment can be saved back to `.novel-writer/style-reports/` for consistent continuation writing.
41
- 2. **12-axis vibe spectrum**: nightmare / angst / heartwarming / fluff / tearjerker / dark / mystery / blaze / absurd / lonesome / aesthetic / sensual with traceable evidence, 0 token.
42
- 3. **Local semantic engine**: bge-small-zh Chinese model (23MB, shipped with the plugin) local CPU inference `novel_semantic_search` finds semantically related passages with natural language (with chapter location), semantic style comparison, semantic implicit emotion; lazy loading + graceful fallback.
43
- 4. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
44
- 5. **Emotion purification & quantification**: strong/weak emotion-word grading, pollution detection, caveat warning + AI re-verification; Valence sliding window variance V / delta Δ / conflict index C + implicit imagery carriers.
45
- 6. **Worldview & pragmatics detection**: auto cultural-baseline detection with confidence; speechStyle title/honorifics/rituals/tone norms; genre & theme + webnovel signals.
46
- 7. **Writing toolkit**: plot tracking / five settings tables (characters·locations·items·timeline·worldview) / chapter summaries / continuity audit / batch import / style check / continuation writing.
47
- 8. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + grouped tool toggles + feature toggles), plain-language labels, data-dir usage & semantic-engine status display.
39
+ 1. **6-dimension writing-metric baseline band** (v3.0.0 core, `novel_style_report` / `novel_style_check`): sentence complexity / modifier density / abstractness / action density / uncertainty / gap (whitespace) index. Per-chapter μ±σ forms the book's baseline band; new chapters are judged dimension by dimension (in-band / half-band △ / out-of-band ⚠). **Themes, plots and characters stay free — the band governs writing style only, never content.**
40
+ 2. **Recommended tolerance (ready to use)**: each dimension auto-suggests a tolerance of **1.5× the book's chapter σ** (rounded to 5%, capped at ±10%~100%); users can also set custom per-dimension ±% in the "Writing Assistant → Style Baseline" panel (signs fixed by position, inputs accept 0–100 only).
41
+ 3. **Style portrait report** (`novel_style_report`): 6-dimension measurement style fingerprint / high-frequency lexicon / genre-theme / emotion quantification / 12-axis vibe spectrum / semantic style distance. **Measurement-judgment separation**: the plugin only reports numbers, never labels; AI judgment can be saved back.
42
+ 4. **12-axis vibe spectrum**: nightmare / angst / heartwarming / fluff / tearjerker / dark / mystery / blaze / absurd / lonesome / aesthetic / sensual — with traceable evidence, 0 token.
43
+ 5. **Local semantic engine**: bge-small-zh Chinese model (24MB, shipped with the plugin) local CPU inference — natural-language search over the whole book (with chapter location), semantic style comparison, semantic implicit emotion; lazy loading + graceful fallback.
44
+ 6. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
45
+ 7. **Emotion purification & quantification**: strong/weak emotion-word grading, pollution detection, caveat warning + AI re-verification; Valence sliding window variance V / delta Δ / conflict index C.
46
+ 8. **Writing toolkit**: plot tracking / five settings tables / chapter summaries / continuity audit / batch import / style check / continuation writing / update check.
47
+ 9. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + grouped tool toggles + feature toggles + style baseline tolerance), data-dir usage & semantic-engine status display.
48
48
 
49
49
  ---
50
50
 
@@ -60,8 +60,8 @@ After installing, **restart the web app** to activate (host registers 15 tools +
60
60
  | `novel_import` | Batch import manuscripts |
61
61
  | `novel_sentence_analysis` | Sentence-pattern analysis |
62
62
  | `novel_sentence_config` | View/set tool & feature toggles |
63
- | `novel_style_check` | Style check (rule + semantic) |
64
- | `novel_style_report` | **Style portrait report** (6-dim measurement) |
63
+ | `novel_style_check` | Style check (rule + semantic + **6-dim baseline**) |
64
+ | `novel_style_report` | Style portrait (6-dim measurement + **baseline band**) |
65
65
  | `novel_plot` | Plot/foreshadowing tracker |
66
66
  | `novel_settings` | Settings management (+worldview) |
67
67
  | `novel_summary` | Chapter summaries |
package/README.md CHANGED
@@ -8,11 +8,10 @@
8
8
  [![GitHub release](https://img.shields.io/github/v/release/siweina/dsh-novel-writer.svg?style=flat-square)](https://github.com/siweina/dsh-novel-writer/releases)
9
9
  [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4b8bbe.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
10
10
 
11
- 为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v2.6.0):章节库管理、句式分析、情感净化与量化、氛围光谱、**风格画像报告**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。**要求 Node ≥ 22.3。**
11
+ 为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v3.0.0):章节库管理、句式分析、情感净化与量化、**文笔六维基线带**、氛围光谱、**风格画像报告**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。**要求 Node ≥ 22.3。**
12
12
 
13
13
  > **致非中文用户**:本插件为中文小说分析写作而设计——句式、情感、意象等核心能力以及内置的语义模型,全部针对中文语料构建与调优。在深耕中文的同时兼顾英文等其他语言,确实超出了我目前的能力范围。若因此给您带来不便,我深感抱歉,恳请谅解。
14
14
 
15
-
16
15
  ---
17
16
 
18
17
  ## 安装
@@ -31,20 +30,21 @@ npm install dsh-novel-writer
31
30
  dsh plugin --profile web add github:siweina/dsh-novel-writer#main
32
31
  ```
33
32
 
34
- 安装后**重启 web 应用**生效(宿主端注册 15 个工具与 state/reveal 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
33
+ 安装后**重启 web 应用**生效(宿主端注册 15 个工具与 state/reveal/update-check 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
35
34
 
36
35
  ---
37
36
 
38
37
  ## 功能
39
38
 
40
- 1. **风格画像报告**(novel_style_report):6 维测量报告——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 / 语义风格距离。**测量与判断分离**:插件只报数不贴标签,AI 判断可回传存盘(`.novel-writer/style-reports/`),续写保持风格一致。
41
- 2. **氛围光谱 12 轴**:噩梦感 / 焦虑压抑 / 温馨治愈 / 甜宠日常 / 催泪虐心 / 黑暗残酷 / 悬疑神秘 / 热血激昂 / 荒诞无厘头 / 孤独疏离 / 文艺唯美 / 情欲暧昧——证据链可追溯,0 token。
42
- 3. **本地语义引擎**:bge-small-zh 中文模型(23MB 随插件分发)本地 CPU 推理——`novel_semantic_search` 自然语言搜全书语义相关段落(带章节定位),语义级风格对比、语义隐性情感,懒加载 + 自动回退。
43
- 4. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹与节奏建议,带缓存与报告导出。
44
- 5. **情感净化 + 量化**:强/弱情绪词分级、污染源检测、caveat 预警 + AI 复核;Valence 滑动窗口 → 方差 V / 斜率 Δ / 矛盾指数 C + 隐性意象载体。
45
- 6. **世界观与语用检测**:文化基准自动判断(西/东/混合)+ 置信度;speechStyle 称谓/客套/仪式/语气规范;题材流派 + 网文信号。
46
- 7. **写作辅助全家桶**:伏笔登记表 / 设定五张表(人物·地点·道具·时间线·世界观)/ 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 续写辅助。
47
- 8. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 工具开关分组 + 功能开关),大白话文案,显示数据目录占用与语义引擎状态。
39
+ 1. **文笔六维基线带**(v3.0.0 核心,novel_style_report / novel_style_check):六维量化指标——句法复杂度 / 修饰密度 / 抽象度 / 动作密度 / 不确定性 / 留白指数;每章测 μ±σ 形成原著基线带,新章逐维对照判定(带内 ✓ / 半带 / 出带 ⚠)。**主题/情节/人物完全自由——基线只管写法层面,不碰内容。**
40
+ 2. **推荐容差(上手即用)**:基线带每维自动给出推荐容差 = 原著章节波动的 **1.5 σ**(取整到 5%,限 ±10%~100%);用户也可在侧边栏「写作助手功能 风格基线」按维度自定义 ±% 容差(正负号按位置固定,输入框只收 0~100 数字)。
41
+ 3. **风格画像报告**(novel_style_report):6 维测量——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 轴 / 语义风格距离。**测量与判断分离**:插件只报数不贴标签,AI 判断可回传存盘。
42
+ 4. **氛围光谱 12 轴**:噩梦感 / 焦虑压抑 / 温馨治愈 / 甜宠日常 / 催泪虐心 / 黑暗残酷 / 悬疑神秘 / 热血激昂 / 荒诞无厘头 / 孤独疏离 / 文艺唯美 / 情欲暧昧——证据链可追溯,0 token。
43
+ 5. **本地语义引擎**:bge-small-zh 中文模型(24MB 随插件分发)本地 CPU 推理——自然语言搜全书语义相关段落(带章节定位),语义级风格对比、语义隐性情感,懒加载 + 自动回退。
44
+ 6. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹与节奏建议,带缓存与报告导出。
45
+ 7. **情感净化 + 量化**:强/弱情绪词分级、污染源检测、caveat 预警 + AI 复核;Valence 滑动窗口 → 方差 V / 斜率 Δ / 矛盾指数 C。
46
+ 8. **写作辅助全家桶**:伏笔登记表 / 设定五张表 / 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 续写辅助 / 更新检查。
47
+ 9. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 工具开关 + 功能开关 + 风格基线容差),显示数据目录占用与语义引擎状态。
48
48
 
49
49
  ---
50
50
 
@@ -58,10 +58,10 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
58
58
  | `novel_keywords` | 关键词:二字组/三字组/疑似人名 |
59
59
  | `novel_new_chapter` | 创建新章节文件 |
60
60
  | `novel_import` | 原稿件批量导入/分类 |
61
- | `novel_sentence_analysis` | 句式模式分析(九类/情感净化/量化/曲线/指纹) |
61
+ | `novel_sentence_analysis` | 句式模式分析(九类/净化/量化/曲线/指纹) |
62
62
  | `novel_sentence_config` | 查看/修改工具与功能开关 |
63
- | `novel_style_check` | 风格自检(规则+语义双维度) |
64
- | `novel_style_report` | **风格画像报告**(6 维测量 + AI 判断分离) |
63
+ | `novel_style_check` | 风格自检(规则+语义+**文笔六维对照**) |
64
+ | `novel_style_report` | 风格画像报告(6 维测量 + **六维基线带**) |
65
65
  | `novel_plot` | 伏笔/剧情线登记表 |
66
66
  | `novel_settings` | 设定管理(人物/地点/道具/时间线/世界观) |
67
67
  | `novel_summary` | 章节摘要(长书续写辅助) |
@@ -83,7 +83,7 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
83
83
 
84
84
  ## 数据目录
85
85
 
86
- `<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
86
+ `<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告+基线)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
87
87
 
88
88
  ---
89
89
 
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ var css = `
30
30
  html[data-dsh-novel-writer-active]:not([data-dsh-ssh-active]):not([data-dsh-taskboard-active]) [class*=centerCol]>:not([data-dsh-novel-writer-view]){display:none!important}
31
31
  [data-dsh-frame][data-sidebar-collapsed] .nwEntry{justify-content:center;width:100%;padding:0}
32
32
  [data-dsh-frame][data-sidebar-collapsed] .nwEntryLabel{display:none}
33
- .nwSectionTitle{font-size:13px;font-weight:700;margin-top:6px}
33
+ .nwSectionTitle{.nwTolVer{font-size:10px;color:var(--dsw-alias-label-tertiary,#94a3b8);margin:-2px 0 2px;letter-spacing:.03em}.nwTolCard{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e2e8f0);border-radius:12px;padding:4px 14px 8px;margin:8px 0 4px;box-shadow:0 1px 4px rgba(15,23,42,.05)}.nwTolRow{display:flex;align-items:center;gap:10px;padding:9px 2px}.nwTolRow+.nwTolRow{border-top:1px dashed var(--dsw-alias-border-l2,#eef2f7)}.nwTolName{flex:1;min-width:0;font-size:13px;color:var(--dsw-alias-label-primary,#334155);font-weight:500;display:flex;align-items:center;gap:8px;white-space:nowrap}.nwTolIcon{font-size:15px;opacity:.9;flex-shrink:0}.nwTolField{width:66px;flex-shrink:0;display:flex;flex-direction:column;align-items:stretch;gap:3px}.nwTolInput{width:100%;height:30px;padding:0 4px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,#d4d4d8);border-radius:8px;font-size:13px;text-align:center;color:var(--dsw-alias-label-primary,#18181b);background:var(--dsw-alias-bg-layer-2,#fafafa);transition:border-color .18s,box-shadow .18s,background .18s;-moz-appearance:textfield}.nwTolInput::-webkit-outer-spin-button,.nwTolInput::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}.nwTolInput:hover{border-color:#a1a1aa}.nwTolInput:focus{outline:none;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.16);background:#fff}.nwTolInput::placeholder{color:#c4c8d0;font-size:11px}.nwTolCaption{font-size:10px;color:var(--dsw-alias-label-tertiary,#94a3b8);text-align:center}.nwTolSep{width:14px;flex-shrink:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#b0b8c4);text-align:center}.nwTolPct{width:14px;flex-shrink:0;font-size:13px;color:var(--dsw-alias-label-secondary,#64748b);text-align:left}.nwTolBtns{display:flex;gap:12px;margin-top:16px}.nwBtnPrimary{background:linear-gradient(135deg,#6366f1,#8b5cf6);border:none;color:#fff;font-weight:600;font-size:13px;padding:9px 20px;border-radius:10px;cursor:pointer;box-shadow:0 2px 6px rgba(99,102,241,.28);transition:transform .15s,box-shadow .15s,opacity .15s}.nwBtnPrimary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(99,102,241,.38)}.nwBtnPrimary:active{transform:translateY(0);box-shadow:0 1px 3px rgba(99,102,241,.3)}.nwBtnDone{background:linear-gradient(135deg,#10b981,#34d399);box-shadow:0 2px 6px rgba(16,185,129,.3)}.nwBtnGhost{background:transparent;border:1px solid var(--dsw-alias-border-l2,#cbd5e1);color:var(--dsw-alias-label-secondary,#475569);font-size:13px;padding:9px 18px;border-radius:10px;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.nwBtnGhost:hover{background:var(--dsw-alias-bg-layer-2,#f1f5f9);border-color:#a1a1aa;transform:translateY(-1px)}.nwUpdateBar{display:block;margin:4px 0 8px;padding:7px 10px;border-radius:6px;background:#e8f5e9;color:#1b5e20;font-size:12px;text-decoration:none;border:1px solid #a5d6a7;cursor:pointer}.nwUpdateBar:hover{background:#c8e6c9}font-size:13px;font-weight:700;margin-top:6px}
34
34
  .nwNavEntry{display:flex;align-items:center;justify-content:space-between;width:100%;box-sizing:border-box;padding:8px 10px;margin:4px 0;border:1px solid var(--dsw-alias-border);border-radius:8px;background:var(--dsw-alias-surface-1);cursor:pointer;text-align:left;color:inherit;font:inherit}
35
35
  .nwNavEntry:hover{border-color:#22c55e}
36
36
  .nwNavEntryText{flex:1;min-width:0;padding-right:8px}
@@ -151,12 +151,18 @@ var css = `
151
151
  .nwToolGroupHeadText{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
152
152
  .nwToolGroupDescInline{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary);font-weight:400;white-space:normal}
153
153
  `;
154
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"dsh-novel-writer\"]") === null) {
155
- var tag = document.createElement("style");
156
- tag.dataset.plugin = "dsh-novel-writer";
157
- tag.dataset.pluginCss = "dsh-novel-writer";
158
- tag.textContent = css;
159
- document.head.appendChild(tag);
154
+ // v3.0.0-UI3:已存在旧 style 标签也覆盖内容——否则插件脚本重复执行时新 CSS 永不注入(UI 停留在旧版)
155
+ if (typeof document !== "undefined") {
156
+ var cssTag = document.querySelector("style[data-plugin-css=\"dsh-novel-writer\"]");
157
+ if (cssTag) {
158
+ if (cssTag.textContent !== css) cssTag.textContent = css;
159
+ } else {
160
+ cssTag = document.createElement("style");
161
+ cssTag.dataset.plugin = "dsh-novel-writer";
162
+ cssTag.dataset.pluginCss = "dsh-novel-writer";
163
+ cssTag.textContent = css;
164
+ document.head.appendChild(cssTag);
165
+ }
160
166
  }
161
167
 
162
168
  // ---- 文案(zh / en)----
@@ -291,7 +297,18 @@ var css = `
291
297
  "model.engineIdle": "可用 · 未加载(首次使用时自动加载)",
292
298
  "model.engineMissing": "模型文件缺失,语义功能不可用",
293
299
  "model.engineError": "加载失败:{err}",
294
- "model.engineUnknown": "状态未知"
300
+ "model.engineUnknown": "状态未知",
301
+ "update.title": "发现新版本",
302
+ "update.go": "前往下载 →",
303
+ "update.latest": "已是最新版本",
304
+ "panel.baselineTitle": "风格基线",
305
+ "panel.baselineHint": "六维文笔指标 ±% 容差带",
306
+ "baseline.desc": "新章六维相对原书基线的允许偏离范围(%)。只填数字 0~100(正负号已按位置固定:低于=−、高于=+);留空 = 使用推荐容差(原著章节波动的 1.5 倍,限 ±10%~100%)。",
307
+ "baseline.save": "保存容差",
308
+ "baseline.reset": "清除自定义(用推荐)",
309
+ "baseline.saved": "容差已保存",
310
+ "baseline.low": "低于%",
311
+ "baseline.high": "高于%"
295
312
  };
296
313
  var en = {
297
314
  "entry.label": "Writing Assistant",
@@ -415,7 +432,18 @@ var css = `
415
432
  "model.engineIdle": "Ready · lazy-loaded on first use",
416
433
  "model.engineMissing": "Model files missing, semantic features unavailable",
417
434
  "model.engineError": "Load failed: {err}",
418
- "model.engineUnknown": "Unknown"
435
+ "model.engineUnknown": "Unknown",
436
+ "update.title": "New version available",
437
+ "update.go": "Download →",
438
+ "update.latest": "Up to date",
439
+ "panel.baselineTitle": "Style Baseline",
440
+ "panel.baselineHint": "±% tolerance band for six writing metrics",
441
+ "baseline.desc": "Allowed deviation (%) of new chapters from the book baseline. Enter 0-100 only (signs fixed by position: below=−, above=+); leave blank to use the recommended tolerance (1.5× the book chapter variance, clamped ±10%~100%). Out-of-band triggers correction — theme is free, writing style stays in the band.",
442
+ "baseline.save": "Save tolerance",
443
+ "baseline.reset": "Reset default",
444
+ "baseline.saved": "Tolerance saved",
445
+ "baseline.low": "Below %",
446
+ "baseline.high": "Above %"
419
447
  };
420
448
  function dictionary() {
421
449
  return (typeof document !== "undefined" && (document.documentElement.lang || "zh").toLowerCase().startsWith("en")) ? en : zh;
@@ -464,6 +492,10 @@ var css = `
464
492
  dataDir: "",
465
493
  dataDirSize: 0,
466
494
  embeddingStatus: null,
495
+ updateCheck: null,
496
+ styleTolerance: null,
497
+ baselineDraft: null,
498
+ baselineSaved: false,
467
499
  features: {},
468
500
  dirs: null,
469
501
  view: "main",
@@ -501,6 +533,7 @@ var css = `
501
533
  dataDir: remote.dataDir || "",
502
534
  dataDirSize: remote.dataDirSize || 0,
503
535
  embeddingStatus: remote.embeddingStatus || null,
536
+ styleTolerance: remote.styleTolerance || null,
504
537
  dirs: remote.dirs || null,
505
538
  file: remote.file || "",
506
539
  hostOk: true,
@@ -702,7 +735,7 @@ var css = `
702
735
  el("div", { className: "nwNavEntryHint" }, hint)
703
736
  ),
704
737
  el("div", { className: "nwNavEntryRight" },
705
- el("span", { className: "nwBadge " + (count > 0 ? "nwBadgeOn" : "nwBadgeOff") }, count + " 开"),
738
+ count === null ? null : el("span", { className: "nwBadge " + (count > 0 ? "nwBadgeOn" : "nwBadgeOff") }, count + " 开"),
706
739
  el("span", { className: "nwNavEntryArrow" }, "›")
707
740
  )
708
741
  );
@@ -889,10 +922,106 @@ var TOOL_GROUPS = [
889
922
  switchRow("semanticImplicit", f.semanticImplicit !== false, function (n, v) { props.toggle({ features: (function (patch) { patch[n] = v; return patch; })({}) }); }),
890
923
  pathBox(t("dir.embedding"), dirs.embeddingDir || "", "embedding-dir")
891
924
  );
925
+ } else if (view === "baseline") {
926
+ // v3.0.0:风格基线——六维文笔指标 ±% 容差带(左=允许低于,右=允许高于)
927
+ var NW_METRICS = [
928
+ ["complexity", "句法复杂度", "📐"], ["modifierDensity", "修饰密度", "🎨"], ["abstractDensity", "抽象度", "☁️"],
929
+ ["actionDensity", "动作密度", "⚡"], ["hedgeDensity", "不确定性", "🌫️"], ["gapIndex", "留白指数", "🕳️"]
930
+ ];
931
+ // v3.0.0:输入框留空 = 使用推荐容差(原著章节波动 1.5σ);填了才保存自定义
932
+ var tolState = state.styleTolerance && typeof state.styleTolerance === "object" ? state.styleTolerance : null;
933
+ var draft = state.baselineDraft;
934
+ if (!draft) {
935
+ draft = {};
936
+ for (var mi = 0; mi < NW_METRICS.length; mi += 1) {
937
+ var mk = NW_METRICS[mi][0];
938
+ var cur = tolState && tolState[mk] ? tolState[mk] : null;
939
+ // v3.0.0:内部统一存正数(正负号由位置决定:低于=负、高于=正)
940
+ draft[mk] = { low: cur && typeof cur.low === "number" ? Math.abs(cur.low) : "", high: cur && typeof cur.high === "number" ? Math.abs(cur.high) : "" };
941
+ }
942
+ }
943
+ var setDraft = function (mk, field, value) {
944
+ var next = {};
945
+ for (var k in draft) { next[k] = { low: draft[k].low, high: draft[k].high }; }
946
+ next[mk] = { low: draft[mk].low, high: draft[mk].high };
947
+ var v = String(value).trim();
948
+ if (v === "") next[mk][field] = "";
949
+ else {
950
+ // v3.0.0:只收 0-100 正数(正负号由位置决定)
951
+ var n = Math.abs(parseInt(v, 10));
952
+ if (isNaN(n)) n = "";
953
+ else n = Math.min(100, n);
954
+ next[mk][field] = n;
955
+ }
956
+ props.controller.set({ baselineDraft: next });
957
+ };
958
+ var saveFlash = function () {
959
+ props.controller.set({ baselineSaved: true });
960
+ setTimeout(function () { props.controller.set({ baselineSaved: false }); }, 1800);
961
+ };
962
+ var saveTol = function () {
963
+ // 只保存填写了低+高的维度;全空 → null(全部用推荐)
964
+ var tol = {};
965
+ var any = false;
966
+ for (var si = 0; si < NW_METRICS.length; si += 1) {
967
+ var sk = NW_METRICS[si][0];
968
+ var sd = draft[sk];
969
+ if (sd && sd.low !== "" && sd.high !== "") { tol[sk] = { low: -sd.low, high: sd.high }; any = true; }
970
+ }
971
+ props.toggle({ styleTolerance: any ? tol : null });
972
+ props.controller.set({ baselineDraft: null, revealMsg: t("baseline.saved"), revealErr: false });
973
+ saveFlash();
974
+ };
975
+ var resetTol = function () {
976
+ // 恢复默认:清除自定义并保存(回到推荐模式),输入框清空
977
+ props.toggle({ styleTolerance: null });
978
+ props.controller.set({ baselineDraft: null, revealMsg: t("baseline.saved"), revealErr: false });
979
+ saveFlash();
980
+ };
981
+ body = el("div", null,
982
+ backBtn(),
983
+ el("div", { className: "nwSectionTitle" }, "🎯 " + t("panel.baselineTitle")),
984
+ el("div", { className: "nwToolsHint" }, t("baseline.desc")),
985
+ el("div", { className: "nwTolVer" }, "v3.0.0-UI3"),
986
+ el("div", { className: "nwTolCard", style: { background: "#fff", border: "1px solid #e2e8f0", borderRadius: "12px", padding: "4px 14px 8px", margin: "8px 0 4px" } },
987
+ NW_METRICS.map(function (item) {
988
+ var mk2 = item[0];
989
+ var d = draft[mk2] || DEFAULT_TOL;
990
+ return el("div", { className: "nwTolRow", key: mk2, style: { display: "flex", alignItems: "center", gap: "10px", padding: "9px 2px" } },
991
+ el("div", { className: "nwTolName", style: { flex: "1", minWidth: "0", display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", fontWeight: "500", whiteSpace: "nowrap", color: "#334155" } }, el("span", { className: "nwTolIcon", style: { fontSize: "15px" } }, item[2]), item[1]),
992
+ el("div", { className: "nwTolField", style: { width: "74px", flexShrink: "0", display: "flex", flexDirection: "column", gap: "3px" } },
993
+ el("div", { style: { display: "flex", alignItems: "center", gap: "4px" } },
994
+ el("span", { className: "nwTolSign", style: { width: "12px", flexShrink: "0", textAlign: "center", fontSize: "14px", color: "#ef4444", fontWeight: "600" } }, "−"),
995
+ el("input", { type: "number", className: "nwTolInput", placeholder: "推荐", value: d.low, min: 0, max: 100, style: { width: "100%", height: "30px", boxSizing: "border-box", border: "1px solid #d4d4d8", borderRadius: "8px", fontSize: "13px", textAlign: "center", background: "#fafafa", color: "#18181b", outline: "none", MozAppearance: "textfield" }, onChange: function (ev) { setDraft(mk2, "low", ev.target.value); } })
996
+ ),
997
+ el("span", { className: "nwTolCaption", style: { fontSize: "10px", color: "#94a3b8", textAlign: "center" } }, t("baseline.low"))
998
+ ),
999
+ el("span", { className: "nwTolSep", style: { width: "14px", flexShrink: "0", fontSize: "12px", color: "#b0b8c4", textAlign: "center" } }, "~"),
1000
+ el("div", { className: "nwTolField", style: { width: "74px", flexShrink: "0", display: "flex", flexDirection: "column", gap: "3px" } },
1001
+ el("div", { style: { display: "flex", alignItems: "center", gap: "4px" } },
1002
+ el("span", { className: "nwTolSign", style: { width: "12px", flexShrink: "0", textAlign: "center", fontSize: "14px", color: "#10b981", fontWeight: "600" } }, "+"),
1003
+ el("input", { type: "number", className: "nwTolInput", placeholder: "推荐", value: d.high, min: 0, max: 100, style: { width: "100%", height: "30px", boxSizing: "border-box", border: "1px solid #d4d4d8", borderRadius: "8px", fontSize: "13px", textAlign: "center", background: "#fafafa", color: "#18181b", outline: "none", MozAppearance: "textfield" }, onChange: function (ev) { setDraft(mk2, "high", ev.target.value); } })
1004
+ ),
1005
+ el("span", { className: "nwTolCaption", style: { fontSize: "10px", color: "#94a3b8", textAlign: "center" } }, t("baseline.high"))
1006
+ ),
1007
+ el("span", { className: "nwTolPct", style: { width: "14px", flexShrink: "0", fontSize: "13px", color: "#64748b" } }, "%")
1008
+ );
1009
+ })
1010
+ ),
1011
+ el("div", { className: "nwTolBtns", style: { display: "flex", gap: "12px", marginTop: "16px" } },
1012
+ el("button", { type: "button", className: "nwBtn nwBtnPrimary" + (state.baselineSaved ? " nwBtnDone" : ""), onClick: saveTol, style: state.baselineSaved ? { background: "linear-gradient(135deg,#10b981,#34d399)", border: "none", color: "#fff", fontWeight: "600", fontSize: "13px", padding: "9px 20px", borderRadius: "10px", cursor: "pointer" } : { background: "linear-gradient(135deg,#6366f1,#8b5cf6)", border: "none", color: "#fff", fontWeight: "600", fontSize: "13px", padding: "9px 20px", borderRadius: "10px", cursor: "pointer" } }, state.baselineSaved ? "✓ " + t("baseline.saved") : "💾 " + t("baseline.save")),
1013
+ el("button", { type: "button", className: "nwBtn nwBtnGhost", onClick: resetTol, style: { background: "transparent", border: "1px solid #cbd5e1", color: "#475569", fontSize: "13px", padding: "9px 18px", borderRadius: "10px", cursor: "pointer" } }, "↺ " + t("baseline.reset"))
1014
+ )
1015
+ );
892
1016
  } else {
893
1017
  var onFeatures = detailFeatures.filter(function (k) { return f[k] !== false; }).length;
894
1018
  var onTools = ALL_TOOLS.filter(function (k) { return tools[k] !== false; }).length;
895
1019
  body = el("div", null,
1020
+ // v2.6.5:更新提示条(有新版才显示,点击跳 GitHub Release)
1021
+ state.updateCheck && state.updateCheck.updateAvailable && state.updateCheck.releaseUrl
1022
+ ? el("a", { className: "nwUpdateBar", href: state.updateCheck.releaseUrl, target: "_blank", rel: "noopener noreferrer" },
1023
+ "📢 " + t("update.title") + " v" + state.updateCheck.latestVersion + " " + t("update.go"))
1024
+ : null,
896
1025
  el("div", { className: "nwRow " + (state.enabled ? "nwRowOn" : "nwRowOff") },
897
1026
  el("div", { className: "nwRowText" },
898
1027
  el("div", { className: "nwRowLabel" }, t("panel.enabled")),
@@ -930,6 +1059,8 @@ var TOOL_GROUPS = [
930
1059
  })(),
931
1060
  entry(t("panel.featuresTitle"), t("panel.featuresHint"), onFeatures, "features"),
932
1061
  entry(t("panel.toolsTitle"), t("panel.toolsHint"), onTools, "tools"),
1062
+ // v3.0.0:风格基线(六维 ±% 容差带)
1063
+ entry(t("panel.baselineTitle"), t("panel.baselineHint"), null, "baseline"),
933
1064
  // v2.6.0:数据目录占用 + 语义引擎状态(一目了然)
934
1065
  el("div", { className: "nwRow nwRowOff" },
935
1066
  el("div", { className: "nwRowText" },
@@ -968,7 +1099,7 @@ var TOOL_GROUPS = [
968
1099
  }
969
1100
  return el("div", { className: "nwPanel" },
970
1101
  el("div", { className: "nwPanelHeader" },
971
- el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : t("model.title")) : "")),
1102
+ el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : view === "baseline" ? t("panel.baselineTitle") : t("model.title")) : "")),
972
1103
  el("button", { type: "button", className: "nwRefresh" + (state.refreshing ? " nwRefreshSpin" : ""), onClick: function () { props.controller.refresh(); } }, state.refreshing ? "⟳" : t("panel.refresh")),
973
1104
  el("button", { type: "button", className: "nwClose", "aria-label": t("panel.close"), onClick: props.onClose }, "×")
974
1105
  ),
@@ -1110,6 +1241,11 @@ var TOOL_GROUPS = [
1110
1241
  applied = true;
1111
1242
  var controller = createController();
1112
1243
  var load = async function () {
1244
+ // v2.6.5:更新检查(独立请求,3s 超时由后端兜底;失败静默不影响面板)
1245
+ fetch("/api/dsh-novel-writer/update-check", { headers: { accept: "application/json" } })
1246
+ .then(function (r) { return r.ok ? r.json() : null; })
1247
+ .then(function (uc) { if (uc) controller.set({ updateCheck: uc }); })
1248
+ .catch(function () { /* 静默 */ });
1113
1249
  try {
1114
1250
  var remote = await fetchState();
1115
1251
  controller.set({
@@ -1122,6 +1258,7 @@ var TOOL_GROUPS = [
1122
1258
  // v2.6.0:初始加载同样接入数据目录占用 + 语义引擎状态(与 refresh 白名单一致)
1123
1259
  dataDirSize: remote.dataDirSize || 0,
1124
1260
  embeddingStatus: remote.embeddingStatus || null,
1261
+ styleTolerance: remote.styleTolerance || null,
1125
1262
  dirs: remote.dirs || null,
1126
1263
  file: remote.file || "",
1127
1264
  source: "host",
package/lib/index.js CHANGED
@@ -28,6 +28,13 @@ import { homedir } from "node:os";
28
28
  import { createHash } from "node:crypto";
29
29
  import { execFile } from "node:child_process";
30
30
  import { analyzeText, CATEGORY_LABELS, CATEGORY_ORDER, fingerprintSimilarity, styleDiffs } from "./analysis.js";
31
+ import { createRequire } from "node:module";
32
+ import { checkForUpdate } from "./update-check.js";
33
+ import { computeBaseline, computeBaselineFromPerChapter, judgeAgainstBaseline, measureStyleMetrics, METRIC_LABELS, METRIC_ORDER } from "./style-metrics.js";
34
+
35
+ const require = createRequire(import.meta.url);
36
+ // v2.6.5:插件当前版本(更新检查用,自动跟随 package.json)
37
+ const PLUGIN_VERSION = require("../package.json").version;
31
38
  import { computeVibe, semanticStyleDistances } from "./vibe.js";
32
39
  import { CULTURE_MARKERS, GENRE_MARKERS, THEME_MARKERS, DEFAULT_BANNED_WORDS, SPEECH_STYLE_RULES } from "./lexicons/markers.js";
33
40
  import { RAW_WRITING_PROMPT, WORKFLOW_TEXT } from "./prompts.js";
@@ -825,6 +832,8 @@ async function readSentenceState() {
825
832
  autoAnalyze: typeof parsed.autoAnalyze === "boolean" ? parsed.autoAnalyze : SENTENCE_ANALYSIS_DEFAULTS.autoAnalyze,
826
833
  tools: parsed.tools !== null && typeof parsed.tools === "object" ? parsed.tools : {},
827
834
  features: parsed.features !== null && typeof parsed.features === "object" ? parsed.features : {},
835
+ // v3.0.0:风格基线容差(±%:用户允许低于/高于基线的范围)
836
+ styleTolerance: parsed.styleTolerance !== null && typeof parsed.styleTolerance === "object" ? parsed.styleTolerance : null,
828
837
  lastRoot: typeof parsed.lastRoot === "string" ? parsed.lastRoot : void 0
829
838
  };
830
839
  } catch {
@@ -857,7 +866,9 @@ async function writeSentenceState(patch) {
857
866
  ...(typeof patch?.autoAnalyze === "boolean" ? { autoAnalyze: patch.autoAnalyze } : {}),
858
867
  ...(patch?.tools !== null && typeof patch?.tools === "object" ? { tools: { ...(current.tools ?? {}), ...patch.tools } } : {}),
859
868
  ...(typeof patch?.lastRoot === "string" ? { lastRoot: patch.lastRoot } : {}),
860
- ...(patch?.features !== null && typeof patch?.features === "object" ? { features: { ...(current.features ?? {}), ...patch.features } } : {})
869
+ ...(patch?.features !== null && typeof patch?.features === "object" ? { features: { ...(current.features ?? {}), ...patch.features } } : {}),
870
+ ...(patch?.styleTolerance !== null && typeof patch?.styleTolerance === "object" ? { styleTolerance: patch.styleTolerance } : {}),
871
+ ...(patch?.styleTolerance === null ? { styleTolerance: null } : {})
861
872
  };
862
873
  const file = stateFilePath();
863
874
  await mkdir(dirname(file), { recursive: true });
@@ -991,6 +1002,8 @@ function makeStateRoutes(allowLan, config) {
991
1002
  autoAnalyze: state.autoAnalyze,
992
1003
  tools: state.tools ?? {},
993
1004
  features: state.features ?? {},
1005
+ // v3.0.0:风格基线容差(±%)
1006
+ styleTolerance: state.styleTolerance ?? null,
994
1007
  dataDirSize,
995
1008
  embeddingStatus,
996
1009
  dataDir,
@@ -1055,9 +1068,16 @@ function makeStateRoutes(allowLan, config) {
1055
1068
  return writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1056
1069
  }
1057
1070
  };
1071
+ // v2.6.5:更新检查路由(GitHub Releases API + 24h 缓存 + 3s 超时,全程静默)
1072
+ const updateCheckHandler = async (req, res) => {
1073
+ if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
1074
+ const result = await checkForUpdate(PLUGIN_VERSION, join(homedir(), ".dsh", "dsh-novel-writer"));
1075
+ return writeJson(res, 200, result);
1076
+ };
1058
1077
  return [
1059
1078
  { kind: "exact", path: routePath, handler },
1060
- { kind: "exact", path: revealPath, handler: revealHandler }
1079
+ { kind: "exact", path: revealPath, handler: revealHandler },
1080
+ { kind: "exact", path: "/api/dsh-novel-writer/update-check", handler: updateCheckHandler }
1061
1081
  ];
1062
1082
  }
1063
1083
 
@@ -1484,6 +1504,11 @@ ${featLines.join("\n")}
1484
1504
  }
1485
1505
  if (Object.keys(featPatch).length > 0) patch.features = featPatch;
1486
1506
  }
1507
+ // v3.0.0:风格基线容差(±%:允许低于/高于基线的百分比;null 清除恢复默认)
1508
+ if (args?.styleTolerance !== void 0) {
1509
+ if (args.styleTolerance === null) patch.styleTolerance = null;
1510
+ else if (args.styleTolerance !== null && typeof args.styleTolerance === "object") patch.styleTolerance = args.styleTolerance;
1511
+ }
1487
1512
  if (Object.keys(patch).length > 0) {
1488
1513
  current = await writeSentenceState(patch);
1489
1514
  updated = true;
@@ -1498,11 +1523,13 @@ ${featLines.join("\n")}
1498
1523
  for (const name of ALL_TOOLS) tools[name] = toolEnabled(current, name);
1499
1524
  const features = {};
1500
1525
  for (const name of Object.keys(FEATURE_DEFAULTS)) features[name] = featureEnabled(current, name);
1526
+ // v3.0.0:风格基线容差(±%)
1527
+ const styleTolerance = current?.styleTolerance !== null && typeof current?.styleTolerance === "object" ? current.styleTolerance : null;
1501
1528
  // v2.0.0:语义引擎状态(懒探测——不加载模型,仅文件检查 + 已加载状态)
1502
1529
  const embStatus = embedding.status();
1503
1530
  const embAvailable = featureEnabled(current, "semanticEmbedding") ? (embStatus.loaded || embStatus.modelPresent) : false;
1504
1531
  const embError = featureEnabled(current, "semanticEmbedding") ? embStatus.error ?? "" : "semanticEmbedding 已关闭";
1505
- return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated, tools, features, embedding: { available: embAvailable, loaded: embStatus.loaded, error: embError } };
1532
+ return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated, tools, features, styleTolerance, embedding: { available: embAvailable, loaded: embStatus.loaded, error: embError } };
1506
1533
  }
1507
1534
  });
1508
1535
  }
@@ -1549,7 +1576,9 @@ function registerNovelStyleCheck(ctx, config) {
1549
1576
  chapterFingerprint: { type: "string" },
1550
1577
  baselineFingerprint: { type: "string" },
1551
1578
  advice: { type: "string" },
1552
- semantic: { type: "object", additionalProperties: true }
1579
+ semantic: { type: "object", additionalProperties: true },
1580
+ // v3.0.0:文笔六维基线对照(enabled=false 时仅 note)
1581
+ metric: { type: "object", additionalProperties: true }
1553
1582
  },
1554
1583
  required: ["book", "chapter", "baselineScope", "similarity", "verdict", "diffs", "chapterFingerprint", "baselineFingerprint", "advice", "semantic"]
1555
1584
  },
@@ -1567,6 +1596,17 @@ function registerNovelStyleCheck(ctx, config) {
1567
1596
  }
1568
1597
  lines.push(`本章指纹: ${value.chapterFingerprint}`);
1569
1598
  lines.push(`基线指纹: ${value.baselineFingerprint}`);
1599
+ // v3.0.0:文笔六维基线对照
1600
+ if (value.metric && value.metric.enabled) {
1601
+ lines.push("", "文笔六维对照(μ=全书其他章基线,±%为相对偏差):");
1602
+ for (const v of value.metric.verdicts || []) {
1603
+ const mark = v.status === "out" ? "⚠ 出带" : v.status === "warn" ? "△ 提醒" : "✓ 在带";
1604
+ lines.push(` ${v.label}: 本章 ${v.value} vs 基线 ${v.mu}(${v.devPct > 0 ? "+" : ""}${v.devPct}%,${mark})`);
1605
+ }
1606
+ if (value.metric.outCount > 0) lines.push(" ⚠ " + value.metric.summary + "——请对照基线修正后再续写");
1607
+ } else if (value.metric && !value.metric.enabled) {
1608
+ lines.push("", "文笔六维对照: " + (value.metric.note || "不可用"));
1609
+ }
1570
1610
  lines.push("", "【建议】");
1571
1611
  lines.push(value.advice);
1572
1612
  lines.push("", "</content>");
@@ -1588,8 +1628,14 @@ function registerNovelStyleCheck(ctx, config) {
1588
1628
  assert(target !== void 0, `在作品 "${book}" 中找不到章节 "${chapterArg}"`);
1589
1629
  const others = chapters.filter((ch) => ch.file !== target.file);
1590
1630
  const targetText = await readTextFile(join(dir, target.file), exec);
1631
+ // v3.0.0:收集其他章逐章文本(六维基线带按章测 μ/σ)
1632
+ const baselineChapters = [];
1591
1633
  let baselineText = "";
1592
- for (const ch of others) baselineText += await readTextFile(join(dir, ch.file), exec);
1634
+ for (const ch of others) {
1635
+ const chText = await readTextFile(join(dir, ch.file), exec);
1636
+ baselineChapters.push({ file: ch.file, text: chText });
1637
+ baselineText += chText;
1638
+ }
1593
1639
  // v2.6.0:基线(全书除本章)分析复用缓存,大书避免重复 analyzeText
1594
1640
  const baseline = (await bookAnalysisCached(root, book + "__base_" + target.file, baselineText, null)).analysis;
1595
1641
  const chapter = analyzeText(targetText, {});
@@ -1647,6 +1693,33 @@ function registerNovelStyleCheck(ctx, config) {
1647
1693
  } else {
1648
1694
  semantic = { enabled: false, note: "语义增强未启用(纯规则模式)" };
1649
1695
  }
1696
+ // v3.0.0:文笔六维基线对照(新章 vs 其他章 μ/σ,支持用户容差)
1697
+ let metric = null;
1698
+ try {
1699
+ // v3.0.0:全书每章测量缓存(一个文件)→ 排除目标章后现算基线(O(n) 秒级)
1700
+ const allCh = others.map((ch) => ({ file: ch.file, text: baselineChapters.find((bc) => bc.file === ch.file)?.text ?? "" })).concat([{ file: target.file, text: targetText }]);
1701
+ const perCh = await metricChaptersCached(root, book, allCh);
1702
+ const excluding = perCh.filter((pc) => pc.file !== target.file);
1703
+ const b = { baseline: computeBaselineFromPerChapter(excluding), chapterCount: excluding.length };
1704
+ if (b.chapterCount > 0) {
1705
+ const targetMetrics = measureStyleMetrics(targetText).metrics;
1706
+ // v3.0.0:容差 = 全书推荐值(原著章节波动 1.5σ)为底,用户自定义只覆盖对应维度(未设置的维度保持全书推荐,不受排除目标章后样本少的影响)
1707
+ const allBaseline = computeBaselineFromPerChapter(perCh);
1708
+ const tol = {};
1709
+ for (const tk of METRIC_ORDER) {
1710
+ const rt = typeof allBaseline[tk]?.recTol === "number" ? allBaseline[tk].recTol : 15;
1711
+ tol[tk] = { low: -rt, high: rt };
1712
+ }
1713
+ if (state.styleTolerance && typeof state.styleTolerance === "object") {
1714
+ for (const tk of METRIC_ORDER) {
1715
+ const ut = state.styleTolerance[tk];
1716
+ if (ut && typeof ut.low === "number" && typeof ut.high === "number") tol[tk] = { low: ut.low, high: ut.high };
1717
+ }
1718
+ }
1719
+ const judge = judgeAgainstBaseline(targetMetrics, b.baseline, tol);
1720
+ metric = { enabled: true, baseline: b.baseline, metrics: targetMetrics, verdicts: judge.verdicts, outCount: judge.outCount, summary: judge.summary };
1721
+ } else metric = { enabled: false, note: "基线章节不足(至少需要 1 个其他章节)" };
1722
+ } catch (e) { metric = { enabled: false, note: "六维测量失败:" + String(e).slice(0, 80) }; }
1650
1723
  return {
1651
1724
  book,
1652
1725
  chapter: target.file,
@@ -1655,6 +1728,7 @@ function registerNovelStyleCheck(ctx, config) {
1655
1728
  verdict,
1656
1729
  diffs,
1657
1730
  semantic,
1731
+ metric,
1658
1732
  chapterFingerprint: chapter.fingerprint,
1659
1733
  baselineFingerprint: baseline.fingerprint,
1660
1734
  advice
@@ -1671,6 +1745,28 @@ function novelDataDir(root) {
1671
1745
 
1672
1746
  /** v2.6.0:全书/基线分析结果缓存(按文本哈希命中、单文件覆盖;style_check/style_report 复用,大书不再重复 analyzeText)。
1673
1747
  * @param {object|null} featState sentence state——null 时不计算/裁剪 genre/theme */
1748
+
1749
+ /**
1750
+ * v3.0.0:文笔六维每章测量缓存(每书一个文件,存每章测量值,不含文本)。
1751
+ * style_report 用全部章算基线;style_check 排除目标章后现算(O(n) 秒级,且只一个缓存文件)。
1752
+ * 指纹 = 章节文本拼接 sha1:任何章节变化自动失效重建。
1753
+ */
1754
+ async function metricChaptersCached(root, book, chapters) {
1755
+ const dir = join(novelDataDir(root), "analysis");
1756
+ const file = join(dir, sanitizeSegment(book, "book") + "-chapters-metrics.json");
1757
+ const text = chapters.map((c) => c.text).join("\u0000");
1758
+ const th = createHash("sha1").update(text).digest("hex").slice(0, 16);
1759
+ try {
1760
+ const cached = JSON.parse(await readFile(file, "utf8"));
1761
+ if (cached && cached.th === th && Array.isArray(cached.perChapter)) return cached.perChapter;
1762
+ } catch { /* 无缓存/损坏 */ }
1763
+ const b = computeBaseline(chapters);
1764
+ try {
1765
+ await mkdir(dir, { recursive: true });
1766
+ await writeFile(file, JSON.stringify({ th, perChapter: b.perChapter }, null, 2), "utf8");
1767
+ } catch { /* 缓存写失败不影响 */ }
1768
+ return b.perChapter;
1769
+ }
1674
1770
  async function bookAnalysisCached(root, keyName, text, featState) {
1675
1771
  const dir = join(novelDataDir(root), "analysis");
1676
1772
  const file = join(dir, sanitizeSegment(keyName, "book") + "-full.json");
@@ -1897,6 +1993,16 @@ function registerNovelStyleReport(ctx, config) {
1897
1993
  const emotion = analysis.emotion;
1898
1994
  const vibe = computeVibe(result, emotion, text);
1899
1995
  result.vibe = vibe;
1996
+ // v3.0.0:文笔六维基线带(按章节测量 μ/σ——原创/续写的风格基线)
1997
+ // v3.0.0:文笔六维基线带(并行读章 + 指纹缓存,大书不重复测量)
1998
+ let metricBaseline = null;
1999
+ try {
2000
+ const bdir = join(root, "novels", sanitizeSegment(book, "book"));
2001
+ const bchapters = await Promise.all(chapters.map(async (ch) => ({ file: ch.file, text: await readTextFile(join(bdir, ch.file), exec) })));
2002
+ const perCh = await metricChaptersCached(root, book, bchapters);
2003
+ result.baseline = computeBaselineFromPerChapter(perCh);
2004
+ result.baselineChapterCount = perCh.length;
2005
+ } catch { result.baseline = null; result.baselineChapterCount = 0; }
1900
2006
  // 语义距离(embedding 可用时)
1901
2007
  let semantic = [];
1902
2008
  try {
@@ -1948,6 +2054,15 @@ function registerNovelStyleReport(ctx, config) {
1948
2054
  if (gap < 0.02) lines.push(" ⚠ 判别度低:各风格原型距离接近(差距 <0.02),无明显风格归属——请结合题材/情感/氛围维度综合判断,勿直接引用该表贴标签");
1949
2055
  } else lines.push(" (语义引擎不可用,跳过)");
1950
2056
  lines.push("");
2057
+ lines.push("七、文笔六维基线带(v3.0.0,按章节测 μ±σ,原创/续写风格基线)");
2058
+ if (result.baseline && result.baselineChapterCount > 0) {
2059
+ for (const k of METRIC_ORDER) {
2060
+ const b = result.baseline[k];
2061
+ lines.push(" " + METRIC_LABELS[k] + " μ=" + b.mu + " σ=" + b.sigma + "(带 " + b.low + " ~ " + b.high + ",推荐容差 ±" + (b.recTol ?? 15) + "%)");
2062
+ }
2063
+ lines.push(" 用途:新章六维应落在带内;novel_style_check 默认按推荐容差(原著章节波动 1.5σ)判定");
2064
+ } else lines.push(" (测量失败)");
2065
+ lines.push("");
1951
2066
  lines.push("── 以上全部为插件测量数据,不包含任何风格判断 ──");
1952
2067
  lines.push("请结合六个维度判断本书的风格气质、读者感受与写法特征。");
1953
2068
  const reportText = lines.join("\n");
@@ -1983,6 +2098,9 @@ function registerNovelStyleReport(ctx, config) {
1983
2098
  return {
1984
2099
  book,
1985
2100
  chars: result.chars,
2101
+ // v3.0.0:文笔六维基线带(novel_style_check 对照用)
2102
+ baseline: result.baseline ?? null,
2103
+ baselineChapterCount: result.baselineChapterCount ?? 0,
1986
2104
  report: reportText + promptLine + (judgment ? "" : "然后将判断结论通过 aiJudgment 参数回传,插件会存入 .novel-writer/style-reports/ 供后续续写参考。"),
1987
2105
  dimensions: { fingerprint: analysis.fingerprint, culture: result.culture, genre: result.genre?.dominant, theme: result.theme?.dominant, emotion: emotion.cleanDominant, vibe: Object.fromEntries(vibe.axes.slice(0, 12).map((a) => [a.name, a.score])) },
1988
2106
  semantic: semantic.map((s, i) => ({ ...s, discrim: i === 0 && semantic.length >= 2 ? Math.round((semantic[0].score - semantic[1].score) * 1000) / 1000 : null })),
@@ -0,0 +1,274 @@
1
+ /**
2
+ * v3.0.0 文笔六维测量(style-metrics.js)——用户提出的六维量化指标
3
+ * 句法复杂度 / 抽象度 / 动作密度 / 不确定性 / 修饰密度 / 留白指数
4
+ *
5
+ * 全部零依赖实现:
6
+ * - 句法复杂度:标点分隔的小句数(逗号/分号/冒号),近似嵌套深度
7
+ * - 修饰密度:X的 / X地 结构统计(“的/地”前的词视为修饰语)
8
+ * - 抽象度:抽象后缀启发式(…感/性/度/情/绪/念… 结尾的 2-3 字词)
9
+ * - 动作密度:高频动作动词 + 动态助词/宾语模式(“她推开门”型)
10
+ * - 不确定性:模糊限制语小词表(似乎/仿佛/大概/可能…)
11
+ * - 留白指数:省略号/破折号密度 + 未完句比例
12
+ */
13
+
14
+ // 模糊限制语(不确定性):叙述犹疑/推测
15
+ const HEDGE_WORDS = [
16
+ "似乎", "仿佛", "好像", "大概", "也许", "或许", "可能", "像是", "隐约", "依稀",
17
+ "差不多", "八成", "兴许", "恍若", "貌似", "如同", "好似", "疑似", "感觉", "像是要"
18
+ ];
19
+
20
+ // 抽象名词后缀(启发式:以这些字结尾的 2-3 字词多为抽象名词)
21
+ const ABSTRACT_SUFFIXES = [
22
+ "感", "性", "度", "情", "绪", "念", "思", "意", "命", "运", "缘", "罪", "愁", "怨",
23
+ "恨", "哀", "悲", "惧", "耻", "愧", "志", "望", "欲", "想", "法", "理", "道",
24
+ "义", "德", "信", "诚", "真", "善", "美", "幻", "虚", "空", "寂", "寞", "孤", "独",
25
+ "茫", "迷", "惑", "悟", "醒", "觉", "变", "化", "限", "界", "域", "境",
26
+ "魂", "魄", "灵", "梦"
27
+ ];
28
+
29
+ // 高频动作动词(动作密度近似:动词 + 了/着/过/起/住 或直接跟宾语名词)
30
+ const ACTION_VERBS = [
31
+ "推", "拉", "抓", "拿", "端", "抱", "举", "抬", "踢", "踩", "打", "拍", "敲", "砸",
32
+ "扔", "丢", "接", "递", "握", "捏", "掐", "拧", "扯", "撕", "拔", "插", "捅", "刺",
33
+ "砍", "劈", "切", "割", "放", "摆", "搁", "挂", "贴", "塞", "填", "灌", "倒", "泼",
34
+ "洒", "翻", "卷", "铺", "盖", "叠", "折", "脱", "穿", "戴", "摘", "系", "解", "开",
35
+ "关", "锁", "按", "压", "顶", "撞", "碰", "触", "摸", "抚", "揉", "搓", "擦", "抹",
36
+ "洗", "刷", "扫", "拖", "铲", "挖", "埋", "堆", "砌", "修", "补", "缝", "织", "编",
37
+ "缠", "绕", "捆", "绑", "扎", "拴", "套", "蒙", "罩", "捂", "堵", "挡", "遮", "掩",
38
+ "藏", "躲", "避", "逃", "追", "赶", "跑", "走", "奔", "冲", "闯", "越", "跨", "跳",
39
+ "跃", "爬", "攀", "登", "降", "升", "沉", "浮", "漂", "荡", "摇", "晃", "摆", "抖",
40
+ "颤", "震", "动", "静", "停", "驻", "立", "坐", "躺", "卧", "跪", "蹲", "站", "靠",
41
+ "倚", "趴", "俯", "仰", "倾", "侧", "转", "回", "返", "退", "进", "出", "入", "起",
42
+ "落", "哭", "笑", "喊", "叫", "嚷", "吼", "骂", "斥", "责", "夸", "赞", "叹", "喘",
43
+ "吸", "呼", "吐", "咽", "吞", "嚼", "咬", "啃", "舔", "吮", "嗅", "闻", "听", "看",
44
+ "望", "瞧", "瞅", "盯", "瞪", "瞥", "瞟", "瞄", "观", "察", "读", "写", "画", "描",
45
+ "绘", "刻", "雕", "铸", "炼", "烧", "煮", "炒", "煎", "炸", "烤", "烘", "熏", "泡",
46
+ "浸", "染", "涂", "喷", "浇", "淋", "滴", "流", "淌", "涌", "冒", "射", "溅", "炸",
47
+ "裂", "碎", "破", "赢", "输", "借", "还", "付", "收", "给", "送", "寄", "递", "交",
48
+ "赐", "赏", "罚", "奖", "惩", "减", "增", "加", "删", "改", "换", "替", "代", "变",
49
+ "创", "建", "设", "置", "安", "装", "配", "组", "拆", "卸", "运", "搬", "携", "带",
50
+ "佩", "持", "执", "操", "控", "驾", "驶", "骑", "乘", "踏", "迈", "步", "驰", "飞",
51
+ "翔", "游", "泳", "潜", "航", "驱", "逐", "灭", "杀", "斩", "擒", "捕", "捉", "逮",
52
+ "拘", "押", "囚", "禁", "铐", "缚", "吊", "悬", "垂", "坠", "掉", "摔", "跌", "倒",
53
+ "仆", "瘫", "软", "麻", "僵", "直", "挺", "竖", "耸", "矗", "凸", "凹", "陷", "塌",
54
+ "崩", "垮", "弯", "折", "断", "裂", "绽", "合", "拢", "闭", "张", "启", "展", "伸",
55
+ "缩", "抽", "拽", "揪", "捋", "拂", "掸", "扑", "扇", "燃", "点", "着", "焚", "灼",
56
+ "烫", "烙", "煨", "炖", "焖", "熬", "烹", "调", "拌", "搅", "和", "团", "发", "烂",
57
+ "腐", "朽", "酸", "甜", "苦", "辣", "咸", "淡", "腻", "胖", "肿", "胀", "鼓", "瘪",
58
+ "皱", "平", "整", "齐", "乱", "杂", "混", "浊", "清", "澈", "净", "洁", "污", "染",
59
+ "脏", "臭", "香", "芳", "馥", "郁", "浓", "淡", "雅", "俗", "野", "蛮", "横", "霸",
60
+ "欺", "压", "榨", "剥", "削", "掠", "夺", "抢", "劫", "偷", "盗", "窃", "扒", "掏",
61
+ "搜", "查", "寻", "找", "觅", "探", "索", "究", "研", "讨", "论", "议", "评", "判",
62
+ "审", "核", "验", "证", "试", "测", "量", "计", "算", "统", "分", "析", "归", "纳",
63
+ "辩", "驳", "争", "吵", "闹", "斗", "殴", "拼", "搏", "挣", "抗", "抵", "防", "守",
64
+ "卫", "护", "保", "救", "援", "助", "帮", "扶", "领", "导", "指", "引", "教", "训",
65
+ "培", "锻", "磨", "砺", "激", "励", "鼓", "振", "奋", "兴", "感", "触", "震", "惊",
66
+ "慨", "唏", "嘘", "呜", "咽", "啜", "泣", "哽", "窒", "憋", "闷", "堵", "慌", "乱",
67
+ "躁", "烦", "恼", "怒", "火", "气", "愤", "憎", "恶", "厌", "嫌", "弃", "鄙", "蔑",
68
+ "轻", "重", "珍", "爱", "惜", "怜", "悯", "疼", "宠", "溺", "娇", "惯", "纵", "容",
69
+ "包", "庇", "袒", "呵", "佑", "祝", "福", "祈", "祷", "祭", "祀", "拜", "念", "诵",
70
+ "咏", "吟", "唱", "歌", "舞", "蹈", "旋", "滚", "滑", "溜", "窜", "遁", "隐", "匿",
71
+ "潜", "伏", "修", "行", "悟", "参", "禅", "坐", "定", "成", "化", "属", "于", "存",
72
+ "拥", "具", "获", "取", "博", "争", "谋", "求",
73
+ "愿", "确", "决", "选", "挑", "拣", "筛", "甄", "辨", "划", "割", "配", "发",
74
+ "派", "颁", "授", "予", "赠", "托", "委", "信", "任", "依", "凭", "仗", "恃", "仰",
75
+ "指", "盼", "期", "渴", "奢", "妄", "幻", "梦", "理", "设", "构", "揣", "猜", "预",
76
+ "料", "征", "兆", "迹", "象", "端", "倪", "苗", "头", "绪", "脉", "络", "纹", "层",
77
+ "次", "框", "架", "体", "系", "统", "流", "程", "序", "步", "骤", "环", "节", "细",
78
+ "重", "难", "焦", "热", "亮", "卖", "痛", "软", "肋", "弱", "短", "板", "瓶",
79
+ "颈", "壁", "垒", "藩", "篱", "桎", "梏", "枷", "锁", "镣", "牢", "笼", "樊", "囚",
80
+ "罩", "络"
81
+ ];
82
+
83
+ // 动态助词(动词后出现 → 动作完成/进行,常见于动作描写)
84
+ const DYNAMIC_PARTICLES = "了着过起住上下进出开完掉得";
85
+
86
+ const HEDGE_SET = new Set(HEDGE_WORDS);
87
+ const ACTION_SET = new Set(ACTION_VERBS);
88
+
89
+ /** 切句:按句末标点切分。 */
90
+ function splitSentences(text) {
91
+ return String(text)
92
+ .split(/(?<=[。!?!?])\s*|\n+/)
93
+ .map(function (s) { return s.trim(); })
94
+ .filter(function (s) { return s.length > 0; });
95
+ }
96
+
97
+ /**
98
+ * 六维测量。
99
+ */
100
+ export function measureStyleMetrics(text) {
101
+ const t = String(text ?? "");
102
+ const totalChars = t.length;
103
+ const per1000 = function (n) { return totalChars > 0 ? (n / totalChars) * 1000 : 0; };
104
+
105
+ // 1) 句法复杂度:平均每句小句数(逗号/分号/冒号分隔)
106
+ const sentences = splitSentences(t);
107
+ let smallClauseTotal = 0;
108
+ for (const s of sentences) {
109
+ const clauses = s.split(/[,,;;::]/).filter(function (c) { return c.trim() !== ""; }).length;
110
+ smallClauseTotal += Math.max(clauses, 1);
111
+ }
112
+ const complexity = sentences.length > 0 ? smallClauseTotal / sentences.length : 0;
113
+
114
+ // 2) 修饰密度:X的 / X地(“的/地”前的 1-4 字修饰语)
115
+ const adjMods = (t.match(/[\u4e00-\u9fff]{1,4}的/g) || []).length;
116
+ const advMods = (t.match(/[\u4e00-\u9fff]{1,4}地/g) || []).length;
117
+ const modifierDensity = per1000(adjMods + advMods);
118
+
119
+ // 3) 抽象度:抽象后缀 2-3 字词(合并单次正则,性能优化)
120
+ const ABSTRACT_RE = new RegExp("[\\u4e00-\\u9fff]{1,2}(?:" + ABSTRACT_SUFFIXES.join("|") + ")(?![\\u4e00-\\u9fff])", "g");
121
+ const abstractCount = (t.match(ABSTRACT_RE) || []).length;
122
+ const abstractDensity = per1000(abstractCount);
123
+
124
+ // 4) 动作密度:动作动词 + 动态助词/宾语模式
125
+ let actionCount = 0;
126
+ let idx = 0;
127
+ while (idx < t.length - 1) {
128
+ const ch = t[idx];
129
+ if (ACTION_SET.has(ch)) {
130
+ const next = t[idx + 1];
131
+ // 动词后跟动态助词(推开门/端起了)
132
+ if (DYNAMIC_PARTICLES.includes(next)) {
133
+ actionCount++;
134
+ idx += 2;
135
+ continue;
136
+ }
137
+ // 动词后直接跟名词性字符(直接宾语:她推门 / 端起碗)
138
+ if (/[\u4e00-\u9fff]/.test(next) && !/[的了着过在是把被给跟从向对于和与以及就都也很又再]/.test(next)) {
139
+ actionCount++;
140
+ idx += 2;
141
+ continue;
142
+ }
143
+ }
144
+ idx++;
145
+ }
146
+ const actionDensity = per1000(actionCount);
147
+
148
+ // 5) 不确定性:模糊限制语
149
+ let hedgeCount = 0;
150
+ for (const w of HEDGE_WORDS) {
151
+ const parts = t.split(w).length - 1;
152
+ hedgeCount += parts;
153
+ }
154
+ const hedgeDensity = per1000(hedgeCount);
155
+
156
+ // 6) 留白指数:省略号/破折号 + 未完句比例
157
+ const ellipsis = (t.match(/……/g) || []).length + (t.match(/\.\.\./g) || []).length;
158
+ const dash = (t.match(/——/g) || []).length;
159
+ const gapChars = per1000(ellipsis * 2 + dash * 2);
160
+ const unfinished = sentences.filter(function (s) { return /[……—…]$/.test(s) || !/[。!?!?]$/.test(s); }).length;
161
+ const unfinishedRatio = sentences.length > 0 ? unfinished / sentences.length : 0;
162
+
163
+ return {
164
+ metrics: {
165
+ complexity: Math.round(complexity * 100) / 100,
166
+ modifierDensity: Math.round(modifierDensity * 100) / 100,
167
+ abstractDensity: Math.round(abstractDensity * 100) / 100,
168
+ actionDensity: Math.round(actionDensity * 100) / 100,
169
+ hedgeDensity: Math.round(hedgeDensity * 100) / 100,
170
+ gapIndex: Math.round((gapChars + unfinishedRatio * 100) * 100) / 100
171
+ },
172
+ detail: {
173
+ totalChars,
174
+ sentenceCount: sentences.length,
175
+ smallClauseTotal,
176
+ adjMods,
177
+ advMods,
178
+ abstractCount,
179
+ actionCount,
180
+ hedgeCount,
181
+ ellipsis,
182
+ dash,
183
+ unfinishedCount: unfinished
184
+ }
185
+ };
186
+ }
187
+
188
+ /** 六维标签(展示用)。 */
189
+ export const METRIC_LABELS = {
190
+ complexity: "句法复杂度",
191
+ modifierDensity: "修饰密度",
192
+ abstractDensity: "抽象度",
193
+ actionDensity: "动作密度",
194
+ hedgeDensity: "不确定性",
195
+ gapIndex: "留白指数"
196
+ };
197
+
198
+ /** 六维顺序。 */
199
+ export const METRIC_ORDER = ["complexity", "modifierDensity", "abstractDensity", "actionDensity", "hedgeDensity", "gapIndex"];
200
+
201
+ /**
202
+ * 从每章测量值计算基线带(μ/σ)——style_report 用全部章,style_check 排除目标章后现算。
203
+ */
204
+ export function computeBaselineFromPerChapter(perChapter, opts) {
205
+ opts = opts || {};
206
+ const minSigmaRatio = opts.minSigmaRatio ?? 0.15;
207
+ const baseline = {};
208
+ for (const key of METRIC_ORDER) {
209
+ const values = (perChapter || []).map(function (c) { return c.metrics[key]; }).filter(function (v) { return typeof v === "number" && isFinite(v); });
210
+ if (values.length === 0) {
211
+ baseline[key] = { mu: 0, sigma: 0, low: 0, high: 0 };
212
+ continue;
213
+ }
214
+ const mu = values.reduce(function (a, b) { return a + b; }, 0) / values.length;
215
+ const variance = values.reduce(function (a, b) { return a + (b - mu) * (b - mu); }, 0) / values.length;
216
+ let sigma = Math.sqrt(variance);
217
+ // 最小 σ 下限:防小书(章节少/波动小)容差带过窄
218
+ const minSigma = Math.max(mu * minSigmaRatio, mu === 0 ? 0 : 0.0001);
219
+ sigma = Math.max(sigma, minSigma);
220
+ // v3.0.0:推荐容差 = 作者自身章节波动的 1.5 倍 σ(相对 μ 的百分比,取整到 5,下限 10%、上限 100%)——未自定义时按此判定;mu=0 回退 15 防 NaN
221
+ let recTol = 15;
222
+ if (mu > 0) {
223
+ recTol = Math.max(Math.round(((1.5 * sigma) / mu) * 100 / 5) * 5, 10);
224
+ recTol = Math.min(recTol, 100);
225
+ }
226
+ baseline[key] = { mu: Math.round(mu * 100) / 100, sigma: Math.round(sigma * 100) / 100, low: Math.round((mu - sigma) * 100) / 100, high: Math.round((mu + sigma) * 100) / 100, recTol };
227
+ }
228
+ return baseline;
229
+ }
230
+
231
+ /**
232
+ * 基线带:按章节测量 → 每维 μ/σ(含每章测量值 perChapter)。
233
+ */
234
+ export function computeBaseline(chapters, opts) {
235
+ opts = opts || {};
236
+ const perChapter = [];
237
+ for (const ch of chapters) {
238
+ if (!ch || !ch.text || ch.text.trim().length < 40) continue;
239
+ const m = measureStyleMetrics(ch.text).metrics;
240
+ perChapter.push({ file: ch.file, metrics: m });
241
+ }
242
+ const baseline = computeBaselineFromPerChapter(perChapter, opts);
243
+ return { baseline, perChapter, chapterCount: perChapter.length };
244
+ }
245
+
246
+ /**
247
+ * 偏离判定:新章 vs 基线,输出每维偏差百分比(相对 μ)与判定。
248
+ */
249
+ export function judgeAgainstBaseline(metrics, baseline, tolerance) {
250
+ tolerance = tolerance || null;
251
+ const verdicts = [];
252
+ const outOfBand = [];
253
+ for (const key of METRIC_ORDER) {
254
+ const b = baseline[key];
255
+ const v = metrics[key];
256
+ if (!b || typeof v !== "number" || b.mu === 0) continue;
257
+ const devPct = Math.round(((v - b.mu) / b.mu) * 1000) / 10;
258
+ // v3.0.0:默认用推荐容差(作者自身波动的 1.5σ),无推荐才回退 ±15%
259
+ const recT = typeof b.recTol === "number" && isFinite(b.recTol) && b.recTol > 0 ? b.recTol : 15;
260
+ const tol = tolerance && tolerance[key]
261
+ ? { low: Number(tolerance[key].low) !== 0 ? (Number(tolerance[key].low) || -recT) : 0, high: Number(tolerance[key].high) !== 0 ? (Number(tolerance[key].high) || recT) : 0 }
262
+ : { low: -recT, high: recT };
263
+ const sigmaDist = b.sigma > 0 ? (v - b.mu) / b.sigma : 0;
264
+ const status = devPct < tol.low || devPct > tol.high ? "out" : (devPct < tol.low / 2 || devPct > tol.high / 2) ? "warn" : "ok";
265
+ verdicts.push({ metric: key, label: METRIC_LABELS[key], mu: b.mu, value: v, devPct, sigma: Math.round(sigmaDist * 10) / 10, tolerance: tol, status });
266
+ if (status === "out") outOfBand.push(verdicts[verdicts.length - 1]);
267
+ }
268
+ return {
269
+ verdicts,
270
+ outOfBand,
271
+ outCount: outOfBand.length,
272
+ summary: outOfBand.length === 0 ? "全部维度在容差带内 ✓" : outOfBand.length + " 个维度超出容差带,需修正"
273
+ };
274
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * v2.6.5 更新检查(update-check.js)——设计移植自 Archon #1039(coleam00/Archon commit 6f1b72e)
3
+ * - GitHub Releases API 获取最新版:GET /repos/<repo>/releases/latest
4
+ * - 24h 缓存(update-check.json,避免 GitHub API 匿名限流 60 次/小时)
5
+ * - 3s 超时 + 全程静默失败(任何异常都不抛,绝不影响插件功能)
6
+ * - 语义化版本对比:按数字逐段比较(2.10 > 2.9),非字符串比较
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const REPO = "siweina/dsh-novel-writer";
12
+ const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
13
+ const CACHE_FILE = "update-check.json";
14
+ const STALENESS_MS = 24 * 60 * 60 * 1000; // 24 小时
15
+ const FETCH_TIMEOUT_MS = 3000; // 3 秒
16
+
17
+ /** 规范化版本号:去 v/V 前缀。 */
18
+ function normalizeVersion(v) {
19
+ return String(v ?? "").trim().replace(/^[vV]/, "");
20
+ }
21
+
22
+ /**
23
+ * 语义化版本对比:latest 比 current 新返回 true。
24
+ * 按数字逐段比较(处理 0.99.99 vs 1.0.0、双位数 2.9 vs 2.10),缺省段视为 0。
25
+ */
26
+ export function isNewerVersion(current, latest) {
27
+ const a = normalizeVersion(current).split(".").map((n) => parseInt(n, 10) || 0);
28
+ const b = normalizeVersion(latest).split(".").map((n) => parseInt(n, 10) || 0);
29
+ const len = Math.max(a.length, b.length);
30
+ for (let i = 0; i < len; i++) {
31
+ const x = a[i] || 0;
32
+ const y = b[i] || 0;
33
+ if (y > x) return true;
34
+ if (y < x) return false;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /** 解析 GitHub /releases/latest 响应 → { latestVersion, releaseUrl };解析失败返回 null。 */
40
+ export function parseLatestRelease(data) {
41
+ if (!data || typeof data !== "object") return null;
42
+ const tag = String(data.tag_name ?? "");
43
+ if (tag === "") return null;
44
+ return {
45
+ latestVersion: normalizeVersion(tag),
46
+ releaseUrl:
47
+ typeof data.html_url === "string" && data.html_url !== ""
48
+ ? data.html_url
49
+ : `https://github.com/${REPO}/releases`
50
+ };
51
+ }
52
+
53
+ /** 读 24h 内缓存;无缓存/过期/损坏返回 null。 */
54
+ export function getCachedUpdateCheck(cacheDir) {
55
+ try {
56
+ const p = join(cacheDir, CACHE_FILE);
57
+ if (!existsSync(p)) return null;
58
+ const data = JSON.parse(readFileSync(p, "utf8"));
59
+ if (!data || typeof data.latestVersion !== "string" || typeof data.checkedAt !== "number") return null;
60
+ if (Date.now() - data.checkedAt > STALENESS_MS) return null;
61
+ return data;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 检查更新:返回 { updateAvailable, currentVersion, latestVersion, releaseUrl }。
69
+ * 任何异常都静默降级为 updateAvailable:false(插件功能绝不受影响)。
70
+ */
71
+ export async function checkForUpdate(currentVersion, cacheDir) {
72
+ const result = {
73
+ updateAvailable: false,
74
+ currentVersion: String(currentVersion ?? ""),
75
+ latestVersion: null,
76
+ releaseUrl: null
77
+ };
78
+ try {
79
+ const cached = getCachedUpdateCheck(cacheDir);
80
+ if (cached) {
81
+ result.latestVersion = cached.latestVersion;
82
+ result.releaseUrl = cached.releaseUrl;
83
+ result.updateAvailable = isNewerVersion(result.currentVersion, cached.latestVersion);
84
+ return result;
85
+ }
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
88
+ let data = null;
89
+ try {
90
+ const res = await fetch(GITHUB_API_URL, {
91
+ headers: { accept: "application/vnd.github+json", "user-agent": "dsh-novel-writer" },
92
+ signal: controller.signal
93
+ });
94
+ if (res.ok) data = await res.json();
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ const parsed = parseLatestRelease(data);
99
+ if (!parsed) return result;
100
+ result.latestVersion = parsed.latestVersion;
101
+ result.releaseUrl = parsed.releaseUrl;
102
+ result.updateAvailable = isNewerVersion(result.currentVersion, parsed.latestVersion);
103
+ try {
104
+ mkdirSync(cacheDir, { recursive: true });
105
+ writeFileSync(
106
+ join(cacheDir, CACHE_FILE),
107
+ JSON.stringify({ latestVersion: parsed.latestVersion, releaseUrl: parsed.releaseUrl, checkedAt: Date.now() }, null, 2),
108
+ "utf8"
109
+ );
110
+ } catch {
111
+ /* 缓存写失败不影响本次结果 */
112
+ }
113
+ } catch {
114
+ /* 静默失败 */
115
+ }
116
+ return result;
117
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "2.6.2",
4
- "description": "小说写作助手插件(v2.6.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告;增量索引与缓存压缩优化。",
3
+ "version": "3.0.0",
4
+ "description": "小说写作助手插件(v3.0.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带与原创风格自检。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -41,4 +41,4 @@
41
41
  "engines": {
42
42
  "node": ">=22.3"
43
43
  }
44
- }
44
+ }
@@ -114,6 +114,9 @@ novel_sentence_analysis 的 emotion.quantification.semanticImplicit(semanticEm
114
114
  ## 风格画像报告(v2.5.0)
115
115
 
116
116
  - 分析/续写前先跑 novel_style_report:输出 6 维测量数据(文风指纹/高频词汇/题材流派/情感量化/氛围光谱 12 轴/语义风格距离),插件不贴标签;
117
+ - **风格基线(v3.0.0)**:novel_style_report 额外输出「文笔六维基线带」(句法复杂度/修饰密度/抽象度/动作密度/不确定性/留白指数,按章节 μ±σ);
118
+ 原创或风格模仿时:① 先读原书基线带 → ② 动笔前声明本书基线(默认取原书 μ±σ,可声明有意偏移,如“节奏加快一档”)→ ③ 每章写完跑 novel_style_check,对照「文笔六维对照」节,出带(超出 ±容差)的维度定向修正后再续写;
119
+ 容差带(允许低于/高于基线的百分比)可在侧边栏「写作助手功能 → 风格基线」调整,默认 ±15%;主题/情节/人物完全自由,基线只管写法层面。
117
120
  - AI 结合数据给出风格气质判断,通过 aiJudgment 参数回传(插件存入 .novel-writer/style-reports/,后续 action=get 读取);续写时参考已保存判断保持风格一致。
118
121
 
119
122
  ## 网文信号层(v2.2.0)