dsh-novel-writer 3.5.0 → 3.6.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/lib/analysis.js CHANGED
@@ -55,18 +55,19 @@ const STRONG_EMOTION_WORDS = Object.freeze({
55
55
  surprise: ["震惊", "目瞪口呆", "难以置信", "不可思议", "惊愕", "骇然", "震撼"]
56
56
  });
57
57
  const WEAK_EMOTION_WORDS = Object.freeze({
58
- joy: ["兴奋", "满足", "愉快", "痛快", "爽快", "爽", "笑眯眯", "笑容", "微笑", "笑", "哈哈", "乐", "欢", "喜", "高兴", "开心", "喜悦"],
59
- anger: ["生气", "恼火", "恼", "怒", "恨", "气愤", "气", "咬牙", "不满", "发火", "气冲冲"],
60
- sorrow: ["难过", "伤心", "痛苦", "哭泣", "哭", "泪", "眼泪", "流泪", "哽咽", "抽泣", "叹息", "叹气", "失落", "悲"],
58
+ joy: ["兴奋", "满足", "愉快", "痛快", "爽快", "笑眯眯", "笑容", "微笑", "哈哈", "高兴", "开心", "喜悦"],
59
+ anger: ["生气", "恼火", "气愤", "咬牙", "不满", "发火", "气冲冲"],
60
+ sorrow: ["难过", "伤心", "痛苦", "哭泣", "眼泪", "流泪", "哽咽", "抽泣", "叹息", "叹气", "失落", ],
61
61
  fear: ["害怕", "惊慌", "不安", "紧张", "担心", "发抖", "哆嗦", "心慌", "忐忑", "心悸", "心虚", "惊惶"],
62
62
  surprise: ["惊讶", "意外", "吃惊", "诧异", "愕然", "愣住", "傻眼", "呆住", "惊奇", "惊呆"]
63
63
  });
64
+ // v3.5.0 M1:情感词表构建去重(欣慰/惊惶等同表重复——双计残留)
64
65
  const EMOTION_WORDS = Object.freeze({
65
- joy: [...STRONG_EMOTION_WORDS.joy, ...WEAK_EMOTION_WORDS.joy],
66
- anger: [...STRONG_EMOTION_WORDS.anger, ...WEAK_EMOTION_WORDS.anger],
67
- sorrow: [...STRONG_EMOTION_WORDS.sorrow, ...WEAK_EMOTION_WORDS.sorrow],
68
- fear: [...STRONG_EMOTION_WORDS.fear, ...WEAK_EMOTION_WORDS.fear],
69
- surprise: [...STRONG_EMOTION_WORDS.surprise, ...WEAK_EMOTION_WORDS.surprise]
66
+ joy: [...new Set([...STRONG_EMOTION_WORDS.joy, ...WEAK_EMOTION_WORDS.joy])],
67
+ anger: [...new Set([...STRONG_EMOTION_WORDS.anger, ...WEAK_EMOTION_WORDS.anger])],
68
+ sorrow: [...new Set([...STRONG_EMOTION_WORDS.sorrow, ...WEAK_EMOTION_WORDS.sorrow])],
69
+ fear: [...new Set([...STRONG_EMOTION_WORDS.fear, ...WEAK_EMOTION_WORDS.fear])],
70
+ surprise: [...new Set([...STRONG_EMOTION_WORDS.surprise, ...WEAK_EMOTION_WORDS.surprise])]
70
71
  });
71
72
 
72
73
  /** v1.5.0 情绪污染源词表:检测到高密度时降低情感可信度并提示 AI 复核。 */
@@ -444,22 +445,20 @@ export function buildGuidance(ratios, topPatterns, chapterSequences) {
444
445
  */
445
446
  const VALENCE_WORDS = Object.freeze({
446
447
  "欣喜": 0.9, "狂喜": 0.9, "欢天喜地": 0.9, "雀跃": 0.7, "高兴": 0.7, "开心": 0.7, "快乐": 0.7,
447
- "喜悦": 0.7, "愉快": 0.5, "欢喜": 0.7, "兴奋": 0.7, "愉悦": 0.5, "欢快": 0.7, "乐": 0.5,
448
- "欢": 0.5, "喜": 0.5, "笑": 0.3, "微笑": 0.3, "笑容": 0.3, "笑眯眯": 0.3, "哈哈": 0.3,
449
- "欣慰": 0.5, "满足": 0.3, "痛快": 0.5, "爽快": 0.3, "甜": 0.5, "甜蜜": 0.5, "幸福": 0.9,
448
+ "喜悦": 0.7, "愉快": 0.5, "欢喜": 0.7, "兴奋": 0.7, "愉悦": 0.5, "欢快": 0.7, "微笑": 0.3, "笑容": 0.3, "笑眯眯": 0.3, "哈哈": 0.3,
449
+ "欣慰": 0.5, "满足": 0.3, "痛快": 0.5, "爽快": 0.3, "甜蜜": 0.5, "幸福": 0.9,
450
450
  "美满": 0.9, "温馨": 0.5, "温暖": 0.3, "踏实": 0.1, "平静": 0.1, "释然": 0.4, "解脱": 0.4,
451
- "喜欢": 0.7, "喜爱": 0.7, "欣赏": 0.5, "爱": 0.9, "疼爱": 0.7, "宠爱": 0.7, "仰慕": 0.7,
451
+ "喜欢": 0.7, "喜爱": 0.7, "欣赏": 0.5, "疼爱": 0.7, "宠爱": 0.7, "仰慕": 0.7,
452
452
  "尊敬": 0.5, "敬仰": 0.7, "崇拜": 0.7, "心动": 0.5, "眷恋": 0.7, "依恋": 0.7, "思念": 0.3,
453
453
  "心疼": 0.3, "怜爱": 0.5, "温柔": 0.3, "珍惜": 0.5, "信赖": 0.5, "感恩": 0.7,
454
454
  "愤怒": -0.9, "暴怒": -0.9, "怒发冲冠": -0.9, "火冒三丈": -0.9, "恼羞成怒": -0.9,
455
- "生气": -0.7, "恼火": -0.7, "气愤": -0.7, "怒": -0.7, "恨": -0.9, "怨恨": -0.9, "憎恨": -0.9,
456
- "厌恶": -0.7, "憎恶": -0.9, "不满": -0.5, "恼": -0.5, "气": -0.3, "发火": -0.7, "咬牙": -0.3,
455
+ "生气": -0.7, "恼火": -0.7, "气愤": -0.7, "怨恨": -0.9, "憎恨": -0.9,
456
+ "厌恶": -0.7, "憎恶": -0.9, "不满": -0.5, "发火": -0.7, "咬牙": -0.3,
457
457
  "怒意": -0.7, "怒火": -0.9, "愤恨": -0.9, "恼羞": -0.7, "气冲冲": -0.7, "咬牙切齿": -0.5,
458
458
  "悲伤": -0.7, "悲痛": -0.9, "悲痛欲绝": -0.9, "悲哀": -0.7, "哀伤": -0.7, "难过": -0.5,
459
- "伤心": -0.7, "痛苦": -0.7, "心碎": -0.9, "绝望": -0.9, "哭泣": -0.7, "哭": -0.5, "泪": -0.3,
460
- "眼泪": -0.3, "流泪": -0.5, "哽咽": -0.5, "抽泣": -0.5, "叹息": -0.3, "叹气": -0.3,
459
+ "伤心": -0.7, "痛苦": -0.7, "心碎": -0.9, "绝望": -0.9, "哭泣": -0.7, "眼泪": -0.3, "流泪": -0.5, "哽咽": -0.5, "抽泣": -0.5, "叹息": -0.3, "叹气": -0.3,
461
460
  "惆怅": -0.5, "失落": -0.5, "忧伤": -0.5, "黯然": -0.5, "心酸": -0.7, "辛酸": -0.7,
462
- "悲": -0.7, "凄凉": -0.7, "苦涩": -0.7, "苦闷": -0.5, "沮丧": -0.7, "消沉": -0.7,
461
+ "凄凉": -0.7, "苦涩": -0.7, "苦闷": -0.5, "沮丧": -0.7, "消沉": -0.7,
463
462
  "落寞": -0.5, "孤寂": -0.5, "郁闷": -0.3, "低落": -0.3, "压抑": -0.5, "心灰意冷": -0.9,
464
463
  "恐惧": -0.9, "害怕": -0.7, "惊慌": -0.7, "不安": -0.3, "紧张": -0.3, "担心": -0.3,
465
464
  "畏惧": -0.7, "惊恐": -0.9, "胆怯": -0.5, "发抖": -0.3, "哆嗦": -0.3, "心慌": -0.5,
@@ -603,9 +602,11 @@ export function implicitEmotionScan(text, semResolver = null) {
603
602
  else { posHits += n * valence; if (false) fragileHits += n; }
604
603
  carrierCounts.set(label + ":" + word, (carrierCounts.get(label + ":" + word) ?? 0) + n);
605
604
  };
606
- // ① 单方向表(原有)
605
+ // ① 单方向表(原有)——v3.5.0 M2:跳过与多方向表重叠的词(雨/黄昏/烛火等),防同一出现计 2-3 次(语境裁决交给 ②)
606
+ const ambWords = new Set(Object.keys(AMBIGUOUS_CARRIERS));
607
607
  for (const carrier of IMPLICIT_CARRIERS) {
608
608
  for (const word of carrier.words) {
609
+ if (ambWords.has(word)) continue;
609
610
  const n = text.split(word).length - 1;
610
611
  if (n > 0) {
611
612
  see(carrier.label, word, n, carrier.valence);
@@ -698,7 +699,8 @@ export function valenceStats(text) {
698
699
  const n = series.length;
699
700
  if (n === 0) return { windows: 0 };
700
701
  const mean = series.reduce((x, y) => x + y, 0) / n;
701
- const variance = series.reduce((s, x) => s + (x - mean) ** 2, 0) / n;
702
+ // v3.6.0:样本方差口径 /(n-1)(n=1 时无方差=0)
703
+ const variance = n > 1 ? series.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1) : 0;
702
704
  let adjSum = 0;
703
705
  for (let i = 1; i < n; i += 1) adjSum += Math.abs(series[i] - series[i - 1]);
704
706
  const adjVariance = n > 1 ? adjSum / (n - 1) : 0;
@@ -867,6 +869,8 @@ export function emotionalQuantification(text, perChapter, blocks, semResolver =
867
869
  * @returns 结构化分析结果(与 novel_sentence_analysis 输出 schema 一致)。
868
870
  */
869
871
  export function analyzeText(text, options = {}) {
872
+ // v3.5.0 M6:入口容错(null/undefined 不崩溃)
873
+ text = String(text ?? "");
870
874
  const top = Number.isInteger(options.top) ? options.top : 8;
871
875
  // v3.5.0 #63:maxSentences 钳制 ≥1(0/负数会让 slice(0,-5) 产生错误语义)
872
876
  const maxSentences = Math.max(1, Number.isInteger(options.maxSentences) ? options.maxSentences : 20000);
@@ -1110,7 +1114,21 @@ export function analyzeText(text, options = {}) {
1110
1114
  // 主观性指数(启发式 0-100)
1111
1115
  const psychRatio = totalSentences === 0 ? 0 : counts.psychology / totalSentences;
1112
1116
  const exclaimRatio = totalSentences === 0 ? 0 : counts.exclamation / totalSentences;
1113
- const firstPersonCount = FIRST_PERSON_WORDS.reduce((sum, word) => sum + (text.split(word).length - 1), 0);
1117
+ // v3.6.0:第一人称最长匹配优先("我们"不拆成 我+我们 双计)
1118
+ const firstPersonCount = (function () {
1119
+ let fpSum = 0;
1120
+ const fpSorted = FIRST_PERSON_WORDS.slice().sort((x, y) => y.length - x.length);
1121
+ let fpText = text;
1122
+ for (const w of fpSorted) {
1123
+ if (w.length < 2) continue;
1124
+ const parts = fpText.split(w);
1125
+ fpSum += parts.length - 1;
1126
+ fpText = parts.join("\u0000".repeat(w.length));
1127
+ }
1128
+ // 剩余孤立单字"我"
1129
+ fpSum += (fpText.match(/我/g) || []).length;
1130
+ return fpSum;
1131
+ })();
1114
1132
  const firstPersonDensity = totalChars === 0 ? 0 : (firstPersonCount / totalChars) * 1000;
1115
1133
  const subjectivityIndex = Math.min(100, Math.round(
1116
1134
  psychRatio * 50 + exclaimRatio * 60 + Math.min(emotionDensity * 2.5, 25) + Math.min(firstPersonDensity * 1.2, 20)
@@ -1204,6 +1222,8 @@ function emotionCurve(blockMeta, maxSegments) {
1204
1222
  intensity: chars === 0 ? 0 : round((total / chars) * 1000, 2)
1205
1223
  });
1206
1224
  }
1225
+ // v3.6.0 复审修正:不裁剪——强度 0 段是"平缓段"(跨章节曲线对齐需要固定段数契约);
1226
+ // segmentCount=min(blocks, maxSegments) 已保证每段至少 1 块,无空 slice
1207
1227
  return curve;
1208
1228
  }
1209
1229
 
@@ -1330,7 +1350,7 @@ function loadDutir() {
1330
1350
  }
1331
1351
  return DUTIR;
1332
1352
  }
1333
- const DUTIR_TO_EMOTION = { 乐: "joy", 好: "joy", 怒: "anger", 哀: "sorrow", 惧: "fear", 恶: "sorrow", 惊: "surprise" };
1353
+ const DUTIR_TO_EMOTION = { 乐: "joy", 好: "joy", 怒: "anger", 哀: "sorrow", 惧: "fear", 恶: "anger", 惊: "surprise" }; // v3.5.0 H4:恶(厌恶) 归 anger 而非 sorrow
1334
1354
  const dutirLookup = new Map();
1335
1355
  function dutirEmotion(word) {
1336
1356
  if (dutirLookup.size === 0) {
@@ -1338,7 +1358,8 @@ function dutirEmotion(word) {
1338
1358
  for (const [cat, words] of Object.entries(dutir)) {
1339
1359
  const emo = DUTIR_TO_EMOTION[cat];
1340
1360
  if (!emo) continue;
1341
- for (const w of words) if (typeof w === "string" && w.length >= 2) dutirLookup.set(w, emo);
1361
+ // v3.5.0 H4:跨类冲突词首见保留(后写不再覆盖——"开心"不会被 恶 类覆盖成 sorrow)
1362
+ for (const w of words) if (typeof w === "string" && w.length >= 2 && !dutirLookup.has(w)) dutirLookup.set(w, emo);
1342
1363
  }
1343
1364
  }
1344
1365
  return dutirLookup.get(word);
package/lib/client.js CHANGED
@@ -16,6 +16,7 @@ window.__ModuleLoader__.load({
16
16
  var module = { exports: {} };
17
17
  // v3.5.0 R1:rawTimer 必须在 createController 闭包外/顶部声明(旧位置在渲染函数内,api 方法访问不到 → ReferenceError)
18
18
  var rawTimer = null;
19
+ var reportReqId = 0; // v3.5.0 M15:模块级请求序号(渲染闭包变量每次渲染归零,守卫失效)
19
20
  var exports = module.exports;
20
21
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
21
22
  let react = require("react");
@@ -213,7 +214,6 @@ var css = `
213
214
  "raw.promiseText": "请输入以下承诺以开启(必须包含「绝不传播」):\n\n我承诺开启此功能仅用于个人创作与研究,绝不传播。",
214
215
  "raw.promisePlaceholder": "我承诺开启此功能仅用于个人创作与研究,绝不传播",
215
216
  "raw.promiseOk": "确认",
216
- "feature.semanticImplicit.desc": "25 个情感原型句扫全书,找词表外疑似意象段落(semanticImplicit)。",
217
217
  "model.title": "小模型(本地语义引擎)",
218
218
  "model.manage": "管理",
219
219
  "model.hint": "总开关:语义增强。以下子功能独立开关,默认全开;关闭总开关后以下全部失效。",
@@ -336,7 +336,23 @@ var css = `
336
336
  "creation.deleted": "已删除该书设定",
337
337
  "creation.newed": "已创建设定,开始填写",
338
338
  "creation.needName": "请先输入新书书名",
339
+ "creation.badName": "书名含非法字符(\\ / : * ? \" < > | 或结尾点/空格/系统保留名)",
340
+ "reports.loading": "读取中…",
339
341
  "stats.pending": "统计未就绪(宿主未提供,点刷新重试)",
342
+ "tol.recommend": "推荐",
343
+ "tol.halfFilled": "维度",
344
+ "tol.halfFilledTail": "」只填了低/高一边——容差需成对填写(或都留空用推荐)",
345
+ "state.unreachable": "宿主端不可达",
346
+ "reports.readFail": "读取失败",
347
+ "reports.readFailDetail": "网络/路由不可用",
348
+ "reports.loading2": "加载中…",
349
+ "demo.fail": "演示失败",
350
+ "ph.e末": "例如:末日后异能世界…",
351
+ "ph.e主": "例如:寻找失踪的同伴,解开身世之谜…",
352
+ "ph.e女": "例如:女主冷静克制,有秘密…",
353
+ "ph.e不": "例如:不能有重生/系统/金手指…",
354
+ "ph.e悬": "例如:悬疑+救赎,都市背景…",
355
+ "ph.e每": "例如:每章结尾留钩子,多对话…",
340
356
  "stats.empty": "书库为空——在 novels/ 放一本小说,或让 AI 原创一本",
341
357
  "stats.title": "书库统计",
342
358
  "stats.week": "7天",
@@ -403,7 +419,7 @@ var css = `
403
419
  "raw.promiseText": "Type the following promise to enable (must contain \"never distribute\"):\n\nI promise to use this feature for personal creation and research only, and never distribute it.",
404
420
  "raw.promisePlaceholder": "I promise to use this only for personal creation and research, never distribute",
405
421
  "raw.promiseOk": "Confirm",
406
- "feature.semanticImplicit.desc": "25 emotion prototypes scan the book for implicit imagery paragraphs.",
422
+ "feature.semanticImplicit.desc": "29 emotion prototypes scan the book for implicit imagery paragraphs.",
407
423
  "model.title": "Local model (semantic engine)",
408
424
  "model.manage": "Manage",
409
425
  "model.hint": "Master switch: semantic enhancement. Sub-switches below are independent; disabling the master disables all.",
@@ -418,6 +434,10 @@ var css = `
418
434
  "toolGroup.analyze": "📊 Analysis",
419
435
  "toolGroup.settings": "📚 Settings",
420
436
  "toolGroup.create": "✍️ Creation",
437
+ "toolGroup.analyze.desc": "Browse books, keywords, sentence/emotion analysis, style check, semantic search, continuity audit.",
438
+ "toolGroup.settings.desc": "Characters, locations, items, timeline, plot tracking, summaries and import — your story's database.",
439
+ "toolGroup.create.desc": "New chapters and switch configuration.",
440
+ "tool.novel_semantic_search.desc": "Semantic search (local embedding, 0 token): natural-language retrieval across the book.",
421
441
  "group.allOn": "All on",
422
442
  "group.allOff": "All off",
423
443
  "group.onCount": "{n} on",
@@ -522,7 +542,23 @@ var css = `
522
542
  "creation.deleted": "Profile deleted",
523
543
  "creation.newed": "Profile created, start filling",
524
544
  "creation.needName": "Please enter a book name first",
545
+ "creation.badName": "Invalid book name (\\ / : * ? \" < > | or trailing dot/space / reserved name)",
546
+ "reports.loading": "Loading…",
525
547
  "stats.pending": "Stats pending (host unavailable — press refresh)",
548
+ "tol.recommend": "recommended",
549
+ "tol.halfFilled": "Dimension",
550
+ "tol.halfFilledTail": " — fill both low & high (or leave both empty to use recommended)",
551
+ "state.unreachable": "host unreachable",
552
+ "reports.readFail": "Failed to read",
553
+ "reports.readFailDetail": "network/route unavailable",
554
+ "reports.loading2": "Loading…",
555
+ "demo.fail": "Demo failed",
556
+ "ph.e末": "e.g. post-apocalyptic world with abilities…",
557
+ "ph.e主": "e.g. find the missing companion and unravel the mystery of her origins…",
558
+ "ph.e女": "e.g. cool-headed heroine with secrets…",
559
+ "ph.e不": "e.g. no rebirth/system/golden finger…",
560
+ "ph.e悬": "e.g. suspense + redemption, urban setting…",
561
+ "ph.e每": "e.g. cliffhanger endings, dialogue-heavy…",
526
562
  "stats.empty": "Library empty — put a novel in novels/, or ask AI to create one",
527
563
  "stats.title": "Library stats",
528
564
  "stats.week": "/7d",
@@ -667,7 +703,7 @@ var css = `
667
703
  });
668
704
  return remote;
669
705
  }).catch(function () {
670
- api.set({ hostOk: false, loading: false, refreshing: false, revealMsg: t("plot.revealErr") + ":宿主端不可达", revealErr: true });
706
+ api.set({ hostOk: false, loading: false, refreshing: false, revealMsg: t("plot.revealErr") + ":" + t("state.unreachable"), revealErr: true });
671
707
  return null;
672
708
  });
673
709
  },
@@ -687,7 +723,7 @@ var css = `
687
723
  api.set({ revealMsg: data.error || t("plot.revealErr"), revealErr: true });
688
724
  }
689
725
  }).catch(function () {
690
- api.set({ revealMsg: t("plot.revealErr") + ":宿主端不可达", revealErr: true });
726
+ api.set({ revealMsg: t("plot.revealErr") + ":" + t("state.unreachable"), revealErr: true });
691
727
  }).finally(function () {
692
728
  api.set({ revealing: false });
693
729
  });
@@ -1056,8 +1092,8 @@ var TOOL_GROUPS = [
1056
1092
  // v3.0.0:风格基线——六维文笔指标 ±% 容差带(左=允许低于,右=允许高于)
1057
1093
  var DEFAULT_TOL = { low: "", high: "" };
1058
1094
  var NW_METRICS = [
1059
- ["complexity", "句法复杂度", "📐"], ["modifierDensity", "修饰密度", "🎨"], ["abstractDensity", "抽象度", "☁️"],
1060
- ["actionDensity", "动作密度", "⚡"], ["hedgeDensity", "不确定性", "🌫️"], ["gapIndex", "留白指数", "🕳️"]
1095
+ ["complexity", "句法复杂度", "📐", "Syntax"], ["modifierDensity", "修饰密度", "🎨", "Modifier"], ["abstractDensity", "抽象度", "☁️", "Abstract"],
1096
+ ["actionDensity", "动作密度", "⚡", "Action"], ["hedgeDensity", "不确定性", "🌫️", "Hedge"], ["gapIndex", "留白指数", "🕳️", "Gap"]
1061
1097
  ];
1062
1098
  // v3.0.0:输入框留空 = 使用推荐容差(原著章节波动 1.5σ);填了才保存自定义
1063
1099
  var tolState = state.styleTolerance && typeof state.styleTolerance === "object" ? state.styleTolerance : null;
@@ -1091,13 +1127,19 @@ var TOOL_GROUPS = [
1091
1127
  setTimeout(function () { props.controller.set({ baselineSaved: false }); }, 1800);
1092
1128
  };
1093
1129
  var saveTol = function () {
1094
- // 只保存填写了低+高的维度;全空 → null(全部用推荐)
1130
+ // v3.5.0 M18:单侧填写提示成对(不再静默丢弃)
1131
+ var halfFilled = null;
1095
1132
  var tol = {};
1096
1133
  var any = false;
1097
1134
  for (var si = 0; si < NW_METRICS.length; si += 1) {
1098
1135
  var sk = NW_METRICS[si][0];
1099
1136
  var sd = draft[sk];
1100
1137
  if (sd && sd.low !== "" && sd.high !== "") { tol[sk] = { low: -sd.low, high: sd.high }; any = true; }
1138
+ else if (sd && (sd.low !== "" || sd.high !== "")) { halfFilled = halfFilled || sk; }
1139
+ }
1140
+ if (halfFilled) {
1141
+ props.controller.set({ revealMsg: t("tol.halfFilled") + "「" + halfFilled + "」" + t("tol.halfFilledTail"), revealErr: true });
1142
+ return;
1101
1143
  }
1102
1144
  props.toggle({ styleTolerance: any ? tol : null });
1103
1145
  props.controller.set({ baselineDraft: null, revealMsg: t("baseline.saved"), revealErr: false });
@@ -1119,11 +1161,11 @@ var TOOL_GROUPS = [
1119
1161
  var mk2 = item[0];
1120
1162
  var d = draft[mk2] || DEFAULT_TOL;
1121
1163
  return el("div", { className: "nwTolRow", key: mk2, style: { display: "flex", alignItems: "center", gap: "10px", padding: "9px 2px" } },
1122
- 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]),
1164
+ 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]), isEn ? (item[3] || item[1]) : item[1]),
1123
1165
  el("div", { className: "nwTolField", style: { width: "74px", flexShrink: "0", display: "flex", flexDirection: "column", gap: "3px" } },
1124
1166
  el("div", { style: { display: "flex", alignItems: "center", gap: "4px" } },
1125
1167
  el("span", { className: "nwTolSign", style: { width: "12px", flexShrink: "0", textAlign: "center", fontSize: "14px", color: "#ef4444", fontWeight: "600" } }, "−"),
1126
- 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); } })
1168
+ el("input", { type: "number", className: "nwTolInput", placeholder: t("tol.recommend"), 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); } })
1127
1169
  ),
1128
1170
  el("span", { className: "nwTolCaption", style: { fontSize: "10px", color: "#94a3b8", textAlign: "center" } }, t("baseline.low"))
1129
1171
  ),
@@ -1131,7 +1173,7 @@ var TOOL_GROUPS = [
1131
1173
  el("div", { className: "nwTolField", style: { width: "74px", flexShrink: "0", display: "flex", flexDirection: "column", gap: "3px" } },
1132
1174
  el("div", { style: { display: "flex", alignItems: "center", gap: "4px" } },
1133
1175
  el("span", { className: "nwTolSign", style: { width: "12px", flexShrink: "0", textAlign: "center", fontSize: "14px", color: "#10b981", fontWeight: "600" } }, "+"),
1134
- 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); } })
1176
+ el("input", { type: "number", className: "nwTolInput", placeholder: t("tol.recommend"), 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); } })
1135
1177
  ),
1136
1178
  el("span", { className: "nwTolCaption", style: { fontSize: "10px", color: "#94a3b8", textAlign: "center" } }, t("baseline.high"))
1137
1179
  ),
@@ -1146,14 +1188,13 @@ var TOOL_GROUPS = [
1146
1188
  );
1147
1189
  } else if (view === "reports") {
1148
1190
  // v3.2.0:报告历史(加载在入口 onClick 触发,此处纯渲染防 React 345 循环)
1149
- var reportReqId = 0;
1150
1191
  var openReport = function (file) {
1151
1192
  var reqId = ++reportReqId;
1152
- props.controller.set({ reportContent: "读取中…" });
1193
+ props.controller.set({ reportContent: t("reports.loading") });
1153
1194
  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) {
1154
1195
  if (reqId !== reportReqId) return; // v3.5.0 #47:快速切换时丢弃过期响应
1155
- props.controller.set({ reportContent: d.ok ? JSON.stringify(d.content, null, 2) : ("读取失败:" + (d.error || "")) });
1156
- }).catch(function () { props.controller.set({ reportContent: "读取失败(网络/路由不可用)" }); });
1196
+ props.controller.set({ reportContent: d.ok ? JSON.stringify(d.content, null, 2) : (t("reports.readFail") + ":" + (d.error || "")) });
1197
+ }).catch(function () { props.controller.set({ reportContent: t("reports.readFailDetail") }); });
1157
1198
  };
1158
1199
  var groups = state.reportsGroups || [];
1159
1200
  var totalFiles = groups.reduce(function (a, g) { return a + (g.files || []).length; }, 0);
@@ -1168,7 +1209,7 @@ var TOOL_GROUPS = [
1168
1209
  el("pre", { style: { margin: "4px 0 0", padding: "10px", borderRadius: "8px", background: "#fff", border: "1px solid #e2e8f0", fontSize: "11px", lineHeight: "1.7", color: "#334155", whiteSpace: "pre-wrap", wordBreak: "break-all", maxHeight: "70vh", overflow: "auto" } }, state.reportContent)
1169
1210
  )
1170
1211
  : el("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
1171
- reportsLoading ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, "加载中…") : totalFiles === 0 ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, t("reports.empty")) : null,
1212
+ reportsLoading ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, t("reports.loading2")) : totalFiles === 0 ? el("div", { style: { fontSize: "12px", color: "#94a3b8", padding: "10px 0" } }, t("reports.empty")) : null,
1172
1213
  groups.map(function (g) {
1173
1214
  return el("div", { key: g.name },
1174
1215
  el("div", { style: { fontSize: "12px", fontWeight: "600", color: "#475569", margin: "6px 0 4px" } }, g.name + "(" + (g.files || []).length + ")"),
@@ -1185,12 +1226,12 @@ var TOOL_GROUPS = [
1185
1226
  } else if (view === "creation" || view === "creation-form") {
1186
1227
  // v3.1.0:原创模式设定库(方案 C:列表页 + 表单页 + 快速切换)
1187
1228
  var CREATION_FIELDS = [
1188
- ["worldview", "🌍", t("creation.worldview"), "例如:末日后异能世界…"],
1189
- ["characters", "🎭", t("creation.characters"), "例如:女主冷静克制,有秘密…"],
1190
- ["forbidden", "🚫", t("creation.forbidden"), "例如:不能有重生/系统/金手指…"],
1191
- ["mainConflict", "🎯", t("creation.mainConflict"), "例如:女主寻找失踪的同伴…"],
1192
- ["genre", "📚", t("creation.genre"), "例如:悬疑+救赎,都市背景…"],
1193
- ["extra", "📝", t("creation.extra"), "例如:每章结尾留钩子,多对话…"]
1229
+ ["worldview", "🌍", t("creation.worldview"), t("ph.e末")],
1230
+ ["characters", "🎭", t("creation.characters"), t("ph.e女")],
1231
+ ["forbidden", "🚫", t("creation.forbidden"), t("ph.e不")],
1232
+ ["mainConflict", "🎯", t("creation.mainConflict"), t("ph.e主")],
1233
+ ["genre", "📚", t("creation.genre"), t("ph.e悬")],
1234
+ ["extra", "📝", t("creation.extra"), t("ph.e每")]
1194
1235
  ];
1195
1236
  var cpGlobal = state.creationProfile && typeof state.creationProfile === "object" ? state.creationProfile : {};
1196
1237
  var cpBooks = state.creationProfiles && typeof state.creationProfiles === "object" ? state.creationProfiles : {};
@@ -1230,7 +1271,7 @@ var TOOL_GROUPS = [
1230
1271
  var nb = String(newBookName || "").trim();
1231
1272
  // v3.5.0 #49:客户端校验——路径分隔符/Windows 保留字符直接提示
1232
1273
  if (nb && (/[\\/:*?"<>|]/.test(nb) || /[.\s]+$/.test(nb) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(nb))) {
1233
- props.controller.set({ revealMsg: "书名含非法字符(\\ / : * ? \" < > | 或结尾点/空格/系统保留名)", revealErr: true });
1274
+ props.controller.set({ revealMsg: t("creation.badName"), revealErr: true });
1234
1275
  return;
1235
1276
  }
1236
1277
  if (!nb) {
@@ -1402,7 +1443,6 @@ var TOOL_GROUPS = [
1402
1443
  ),
1403
1444
  pathBox(t("dir.data"), dirs.dataDir || state.dataDir || "", "data-dir"),
1404
1445
  // v3.2.0:书库统计(写作打卡);null=未就绪不渲染,[]=真空提示
1405
- state.booksStats === null ? null :
1406
1446
  el("div", { key: "statsCard", style: { marginTop: "10px", padding: "10px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff" } },
1407
1447
  el("div", { style: { fontSize: "12px", fontWeight: "600", color: "#475569", marginBottom: "6px" } }, "📚 " + t("stats.title")),
1408
1448
  el("div", { style: { display: "flex", flexDirection: "column", gap: "5px" } },
@@ -1440,8 +1480,8 @@ var TOOL_GROUPS = [
1440
1480
  if (state.demoLoading) return;
1441
1481
  props.controller.set({ demoLoading: true });
1442
1482
  fetch("/api/dsh-novel-writer/demo", { headers: { accept: "application/json" } }).then(function (r2) { if (!r2.ok) throw new Error("HTTP " + r2.status); return r2.json(); }).then(function (d) {
1443
- props.controller.set({ demoReport: d.ok ? d.report : ("演示失败:" + (d.error || "")), demoLoading: false });
1444
- }).catch(function () { props.controller.set({ demoReport: "演示失败(网络/路由不可用)", demoLoading: false }); });
1483
+ props.controller.set({ demoReport: d.ok ? d.report : (t("demo.fail") + ":" + (d.error || "")), demoLoading: false });
1484
+ }).catch(function () { props.controller.set({ demoReport: t("reports.readFailDetail"), demoLoading: false }); });
1445
1485
  } },
1446
1486
  el("span", { style: { fontSize: "13px", fontWeight: "600", color: "#6366f1" } }, state.demoLoading ? t("demo.loading") : t("demo.run")),
1447
1487
  el("span", { style: { fontSize: "11px", color: "#94a3b8" } }, t("demo.hint"))
package/lib/embedding.js CHANGED
@@ -186,7 +186,12 @@ const IMPLICIT_EMOTION_PROTOTYPES = Object.freeze([
186
186
  { emotion: "不舍", text: "她站在门口,忍不住回头望了又望,脚却迈不出去。" },
187
187
  { emotion: "无奈", text: "她苦笑了一下,摇了摇头,什么也没说,转身默默收拾东西。" },
188
188
  { emotion: "脆弱", text: "烛火摇曳,她抱住自己蜷缩在角落,轻声啜泣,怕被人听见。" },
189
- { emotion: "苦涩", text: "他把酒喝完,杯子放回桌上时,笑声还在,眼里却没有光了。" }
189
+ { emotion: "苦涩", text: "他把酒喝完,杯子放回桌上时,笑声还在,眼里却没有光了。" },
190
+ // v3.5.0 M7b:4 轴独立原型标签(不占现有情感标签)
191
+ { emotion: "甜宠", text: "他把她圈在怀里,低声哄着,她笑着躲了躲,心里甜得发软。" },
192
+ { emotion: "悬疑", text: "走廊尽头的门虚掩着,地板上有一串陌生的脚印,她屏住呼吸凑近。" },
193
+ { emotion: "唯美", text: "暮色把远山染成淡紫,水面浮着细碎的光,她沿着长堤慢慢走,风很轻。" },
194
+ { emotion: "情欲", text: "灯光昏黄,他俯身靠近,指尖轻轻划过她的锁骨,呼吸渐渐滚烫。" }
190
195
  ]);
191
196
 
192
197
  /**
package/lib/index.js CHANGED
@@ -396,11 +396,16 @@ function bookNameFromContent(text) {
396
396
  const line = raw.replace(/^#+\s*/, "").replace(/^\uFEFF/, "").trim();
397
397
  if (line === "") continue;
398
398
  if (IMPORT_NOISE.has(line)) continue;
399
- const hasChapterMark = /第?(\d{1,4})[章回话]|第[零一二三四五六七八九十百两]+[章回话]/.test(line);
400
- if (hasChapterMark) {
401
- const cleaned = cleanChapterTitle(line);
402
- if (cleaned !== "") return cleaned;
399
+ // v3.5.0 M9b:《书名》提取在前(《斗破苍穹》第一章 陨落 同行也能识别书名)——先看有没有《》包裹
400
+ const bookMatch = line.match(/[《〈]\s*([^》〉]{2,30}?)\s*[》〉]/);
401
+ if (bookMatch) {
402
+ const name = bookMatch[1].trim();
403
+ if (name.length >= 2 && name.length <= 30) return name;
404
+ continue;
403
405
  }
406
+ // v3.5.0 M9b:章号行不是书名——跳过(防止章节标题当书名;第一章 初遇 无《》落这里)
407
+ const hasChapterMark = /第?(\d{1,4})[章回话]|第[零一二三四五六七八九十百两]+[章回话]|^\d{1,4}[-._\s]/.test(line);
408
+ if (hasChapterMark) continue;
404
409
  }
405
410
  return void 0;
406
411
  }
@@ -673,9 +678,24 @@ const TOOL_LABELS = Object.freeze({
673
678
  function detectCulture(text) {
674
679
  const scores = { western: 0, eastern: 0, modern: 0 };
675
680
  const evidence = { western: [], eastern: [], modern: [] };
681
+ // v3.5.0 M4:词表去重扫描(同表重复词如 城堡×2/便利店×2 不再双计)
682
+ // v3.6.0:同文化子串最长匹配("高潮迭起"命中后 "高潮" 不再重复计)
676
683
  for (const [culture, table] of Object.entries(CULTURE_MARKERS)) {
677
- for (const word of table.words) {
678
- const n = text.split(word).length - 1;
684
+ const seenWords = new Set();
685
+ const sorted = table.words.slice().sort(function (a, b) { return b.length - a.length; });
686
+ let scanText = text;
687
+ for (const word of sorted) {
688
+ if (seenWords.has(word)) continue;
689
+ seenWords.add(word);
690
+ let n = 0;
691
+ let from = 0;
692
+ while (true) {
693
+ const hit = scanText.indexOf(word, from);
694
+ if (hit === -1) break;
695
+ n += 1;
696
+ scanText = scanText.slice(0, hit) + "\u0000".repeat(word.length) + scanText.slice(hit + word.length);
697
+ from = hit + word.length;
698
+ }
679
699
  if (n > 0) {
680
700
  scores[culture] += n;
681
701
  if (evidence[culture].length < 8) evidence[culture].push(word + "×" + n);
@@ -732,8 +752,19 @@ function detectTheme(text) {
732
752
  for (const [theme, words] of Object.entries(THEME_MARKERS)) {
733
753
  let count = 0;
734
754
  const found = [];
735
- for (const word of words) {
736
- const n = text.split(word).length - 1;
755
+ // v3.5.0:同主题子串最长匹配("高潮迭起"命中后 "高潮" 不再重复计;先长后短 + 占位)
756
+ const sorted = words.slice().sort(function (x, y) { return y.length - x.length; });
757
+ let scanText = text;
758
+ for (const word of sorted) {
759
+ let n = 0;
760
+ let from = 0;
761
+ while (true) {
762
+ const hit = scanText.indexOf(word, from);
763
+ if (hit === -1) break;
764
+ n += 1;
765
+ scanText = scanText.slice(0, hit) + "\u0000".repeat(word.length) + scanText.slice(hit + word.length);
766
+ from = hit + word.length;
767
+ }
737
768
  if (n > 0) { count += n; if (found.length < 6) found.push(word + "×" + n); }
738
769
  }
739
770
  if (count > 0) { hits[theme] = count; evidence[theme] = found; }
@@ -1205,6 +1236,8 @@ function makeStateRoutes(allowLan, config) {
1205
1236
  // v2.6.5:更新检查路由(GitHub Releases API + 24h 缓存 + 3s 超时,全程静默)
1206
1237
  const updateCheckHandler = async (req, res) => {
1207
1238
  if (!isAllowedRequest(req, allowLan)) return writeJson(res, 403, { error: "forbidden: loopback-only" });
1239
+ // v3.5.0 L3:仅 GET(POST 也会触发 GitHub 请求)
1240
+ if ((req.method ?? "GET") !== "GET") return writeJson(res, 405, { error: "method not allowed" });
1208
1241
  const result = await checkForUpdate(PLUGIN_VERSION, join(homedir(), ".dsh", "dsh-novel-writer"));
1209
1242
  return writeJson(res, 200, result);
1210
1243
  };
@@ -1535,7 +1568,9 @@ function registerNovelSentenceAnalysis(ctx, config) {
1535
1568
  const top3b = cached.categories.slice().sort(function (a, b) { return b.count - a.count; }).slice(0, 3).map(function (c) { return c.label + " " + Math.round(c.ratio * 100) + "%"; });
1536
1569
  cached.brief = "全书 " + cached.totalSentences + " 句:句式以 " + top3b.join("、") + " 为主;均句长 " + cached.lengths.avg.toFixed(1) + ";主导情绪 " + (cached.emotion.dominant || "-") + "。";
1537
1570
  }
1538
- return { ...cached, book, scope, cache: "hit", reportFile, cachedAt: cached.cachedAt ?? new Date().toISOString() };
1571
+ // v3.5.0 H2:缓存命中剥离内部 ver 键(契约:output schema 无 ver)
1572
+ const { ver: _ver, ...rest } = cached;
1573
+ return { ...rest, book, scope, cache: "hit", reportFile, cachedAt: cached.cachedAt ?? new Date().toISOString() };
1539
1574
  } catch { /* 缓存缺失/损坏,重新分析 */ }
1540
1575
  }
1541
1576
  const options = { top, maxSentences, curveSegments };
@@ -1650,6 +1685,8 @@ ${featLines.join("\n")}
1650
1685
  for (const name of ALL_TOOLS) {
1651
1686
  if (typeof args.tools[name] === "boolean") toolsPatch[name] = args.tools[name];
1652
1687
  }
1688
+ // v3.5.0 M13:novel_sentence_config 禁止被关(关了自己就无法再开——死锁)
1689
+ delete toolsPatch.novel_sentence_config;
1653
1690
  if (Object.keys(toolsPatch).length > 0) patch.tools = toolsPatch;
1654
1691
  }
1655
1692
  if (args?.features !== null && typeof args?.features === "object") {
@@ -2048,7 +2085,9 @@ async function bookStats(root) {
2048
2085
  if (!/\.(md|txt)$/i.test(f.name)) continue;
2049
2086
  const st = await stat(join(bookPath, f.name));
2050
2087
  chapters += 1;
2051
- const txt = await readFile(join(bookPath, f.name), "utf8");
2088
+ // v3.5.0 M11:与其余工具同口径解码(GBK/UTF-16 章节字数不再按 UTF-8 误读)
2089
+ const buf11 = await readFile(join(bookPath, f.name));
2090
+ const txt = decodeTextBuffer(buf11, join(bookPath, f.name));
2052
2091
  chars += txt.length;
2053
2092
  if (st.mtimeMs >= weekAgo) recent7 += txt.length;
2054
2093
  }
@@ -2144,18 +2183,7 @@ function buildCreationCharacterSection(book) {
2144
2183
  return "";
2145
2184
  }
2146
2185
 
2147
- /** 状态卡内容:显示创作进度 + 未回填钩子提醒(强制机制,保证每章钩子都回填)。 */
2148
- async function readCreationStatus(root, book, exec) {
2149
- const dir = creationDir(root, book);
2150
- const f = join(dir, CREATION_FILES.status);
2151
- try {
2152
- const txt = await readTextFile(f, exec);
2153
- // 检测未回填钩子(钩子记录有章节号但状态卡进度低于它?——直接比对大纲与钩子)
2154
- return txt;
2155
- } catch {
2156
- return null;
2157
- }
2158
- }
2186
+
2159
2187
 
2160
2188
  function createOutlineTool(config) {
2161
2189
  return {
@@ -2444,7 +2472,7 @@ function plotKeywords(content) {
2444
2472
  function normalizePlotEntry(entry) {
2445
2473
  // v3.5.0 #17:脏数据兜底——非对象或缺 id/content 直接跳过(不污染输出)
2446
2474
  if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || typeof entry.content !== "string") return null;
2447
- const out = { id: entry.id, content: entry.content, status: typeof entry.status === "string" ? entry.status : "open" };
2475
+ const out = { id: entry.id, content: entry.content, status: entry.status === "done" ? "done" : "open" }; // v3.5.0 M12:status 归一(脏数据 in-progress 等一律 open,符合 schema enum)
2448
2476
  for (const key of ["chapter", "note", "payoffCondition", "lastMentioned", "type", "priority"]) {
2449
2477
  if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
2450
2478
  }
@@ -2525,6 +2553,7 @@ function registerNovelStyleReport(ctx, config) {
2525
2553
  book: { type: "string", description: "书名(novels 下的子目录名)" },
2526
2554
  root: { type: "string", description: "章节库根目录(含 novels 子目录)。" },
2527
2555
  action: { type: "string", description: "report=生成测量报告(默认);get=读取已保存的 AI 风格判断" },
2556
+ brief: { type: "boolean", description: "true=返回精简摘要(一句话结论),省略=完整报告。" },
2528
2557
  aiJudgment: { type: "string", description: "AI 的风格气质判断结论:后插件将测量数据与判断存入 style-reports 供后续使用" }
2529
2558
  },
2530
2559
  required: ["book"]
@@ -2904,7 +2933,8 @@ function registerNovelSettings(ctx, config) {
2904
2933
  } else if (action === "update" || action === "delete") {
2905
2934
  const name = optionalString(args, "name") ?? optionalString(args, "day");
2906
2935
  assert(name !== void 0, `novel_settings ${action} 需要 name(timeline 用 day)参数`);
2907
- const index = list.findIndex((e) => e.name === name);
2936
+ // v3.5.0 M10:timeline 条目 name=day 值——update 按新 day 传时双查(name 或 day 字段)
2937
+ const index = list.findIndex((e) => e.name === name || (category === "timeline" && e.day === name));
2908
2938
  assert(index !== -1, `${categoryLabel}中不存在「${name}」`);
2909
2939
  if (action === "delete") {
2910
2940
  list.splice(index, 1);
@@ -3650,8 +3680,11 @@ function registerNovelPlot(ctx, config) {
3650
3680
  message = `已更新伏笔 #${id}`;
3651
3681
  }
3652
3682
  }
3653
- await writePlots(root, book, entries);
3654
- try { await writeSentenceState({ lastRoot: root }); } catch { /* 记录失败不影响伏笔功能 */ }
3683
+ // v3.5.0 M8:list(只读)不写盘——读操作不做多余 I/O、只读挂载不失败
3684
+ if (action !== "list") {
3685
+ await writePlots(root, book, entries);
3686
+ try { await writeSentenceState({ lastRoot: root }); } catch { /* 记录失败不影响伏笔功能 */ }
3687
+ }
3655
3688
  // v3.5.0 #17:旧/手改数据缺 createdAt 兜底(undefined.localeCompare 会 TypeError)
3656
3689
  // v3.5.0 #17:先清洗(过滤脏条目/补默认值)再排序——排序不再碰 null
3657
3690
  const cleanEntries = entries.map(normalizePlotEntry).filter(Boolean);
@@ -4046,7 +4079,7 @@ ${footer}
4046
4079
  function registerNovelKeywords(ctx, config) {
4047
4080
  ctx.tools.register({
4048
4081
  name: "novel_keywords",
4049
- description: "统计某部作品(或单个章节)中出现频率较高的关键词:中文相邻二字词组、高频单字与英文词。",
4082
+ description: "统计某部作品(或单个章节)中出现频率较高的关键词:中文相邻二字/三字词组与英文词(novel_keywords 输出不含单字)。",
4050
4083
  parameters: {
4051
4084
  type: "object",
4052
4085
  properties: {
@@ -4203,9 +4236,11 @@ Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
4203
4236
  }
4204
4237
  const fileName = `第${String(number).padStart(2, "0")}章.md`;
4205
4238
  const filePath = join(dir, fileName);
4206
- // v3.5.0 #2:防覆盖——显式章号已存在时拒绝写入(不静默丢稿)
4207
- if (existsSync(filePath)) {
4208
- throw new Error(`第 ${number} 章已存在(${fileName})——不会覆盖旧稿;如需更新请先确认或使用其他章号`);
4239
+ // v3.5.0 #2/M14:防覆盖——章号归一化查重(第1章/第一章/第01章 视为同号,避免同号重复章并存)
4240
+ const existingSame = await scanChapters(dir);
4241
+ const sameNo = existingSame.find(function (c) { return (c.number ?? -1) === number; });
4242
+ if (sameNo) {
4243
+ throw new Error(`第 ${number} 章已存在(${sameNo.file})——不会覆盖旧稿;如需更新请先确认或使用其他章号`);
4209
4244
  }
4210
4245
  const parts = [];
4211
4246
  if (title !== void 0) parts.push(`# ${title}`, "");
package/lib/prompts.js CHANGED
@@ -31,10 +31,8 @@ const WORKFLOW_TEXT = [
31
31
  " 重要:句式模式是「参考节奏」而非「模板套用」。若机械复刻导致句子僵硬、重复、模式化,必须优先回归自然表达。",
32
32
  "5. 导入/整理原稿件时:用 novel_import 扫描存放多本小说稿件的文件夹(src),先以 scan 模式查看分组建议,",
33
33
  " 若发现异名同书(如\"旧版/精修版\"实为同一本),用 book 参数强制合并后以 apply 模式导入到 novels/<书名>/ 分类存放。",
34
- "6. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
35
- " 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
36
- "7. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
37
- "8. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
34
+ "6. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。",
35
+ "7. 工具链提示:续写/分析前可用 novel_plot 查看未回收伏笔(open 条目,含类型/优先级/提及章节);写完新章节可用 novel_style_check 做风格自检(相似度+偏差+细节密度),novel_plot scan 自动更新伏笔提及记录;",
38
36
  " 世界观一致性:续写前先确认文化基准——novel_settings category=worldview action=detect 自动判断(或人工 add/update),动笔时对照其 bannedWords/recommended 用词,避免中西意象混搭(如欧式背景不写老夫/上香/时辰);novel_continuity_check 会扫描用语冲突候选;",
39
37
  " 语用一致性:不只管词,还要管'怎么说话'——对照 worldview 的 speechStyle(title 称谓规范/honorBad 客套禁词/ritualBadPatterns 仪式禁式/tone 语气),人物开口前检查称谓是否欧式(Miss+名)、客套是否避免'提点/承蒙/在下'、宗教仪式是否点烛而非烧香/上X柱香、对话是否口语化不文言;",
40
38
  " novel_settings 维护五张设定表(人物/地点/道具/时间线/世界观用语规范),novel_summary 保存每章摘要(长书续写先读摘要再按需细读),novel_continuity_check 输出设定矛盾候选;",
@@ -31,7 +31,7 @@ const ACTION_VERBS = [
31
31
  "推", "拉", "抓", "拿", "端", "抱", "举", "抬", "踢", "踩", "打", "拍", "敲", "砸",
32
32
  "扔", "丢", "接", "递", "握", "捏", "掐", "拧", "扯", "撕", "拔", "插", "捅", "刺",
33
33
  "砍", "劈", "切", "割", "放", "摆", "搁", "挂", "贴", "塞", "填", "灌", "倒", "泼",
34
- "洒", "翻", "卷", "铺", "盖", "叠", "折", "脱", "穿", "戴", "摘", "系", "解", "开",
34
+ "洒", "翻", "卷", "铺", "盖", "叠", "折", "脱", "穿", "戴", "摘", "解", "开",
35
35
  "关", "锁", "按", "压", "顶", "撞", "碰", "触", "摸", "抚", "揉", "搓", "擦", "抹",
36
36
  "洗", "刷", "扫", "拖", "铲", "挖", "埋", "堆", "砌", "修", "补", "缝", "织", "编",
37
37
  "缠", "绕", "捆", "绑", "扎", "拴", "套", "蒙", "罩", "捂", "堵", "挡", "遮", "掩",
@@ -43,29 +43,29 @@ const ACTION_VERBS = [
43
43
  "吸", "呼", "吐", "咽", "吞", "嚼", "咬", "啃", "舔", "吮", "嗅", "闻", "听", "看",
44
44
  "望", "瞧", "瞅", "盯", "瞪", "瞥", "瞟", "瞄", "观", "察", "读", "写", "画", "描",
45
45
  "绘", "刻", "雕", "铸", "炼", "烧", "煮", "炒", "煎", "炸", "烤", "烘", "熏", "泡",
46
- "浸", "染", "涂", "喷", "浇", "淋", "滴", "流", "淌", "涌", "冒", "射", "溅", "炸",
46
+ "浸", "染", "涂", "喷", "浇", "淋", "滴", "淌", "涌", "冒", "射", "溅", "炸",
47
47
  "裂", "碎", "破", "赢", "输", "借", "还", "付", "收", "给", "送", "寄", "递", "交",
48
48
  "赐", "赏", "罚", "奖", "惩", "减", "增", "加", "删", "改", "换", "替", "代", "变",
49
49
  "创", "建", "设", "置", "安", "装", "配", "组", "拆", "卸", "运", "搬", "携", "带",
50
- "佩", "持", "执", "操", "控", "驾", "驶", "骑", "乘", "踏", "迈", "步", "驰", "飞",
50
+ "佩", "持", "执", "操", "控", "驾", "驶", "骑", "乘", "踏", "迈", "驰", "飞",
51
51
  "翔", "游", "泳", "潜", "航", "驱", "逐", "灭", "杀", "斩", "擒", "捕", "捉", "逮",
52
52
  "拘", "押", "囚", "禁", "铐", "缚", "吊", "悬", "垂", "坠", "掉", "摔", "跌", "倒",
53
- "仆", "瘫", "软", "麻", "僵", "直", "挺", "竖", "耸", "矗", "凸", "凹", "陷", "塌",
53
+ "仆", "瘫", "麻", "僵", "直", "挺", "竖", "耸", "矗", "凸", "凹", "陷", "塌",
54
54
  "崩", "垮", "弯", "折", "断", "裂", "绽", "合", "拢", "闭", "张", "启", "展", "伸",
55
55
  "缩", "抽", "拽", "揪", "捋", "拂", "掸", "扑", "扇", "燃", "点", "着", "焚", "灼",
56
56
  "烫", "烙", "煨", "炖", "焖", "熬", "烹", "调", "拌", "搅", "和", "团", "发", "烂",
57
- "腐", "朽", "酸", "甜", "苦", "辣", "咸", "淡", "腻", "胖", "肿", "胀", "鼓", "瘪",
57
+ "腐", "朽", "酸", "甜", "苦", "辣", "咸", "腻", "胖", "肿", "胀", "鼓", "瘪",
58
58
  "皱", "平", "整", "齐", "乱", "杂", "混", "浊", "清", "澈", "净", "洁", "污", "染",
59
- "脏", "臭", "香", "芳", "馥", "郁", "浓", "淡", "雅", "俗", "野", "蛮", "横", "霸",
59
+ "脏", "臭", "香", "芳", "馥", "郁", "浓", "雅", "俗", "野", "蛮", "横", "霸",
60
60
  "欺", "压", "榨", "剥", "削", "掠", "夺", "抢", "劫", "偷", "盗", "窃", "扒", "掏",
61
61
  "搜", "查", "寻", "找", "觅", "探", "索", "究", "研", "讨", "论", "议", "评", "判",
62
- "审", "核", "验", "证", "试", "测", "量", "计", "算", "统", "分", "析", "归", "纳",
62
+ "审", "核", "验", "证", "试", "测", "量", "计", "算", "分", "析", "归", "纳",
63
63
  "辩", "驳", "争", "吵", "闹", "斗", "殴", "拼", "搏", "挣", "抗", "抵", "防", "守",
64
64
  "卫", "护", "保", "救", "援", "助", "帮", "扶", "领", "导", "指", "引", "教", "训",
65
65
  "培", "锻", "磨", "砺", "激", "励", "鼓", "振", "奋", "兴", "感", "触", "震", "惊",
66
66
  "慨", "唏", "嘘", "呜", "咽", "啜", "泣", "哽", "窒", "憋", "闷", "堵", "慌", "乱",
67
67
  "躁", "烦", "恼", "怒", "火", "气", "愤", "憎", "恶", "厌", "嫌", "弃", "鄙", "蔑",
68
- "轻", "重", "珍", "爱", "惜", "怜", "悯", "疼", "宠", "溺", "娇", "惯", "纵", "容",
68
+ "轻", "珍", "爱", "惜", "怜", "悯", "疼", "宠", "溺", "娇", "惯", "纵", "容",
69
69
  "包", "庇", "袒", "呵", "佑", "祝", "福", "祈", "祷", "祭", "祀", "拜", "念", "诵",
70
70
  "咏", "吟", "唱", "歌", "舞", "蹈", "旋", "滚", "滑", "溜", "窜", "遁", "隐", "匿",
71
71
  "潜", "伏", "修", "行", "悟", "参", "禅", "坐", "定", "成", "化", "属", "于", "存",
@@ -74,8 +74,8 @@ const ACTION_VERBS = [
74
74
  "派", "颁", "授", "予", "赠", "托", "委", "信", "任", "依", "凭", "仗", "恃", "仰",
75
75
  "指", "盼", "期", "渴", "奢", "妄", "幻", "梦", "理", "设", "构", "揣", "猜", "预",
76
76
  "料", "征", "兆", "迹", "象", "端", "倪", "苗", "头", "绪", "脉", "络", "纹", "层",
77
- "次", "框", "架", "体", "系", "统", "流", "程", "序", "步", "骤", "环", "节", "细",
78
- "重", "难", "焦", "热", "亮", "卖", "痛", "软", "肋", "弱", "短", "板", "瓶",
77
+ "次", "框", "架", "体", "程", "序", "骤", "环", "节", "细",
78
+ "难", "焦", "热", "卖", "痛", "肋", "弱", "短", "板", "瓶",
79
79
  "颈", "壁", "垒", "藩", "篱", "桎", "梏", "枷", "锁", "镣", "牢", "笼", "樊", "囚",
80
80
  "罩", "络"
81
81
  ];
@@ -89,9 +89,10 @@ const ACTION_SET = new Set(ACTION_VERBS);
89
89
  /** 切句:按句末标点切分。 */
90
90
  function splitSentences(text) {
91
91
  // v3.5.0 #50b:标点切句 + 无标点换行也切(逐行排版正确分句);未完句判定按"行末豁免"处理(见 measureStyleMetrics)
92
+ // v3.5.0 H3:句末标点后吞闭合引号("她说:\u201c走吧。\u201d" 不再切成孤儿引号句)
92
93
  return String(text)
93
94
  .split(/(?<=[。!?!?])\s*|\n+/)
94
- .map(function (s) { return s.trim(); })
95
+ .map(function (s) { return s.replace(/[”」』’\"']+$/, "").trim(); })
95
96
  .filter(function (s) { return s.length > 0; });
96
97
  }
97
98
 
@@ -113,7 +114,8 @@ export function measureStyleMetrics(text) {
113
114
  const complexity = sentences.length > 0 ? smallClauseTotal / sentences.length : 0;
114
115
 
115
116
  // 2) 修饰密度:X的 / X地(“的/地”前的 1-4 字修饰语)
116
- const adjMods = (t.match(/[\u4e00-\u9fff]{1,4}的/g) || []).length;
117
+ // v3.6.0:「目的/的确/的的确确」的"的"不是修饰助词——lookbehind 排除(真修饰语不受影响)
118
+ const adjMods = (t.match(/[\u4e00-\u9fff]{1,3}(?<!目)(?<!的)的/g) || []).length;
117
119
  const advMods = (t.match(/[\u4e00-\u9fff]{1,4}地/g) || []).length;
118
120
  const modifierDensity = per1000(adjMods + advMods);
119
121
 
@@ -168,10 +170,13 @@ export function measureStyleMetrics(text) {
168
170
  const dash = (t.match(/——/g) || []).length;
169
171
  const gapChars = per1000(ellipsis * 2 + dash * 2);
170
172
  // v3.5.0 #50b:未完句判定——省略号结尾或行内无句末标点;行末无标点(排版分行)视为完整句,不再虚高 gapIndex
173
+ // v3.5.0 L1:顺序游标定位(重复/子串文本不再误判——"他走了。他走了\n"第二句正确豁免)
174
+ let searchFrom = 0;
171
175
  const unfinished = sentences.filter(function (s) {
176
+ const idxU = t.indexOf(s, searchFrom);
177
+ if (idxU !== -1) searchFrom = idxU + s.length;
172
178
  if (/[……—…]$/.test(s)) return true;
173
179
  if (/[。!?!?]$/.test(s)) return false;
174
- const idxU = t.indexOf(s);
175
180
  if (idxU === -1) return true;
176
181
  const afterU = t[idxU + s.length];
177
182
  if (afterU === undefined || afterU === "\n" || afterU === "\r") return false;
package/lib/vibe.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * v2.1.0 气质聚合层:把插件各工具的已有输出(detect 词频/题材、情感净化、
3
- * 情感量化 V/Δ/C、意象极性/歧义、语义隐性情感)汇总成"氛围光谱"(10 轴)。
3
+ * 情感量化 V/Δ/C、意象极性/歧义、语义隐性情感)汇总成"氛围光谱"(12 轴)。
4
4
  * 纯规则加权 0 token;不做"贴标签",只输出数值坐标 + 组合结论 + 证据链。
5
5
  */
6
6
 
@@ -44,14 +44,16 @@ function freqIn(evArr, words) {
44
44
  /** 语义隐性情感分布 → 各轴信号 */
45
45
  function semSignals(dist) {
46
46
  const s = { nightmare: 0, angst: 0, heartwarming: 0, tearjerker: 0, dark: 0, mystery: 0, blaze: 0, absurd: 0, lonesome: 0, fluff: 0, aesthetic: 0, sensual: 0 };
47
- // 键与 embedding.js IMPLICIT_EMOTION_PROTOTYPES 的 26 个 emotion 标签严格对齐(v2.5 修复)
47
+ // 键与 embedding.js IMPLICIT_EMOTION_PROTOTYPES 的 29 个 emotion 标签严格对齐(v2.5 修复)
48
48
  const map = {
49
49
  脆弱: "nightmare", 恐惧: "nightmare", 不安: "nightmare",
50
50
  焦虑: "angst", 隐忍: "angst", "压抑的愤怒": "angst", 无奈: "angst",
51
51
  温暖: "heartwarming", 温柔: "heartwarming", 幸福: "heartwarming", 甜蜜: "heartwarming", 释然: "heartwarming", 仰慕: "heartwarming",
52
52
  失落: "tearjerker", 悲伤: "tearjerker", 怅惘: "tearjerker", 心碎: "tearjerker", 不舍: "tearjerker", 眷恋: "tearjerker", 苦涩: "tearjerker",
53
53
  孤独: "lonesome", 疏离: "lonesome", 决绝: "lonesome",
54
- 厌恶: "dark", 震惊: "absurd"
54
+ 厌恶: "dark", 震惊: "absurd",
55
+ // v3.5.0 M7:近义近似映射(原型无 4 轴域,语义信号不再恒 0)
56
+ 甜宠: "fluff", 悬疑: "mystery", 唯美: "aesthetic", 情欲: "sensual"
55
57
  };
56
58
  for (const [k, v] of Object.entries(dist || {})) {
57
59
  const axis = map[k];
@@ -77,6 +79,10 @@ const WEB_NOVEL_SIGNALS = {
77
79
  aesthetic: ["月色", "清辉", "烟雨", "荷塘", "落英", "余韵", "浮光", "静默", "素净", "微凉", "风过", "细碎"],
78
80
  sensual: ["呼吸", "发烫", "贴近", "肌肤", "颤栗", "酥麻", "灼热", "喘息", "缠绕", "柔软", "耳畔", "温存"]
79
81
  };
82
+ // v3.5.0 H1b:词表统一剥 ★ 字尾(模块加载时一次;★ 是词源标注,带星号永不命中——61 个词大面积作废)
83
+ for (const axis of Object.keys(WEB_NOVEL_SIGNALS)) {
84
+ WEB_NOVEL_SIGNALS[axis] = WEB_NOVEL_SIGNALS[axis].map(function (w) { return w.replace(/★$/, ""); });
85
+ }
80
86
  const THEME_AXIS_LINK = [
81
87
  { themes: ["豪门总裁", "总裁", "现言"], axis: "fluff", bonus: 0.2, needBase: 0.1 },
82
88
  { themes: ["虐恋", "替身"], axis: "tearjerker", bonus: 0.25, needBase: 0.1 },
@@ -121,7 +127,7 @@ export function computeVibe(detect, emotion, text = "") {
121
127
  // v2.2.0 网文信号:动作/套路词群扫描(封顶计分)
122
128
  // v2.6.0 提速:原 12 轴×164 词全文本 indexOf 扫描 → 先滑窗统计 2-4 字词频(Map),词表命中改查表
123
129
  // v3.5.0 #58:只生成词表实际词长的 n-gram(旧版逐位生成 2/3/4 字 ≈ 3×字数条目,50 万字书 ~150 万键)
124
- // v3.5.0 R5(#58):滑窗只生成 2-4 字 n-gram(词表 >4 字词低频,单独 indexOf 查,不放大内存)
130
+ // v3.5.0 R5(#58):滑窗只生成 2-4 字 n-gram(词表 >4 字词低频,单独 indexOf 查,不放大内存)
125
131
  const longWords = new Set();
126
132
  const signalLens = new Set();
127
133
  for (const axisWords of Object.values(WEB_NOVEL_SIGNALS)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "小说写作助手插件(v3.5.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带、原创模式与创作资料、写作哨兵(衔接/OOC/大纲)。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -125,7 +125,7 @@ novel_sentence_analysis 的 emotion.quantification.semanticImplicit(semanticEm
125
125
  - 分析/续写前先跑 novel_style_report:输出 6 维测量数据(文风指纹/高频词汇/题材流派/情感量化/氛围光谱 12 轴/语义风格距离),插件不贴标签;
126
126
  - **风格基线(v3.0.0)**:novel_style_report 额外输出「文笔六维基线带」(句法复杂度/修饰密度/抽象度/动作密度/不确定性/留白指数,按章节 μ±σ);
127
127
  原创或风格模仿时:① 先读原书基线带 → ② 动笔前声明本书基线(默认取原书 μ±σ,可声明有意偏移,如“节奏加快一档”)→ ③ 每章写完跑 novel_style_check,对照「文笔六维对照」节,出带(超出 ±容差)的维度定向修正后再续写;
128
- 容差带(允许低于/高于基线的百分比)可在侧边栏「写作助手功能 → 风格基线」调整,默认推荐 = 原著章节波动的 1.5σ(每维独立,下限 ±15% 兜底);主题/情节/人物完全自由,基线只管写法层面。
128
+ 容差带(允许低于/高于基线的百分比)可在侧边栏「写作助手功能 → 风格基线」调整,默认推荐 = 原著章节波动的 1.5σ(每维独立,下限 ±10%~100%(推荐值自动取整));主题/情节/人物完全自由,基线只管写法层面。
129
129
  - AI 结合数据给出风格气质判断,通过 aiJudgment 参数回传(插件存入 .novel-writer/style-reports/,后续 action=get 读取);续写时参考已保存判断保持风格一致。
130
130
 
131
131
  ## 原创模式(v3.1.0)