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 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.0 bundle patch: 16 tools (incl. novel_semantic_search) + local embedding engine.
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 QUESTION_WORDS = ["谁", "什么", "为什么", "怎么", "怎样", "如何", "哪", "几", "多少", "何时", "哪里", "哪儿", "是否", "能不能", "会不会", "要不要", "行不行", "好不好", "对不对", "有没有", "为啥", "吗", "么", "呢"];
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
- const hasBlank = lines.some((line, index) => line.trim() === "" && index > 0 && index < lines.length - 1);
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
- if (!/吧/.test(text) && !/[!!]$/.test(text)) return false;
248
- for (const verb of IMPERATIVE_HARD) {
249
- if (text.includes(verb)) return true;
250
- }
251
- return new RegExp("^(你|您|我们|咱们|大家)?(" + IMPERATIVE_START.join("|") + ")").test(text) && /吧/.test(text);
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
- const EMOTION_EXCLUDE = {
279
- joy: { "笑": ["笑道", "说笑", "玩笑", "搞笑", "笑话", "苦笑", "冷笑", "嘲笑", "嬉笑", "傻笑", "微笑", "笑死", "笑呵呵", "笑眯眯"] },
280
- anger: { "气": ["叹气", "运气", "客气", "一口气", "服气", "脾气", "争气", "节气", "淘气", "景气", "暖气", "口气", "名气", "勇气", "底气", "俗气", "风气", "香气", "水气", "湿气", "潮气", "和气", "生气勃勃"], "生气": ["生气勃勃"] },
281
- sorrow: { "悲": ["慈悲"], "哭": ["哭丧"] },
282
- fear: { "怕": ["哪怕", "只怕", "生怕"] },
283
- surprise: { "愣": ["愣住"] }
284
- };
285
-
286
- /** 情感词前紧邻的否定字("不害怕"不计入恐惧)。 */
287
- const EMOTION_NEGATORS = new Set(["不", "没", "无", "别", "莫", "未"]);
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
- const exclude = EMOTION_EXCLUDE[emotion]?.[word];
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.7.0 ④:按实际出现次数计(与主词表口径一致——"恶心恶心恶心"计 3 而非 1);否定只过滤首次位置前一字(接受)
380
- const hitAt = text.indexOf(w);
381
- if (hitAt > 0 && EMOTION_NEGATORS.has(text[hitAt - 1])) continue;
382
- const nOccur = (text.split(w).length - 1);
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 { posHits += n * valence; if (false) fragileHits += n; }
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
- sum += val; n += 1;
694
- if (val > 0) pos += 1; else if (val < 0) neg += 1;
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(/我/g) || []).length;
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
- const guidance = buildGuidance(ratios, motifs, chapterPatterns);
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.0)
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, reportLoading: true });
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 || [], reportLoading: false });
1454
- }).catch(function () { props.controller.set({ reportsGroups: [], reportLoading: false }); });
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
- source: "host",
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
- file: remote.file || controller.getSnapshot().file,
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({