dsh-novel-writer 3.9.1 → 3.9.5
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 +0 -9
- package/README.md +0 -9
- package/cordis.patch.yml +1 -1
- package/lib/analysis.js +78 -61
- package/lib/client.js +37 -23
- package/lib/embedding.js +72 -18
- package/lib/index.js +306 -160
- package/lib/lexicons/markers.js +2 -1
- package/lib/style-metrics.js +19 -5
- package/lib/update-check.js +6 -1
- package/lib/vibe.js +60 -20
- package/package.json +65 -65
package/README.en.md
CHANGED
|
@@ -90,15 +90,6 @@ After installing, **restart the web app** to activate (host registers 16 tools +
|
|
|
90
90
|
|
|
91
91
|
Under `<library-root>/.novel-writer/`: `plots` / `settings` / `summaries` / `analysis` / `audits` / `embedding` / `style-reports`.
|
|
92
92
|
|
|
93
|
-
## Changelog
|
|
94
|
-
|
|
95
|
-
### v3.9.1 (2026-09-05)
|
|
96
|
-
|
|
97
|
-
- 🐛 Fix: per-chapter style analysis (semantic implicit emotion) previously searched the whole book and leaked hits from other chapters into the report; it is now scoped to the chapter, and stale out-of-scope caches are recomputed and written back automatically.
|
|
98
|
-
- 🐛 Fix: `brief=true` previously still returned the full report and anchor payload (token saving never worked); it now returns a single compact brief and omits `dimensions` / `semantic` / anchors / skeletons.
|
|
99
|
-
- 🔧 Tweak: the brief now follows `METRIC_ORDER` with Chinese dimension labels, consistent with the full report.
|
|
100
|
-
- 🧹 Chore: removed the BOM and normalized formatting in `package.json`; `description` bumped to v3.9.1.
|
|
101
|
-
|
|
102
93
|
---
|
|
103
94
|
|
|
104
95
|
## License
|
package/README.md
CHANGED
|
@@ -90,15 +90,6 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
|
90
90
|
|
|
91
91
|
`<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
|
|
92
92
|
|
|
93
|
-
## 更新日志
|
|
94
|
-
|
|
95
|
-
### v3.9.1(2026-09-05)
|
|
96
|
-
|
|
97
|
-
- 🐛 修复:单章风格分析(语义隐性情感)此前按整书检索,会把其他章节的意象命中混入本章报告;现改为按章节范围检测与检索,越界的旧缓存会自动重算并写回,不再重复触发。
|
|
98
|
-
- 🐛 修复:`brief=true` 精简模式此前仍返回完整报告与锚包,未达到省 token 的目的;现仅返回一行精简简报,并省略六维 `dimensions` / `semantic` / 锚点包等冗余字段。
|
|
99
|
-
- 🔧 优化:简报按 `METRIC_ORDER` 顺序输出并使用中文维度标签,与完整报告口径一致。
|
|
100
|
-
- 🧹 整理:`package.json` 去除 BOM、恢复规范排版,`description` 更新至 v3.9.1。
|
|
101
|
-
|
|
102
93
|
---
|
|
103
94
|
|
|
104
95
|
## 许可证
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-novel-writer v3.9.
|
|
1
|
+
# dsh-novel-writer v3.9.5 bundle patch: 16 tools (incl. novel_semantic_search) + local embedding engine.
|
|
2
2
|
# dsh-novel-writer bundle patch: mounts the novel-writing assistant plugin
|
|
3
3
|
# into the profile's loader tree. The entry's name is the plugin package
|
|
4
4
|
# itself, resolved from the profile's node_modules.
|
package/lib/analysis.js
CHANGED
|
@@ -106,12 +106,10 @@ const RHETORIC_MARKERS = [
|
|
|
106
106
|
/不是[^。!?]{0,10}吗/
|
|
107
107
|
];
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const IMPERATIVE_HARD = ["请", "快", "赶紧", "立刻", "马上", "别", "不要", "莫", "勿", "不许", "不准", "滚", "住手", "放手", "站住", "听我说", "给我", "让", "快点儿", "快点"];
|
|
114
|
-
const IMPERATIVE_START = ["走", "来", "去", "坐", "站", "停", "开始", "出发", "算了吧", "算了", "吃", "喝", "看", "听", "说", "做", "干", "起来", "坐下", "住口"];
|
|
109
|
+
/** 祈使句硬词(v3.9.1 #7):句首出现即祈使(感叹只留给无祈使词的 !/! 句)。 */
|
|
110
|
+
const IMPERATIVE_HARD = ["不准", "不许", "禁止", "闭嘴", "住手", "住口", "站住", "停下", "小心", "当心", "注意", "赶紧", "立刻", "马上", "给我", "跟我", "起来", "坐下", "躺下", "放下", "松开", "放开", "请", "快", "别", "勿", "莫", "滚"];
|
|
111
|
+
/** 祈使句动词(句首、需带命令语气:吧/标点/句尾——"走了几步"这类陈述不算)。 */
|
|
112
|
+
const IMPERATIVE_VERBS = ["去", "来", "走"];
|
|
115
113
|
|
|
116
114
|
/** 环境描写标记词(天色/天气/景物类名词,来自 v0.4.0,v0.6.0 扩充)。 */
|
|
117
115
|
const ENV_MARKERS = /夜色|月光|晨光|暮色|夕阳|黄昏|清晨|深夜|夜空|天边|星光|灯火|细雨|微风|大雨|暴雨|风雪|薄雾|浓雾|云层|远山|山影|河面|湖水|街道|巷子|烛火|窗|雨声|风声|树影|落叶|残雪|霜|天色|晚风|夜风|晨曦|晚霞|晴空|雨滴|溪水|江水|海浪|沙漠|草原|森林|群山|雪地|庭院|檐下|廊下/;
|
|
@@ -127,7 +125,11 @@ export function splitBlocks(text) {
|
|
|
127
125
|
const normalized = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
128
126
|
const lines = normalized.split("\n");
|
|
129
127
|
// 段落分隔符 = 前后都有内容的"中间空行";仅开头/结尾的空行不算分段。
|
|
130
|
-
|
|
128
|
+
// v3.9.1 #4:hasBlank 必须同时要求空行两侧都是非空行——文末 \n\n 不再把全文并成一块
|
|
129
|
+
const hasBlank = lines.some((line, index) =>
|
|
130
|
+
line.trim() === "" && index > 0 && index < lines.length - 1 &&
|
|
131
|
+
lines[index - 1].trim() !== "" && lines[index + 1].trim() !== ""
|
|
132
|
+
);
|
|
131
133
|
const blocks = [];
|
|
132
134
|
let current = [];
|
|
133
135
|
for (const line of lines) {
|
|
@@ -243,16 +245,24 @@ function isExclamation(text) {
|
|
|
243
245
|
return false;
|
|
244
246
|
}
|
|
245
247
|
|
|
248
|
+
/**
|
|
249
|
+
* v3.9.1 #7:硬祈使判定——句首硬祈使词(可带呼语前缀),或句首单字动词 去/来/走
|
|
250
|
+
* 且紧跟命令语气(吧/句末标点/句尾),或含"吧"的非疑问句。
|
|
251
|
+
* 疑问/反问在前置阶段已返回,这里不重复处理。
|
|
252
|
+
*/
|
|
246
253
|
function isImperative(text) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
254
|
+
const starts = "^(你|您|我们|咱们|大家|喂)?(" + IMPERATIVE_HARD.sort(function (a, b) { return b.length - a.length; }).join("|") + ")";
|
|
255
|
+
if (new RegExp(starts).test(text)) return true;
|
|
256
|
+
const verbRe = new RegExp("^(你|您|我们|咱们|大家|喂)?(" + IMPERATIVE_VERBS.join("|") + ")(吧|[。!?!?…]|$)");
|
|
257
|
+
if (verbRe.test(text)) return true;
|
|
258
|
+
// "太好了吧!"这类感叹式"吧"句不算祈使(太…了吧 → 感叹留给 isExclamation)
|
|
259
|
+
if (/太[^。!?]{0,12}了吧/.test(text)) return false;
|
|
260
|
+
return /吧/.test(text);
|
|
252
261
|
}
|
|
253
262
|
|
|
254
263
|
/**
|
|
255
|
-
* 单句分类(优先级:对话 > 心理 > 反问 > 疑问 >
|
|
264
|
+
* 单句分类(优先级:对话 > 心理 > 反问 > 疑问 > 祈使 > 感叹 > 环境 > 省略 > 陈述)。
|
|
265
|
+
* v3.9.1 #7:祈使先于感叹判定——含祈使词的 !/! 句是祈使,感叹只留给无祈使词的 !/! 句。
|
|
256
266
|
* 引语优先:引号内的直接引语算对话;引号内含心理标记(内心独白)算心理。
|
|
257
267
|
*/
|
|
258
268
|
export function classifySentence(raw) {
|
|
@@ -267,26 +277,35 @@ export function classifySentence(raw) {
|
|
|
267
277
|
if (hasPsychMarker(text)) return { type: "psychology", text, quoted: false };
|
|
268
278
|
if (isRhetoric(text)) return { type: "rhetoric-question", text, quoted: false };
|
|
269
279
|
if (isQuestion(text)) return { type: "question", text, quoted: false };
|
|
270
|
-
if (isExclamation(text)) return { type: "exclamation", text, quoted: false };
|
|
271
280
|
if (isImperative(text)) return { type: "imperative", text, quoted: false };
|
|
281
|
+
if (isExclamation(text)) return { type: "exclamation", text, quoted: false };
|
|
272
282
|
if (ENV_MARKERS.test(text)) return { type: "environment", text, quoted: false };
|
|
273
283
|
if (/…+$/.test(text)) return { type: "ellipsis", text, quoted: false };
|
|
274
284
|
return { type: "statement", text, quoted: false };
|
|
275
285
|
}
|
|
276
286
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
287
|
+
/**
|
|
288
|
+
* v3.9.1 #6:EMOTION_EXCLUDE(单字情感词的多字搭配排除)已删除——STRONG/WEAK 情感词表
|
|
289
|
+
* 与 DUTIR 兜底词表都不含单字词(叹气/笑道里的"气/笑"从来不会被查),该排除分支是
|
|
290
|
+
* 永不触发的死代码;行为与现状一致("叹气"不产生 anger 等)。
|
|
291
|
+
*
|
|
292
|
+
* v3.9.1 #5:情感否定统一判定。命中情感词时检查其前 1-3 字:
|
|
293
|
+
* - 单字否定:不/没/无/别/莫/未
|
|
294
|
+
* - 双/三字否定前缀:不要/不用/不必/不再/不是/并非/绝非/毫不/丝毫不/从不/从未/没有/没啥/未曾/无法/难以
|
|
295
|
+
*/
|
|
296
|
+
const NEGATION_PREFIXES = ["不要", "不用", "不必", "不再", "不是", "并非", "绝非", "毫不", "丝毫不", "从不", "从未", "没有", "没啥", "未曾", "无法", "难以"];
|
|
297
|
+
const NEGATION_SINGLE = new Set(["不", "没", "无", "别", "莫", "未"]);
|
|
298
|
+
|
|
299
|
+
/** text[idx] 起的情感词命中是否被紧邻否定语否定。 */
|
|
300
|
+
function negatedAt(text, idx) {
|
|
301
|
+
if (idx <= 0 || idx >= text.length) return false;
|
|
302
|
+
if (NEGATION_SINGLE.has(text[idx - 1])) return true;
|
|
303
|
+
if (idx >= 2 && NEGATION_PREFIXES.includes(text.slice(idx - 2, idx))) return true;
|
|
304
|
+
if (idx >= 3 && NEGATION_PREFIXES.includes(text.slice(idx - 3, idx))) return true;
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
288
307
|
|
|
289
|
-
/** 情感词计数(v0.6.0:扫描式匹配 +
|
|
308
|
+
/** 情感词计数(v0.6.0:扫描式匹配 + 否定过滤 + 副词窗口加权)。 */
|
|
290
309
|
function emotionOf(text) {
|
|
291
310
|
const scores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
|
|
292
311
|
const cleanScores = { joy: 0, anger: 0, sorrow: 0, fear: 0, surprise: 0 };
|
|
@@ -303,27 +322,8 @@ function emotionOf(text) {
|
|
|
303
322
|
while (from < text.length) {
|
|
304
323
|
const idx = text.indexOf(word, from);
|
|
305
324
|
if (idx === -1) break;
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
if (exclude !== void 0) {
|
|
309
|
-
// v3.5.0 #54:窗口按最长排除词取("生气勃勃"=4 字,旧窗口永远命不中)
|
|
310
|
-
const maxLen = Math.max(4, ...exclude.map(function (x) { return x.length; }));
|
|
311
|
-
const two = text.slice(Math.max(0, idx - 1), idx + 2);
|
|
312
|
-
const prevTwo = text.slice(Math.max(0, idx - 1), idx + 1);
|
|
313
|
-
const nextTwo = text.slice(idx, idx + 2);
|
|
314
|
-
const prevFour = text.slice(Math.max(0, idx - maxLen), idx + maxLen);
|
|
315
|
-
let excluded = false;
|
|
316
|
-
for (const bad of exclude) {
|
|
317
|
-
if (two.includes(bad) || prevTwo === bad || nextTwo === bad || prevFour.includes(bad)) {
|
|
318
|
-
excluded = true;
|
|
319
|
-
break;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
if (excluded) { from = idx + word.length; continue; }
|
|
323
|
-
}
|
|
324
|
-
// 否定过滤:"不害怕"不计入
|
|
325
|
-
const prevChar = idx > 0 ? text[idx - 1] : "";
|
|
326
|
-
if (EMOTION_NEGATORS.has(prevChar)) { from = idx + word.length; continue; }
|
|
325
|
+
// v3.9.1 #5:否定过滤(共享 negatedAt)——"不要害怕/不再担心/丝毫不慌"不计该次
|
|
326
|
+
if (negatedAt(text, idx)) { from = idx + word.length; continue; }
|
|
327
327
|
// 副词窗口加权:词前 6 字符内出现的强/弱副词生效一次
|
|
328
328
|
const window = text.slice(Math.max(0, idx - 6), idx);
|
|
329
329
|
let weight = 1;
|
|
@@ -376,12 +376,15 @@ function emotionOf(text) {
|
|
|
376
376
|
const emo = dutirLookup.get(w);
|
|
377
377
|
if (!emo) continue;
|
|
378
378
|
// v3.7.0 ⑥:DUTIR 兜底同样做否定过滤("不害怕"不再计 fear——emotionOf 有过滤,兜底漏了)
|
|
379
|
-
// v3.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
379
|
+
// v3.9.1 #5:逐次 indexOf 循环——每次命中处都做 negatedAt 过滤,按通过次数计 nOcc(不再"只过滤首次+按全文出现次数")
|
|
380
|
+
let nOccur = 0;
|
|
381
|
+
let hitPos = text.indexOf(w);
|
|
382
|
+
while (hitPos !== -1) {
|
|
383
|
+
if (!negatedAt(text, hitPos)) nOccur += 1;
|
|
384
|
+
hitPos = text.indexOf(w, hitPos + w.length);
|
|
385
|
+
}
|
|
383
386
|
if (nOccur === 0) continue;
|
|
384
|
-
scores[emo] += nOccur; // v3.7.0
|
|
387
|
+
scores[emo] += nOccur; // v3.7.0 ④:按通过过滤的出现次数(与主词表口径一致)
|
|
385
388
|
// v3.7.0 高5:DUTIR 27k 词无强弱分级(呵呵/傻乐等口语弱词)——只进 raw,不进 clean(clean 保持小词表强词口径,"净化后情感"承诺不失效)
|
|
386
389
|
words[emo].push(w);
|
|
387
390
|
seenWords.add(w);
|
|
@@ -610,7 +613,7 @@ export function implicitEmotionScan(text, semResolver = null) {
|
|
|
610
613
|
const ambiguous = [];
|
|
611
614
|
const see = (label, word, n, valence) => {
|
|
612
615
|
if (valence < 0) negHits += n * -valence;
|
|
613
|
-
else
|
|
616
|
+
else posHits += n * valence; // v3.9.1 #9:移除 if(false) fragileHits 死分支(保留 else 逻辑)
|
|
614
617
|
carrierCounts.set(label + ":" + word, (carrierCounts.get(label + ":" + word) ?? 0) + n);
|
|
615
618
|
};
|
|
616
619
|
// ① 单方向表(原有)——v3.5.0 M2:跳过与多方向表重叠的词(雨/黄昏/烛火等),防同一出现计 2-3 次(语境裁决交给 ②)
|
|
@@ -666,6 +669,10 @@ export function implicitEmotionScan(text, semResolver = null) {
|
|
|
666
669
|
ambiguousRatio: total === 0 ? 0 : Math.round((ambHits / (total + ambHits)) * 100) / 100,
|
|
667
670
|
fragile: fragileHits > 0,
|
|
668
671
|
totalHits: negHits + posHits,
|
|
672
|
+
// v3.9.1 #11:透出内部实际使用的加权计数(与上面比率计算同源;比率口径不变)
|
|
673
|
+
negHits,
|
|
674
|
+
posHits,
|
|
675
|
+
ambHits,
|
|
669
676
|
ambiguous,
|
|
670
677
|
topCarriers: top
|
|
671
678
|
};
|
|
@@ -690,8 +697,11 @@ export function valenceSeries(text, winChars = 100) {
|
|
|
690
697
|
while (from < slice.length) {
|
|
691
698
|
const idx = slice.indexOf(word, from);
|
|
692
699
|
if (idx === -1) break;
|
|
693
|
-
|
|
694
|
-
if (
|
|
700
|
+
// v3.9.1 #5:对每个情感词命中位置做 negatedAt 过滤——被否定则该次不计词、不进 pos/neg 计数
|
|
701
|
+
if (!negatedAt(text, start + idx)) {
|
|
702
|
+
sum += val; n += 1;
|
|
703
|
+
if (val > 0) pos += 1; else if (val < 0) neg += 1;
|
|
704
|
+
}
|
|
695
705
|
from = idx + word.length;
|
|
696
706
|
}
|
|
697
707
|
}
|
|
@@ -892,12 +902,14 @@ export function analyzeText(text, options = {}) {
|
|
|
892
902
|
const blocks = splitBlocks(text);
|
|
893
903
|
const sentences = [];
|
|
894
904
|
const blockMeta = [];
|
|
905
|
+
// v3.9.1 #10:是否因 maxSentences 上限提前 break(前缀采样)——只影响 guidance 尾注,不加顶层字段
|
|
906
|
+
let truncated = false;
|
|
895
907
|
for (let b = 0; b < blocks.length; b += 1) {
|
|
896
|
-
if (sentences.length >= maxSentences) break;
|
|
908
|
+
if (sentences.length >= maxSentences) { truncated = true; break; }
|
|
897
909
|
const parts = splitSentences(blocks[b]);
|
|
898
910
|
const meta = { sentences: [], opening: void 0, closing: void 0 };
|
|
899
911
|
for (const part of parts) {
|
|
900
|
-
if (sentences.length >= maxSentences) break;
|
|
912
|
+
if (sentences.length >= maxSentences) { truncated = true; break; }
|
|
901
913
|
const classified = classifySentence(part);
|
|
902
914
|
const record = {
|
|
903
915
|
text: part,
|
|
@@ -1152,8 +1164,8 @@ export function analyzeText(text, options = {}) {
|
|
|
1152
1164
|
fpSum += parts.length - 1;
|
|
1153
1165
|
fpText = parts.join("\u0000".repeat(w.length));
|
|
1154
1166
|
}
|
|
1155
|
-
//
|
|
1156
|
-
fpSum += (fpText.match(
|
|
1167
|
+
// v3.9.1 #8:剩余孤立单字统计 我/俺/咱 三者(原只补"我",俺/咱方言第一人称漏计)
|
|
1168
|
+
fpSum += (fpText.match(/[我俺咱]/g) || []).length;
|
|
1157
1169
|
return fpSum;
|
|
1158
1170
|
})();
|
|
1159
1171
|
const firstPersonDensity = totalChars === 0 ? 0 : (firstPersonCount / totalChars) * 1000;
|
|
@@ -1192,7 +1204,11 @@ export function analyzeText(text, options = {}) {
|
|
|
1192
1204
|
}
|
|
1193
1205
|
|
|
1194
1206
|
const ratios = Object.fromEntries(CATEGORY_ORDER.map((type) => [type, totalSentences === 0 ? 0 : round(counts[type] / totalSentences, 4)]));
|
|
1195
|
-
|
|
1207
|
+
let guidance = buildGuidance(ratios, motifs, chapterPatterns);
|
|
1208
|
+
// v3.9.1 #10:因 maxSentences 前缀截断时在 guidance 末尾追加一行提示(不新增顶层字段)
|
|
1209
|
+
if (truncated) {
|
|
1210
|
+
guidance += "\n⚠️ 文本超过采样上限(" + maxSentences + " 句),以上统计基于前缀采样;建议分章分析。";
|
|
1211
|
+
}
|
|
1196
1212
|
|
|
1197
1213
|
const fingerprint = buildFingerprint(categories, lengths, style, emotion, motifs);
|
|
1198
1214
|
const density = densityOf(text);
|
|
@@ -1321,7 +1337,8 @@ const DENSITY_OBJECT_WORDS = /油灯|灯|床|门|窗|桌|椅|凳|杯|碗|碟|盘
|
|
|
1321
1337
|
const DENSITY_SENSE = {
|
|
1322
1338
|
visual: /看|见|映入|光|亮|暗|色|影|闪|照|映|目光|眼神|脸|面|状|样/,
|
|
1323
1339
|
auditory: /听|声|音|响|钟|脚步|敲|喊|叫|低语|呢喃|咕哝|咚|砰|咔/,
|
|
1324
|
-
tactile
|
|
1340
|
+
// v3.9.1 #12:温度词(冷/热/凉/暖/烫)只归 temperature 通道——从 tactile 剔除,防同一词 sensePer1000 双计
|
|
1341
|
+
tactile: /疼|痛|滑|糙|冰|湿|干|硬|软|麻|痒/,
|
|
1325
1342
|
olfactory: /香|臭|味|气|腥|甜|苦|酸/,
|
|
1326
1343
|
temperature: /热|烫|凉|冷|暖|温/
|
|
1327
1344
|
};
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-novel-writer — 浏览器端(client half, v3.9.
|
|
2
|
+
* dsh-novel-writer — 浏览器端(client half, v3.9.5)
|
|
3
3
|
*
|
|
4
4
|
* 在 Web GUI 侧边栏挂载「句式分析」入口,点击后在会话区打开开关面板:
|
|
5
5
|
* - 启用句式分析(enabled):控制 AI 是否可用 novel_sentence_analysis
|
|
@@ -181,10 +181,6 @@ var css = `
|
|
|
181
181
|
"panel.back": "Back",
|
|
182
182
|
"panel.featuresTitle": "功能开关",
|
|
183
183
|
"panel.featuresHint": "情感净化预警:剔除感官/爽感词干扰后提示 AI 复核真实情绪;题材与流派检测:detect 时输出主/副题材与设定流派。",
|
|
184
|
-
"feature.emotionDeep": "情感深度检测",
|
|
185
|
-
"feature.emotionDeep.desc": "剔除感官性反应词汇的干扰,呈现作品真实的情感基调",
|
|
186
|
-
"feature.styleDetect": "风格检测",
|
|
187
|
-
"feature.styleDetect.desc": "识别世界观背景、题材类型与整体气质(如西幻、克苏鲁、甜宠)",
|
|
188
184
|
"feature.webnovelVibe": "网文信号(风格检测内)",
|
|
189
185
|
"feature.webnovelVibe.desc": "识别网文套路词与题材联动(打脸/秒杀/追妻火葬场),让甜宠/热血也能被识别",
|
|
190
186
|
"feature.emotionCaveat": "情感净化预警(高级)",
|
|
@@ -387,10 +383,6 @@ var css = `
|
|
|
387
383
|
"panel.autoAnalyzeHint": "When on, the AI proactively includes sentence analysis; otherwise only on explicit request.",
|
|
388
384
|
"panel.featuresTitle": "Feature switches",
|
|
389
385
|
"panel.featuresHint": "Emotion caveat: strip physiological/euphoric words and ask the AI to re-check real tone. Genre/theme: output main/secondary theme and setting genre on detect.",
|
|
390
|
-
"feature.emotionDeep": "Emotion Depth",
|
|
391
|
-
"feature.emotionDeep.desc": "Filters physiological reaction words to see the true mood",
|
|
392
|
-
"feature.styleDetect": "Style Detect",
|
|
393
|
-
"feature.styleDetect.desc": "Detects worldview, theme and vibe (fantasy/cthulhu/romance)",
|
|
394
386
|
"feature.webnovelVibe": "Webnovel signals",
|
|
395
387
|
"feature.webnovelVibe.desc": "Recognizes webnovel trope words (face-slap/one-shot kill)",
|
|
396
388
|
"feature.emotionCaveat": "Emotion caveat (advanced)",
|
|
@@ -644,7 +636,6 @@ var css = `
|
|
|
644
636
|
demoLoading: false,
|
|
645
637
|
reportsGroups: null,
|
|
646
638
|
reportContent: null,
|
|
647
|
-
reportLoading: false,
|
|
648
639
|
features: {},
|
|
649
640
|
dirs: null,
|
|
650
641
|
view: "main",
|
|
@@ -1177,7 +1168,7 @@ var TOOL_GROUPS = [
|
|
|
1177
1168
|
fetch("/api/dsh-novel-writer/reports?read=" + encodeURIComponent(file), { headers: { accept: "application/json" } }).then(function (r2) { if (!r2.ok) throw new Error("HTTP " + r2.status); return r2.json(); }).then(function (d) {
|
|
1178
1169
|
if (reqId !== reportReqId) return; // v3.5.0 #47:快速切换时丢弃过期响应
|
|
1179
1170
|
props.controller.set({ reportContent: d.ok ? JSON.stringify(d.content, null, 2) : (t("reports.readFail") + ":" + (d.error || "")) });
|
|
1180
|
-
}).catch(function () { props.controller.set({ reportContent: t("reports.readFailDetail") }); });
|
|
1171
|
+
}).catch(function () { if (reqId !== reportReqId) return; props.controller.set({ reportContent: t("reports.readFailDetail") }); });
|
|
1181
1172
|
};
|
|
1182
1173
|
var groups = state.reportsGroups || [];
|
|
1183
1174
|
var totalFiles = groups.reduce(function (a, g) { return a + (g.files || []).length; }, 0);
|
|
@@ -1448,10 +1439,10 @@ var TOOL_GROUPS = [
|
|
|
1448
1439
|
),
|
|
1449
1440
|
// v3.2.0:报告历史入口
|
|
1450
1441
|
el("button", { key: "reportsEntry", type: "button", style: { display: "flex", alignItems: "center", width: "100%", marginTop: "8px", padding: "9px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff", cursor: "pointer", textAlign: "left" }, onClick: function () {
|
|
1451
|
-
props.controller.set({ reportsGroups: null, reportContent: null
|
|
1442
|
+
props.controller.set({ reportsGroups: null, reportContent: null });
|
|
1452
1443
|
fetch("/api/dsh-novel-writer/reports", { headers: { accept: "application/json" } }).then(function (r2) { if (!r2.ok) throw new Error("HTTP " + r2.status); return r2.json(); }).then(function (d) {
|
|
1453
|
-
props.controller.set({ reportsGroups: d.groups || []
|
|
1454
|
-
}).catch(function () { props.controller.set({ reportsGroups: []
|
|
1444
|
+
props.controller.set({ reportsGroups: d.groups || [] });
|
|
1445
|
+
}).catch(function () { props.controller.set({ reportsGroups: [] }); });
|
|
1455
1446
|
props.controller.openView("reports");
|
|
1456
1447
|
} },
|
|
1457
1448
|
el("span", { style: { flex: "1", fontSize: "13px", fontWeight: "600", color: "#334155" } }, "📊 " + t("reports.title")),
|
|
@@ -1596,6 +1587,10 @@ var TOOL_GROUPS = [
|
|
|
1596
1587
|
if (container !== void 0 && container.isConnected === true) return;
|
|
1597
1588
|
var column = conversationColumn();
|
|
1598
1589
|
if (column === void 0) return;
|
|
1590
|
+
// v3.9.5 修正:宿主重建会话列导致容器脱离 DOM 时,先卸载旧 React root 并移除旧容器,
|
|
1591
|
+
// 避免 PanelView 的订阅闭包累积(旧 root 永不 unmount = 监听泄漏)
|
|
1592
|
+
if (root !== void 0) { try { root.unmount(); } catch (error) { /* ignore */ } root = void 0; }
|
|
1593
|
+
if (container !== void 0) { try { container.remove(); } catch (error) { /* ignore */ } container = void 0; }
|
|
1599
1594
|
container = document.createElement("div");
|
|
1600
1595
|
container.dataset.dshNovelWriterView = "";
|
|
1601
1596
|
container.style.display = "none";
|
|
@@ -1640,6 +1635,8 @@ var TOOL_GROUPS = [
|
|
|
1640
1635
|
var inject = ["slots", "locale"];
|
|
1641
1636
|
var applied = false;
|
|
1642
1637
|
function apply(ctx) {
|
|
1638
|
+
// 注(v3.9.5):applied 防重入依赖宿主 module materialize 语义——若宿主在同一模块实例上 disable→enable/HMR,
|
|
1639
|
+
// 第二次 apply 会被静默跳过且旧 UI 已卸载;如遇此场景需宿主重新加载模块实例
|
|
1643
1640
|
if (applied) return;
|
|
1644
1641
|
applied = true;
|
|
1645
1642
|
var controller = createController();
|
|
@@ -1650,26 +1647,31 @@ var TOOL_GROUPS = [
|
|
|
1650
1647
|
.then(function (uc) { if (uc) controller.set({ updateCheck: uc }); })
|
|
1651
1648
|
.catch(function () { /* 静默 */ });
|
|
1652
1649
|
try {
|
|
1650
|
+
var revAt = controller.getSnapshot().rev;
|
|
1653
1651
|
var remote = await fetchState();
|
|
1652
|
+
// v3.9.5 修正:初次加载接入 rev 竞态守卫——慢 GET 到达时若用户已操作(rev 变化),
|
|
1653
|
+
// 不得用旧快照覆盖 enabled/autoAnalyze/tools/features/容差/设定等开关字段
|
|
1654
|
+
var revSame = controller.getSnapshot().rev === revAt;
|
|
1655
|
+
var snap = controller.getSnapshot();
|
|
1654
1656
|
controller.set({
|
|
1655
|
-
enabled: !!remote.enabled,
|
|
1656
|
-
autoAnalyze: !!remote.autoAnalyze,
|
|
1657
|
-
tools: remote.tools || {},
|
|
1658
|
-
features: remote.features || {},
|
|
1657
|
+
enabled: revSame ? !!remote.enabled : snap.enabled,
|
|
1658
|
+
autoAnalyze: revSame ? !!remote.autoAnalyze : snap.autoAnalyze,
|
|
1659
|
+
tools: revSame ? (remote.tools || {}) : (snap.tools || {}),
|
|
1660
|
+
features: revSame ? (remote.features || {}) : (snap.features || {}),
|
|
1659
1661
|
plotsDir: remote.plotsDir || "",
|
|
1660
1662
|
dataDir: remote.dataDir || "",
|
|
1661
1663
|
// v2.6.0:初始加载同样接入数据目录占用 + 语义引擎状态(与 refresh 白名单一致)
|
|
1662
1664
|
dataDirSize: remote.dataDirSize || 0,
|
|
1663
1665
|
embeddingStatus: remote.embeddingStatus || null,
|
|
1664
|
-
styleTolerance: remote.styleTolerance || null,
|
|
1665
|
-
creationProfile: remote.creationProfile || null,
|
|
1666
|
-
creationProfiles: remote.creationProfiles || {},
|
|
1666
|
+
styleTolerance: revSame ? (remote.styleTolerance || null) : (snap.styleTolerance || null),
|
|
1667
|
+
creationProfile: revSame ? (remote.creationProfile || null) : (snap.creationProfile || null),
|
|
1668
|
+
creationProfiles: revSame ? (remote.creationProfiles || {}) : (snap.creationProfiles || {}),
|
|
1667
1669
|
books: remote.books || [],
|
|
1668
1670
|
booksStats: typeof remote.booksStats === "undefined" ? null : (remote.booksStats || []),
|
|
1669
1671
|
dirs: remote.dirs || null,
|
|
1670
1672
|
file: remote.file || "",
|
|
1671
1673
|
saveFailed: false, // v3.7.0 ③:刷新成功清除错误横幅
|
|
1672
|
-
|
|
1674
|
+
source: "host",
|
|
1673
1675
|
hostOk: true,
|
|
1674
1676
|
loading: false
|
|
1675
1677
|
});
|
|
@@ -1716,11 +1718,21 @@ var TOOL_GROUPS = [
|
|
|
1716
1718
|
}
|
|
1717
1719
|
controller.set(merged);
|
|
1718
1720
|
try {
|
|
1721
|
+
var revAt2 = controller.getSnapshot().rev;
|
|
1719
1722
|
var remote = await saveState(patch);
|
|
1723
|
+
var revSame2 = controller.getSnapshot().rev === revAt2;
|
|
1724
|
+
var snap2 = controller.getSnapshot();
|
|
1720
1725
|
controller.set({
|
|
1721
1726
|
enabled: !!remote.enabled,
|
|
1722
1727
|
autoAnalyze: !!remote.autoAnalyze,
|
|
1723
|
-
|
|
1728
|
+
// v3.9.5 修正:保存成功后回读宿主完整 next(tools/features/容差/设定),
|
|
1729
|
+
// 前提是期间没有更新的用户操作(rev 未变)——避免服务端钳制/规范化与 UI 长期不一致
|
|
1730
|
+
tools: revSame2 && remote.tools ? remote.tools : (snap2.tools || {}),
|
|
1731
|
+
features: revSame2 && remote.features ? remote.features : (snap2.features || {}),
|
|
1732
|
+
styleTolerance: revSame2 && remote.styleTolerance ? remote.styleTolerance : (snap2.styleTolerance || null),
|
|
1733
|
+
creationProfile: revSame2 && remote.creationProfile ? remote.creationProfile : (snap2.creationProfile || null),
|
|
1734
|
+
creationProfiles: revSame2 && remote.creationProfiles ? remote.creationProfiles : (snap2.creationProfiles || {}),
|
|
1735
|
+
file: remote.file || snap2.file,
|
|
1724
1736
|
source: "host",
|
|
1725
1737
|
hostOk: true
|
|
1726
1738
|
});
|
|
@@ -1746,6 +1758,8 @@ var openPanel = function () {
|
|
|
1746
1758
|
console.warn("[dsh-novel-writer] UI mount failed:", error);
|
|
1747
1759
|
}
|
|
1748
1760
|
// 官方设置页「插件配置」卡片(v0.4.0 合并;slots 服务由 inject 声明)
|
|
1761
|
+
// 注(v3.9.5):settings.plugin.item 是 keyed slot——宿主需在其 settingsScope served 集合中登记
|
|
1762
|
+
// novel-writer-config 命名空间该卡片才会被渲染;若宿主未实现则此卡片为“仅侧边栏入口”模式,属预期行为
|
|
1749
1763
|
try {
|
|
1750
1764
|
ctx.slots.inject("settings.plugin.item", function () {
|
|
1751
1765
|
return ctx.slots.register({
|