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 +13 -13
- package/README.md +15 -15
- package/lib/client.js +148 -11
- package/lib/index.js +123 -5
- package/lib/style-metrics.js +274 -0
- package/lib/update-check.js +117 -0
- package/package.json +3 -3
- package/skills/novel-writing/SKILL.md +3 -0
package/README.en.md
CHANGED
|
@@ -8,11 +8,10 @@ English | [**中文**](./README.md)
|
|
|
8
8
|
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
9
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
10
|
|
|
11
|
-
A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (
|
|
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
|
|
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. **
|
|
41
|
-
2. **
|
|
42
|
-
3. **
|
|
43
|
-
4. **
|
|
44
|
-
5. **
|
|
45
|
-
6. **
|
|
46
|
-
7. **
|
|
47
|
-
8. **
|
|
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` |
|
|
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
|
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
9
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
10
|
|
|
11
|
-
为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(
|
|
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.
|
|
41
|
-
2.
|
|
42
|
-
3.
|
|
43
|
-
4.
|
|
44
|
-
5.
|
|
45
|
-
6.
|
|
46
|
-
7.
|
|
47
|
-
8.
|
|
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` |
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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)
|
|
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": "
|
|
4
|
-
"description": "小说写作助手插件(
|
|
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)
|