dsh-novel-writer 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -8,7 +8,7 @@ English | [**中文**](./README.md)
8
8
  [![GitHub release](https://img.shields.io/github/v/release/siweina/dsh-novel-writer.svg?style=flat-square)](https://github.com/siweina/dsh-novel-writer/releases)
9
9
  [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4b8bbe.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
10
10
 
11
- A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v3.0.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **6-dimension writing-metric baseline band**, 12-axis vibe spectrum, **style portrait report**, plot & settings management, local semantic search (0 token), webnovel signal detection, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host. **Requires Node ≥ 22.3.**
11
+ A novel-writing assistant plugin for **DeepSeek Harness (DSH)** (v3.1.0): chapter library management, sentence-pattern analysis, emotion purification & quantification, **6-dimension writing-metric baseline band**, 12-axis vibe spectrum, **style portrait report**, **original-creation mode with per-book profile library**, plot & settings management, local semantic search (0 token), webnovel signal detection, batch import, and AI-assisted continuation writing. Zero third-party dependencies on host. **Requires Node ≥ 22.3.**
12
12
 
13
13
  > **A note to non-Chinese users**: This plugin is designed specifically for Chinese-language novel analysis and writing — its core capabilities (sentence-pattern analysis, emotion quantification, imagery detection) and its built-in semantic model are all built and tuned for Chinese text. Fully supporting English or other languages alongside Chinese is beyond my current capability. I sincerely apologize for any inconvenience this may cause.
14
14
 
@@ -30,25 +30,24 @@ npm install dsh-novel-writer
30
30
  dsh plugin --profile web add github:siweina/dsh-novel-writer#main
31
31
  ```
32
32
 
33
- After installing, **restart the web app** to activate (host registers 15 tools + state/reveal/update-check routes; browser mounts the "Writing Assistant" sidebar panel).
33
+ After installing, **restart the web app** to activate (host registers 16 tools + state/reveal/update-check routes; browser mounts the "Writing Assistant" sidebar panel).
34
34
 
35
35
  ---
36
36
 
37
37
  ## Features
38
38
 
39
- 1. **6-dimension writing-metric baseline band** (v3.0.0 core, `novel_style_report` / `novel_style_check`): sentence complexity / modifier density / abstractness / action density / uncertainty / gap (whitespace) index. Per-chapter μ±σ forms the book's baseline band; new chapters are judged dimension by dimension (in-band / half-band / out-of-band ⚠). **Themes, plots and characters stay free the band governs writing style only, never content.**
40
- 2. **Recommended tolerance (ready to use)**: each dimension auto-suggests a tolerance of **1.5× the book's chapter σ** (rounded to 5%, capped at ±10%~100%); users can also set custom per-dimension ±% in the "Writing Assistant Style Baseline" panel (signs fixed by position, inputs accept 0–100 only).
41
- 3. **Style portrait report** (`novel_style_report`): 6-dimension measurement style fingerprint / high-frequency lexicon / genre-theme / emotion quantification / 12-axis vibe spectrum / semantic style distance. **Measurement-judgment separation**: the plugin only reports numbers, never labels; AI judgment can be saved back.
42
- 4. **12-axis vibe spectrum**: nightmare / angst / heartwarming / fluff / tearjerker / dark / mystery / blaze / absurd / lonesome / aesthetic / sensual with traceable evidence, 0 token.
43
- 5. **Local semantic engine**: bge-small-zh Chinese model (24MB, shipped with the plugin) local CPU inference — natural-language search over the whole book (with chapter location), semantic style comparison, semantic implicit emotion; lazy loading + graceful fallback.
44
- 6. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint + guidance, with cache & report export.
45
- 7. **Emotion purification & quantification**: strong/weak emotion-word grading, pollution detection, caveat warning + AI re-verification; Valence sliding window → variance V / delta Δ / conflict index C.
46
- 8. **Writing toolkit**: plot tracking / five settings tables / chapter summaries / continuity audit / batch import / style check / continuation writing / update check.
47
- 9. **Per-tool UI toggles**: "Writing Assistant" sidebar panel (master + grouped tool toggles + feature toggles + style baseline tolerance), data-dir usage & semantic-engine status display.
39
+ 1. **Original-creation mode (v3.1.0 core, per-book profile library)**: maintain creative intent per book in the sidebar worldview / characters / forbidden events / main conflict / genre preference / extra requirements (6 dimensions); **books without a directory yet can be pre-configured**; saving auto-creates `novels/创作资料/<book>/创作设定.md`; a "default profile" covers new books; character settings sync into 《主要人物设定.md》.
40
+ 2. **novel_outline (creation-material management, 1 of 16 tools)**: creation bible / main & minor characters / plot outline / hook log / status card (6 files, 7 actions); **enforced workflow** read status+outline before writing, backfill the chapter-end hook after each chapter (auto reminder for missing hooks), dynamic batch sizing (10/20/30 chapters).
41
+ 3. **6-dimension writing-metric baseline band**: sentence complexity / modifier density / abstractness / action density / uncertainty / gap index; per-chapter μ±σ band; recommended tolerance = 1. (capped ±10%~100%); per-dimension verdicts (in-band / half-band / out-of-band ⚠). **Themes/plots/characters stay free the band governs writing style only.**
42
+ 4. **Style portrait report**: 6-dimension measurement style fingerprint / high-frequency lexicon / genre-theme / emotion quantification / 12-axis vibe spectrum / semantic style distance. Measurement-judgment separation with save-back.
43
+ 5. **Local semantic engine**: bge-small-zh Chinese model (24MB, shipped) local CPU inference — natural-language whole-book search (with chapter location), semantic style comparison, semantic implicit emotion; 0 token, lazy loading + graceful fallback.
44
+ 6. **Sentence-pattern analysis**: 9 categories, arrangement patterns, rhythm, emotion curve, style fingerprint, with cache & export; emotion purification + Valence quantification (V / Δ / C).
45
+ 7. **Writing toolkit**: plot tracking / five settings tables / chapter summaries / continuity audit / batch import / style check / update check.
46
+ 8. **Per-tool UI toggles**: master + grouped tool toggles + feature toggles + style baseline tolerance + original-creation profile library; data-dir usage & semantic-engine status.
48
47
 
49
48
  ---
50
49
 
51
- ## Provided Tools (15)
50
+ ## Provided Tools (16)
52
51
 
53
52
  | Tool | Description |
54
53
  |------|-------------|
@@ -67,6 +66,7 @@ After installing, **restart the web app** to activate (host registers 15 tools +
67
66
  | `novel_summary` | Chapter summaries |
68
67
  | `novel_continuity_check` | Continuity audit |
69
68
  | `novel_semantic_search` | Semantic search (local embedding, 0 token) |
69
+ | `novel_outline` | **Creation-material management** (bible/characters/outline/hooks/status) |
70
70
 
71
71
  ---
72
72
 
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![GitHub release](https://img.shields.io/github/v/release/siweina/dsh-novel-writer.svg?style=flat-square)](https://github.com/siweina/dsh-novel-writer/releases)
9
9
  [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4b8bbe.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
10
10
 
11
- 为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v3.0.0):章节库管理、句式分析、情感净化与量化、**文笔六维基线带**、氛围光谱、**风格画像报告**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。**要求 Node ≥ 22.3。**
11
+ 为 **DeepSeek Harness (DSH)** 打造的小说写作助手插件(v3.1.0):章节库管理、句式分析、情感净化与量化、**文笔六维基线带**、氛围光谱、**风格画像报告**、**原创模式(多书设定库)**、伏笔设定管理、本地语义检索(0 token)、网文信号识别、批量导入与 AI 续写辅助。宿主端零第三方依赖,浏览器端仅依赖 Web GUI 自带的 react。**要求 Node ≥ 22.3。**
12
12
 
13
13
  > **致非中文用户**:本插件为中文小说分析写作而设计——句式、情感、意象等核心能力以及内置的语义模型,全部针对中文语料构建与调优。在深耕中文的同时兼顾英文等其他语言,确实超出了我目前的能力范围。若因此给您带来不便,我深感抱歉,恳请谅解。
14
14
 
@@ -30,25 +30,24 @@ npm install dsh-novel-writer
30
30
  dsh plugin --profile web add github:siweina/dsh-novel-writer#main
31
31
  ```
32
32
 
33
- 安装后**重启 web 应用**生效(宿主端注册 15 个工具与 state/reveal/update-check 路由,浏览器端挂载侧边栏「写作助手功能」开关面板)。
33
+ 安装后**重启 web 应用**生效(宿主端注册 16 个工具与 state/reveal/update-check 路由,浏览器端挂载侧边栏「写作助手功能」面板)。
34
34
 
35
35
  ---
36
36
 
37
37
  ## 功能
38
38
 
39
- 1. **文笔六维基线带**(v3.0.0 核心,novel_style_report / novel_style_check):六维量化指标——句法复杂度 / 修饰密度 / 抽象度 / 动作密度 / 不确定性 / 留白指数;每章测 μ±σ 形成原著基线带,新章逐维对照判定(带内 ✓ / 半带 △ / 出带 ⚠)。**主题/情节/人物完全自由——基线只管写法层面,不碰内容。**
40
- 2. **推荐容差(上手即用)**:基线带每维自动给出推荐容差 = 原著章节波动的 **1.5 σ**(取整到 5%,限 ±10%~100%);用户也可在侧边栏「写作助手功能 风格基线」按维度自定义 ±% 容差(正负号按位置固定,输入框只收 0~100 数字)。
41
- 3. **风格画像报告**(novel_style_report):6 维测量——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 / 语义风格距离。**测量与判断分离**:插件只报数不贴标签,AI 判断可回传存盘。
42
- 4. **氛围光谱 12 轴**:噩梦感 / 焦虑压抑 / 温馨治愈 / 甜宠日常 / 催泪虐心 / 黑暗残酷 / 悬疑神秘 / 热血激昂 / 荒诞无厘头 / 孤独疏离 / 文艺唯美 / 情欲暧昧——证据链可追溯,0 token。
43
- 5. **本地语义引擎**:bge-small-zh 中文模型(24MB 随插件分发)本地 CPU 推理——自然语言搜全书语义相关段落(带章节定位),语义级风格对比、语义隐性情感,懒加载 + 自动回退。
44
- 6. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹与节奏建议,带缓存与报告导出。
45
- 7. **情感净化 + 量化**:强/弱情绪词分级、污染源检测、caveat 预警 + AI 复核;Valence 滑动窗口 方差 V / 斜率 Δ / 矛盾指数 C。
46
- 8. **写作辅助全家桶**:伏笔登记表 / 设定五张表 / 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 续写辅助 / 更新检查。
47
- 9. **全工具 UI 开关**:侧边栏「写作助手功能」面板(总开关 + 工具开关 + 功能开关 + 风格基线容差),显示数据目录占用与语义引擎状态。
39
+ 1. **原创模式(v3.1.0 核心,多书设定库)**:侧边栏为每本书独立维护创作意图——世界观 / 角色设定 / 不允许的事件 / 主线目的 / 题材偏好 / 额外要求 6 维;**未建目录的新书也能预配置**,保存即自动创建 `novels/创作资料/<书>/创作设定.md`;「默认设定」兜底新书;角色设定同步进《主要人物设定.md》。
40
+ 2. **novel_outline(创作资料管理,16 个工具之一)**:创作设定 / 主要·次要人物 / 剧情大纲 / 钩子记录 / 创作状态卡 6 文件、7 action;**强制链条**——原创前必读状态卡+大纲,写完每章必回填结尾钩子(未回填自动提醒),动态批次(10/20/30 章)自动升档。
41
+ 3. **文笔六维基线带**:句法复杂度 / 修饰密度 / 抽象度 / 动作密度 / 不确定性 / 留白指数,每章测 μ±σ 形成基线带;推荐容差 = 1.5σ(限 ±10%~100%);新章逐维对照(带内 ✓ / 半带 △ / 出带 ⚠)。**主题/情节/人物自由——基线只管写法层面。**
42
+ 4. **风格画像报告**:6 维测量——文风指纹 / 高频词汇 / 题材流派 / 情感量化 / 氛围光谱 12 / 语义风格距离。测量与判断分离,AI 判断可回传存盘。
43
+ 5. **本地语义引擎**:bge-small-zh 中文模型(24MB 随插件分发)本地 CPU 推理——自然语言搜全书(带章节定位)、语义级风格对比、语义隐性情感,0 token,懒加载 + 自动回退。
44
+ 6. **句式模式分析**:九类句式分布、排列规律、句长节奏、情感曲线、风格指纹,带缓存与报告导出;情感净化 + Valence 量化(方差 V / 斜率 Δ / 矛盾 C)。
45
+ 7. **写作辅助全家桶**:伏笔登记 / 设定五张表 / 章节摘要 / 连贯性审计 / 批量导入 / 风格自检 / 更新检查。
46
+ 8. **全工具 UI 开关**:总开关 + 工具开关 + 功能开关 + 风格基线容差 + 原创模式设定库,显示数据目录占用与语义引擎状态。
48
47
 
49
48
  ---
50
49
 
51
- ## 提供的工具(15 个)
50
+ ## 提供的工具(16 个)
52
51
 
53
52
  | 工具 | 说明 |
54
53
  |------|------|
@@ -67,6 +66,7 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
67
66
  | `novel_summary` | 章节摘要(长书续写辅助) |
68
67
  | `novel_continuity_check` | 连贯性审计(设定矛盾+语用冲突候选) |
69
68
  | `novel_semantic_search` | 语义检索(本地 embedding,0 token) |
69
+ | `novel_outline` | **创作资料管理**(设定/人物/大纲/钩子/状态卡) |
70
70
 
71
71
  ---
72
72
 
@@ -83,7 +83,7 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
83
83
 
84
84
  ## 数据目录
85
85
 
86
- `<书库根>/.novel-writer/`:`plots`(伏笔)/ `settings`(设定)/ `summaries`(摘要)/ `analysis`(分析报告+基线)/ `audits`(审计)/ `embedding`(语义索引)/ `style-reports`(风格画像)。
86
+ `<书库根>/.novel-writer/`:`plots` / `settings` / `summaries` / `analysis` / `audits` / `embedding` / `style-reports`。
87
87
 
88
88
  ---
89
89
 
package/lib/client.js CHANGED
@@ -246,6 +246,8 @@ var css = `
246
246
  "badge.on": "已开启",
247
247
  "badge.off": "已关闭",
248
248
  "tool.novel_books": "作品列表 novel_books",
249
+ "tool.novel_outline": "创作资料 novel_outline",
250
+ "tool.novel_outline.desc": "维护原创小说创作设定/主要次要人物/剧情大纲/钩子记录/创作状态卡(novels/创作资料/<书名>/);原创前必读状态卡与大纲,写完每章必回填钩子。",
249
251
  "tool.novel_chapters": "章节清单 novel_chapters",
250
252
  "tool.novel_read": "阅读章节 novel_read",
251
253
  "tool.novel_keywords": "关键词分析 novel_keywords",
@@ -308,7 +310,36 @@ var css = `
308
310
  "baseline.reset": "清除自定义(用推荐)",
309
311
  "baseline.saved": "容差已保存",
310
312
  "baseline.low": "低于%",
311
- "baseline.high": "高于%"
313
+ "baseline.high": "高于%",
314
+ "panel.creationTitle": "原创模式",
315
+ "panel.creationHint": "创作设定(留空=让模型自己定)",
316
+ "creation.desc": "填写你的创作意图,保存后每次原创自动带上(留空的维度由模型自行设定)。也可在对话中随时补充调整。",
317
+ "creation.worldview": "世界观",
318
+ "creation.characters": "角色设定",
319
+ "creation.forbidden": "不允许的事件",
320
+ "creation.mainConflict": "主线目的",
321
+ "creation.genre": "题材偏好",
322
+ "creation.extra": "额外要求",
323
+ "creation.listTitle": "设定库",
324
+ "creation.desc2": "每本书一份设定(未建目录的新书也能预配置);写书时模型自动用对应设定。",
325
+ "creation.new": "+ 新建设定",
326
+ "creation.newPlaceholder": "输入新书书名…",
327
+ "creation.default": "默认设定(新书/通用)",
328
+ "creation.defaultHint": "没在设定库里的新书用这份",
329
+ "creation.items": "项已填",
330
+ "creation.none": "未填写(全部交给模型)",
331
+ "creation.edit": "编辑",
332
+ "creation.delete": "删除",
333
+ "creation.deleted": "已删除该书设定",
334
+ "creation.newed": "已创建设定,开始填写",
335
+ "creation.needName": "请先输入新书书名",
336
+ "creation.dirtyWarn": "有未保存的修改,切换将丢弃。继续?",
337
+ "creation.formTitle": "编辑设定",
338
+ "creation.bookFor": "设定应用书",
339
+ "creation.save": "保存设定",
340
+ "creation.clear": "清空",
341
+ "creation.saved": "原创设定已保存",
342
+ "creation.cleared": "已清空(全部交给模型)"
312
343
  };
313
344
  var en = {
314
345
  "entry.label": "Writing Assistant",
@@ -382,6 +413,8 @@ var css = `
382
413
  "badge.on": "ON",
383
414
  "badge.off": "OFF",
384
415
  "tool.novel_books": "Books novel_books",
416
+ "tool.novel_outline": "Creation files novel_outline",
417
+ "tool.novel_outline.desc": "Maintain creation settings/characters/outline/hooks/status (novels/创作资料/<book>/); read status & outline before writing, fill in each chapter hook after writing.",
385
418
  "tool.novel_chapters": "Chapters novel_chapters",
386
419
  "tool.novel_read": "Read novel_read",
387
420
  "tool.novel_keywords": "Keywords novel_keywords",
@@ -443,7 +476,36 @@ var css = `
443
476
  "baseline.reset": "Reset default",
444
477
  "baseline.saved": "Tolerance saved",
445
478
  "baseline.low": "Below %",
446
- "baseline.high": "Above %"
479
+ "baseline.high": "Above %",
480
+ "panel.creationTitle": "Original Mode",
481
+ "panel.creationHint": "Creation settings (blank = let the model decide)",
482
+ "creation.desc": "Fill in your creative intent; saved once, applied to every original writing task (blank dimensions are decided by the model). You can also add notes in chat anytime.",
483
+ "creation.worldview": "Worldview",
484
+ "creation.characters": "Characters",
485
+ "creation.forbidden": "Forbidden events",
486
+ "creation.mainConflict": "Main conflict",
487
+ "creation.genre": "Genre",
488
+ "creation.extra": "Extra requirements",
489
+ "creation.listTitle": "Settings library",
490
+ "creation.desc2": "One profile per book (new books without a folder can be pre-configured); the model uses the matching profile automatically.",
491
+ "creation.new": "+ New profile",
492
+ "creation.newPlaceholder": "Enter new book name…",
493
+ "creation.default": "Default (new / general)",
494
+ "creation.defaultHint": "Used for new books not in the library",
495
+ "creation.items": "filled",
496
+ "creation.none": "Not filled (model decides)",
497
+ "creation.edit": "Edit",
498
+ "creation.delete": "Delete",
499
+ "creation.deleted": "Profile deleted",
500
+ "creation.newed": "Profile created, start filling",
501
+ "creation.needName": "Please enter a book name first",
502
+ "creation.dirtyWarn": "Unsaved changes will be lost. Continue?",
503
+ "creation.formTitle": "Edit profile",
504
+ "creation.bookFor": "Applied book",
505
+ "creation.save": "Save settings",
506
+ "creation.clear": "Clear",
507
+ "creation.saved": "Original settings saved",
508
+ "creation.cleared": "Cleared (all decided by model)"
447
509
  };
448
510
  function dictionary() {
449
511
  return (typeof document !== "undefined" && (document.documentElement.lang || "zh").toLowerCase().startsWith("en")) ? en : zh;
@@ -454,7 +516,7 @@ var css = `
454
516
  }
455
517
 
456
518
  // ---- 工具清单(与宿主端 ALL_TOOLS 一致)----
457
- var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_style_report", "novel_plot", "novel_settings", "novel_summary", "novel_continuity_check", "novel_semantic_search"];
519
+ var ALL_TOOLS = ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter", "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_style_report", "novel_plot", "novel_settings", "novel_summary", "novel_continuity_check", "novel_semantic_search", "novel_outline"];
458
520
 
459
521
  // ---- 与宿主端同步 ----
460
522
  var STATE_ROUTE = "/api/dsh-novel-writer/state";
@@ -496,6 +558,13 @@ var css = `
496
558
  styleTolerance: null,
497
559
  baselineDraft: null,
498
560
  baselineSaved: false,
561
+ creationProfile: null,
562
+ creationProfiles: {},
563
+ creationDraft: null,
564
+ creationDirty: false,
565
+ creationBook: "",
566
+ creationNewBook: "",
567
+ books: [],
499
568
  features: {},
500
569
  dirs: null,
501
570
  view: "main",
@@ -534,6 +603,9 @@ var css = `
534
603
  dataDirSize: remote.dataDirSize || 0,
535
604
  embeddingStatus: remote.embeddingStatus || null,
536
605
  styleTolerance: remote.styleTolerance || null,
606
+ creationProfile: remote.creationProfile || null,
607
+ creationProfiles: remote.creationProfiles || {},
608
+ books: remote.books || [],
537
609
  dirs: remote.dirs || null,
538
610
  file: remote.file || "",
539
611
  hostOk: true,
@@ -741,7 +813,7 @@ var css = `
741
813
  );
742
814
  };
743
815
  var backBtn = function () {
744
- return el("button", { type: "button", className: "nwBackBtn", onClick: function () { props.controller.openView(view === "model" ? "features" : "main"); } }, "‹ " + t("panel.back"));
816
+ return el("button", { type: "button", className: "nwBackBtn", onClick: function () { props.controller.openView(view === "baseline" ? "main" : view === "creation-form" ? "creation" : view === "model" || view === "creation" ? "features" : "main"); } }, "‹ " + t("panel.back"));
745
817
  };
746
818
  var switchRow = function (name, fOn, onToggle, extra) {
747
819
  return el("div", { className: "nwToolRow" + (fOn ? " nwToolRowOn" : ""), key: name },
@@ -821,7 +893,7 @@ var css = `
821
893
  var TOOL_GROUPS = [
822
894
  { key: "analyze", icon: "📊", tools: ["novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_sentence_analysis", "novel_style_check", "novel_style_report", "novel_semantic_search", "novel_continuity_check"] },
823
895
  { key: "settings", icon: "📚", tools: ["novel_settings", "novel_plot", "novel_summary", "novel_import"] },
824
- { key: "create", icon: "✍️", tools: ["novel_new_chapter", "novel_sentence_config"] }
896
+ { key: "create", icon: "✍️", tools: ["novel_new_chapter", "novel_sentence_config", "novel_outline"] }
825
897
  ];
826
898
  var toolGroupOpen = state.toolGroupOpen || {};
827
899
  var toolRows = TOOL_GROUPS.map(function (grp) {
@@ -896,7 +968,19 @@ var TOOL_GROUPS = [
896
968
  extra = el("button", { type: "button", className: "nwModelBtn", title: t("model.manage"), onClick: function (ev) { ev.stopPropagation(); props.controller.openView("model"); } }, "⚙ " + t("model.manage"));
897
969
  }
898
970
  return switchRow(name, f[name] !== false, function (n, v) { props.toggle({ features: (function (patch) { patch[n] = v; return patch; })({}) }); }, extra);
899
- })
971
+ }),
972
+ // v3.1.0:原创模式入口
973
+ el("div", { key: "creationEntry", style: { margin: "8px 0 4px", padding: "10px 12px", borderRadius: "10px", background: "linear-gradient(135deg, rgba(99,102,241,.08), rgba(139,92,246,.08))", border: "1px solid rgba(99,102,241,.25)" } },
974
+ el("button", { type: "button", className: "nwNavEntry", style: { width: "100%", background: "transparent", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }, onClick: function () { props.controller.openView("creation"); } },
975
+ el("div", { className: "nwNavEntryText" },
976
+ el("div", { className: "nwNavEntryTitle" }, "🎨 " + t("panel.creationTitle")),
977
+ el("div", { className: "nwNavEntryHint" }, t("panel.creationHint"))
978
+ ),
979
+ el("div", { className: "nwNavEntryRight" },
980
+ el("span", { className: "nwNavEntryArrow" }, "›")
981
+ )
982
+ )
983
+ )
900
984
  );
901
985
  } else if (view === "tools") {
902
986
  body = el("div", null,
@@ -1013,7 +1097,161 @@ var TOOL_GROUPS = [
1013
1097
  el("button", { type: "button", className: "nwBtn nwBtnGhost", onClick: resetTol, style: { background: "transparent", border: "1px solid #cbd5e1", color: "#475569", fontSize: "13px", padding: "9px 18px", borderRadius: "10px", cursor: "pointer" } }, "↺ " + t("baseline.reset"))
1014
1098
  )
1015
1099
  );
1016
- } else {
1100
+ } else if (view === "creation" || view === "creation-form") {
1101
+ // v3.1.0:原创模式设定库(方案 C:列表页 + 表单页 + 快速切换)
1102
+ var CREATION_FIELDS = [
1103
+ ["worldview", "🌍", t("creation.worldview"), "例如:末日后异能世界…"],
1104
+ ["characters", "🎭", t("creation.characters"), "例如:女主冷静克制,有秘密…"],
1105
+ ["forbidden", "🚫", t("creation.forbidden"), "例如:不能有重生/系统/金手指…"],
1106
+ ["mainConflict", "🎯", t("creation.mainConflict"), "例如:女主寻找失踪的同伴…"],
1107
+ ["genre", "📚", t("creation.genre"), "例如:悬疑+救赎,都市背景…"],
1108
+ ["extra", "📝", t("creation.extra"), "例如:每章结尾留钩子,多对话…"]
1109
+ ];
1110
+ var cpGlobal = state.creationProfile && typeof state.creationProfile === "object" ? state.creationProfile : {};
1111
+ var cpBooks = state.creationProfiles && typeof state.creationProfiles === "object" ? state.creationProfiles : {};
1112
+ var bookList = Array.isArray(state.books) ? state.books.slice() : [];
1113
+ // 合并:设定库条目 ∪ novels 目录(去重,默认放最前)
1114
+ var allBooks = [];
1115
+ for (var bi2 = 0; bi2 < bookList.length; bi2 += 1) if (allBooks.indexOf(bookList[bi2]) === -1) allBooks.push(bookList[bi2]);
1116
+ for (var bk2 in cpBooks) if (allBooks.indexOf(bk2) === -1) allBooks.push(bk2);
1117
+ allBooks.sort(function (a, b) { return a.localeCompare(b, "zh"); });
1118
+
1119
+ function profileOf(bookName) {
1120
+ if (!bookName || bookName === "") return cpGlobal;
1121
+ return cpBooks[bookName] && typeof cpBooks[bookName] === "object" ? cpBooks[bookName] : {};
1122
+ }
1123
+ function filledCount(profile) {
1124
+ var c = 0;
1125
+ for (var fi = 0; fi < CREATION_FIELDS.length; fi += 1) {
1126
+ var fk = CREATION_FIELDS[fi][0];
1127
+ if (typeof profile[fk] === "string" && profile[fk].trim()) c += 1;
1128
+ }
1129
+ return c;
1130
+ }
1131
+ function summaryOf(profile) {
1132
+ var c = filledCount(profile);
1133
+ if (c === 0) return t("creation.none");
1134
+ var first = "";
1135
+ for (var fi2 = 0; fi2 < CREATION_FIELDS.length; fi2 += 1) {
1136
+ var fk2 = CREATION_FIELDS[fi2][0];
1137
+ if (typeof profile[fk2] === "string" && profile[fk2].trim()) { first = profile[fk2].trim(); break; }
1138
+ }
1139
+ return c + " " + t("creation.items") + " · " + (first.length > 18 ? first.slice(0, 18) + "…" : first);
1140
+ }
1141
+ // ---- 列表页 ----
1142
+ if (view === "creation") {
1143
+ var newBookName = state.creationNewBook || "";
1144
+ var createNewBook = function () {
1145
+ var nb = String(newBookName || "").trim();
1146
+ if (!nb) {
1147
+ var elInp = document.getElementById("nwNewBookInput");
1148
+ if (elInp) { elInp.focus(); elInp.style.borderColor = "#ef4444"; elInp.style.boxShadow = "0 0 0 3px rgba(239,68,68,.15)"; }
1149
+ props.controller.set({ revealMsg: t("creation.needName"), revealErr: true });
1150
+ return;
1151
+ }
1152
+ props.controller.set({ creationNewBook: "", creationBook: nb, creationDraft: null, creationDirty: false, revealMsg: t("creation.newed"), revealErr: false });
1153
+ props.controller.openView("creation-form");
1154
+ };
1155
+ function bookRow(name, profile, isDefault) {
1156
+ var filled = filledCount(profile);
1157
+ return el("div", { key: "bk-" + name, style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px 12px", borderRadius: "10px", border: "1px solid #e2e8f0", background: "#fff" } },
1158
+ el("div", { style: { flex: "1", minWidth: "0" } },
1159
+ el("div", { style: { fontSize: "13px", fontWeight: "600", color: "#334155" } }, isDefault ? "🌐 " + t("creation.default") : "📕 " + name),
1160
+ el("div", { style: { fontSize: "11px", color: filled > 0 ? "#6366f1" : "#94a3b8", marginTop: "2px" } }, summaryOf(profile)),
1161
+ isDefault ? el("div", { style: { fontSize: "10px", color: "#cbd5e1", marginTop: "1px" } }, t("creation.defaultHint")) : null
1162
+ ),
1163
+ el("button", { type: "button", onClick: function () { props.controller.set({ creationBook: name, creationDraft: null, creationDirty: false }); props.controller.openView("creation-form"); } }, t("creation.edit")),
1164
+ isDefault ? null : el("button", { type: "button", onClick: function () { var patch = {}; patch[name] = null; props.toggle({ creationProfiles: patch }); props.controller.set({ revealMsg: t("creation.deleted"), revealErr: false }); } }, t("creation.delete"))
1165
+ );
1166
+ }
1167
+ body = el("div", null,
1168
+ backBtn(),
1169
+ el("div", { className: "nwSectionTitle" }, "🎨 " + t("panel.creationTitle") + " · " + t("creation.listTitle")),
1170
+ el("div", { className: "nwToolsHint" }, t("creation.desc2")),
1171
+ // 新建设定
1172
+ el("div", { style: { display: "flex", gap: "8px", marginTop: "8px", marginBottom: "10px" } },
1173
+ el("input", { id: "nwNewBookInput", type: "text", value: newBookName, placeholder: t("creation.newPlaceholder"), style: { flex: "1", height: "34px", boxSizing: "border-box", padding: "0 12px", border: "1px solid #d4d4d8", borderRadius: "10px", fontSize: "13px", background: "#fff", color: "#18181b", outline: "none", transition: "border-color .15s ease, box-shadow .15s ease" }, onInput: function (ev) { props.controller.set({ creationNewBook: ev.target.value }); }, onFocus: function (ev) { ev.target.style.borderColor = "#6366f1"; ev.target.style.boxShadow = "0 0 0 3px rgba(99,102,241,.14)"; }, onBlur: function (ev) { ev.target.style.borderColor = "#d4d4d8"; ev.target.style.boxShadow = "none"; }, onKeyDown: function (ev) { if (ev.key === "Enter") { ev.preventDefault(); createNewBook(); } } }),
1174
+ el("button", { type: "button", style: { height: "34px", padding: "0 16px", borderRadius: "10px", border: "none", background: "linear-gradient(135deg,#6366f1,#8b5cf6)", color: "#fff", fontWeight: "600", fontSize: "13px", cursor: "pointer" }, onClick: createNewBook }, t("creation.new"))
1175
+ ),
1176
+ el("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
1177
+ bookRow("", cpGlobal, true),
1178
+ allBooks.map(function (name) { return bookRow(name, profileOf(name), false); })
1179
+ )
1180
+ );
1181
+ } else {
1182
+ // ---- 表单页 ----
1183
+ var curBook = state.creationBook || "";
1184
+ var curProfile = profileOf(curBook);
1185
+ var formDraft = state.creationDraft;
1186
+ if (!formDraft) {
1187
+ formDraft = {};
1188
+ for (var ci3 = 0; ci3 < CREATION_FIELDS.length; ci3 += 1) {
1189
+ var cf3 = CREATION_FIELDS[ci3][0];
1190
+ formDraft[cf3] = typeof curProfile[cf3] === "string" ? curProfile[cf3] : "";
1191
+ }
1192
+ }
1193
+ function setFormDraft(field, value) {
1194
+ var next = {};
1195
+ for (var k3 in formDraft) next[k3] = formDraft[k3];
1196
+ next[field] = value;
1197
+ props.controller.set({ creationDraft: next, creationDirty: true });
1198
+ }
1199
+ function saveForm() {
1200
+ var profile = {};
1201
+ var any = false;
1202
+ for (var si3 = 0; si3 < CREATION_FIELDS.length; si3 += 1) {
1203
+ var sk3 = CREATION_FIELDS[si3][0];
1204
+ var sv3 = String(formDraft[sk3] || "").trim();
1205
+ if (sv3 !== "") { profile[sk3] = sv3; any = true; }
1206
+ }
1207
+ if (!curBook) {
1208
+ props.toggle({ creationProfile: any ? profile : null });
1209
+ } else {
1210
+ var patch2 = {};
1211
+ patch2[curBook] = any ? profile : null;
1212
+ props.toggle({ creationProfiles: patch2 });
1213
+ }
1214
+ props.controller.set({ creationDraft: null, creationDirty: false, revealMsg: t(any ? "creation.saved" : "creation.cleared"), revealErr: false });
1215
+ }
1216
+ function clearForm() {
1217
+ if (!curBook) { props.toggle({ creationProfile: null }); }
1218
+ else { var p3 = {}; p3[curBook] = null; props.toggle({ creationProfiles: p3 }); }
1219
+ props.controller.set({ creationDraft: null, creationDirty: false, revealMsg: t("creation.cleared"), revealErr: false });
1220
+ }
1221
+ function switchBook(newName) {
1222
+ if (state.creationDirty && newName !== curBook) {
1223
+ if (!window.confirm(t("creation.dirtyWarn"))) return;
1224
+ }
1225
+ props.controller.set({ creationBook: newName, creationDraft: null, creationDirty: false });
1226
+ }
1227
+ var switchOpts = [el("option", { key: "opt-default", value: "" }, "🌐 " + t("creation.default"))];
1228
+ for (var si4 = 0; si4 < allBooks.length; si4 += 1) {
1229
+ var bn = allBooks[si4];
1230
+ switchOpts.push(el("option", { key: "opt-" + bn, value: bn }, "📕 " + bn));
1231
+ }
1232
+ body = el("div", null,
1233
+ backBtn(),
1234
+ el("div", { className: "nwSectionTitle" }, "🎨 " + (curBook ? curBook : t("creation.default")) + " · " + t("creation.formTitle")),
1235
+ // 快速切换
1236
+ el("div", { style: { display: "flex", alignItems: "center", gap: "8px", margin: "4px 0 10px" } },
1237
+ el("span", { style: { fontSize: "12px", color: "#64748b" } }, t("creation.bookFor")),
1238
+ el("select", { value: curBook, style: { height: "32px", padding: "0 10px", borderRadius: "8px", border: "1px solid #d4d4d8", background: "#fff", color: "#18181b", fontSize: "13px", outline: "none" }, onChange: function (ev) { switchBook(ev.target.value); } }, switchOpts)
1239
+ ),
1240
+ el("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
1241
+ CREATION_FIELDS.map(function (item) {
1242
+ var cf4 = item[0];
1243
+ return el("div", { key: cf4, style: { display: "flex", flexDirection: "column", gap: "4px" } },
1244
+ el("div", { style: { fontSize: "13px", fontWeight: "500", color: "#334155", display: "flex", alignItems: "center", gap: "6px" } }, item[1] + " " + item[2]),
1245
+ el("textarea", { className: "nwCreationInput", value: formDraft[cf4] || "", rows: 2, placeholder: item[3], style: { width: "100%", boxSizing: "border-box", minHeight: "56px", padding: "10px 12px", border: "1px solid #d4d4d8", borderRadius: "10px", fontSize: "13px", fontFamily: "inherit", resize: "vertical", background: "#fff", color: "#18181b", outline: "none", lineHeight: "1.6", transition: "border-color .15s ease, box-shadow .15s ease, background .15s ease", boxShadow: "0 1px 2px rgba(15,23,42,.04)" }, onInput: function (ev) { setFormDraft(cf4, ev.target.value); }, onFocus: function (ev) { ev.target.style.borderColor = "#6366f1"; ev.target.style.boxShadow = "0 0 0 3px rgba(99,102,241,.14)"; }, onBlur: function (ev) { ev.target.style.borderColor = "#d4d4d8"; ev.target.style.boxShadow = "0 1px 2px rgba(15,23,42,.04)"; } })
1246
+ );
1247
+ })
1248
+ ),
1249
+ el("div", { style: { display: "flex", gap: "12px", marginTop: "14px" } },
1250
+ el("button", { type: "button", className: "nwBtn", style: { background: "linear-gradient(135deg,#6366f1,#8b5cf6)", border: "none", color: "#fff", fontWeight: "600", fontSize: "13px", padding: "9px 20px", borderRadius: "10px", cursor: "pointer" }, onClick: saveForm }, "💾 " + t("creation.save")),
1251
+ el("button", { type: "button", className: "nwBtn", style: { background: "transparent", border: "1px solid #cbd5e1", color: "#475569", fontSize: "13px", padding: "9px 18px", borderRadius: "10px", cursor: "pointer" }, onClick: clearForm }, "↺ " + t("creation.clear"))
1252
+ )
1253
+ );
1254
+ } } else {
1017
1255
  var onFeatures = detailFeatures.filter(function (k) { return f[k] !== false; }).length;
1018
1256
  var onTools = ALL_TOOLS.filter(function (k) { return tools[k] !== false; }).length;
1019
1257
  body = el("div", null,
@@ -1099,7 +1337,7 @@ var TOOL_GROUPS = [
1099
1337
  }
1100
1338
  return el("div", { className: "nwPanel" },
1101
1339
  el("div", { className: "nwPanelHeader" },
1102
- el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : view === "baseline" ? t("panel.baselineTitle") : t("model.title")) : "")),
1340
+ el("div", { className: "nwPanelTitle" }, t("panel.title") + (view !== "main" ? " › " + (view === "features" ? t("panel.featuresTitle") : view === "tools" ? t("panel.toolsTitle") : view === "baseline" ? t("panel.baselineTitle") : view === "creation" ? t("panel.creationTitle") : view === "creation-form" ? t("panel.creationTitle") + " · " + t("creation.formTitle") : t("model.title")) : "")),
1103
1341
  el("button", { type: "button", className: "nwRefresh" + (state.refreshing ? " nwRefreshSpin" : ""), onClick: function () { props.controller.refresh(); } }, state.refreshing ? "⟳" : t("panel.refresh")),
1104
1342
  el("button", { type: "button", className: "nwClose", "aria-label": t("panel.close"), onClick: props.onClose }, "×")
1105
1343
  ),
@@ -1259,6 +1497,9 @@ var TOOL_GROUPS = [
1259
1497
  dataDirSize: remote.dataDirSize || 0,
1260
1498
  embeddingStatus: remote.embeddingStatus || null,
1261
1499
  styleTolerance: remote.styleTolerance || null,
1500
+ creationProfile: remote.creationProfile || null,
1501
+ creationProfiles: remote.creationProfiles || {},
1502
+ books: remote.books || [],
1262
1503
  dirs: remote.dirs || null,
1263
1504
  file: remote.file || "",
1264
1505
  source: "host",
package/lib/index.js CHANGED
@@ -639,13 +639,15 @@ const SENTENCE_ANALYSIS_DEFAULTS = Object.freeze({ enabled: true, autoAnalyze: t
639
639
  const ALL_TOOLS = Object.freeze([
640
640
  "novel_books", "novel_chapters", "novel_read", "novel_keywords", "novel_new_chapter",
641
641
  "novel_import", "novel_sentence_analysis", "novel_sentence_config", "novel_style_check", "novel_plot",
642
- "novel_settings", "novel_summary", "novel_continuity_check", "novel_semantic_search", "novel_style_report"
642
+ "novel_settings", "novel_summary", "novel_continuity_check", "novel_semantic_search", "novel_style_report",
643
+ "novel_outline"
643
644
  ]);
644
645
  const TOOL_LABELS = Object.freeze({
645
646
  novel_books: "作品列表", novel_chapters: "章节清单", novel_read: "阅读章节", novel_keywords: "关键词分析",
646
647
  novel_new_chapter: "新建章节", novel_import: "稿件导入", novel_sentence_analysis: "句式模式分析",
647
648
  novel_sentence_config: "开关配置", novel_style_check: "风格自检", novel_plot: "伏笔登记",
648
- novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计"
649
+ novel_settings: "设定管理", novel_summary: "章节摘要", novel_continuity_check: "连贯性审计",
650
+ novel_semantic_search: "语义检索", novel_style_report: "风格画像", novel_outline: "创作资料"
649
651
  });
650
652
 
651
653
  /** 自动判断文本的文化基准:统计中西词表命中,返回建议与证据。 */
@@ -834,6 +836,10 @@ async function readSentenceState() {
834
836
  features: parsed.features !== null && typeof parsed.features === "object" ? parsed.features : {},
835
837
  // v3.0.0:风格基线容差(±%:用户允许低于/高于基线的范围)
836
838
  styleTolerance: parsed.styleTolerance !== null && typeof parsed.styleTolerance === "object" ? parsed.styleTolerance : null,
839
+ // v3.1.0:原创模式设定(用户填写的创作意图,留空项=让模型自行设定)
840
+ creationProfile: parsed.creationProfile !== null && typeof parsed.creationProfile === "object" ? parsed.creationProfile : null,
841
+ // v3.1.0:按书专属设定(书名为键;无该书时回退全局 creationProfile)
842
+ creationProfiles: parsed.creationProfiles !== null && typeof parsed.creationProfiles === "object" ? parsed.creationProfiles : {},
837
843
  lastRoot: typeof parsed.lastRoot === "string" ? parsed.lastRoot : void 0
838
844
  };
839
845
  } catch {
@@ -853,6 +859,10 @@ function readSentenceStateSync() {
853
859
  autoAnalyze: parsed.autoAnalyze !== false,
854
860
  tools: parsed.tools !== null && typeof parsed.tools === "object" ? parsed.tools : {},
855
861
  features: parsed.features !== null && typeof parsed.features === "object" ? parsed.features : {},
862
+ // v3.1.0:原创模式设定(同步版,供 novel_outline 预填)
863
+ creationProfile: parsed.creationProfile !== null && typeof parsed.creationProfile === "object" ? parsed.creationProfile : null,
864
+ // v3.1.0:按书专属设定(书名为键;无该书时回退全局 creationProfile)
865
+ creationProfiles: parsed.creationProfiles !== null && typeof parsed.creationProfiles === "object" ? parsed.creationProfiles : {},
856
866
  lastRoot: typeof parsed.lastRoot === "string" ? parsed.lastRoot : void 0
857
867
  };
858
868
  } catch { return {}; }
@@ -868,7 +878,11 @@ async function writeSentenceState(patch) {
868
878
  ...(typeof patch?.lastRoot === "string" ? { lastRoot: patch.lastRoot } : {}),
869
879
  ...(patch?.features !== null && typeof patch?.features === "object" ? { features: { ...(current.features ?? {}), ...patch.features } } : {}),
870
880
  ...(patch?.styleTolerance !== null && typeof patch?.styleTolerance === "object" ? { styleTolerance: patch.styleTolerance } : {}),
871
- ...(patch?.styleTolerance === null ? { styleTolerance: null } : {})
881
+ ...(patch?.styleTolerance === null ? { styleTolerance: null } : {}),
882
+ ...(patch?.creationProfile !== null && typeof patch?.creationProfile === "object" ? { creationProfile: patch.creationProfile } : {}),
883
+ ...(patch?.creationProfile === null ? { creationProfile: null } : {}),
884
+ ...(patch?.creationProfiles !== null && typeof patch?.creationProfiles === "object" ? { creationProfiles: (function (merged) { for (const k of Object.keys(merged)) if (merged[k] === null || merged[k] === void 0) delete merged[k]; return merged; })({ ...(current.creationProfiles ?? {}), ...patch.creationProfiles }) } : {}),
885
+ ...(patch?.creationProfiles === null ? { creationProfiles: {} } : {})
872
886
  };
873
887
  const file = stateFilePath();
874
888
  await mkdir(dirname(file), { recursive: true });
@@ -1004,6 +1018,12 @@ function makeStateRoutes(allowLan, config) {
1004
1018
  features: state.features ?? {},
1005
1019
  // v3.0.0:风格基线容差(±%)
1006
1020
  styleTolerance: state.styleTolerance ?? null,
1021
+ // v3.1.0:原创模式设定
1022
+ creationProfile: state.creationProfile ?? null,
1023
+ // v3.1.0:按书专属设定
1024
+ creationProfiles: state.creationProfiles ?? {},
1025
+ // v3.1.0:书列表(原创模式设定库用)——novels 目录 ∪ 已预配置设定的书(含未建目录的新书)
1026
+ books: [...new Set([...(await listBookNames(root)), ...Object.keys(state.creationProfiles ?? {})])].sort(function (a, b) { return a.localeCompare(b, "zh"); }),
1007
1027
  dataDirSize,
1008
1028
  embeddingStatus,
1009
1029
  dataDir,
@@ -1026,6 +1046,9 @@ function makeStateRoutes(allowLan, config) {
1026
1046
  if (body === void 0 || typeof body !== "object" || Array.isArray(body)) return writeJson(res, 400, { error: "invalid JSON body" });
1027
1047
  try {
1028
1048
  const next = await writeSentenceState(body);
1049
+ // v3.1.0:保存设定 → 同步创建/更新该书创作资料(有书库根时;文件写失败不影响设定保存)
1050
+ const cpRoot = (typeof config?.root === "string" && config.root.length > 0) ? config.root : (next.lastRoot || null);
1051
+ if (cpRoot) await syncCreationProfileFiles(cpRoot, body?.creationProfiles, next.creationProfiles);
1029
1052
  return writeJson(res, 200, { ...next, file: stateFilePath() });
1030
1053
  } catch (error) {
1031
1054
  return writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
@@ -1437,7 +1460,10 @@ function registerNovelSentenceConfig(ctx, config) {
1437
1460
  enabled: { type: "boolean", description: "set 时指定:写作助手功能总开关。" },
1438
1461
  autoAnalyze: { type: "boolean", description: "set 时指定:分析作品时是否主动使用句式分析。" },
1439
1462
  tools: { type: "object", additionalProperties: true, description: "set 时指定:各工具开关(如 { novel_plot: false }),键必须是 novel_* 工具名。" },
1440
- features: { type: "object", additionalProperties: true, description: "set 时指定:功能开关(emotionCaveat=情感净化预警 / genreTheme=题材与流派检测),如 { emotionCaveat: false }。" }
1463
+ features: { type: "object", additionalProperties: true, description: "set 时指定:功能开关(emotionCaveat=情感净化预警 / genreTheme=题材与流派检测),如 { emotionCaveat: false }。" },
1464
+ styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(每维 { low: -20, high: 20 },low 为负/高为正;传空对象 {} 清除恢复推荐)。" },
1465
+ creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(worldview/characters/forbidden/mainConflict/genre/extra 字符串键,留空项省略;传空对象 {} 清除全部交给模型)。" },
1466
+ creationProfiles: { type: "object", additionalProperties: true, description: "按书专属原创设定(键=书名,值=同上结构;传空对象 {} 清除全部书的专属设定)。" }
1441
1467
  },
1442
1468
  additionalProperties: false
1443
1469
  },
@@ -1453,9 +1479,11 @@ function registerNovelSentenceConfig(ctx, config) {
1453
1479
  updated: { type: "boolean" },
1454
1480
  tools: { type: "object", additionalProperties: true, description: "各工具当前开关状态。" },
1455
1481
  features: { type: "object", additionalProperties: true, description: "各功能开关状态(emotionCaveat/genreTheme/emotionComplexity/semanticEmbedding)。" },
1456
- embedding: { type: "object", additionalProperties: true, description: "语义嵌入引擎状态(available/error)。" }
1482
+ embedding: { type: "object", additionalProperties: true, description: "语义嵌入引擎状态(available/error)。" },
1483
+ styleTolerance: { type: "object", additionalProperties: true, description: "风格基线容差(用户自定义 ±%;空对象=使用推荐)。" },
1484
+ creationProfile: { type: "object", additionalProperties: true, description: "原创模式设定(用户在侧边栏填写的创作意图;空对象=未设置)。" }
1457
1485
  },
1458
- required: ["file", "enabled", "autoAnalyze", "tools", "features", "embedding"]
1486
+ required: ["file", "enabled", "autoAnalyze", "tools", "features", "embedding", "styleTolerance", "creationProfile"]
1459
1487
  },
1460
1488
  render: (_args, value) => {
1461
1489
  const toolLines = ALL_TOOLS.map((name) => ` - ${TOOL_LABELS[name] ?? name} (${name}): ${value.tools?.[name] === false ? "关闭" : "开启"}`);
@@ -1507,8 +1535,21 @@ ${featLines.join("\n")}
1507
1535
  // v3.0.0:风格基线容差(±%:允许低于/高于基线的百分比;null 清除恢复默认)
1508
1536
  if (args?.styleTolerance !== void 0) {
1509
1537
  if (args.styleTolerance === null) patch.styleTolerance = null;
1538
+ // v3.1.0:空对象 {} = 清除(DSH schema 不支持 type 数组/剥离 null,空 object 是合规的“传了但清空”)
1539
+ else if (args.styleTolerance !== null && typeof args.styleTolerance === "object" && Object.keys(args.styleTolerance).length === 0) patch.styleTolerance = null;
1510
1540
  else if (args.styleTolerance !== null && typeof args.styleTolerance === "object") patch.styleTolerance = args.styleTolerance;
1511
1541
  }
1542
+ // v3.1.0:原创模式设定(世界观/角色/禁忌/主线/题材/额外要求;null/空对象清除)
1543
+ if (args?.creationProfile !== void 0) {
1544
+ if (args.creationProfile === null) patch.creationProfile = null;
1545
+ else if (args.creationProfile !== null && typeof args.creationProfile === "object" && Object.keys(args.creationProfile).length === 0) patch.creationProfile = null;
1546
+ else if (args.creationProfile !== null && typeof args.creationProfile === "object") patch.creationProfile = args.creationProfile;
1547
+ }
1548
+ // v3.1.0:按书专属设定(整体替换该书条目;空对象清除全部)
1549
+ if (args?.creationProfiles !== void 0) {
1550
+ if (args.creationProfiles === null || (typeof args.creationProfiles === "object" && Object.keys(args.creationProfiles).length === 0)) patch.creationProfiles = null;
1551
+ else if (typeof args.creationProfiles === "object") patch.creationProfiles = args.creationProfiles;
1552
+ }
1512
1553
  if (Object.keys(patch).length > 0) {
1513
1554
  current = await writeSentenceState(patch);
1514
1555
  updated = true;
@@ -1525,11 +1566,14 @@ ${featLines.join("\n")}
1525
1566
  for (const name of Object.keys(FEATURE_DEFAULTS)) features[name] = featureEnabled(current, name);
1526
1567
  // v3.0.0:风格基线容差(±%)
1527
1568
  const styleTolerance = current?.styleTolerance !== null && typeof current?.styleTolerance === "object" ? current.styleTolerance : null;
1569
+ // v3.1.0:原创模式设定
1570
+ const creationProfile = current?.creationProfile !== null && typeof current?.creationProfile === "object" ? current.creationProfile : null;
1571
+ const creationProfiles = current?.creationProfiles !== null && typeof current?.creationProfiles === "object" ? current.creationProfiles : {};
1528
1572
  // v2.0.0:语义引擎状态(懒探测——不加载模型,仅文件检查 + 已加载状态)
1529
1573
  const embStatus = embedding.status();
1530
1574
  const embAvailable = featureEnabled(current, "semanticEmbedding") ? (embStatus.loaded || embStatus.modelPresent) : false;
1531
1575
  const embError = featureEnabled(current, "semanticEmbedding") ? embStatus.error ?? "" : "semanticEmbedding 已关闭";
1532
- return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated, tools, features, styleTolerance, embedding: { available: embAvailable, loaded: embStatus.loaded, error: embError } };
1576
+ return { file: stateFilePath(), enabled: after.enabled, autoAnalyze: after.autoAnalyze, source, updated, tools, features, styleTolerance, creationProfile, creationProfiles, embedding: { available: embAvailable, loaded: embStatus.loaded, error: embError } };
1533
1577
  }
1534
1578
  });
1535
1579
  }
@@ -1737,6 +1781,328 @@ function registerNovelStyleCheck(ctx, config) {
1737
1781
  });
1738
1782
  }
1739
1783
 
1784
+ /** v3.1.0:保存设定时同步创作资料——设定库的书自动创建 novels/创作资料/<书>/创作设定.md(含【用户原创设定】段);删除设定则移除段。文件写失败不影响设定保存。 */
1785
+ async function syncCreationProfileFiles(root, patchProfiles, nextProfiles) {
1786
+ if (!root) return;
1787
+ const base = join(root, "novels", CREATION_DIR);
1788
+ // 删除的键(patch 中 null):移除用户段
1789
+ if (patchProfiles && typeof patchProfiles === "object") {
1790
+ for (const book of Object.keys(patchProfiles)) {
1791
+ if (patchProfiles[book] === null || patchProfiles[book] === void 0) {
1792
+ try {
1793
+ const f = join(base, sanitizeSegment(book, "book"), CREATION_FILES.bible);
1794
+ if (existsSync(f)) {
1795
+ const cur = await readFile(f, "utf8");
1796
+ const next = upsertCreationUserSection(cur, "");
1797
+ if (next !== cur) await writeFile(f, next, "utf8");
1798
+ }
1799
+ // v3.1.0:同时移除主要人物设定.md 的【用户角色设定】段
1800
+ const cf = join(base, sanitizeSegment(book, "book"), CREATION_FILES["characters-main"]);
1801
+ if (existsSync(cf)) {
1802
+ const ccur = await readFile(cf, "utf8");
1803
+ const cnext = upsertCreationSection(ccur, "", "【用户角色设定】");
1804
+ if (cnext !== ccur) await writeFile(cf, cnext, "utf8");
1805
+ }
1806
+ } catch { /* 忽略 */ }
1807
+ }
1808
+ }
1809
+ }
1810
+ // 现存书(有有效设定):创建/更新创作设定.md
1811
+ if (nextProfiles && typeof nextProfiles === "object") {
1812
+ for (const book of Object.keys(nextProfiles)) {
1813
+ const v = nextProfiles[book];
1814
+ if (!v || typeof v !== "object" || Object.keys(v).length === 0) continue;
1815
+ const dir = join(base, sanitizeSegment(book, "book"));
1816
+ try {
1817
+ await mkdir(dir, { recursive: true });
1818
+ const f = join(dir, CREATION_FILES.bible);
1819
+ if (!existsSync(f)) {
1820
+ await writeFile(f, "# 创作设定\n\n(世界观规则 / 主线冲突 / 分卷目的 / 禁忌)\n\n" + buildCreationUserSection(book), "utf8");
1821
+ } else {
1822
+ const cur = await readFile(f, "utf8");
1823
+ const next = upsertCreationUserSection(cur, buildCreationUserSection(book));
1824
+ if (next !== cur) await writeFile(f, next, "utf8");
1825
+ }
1826
+ // v3.1.0:主要人物设定.md 同步【用户角色设定】段(方案 A:角色设定各归其位)
1827
+ const cs = buildCreationCharacterSection(book);
1828
+ if (cs !== "") {
1829
+ const cf2 = join(dir, CREATION_FILES["characters-main"]);
1830
+ if (!existsSync(cf2)) {
1831
+ await writeFile(cf2, "# 主要人物设定\n\n(目标→动机→弱点→说话方式→关系)\n\n" + cs, "utf8");
1832
+ } else {
1833
+ const ccur = await readFile(cf2, "utf8");
1834
+ const cnext = upsertCreationSection(ccur, cs, "【用户角色设定】");
1835
+ if (cnext !== ccur) await writeFile(cf2, cnext, "utf8");
1836
+ }
1837
+ }
1838
+ } catch { /* 忽略 */ }
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ /** v3.1.0:列出书库中的作品名(novels 下目录,跳过创作资料;原创模式书选择器用)。 */
1844
+ async function listBookNames(root) {
1845
+ try {
1846
+ const dir = join(root, "novels");
1847
+ const entries = await readdir(dir, { withFileTypes: true });
1848
+ const names = [];
1849
+ for (const entry of entries) {
1850
+ if (!entry.isDirectory() || entry.name === "创作资料") continue;
1851
+ if (entry.name.startsWith(".")) continue;
1852
+ names.push(entry.name);
1853
+ }
1854
+ return names.sort((a, b) => a.localeCompare(b, "zh"));
1855
+ } catch {
1856
+ return [];
1857
+ }
1858
+ }
1859
+
1860
+ /** v3.1.0:原创创作资料(novel_outline)——创作设定/人物/大纲/钩子/状态卡。 */
1861
+ const CREATION_DIR = "创作资料";
1862
+ const CREATION_FILES = {
1863
+ bible: "创作设定.md", "characters-main": "主要人物设定.md", "characters-minor": "次要人物设定.md",
1864
+ outline: "剧情大纲.md", hooks: "钩子记录.md", status: "创作状态卡.md"
1865
+ };
1866
+
1867
+ function creationDir(root, book) {
1868
+ return join(root, "novels", CREATION_DIR, sanitizeSegment(book, "book"));
1869
+ }
1870
+
1871
+ /** v3.1.0:构建【用户原创设定】段(读侧边栏 state;无设定返回空串)。 */
1872
+ function buildCreationUserSection(book) {
1873
+ // v3.1.0:字段值单行化(多行文本 → 空格),保证段内无空行、与 upsert 的空行边界配合
1874
+ const norm = function (s) { return String(s).replace(/\s*\n+\s*/g, " ").trim(); };
1875
+ var segs = [];
1876
+ try {
1877
+ // v3.1.0:按书专属设定优先(用户书选择器指定),无该书专属时回退全局默认
1878
+ var st = readSentenceStateSync();
1879
+ var cp = book && st.creationProfiles && typeof st.creationProfiles === "object" && typeof st.creationProfiles[book] === "object" ? st.creationProfiles[book] : st.creationProfile;
1880
+ if (cp && typeof cp === "object") {
1881
+ if (typeof cp.worldview === "string" && cp.worldview.trim()) segs.push("世界观:" + norm(cp.worldview));
1882
+ if (typeof cp.characters === "string" && cp.characters.trim()) segs.push("角色设定:" + norm(cp.characters));
1883
+ if (typeof cp.forbidden === "string" && cp.forbidden.trim()) segs.push("不允许的事件:" + norm(cp.forbidden));
1884
+ if (typeof cp.mainConflict === "string" && cp.mainConflict.trim()) segs.push("主线目的:" + norm(cp.mainConflict));
1885
+ if (typeof cp.genre === "string" && cp.genre.trim()) segs.push("题材偏好:" + norm(cp.genre));
1886
+ if (typeof cp.extra === "string" && cp.extra.trim()) segs.push("额外要求:" + norm(cp.extra));
1887
+ }
1888
+ } catch { /* 读不到则视为无设定 */ }
1889
+ if (segs.length === 0) return "";
1890
+ return "【用户原创设定】(侧边栏填写,必须遵守;未列出的维度由模型自行设定)\n" + segs.map(function (s) { return "- " + s; }).join("\n") + "\n";
1891
+ }
1892
+
1893
+ /** v3.1.0:upsert 任意标记段(如【用户原创设定】【用户角色设定】)——存在则替换(保留手写部分),不存在则追加;section 为空串则移除旧段。 */
1894
+ function upsertCreationSection(text, section, marker) {
1895
+ // 段边界 = 下一个标题 / 空行 / 文件尾(防吞掉段后手写内容)
1896
+ const re = new RegExp("(?:\\n\\n)?" + marker + "[\\s\\S]*?(?=\\n# |\\n\\n|\\s*$)");
1897
+ const has = text.includes(marker);
1898
+ if (section === "") {
1899
+ if (!has) return text;
1900
+ const t = text.replace(re, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1901
+ return t;
1902
+ }
1903
+ if (has) {
1904
+ return text.replace(re, "\n\n" + section.trimEnd());
1905
+ }
1906
+ return text.trimEnd() + "\n\n" + section;
1907
+ }
1908
+
1909
+ /** v3.1.0:upsert【用户原创设定】段(兼容旧名)。 */
1910
+ function upsertCreationUserSection(text, section) {
1911
+ return upsertCreationSection(text, section, "【用户原创设定】");
1912
+ }
1913
+
1914
+ /** v3.1.0:构建【用户角色设定】段(只取用户填的“角色设定”项;未填返回空串)。 */
1915
+ function buildCreationCharacterSection(book) {
1916
+ const norm = function (s) { return String(s).replace(/\s*\n+\s*/g, " ").trim(); };
1917
+ try {
1918
+ var st = readSentenceStateSync();
1919
+ var cp = book && st.creationProfiles && typeof st.creationProfiles === "object" && typeof st.creationProfiles[book] === "object" ? st.creationProfiles[book] : st.creationProfile;
1920
+ if (cp && typeof cp === "object" && typeof cp.characters === "string" && cp.characters.trim()) {
1921
+ return "【用户角色设定】(侧边栏填写,必须遵守)\n- 角色设定:" + norm(cp.characters) + "\n";
1922
+ }
1923
+ } catch { /* 读不到则视为无 */ }
1924
+ return "";
1925
+ }
1926
+
1927
+ /** 状态卡内容:显示创作进度 + 未回填钩子提醒(强制机制,保证每章钩子都回填)。 */
1928
+ async function readCreationStatus(root, book, exec) {
1929
+ const dir = creationDir(root, book);
1930
+ const f = join(dir, CREATION_FILES.status);
1931
+ try {
1932
+ const txt = await readTextFile(f, exec);
1933
+ // 检测未回填钩子(钩子记录有章节号但状态卡进度低于它?——直接比对大纲与钩子)
1934
+ return txt;
1935
+ } catch {
1936
+ return null;
1937
+ }
1938
+ }
1939
+
1940
+ function createOutlineTool(config) {
1941
+ return {
1942
+ name: "novel_outline",
1943
+ description: "维护原创小说的创作资料(novels/创作资料/<书名>/):创作设定/主要人物/次要人物/剧情大纲/钩子记录/创作状态卡的初始化、读取与更新。原创前必读状态卡与大纲,写完每章必回填钩子。",
1944
+ parameters: {
1945
+ type: "object",
1946
+ additionalProperties: false,
1947
+ properties: {
1948
+ book: { type: "string", description: "书名(novels/创作资料 下的子目录名)。" },
1949
+ action: { type: "string", enum: ["init", "read", "bible", "character", "chapter", "hook", "status"], description: "init=初始化创作资料;read=读文件(bible/characters-main/characters-minor/outline/hooks/status);bible=写入创作设定;character=登记/提升人物;chapter=补大纲方向行;hook=回填某章结尾钩子;status=刷新创作状态卡。" },
1950
+ file: { type: "string", description: "read 时指定(bible/characters-main/characters-minor/outline/hooks/status,省略=status)。" },
1951
+ content: { type: "string", description: "bible(创作设定全文)/ status(状态卡全文)时用。" },
1952
+ role: { type: "string", enum: ["main", "minor"], description: "character 时:main=主要人物 / minor=次要人物。" },
1953
+ name: { type: "string", description: "character 时:人物名。" },
1954
+ description: { type: "string", description: "character 时:人物简介(目标/动机/弱点/说话方式)。" },
1955
+ number: { type: "number", description: "chapter/hook 时:章节号。" },
1956
+ title: { type: "string", description: "chapter 时:本章标题/方向(一行)。" },
1957
+ body: { type: "string", description: "hook 时:本章结尾钩子(状态/悬念/时间场景)。" }
1958
+ },
1959
+ required: ["book", "action"]
1960
+ },
1961
+ output: {
1962
+ schema: {
1963
+ type: "object",
1964
+ additionalProperties: true,
1965
+ properties: { book: { type: "string" }, action: { type: "string" }, message: { type: "string" }, content: { type: "string" }, files: { type: "array" } }
1966
+ },
1967
+ render: (_args, value) => {
1968
+ const lines = [`<path>novels/创作资料/${value.book}</path>`, "<type>novel-outline</type>", "<content>", ""];
1969
+ if (value.action === "read" && value.content) { lines.push(value.content); }
1970
+ else if (value.message) lines.push(value.message);
1971
+ lines.push("", "</content>");
1972
+ return [{ type: "text", text: lines.join("\n") }];
1973
+ }
1974
+ },
1975
+ async execute(args, exec) {
1976
+ await assertToolEnabled(config, "novel_outline");
1977
+ const book = sanitizeSegment(requiredString(args, "book"), "book");
1978
+ const action = args?.action ?? "read";
1979
+ const root = resolveRoot(config, args, exec);
1980
+ const dir = creationDir(root, book);
1981
+
1982
+ if (action === "init") {
1983
+ await mkdir(dir, { recursive: true });
1984
+ const files = [
1985
+ ["bible", "# 创作设定\n\n(世界观规则 / 主线冲突 / 分卷目的 / 禁忌)\n\n" + buildCreationUserSection(book)],
1986
+ ["characters-main", "# 主要人物设定\n\n(目标→动机→弱点→说话方式→关系)\n"],
1987
+ ["characters-minor", "# 次要人物设定\n\n(谁/作用/特征;不再出场标注)\n"],
1988
+ ["outline", "# 剧情大纲\n\n(每章方向行,写完回填钩子到钩子记录)\n"],
1989
+ ["hooks", "# 钩子记录\n\n(每章结尾钩子:状态/悬念/时间场景)\n"],
1990
+ ["status", "# 创作状态卡\n\n(进度/上一章结尾/时间线/活跃角色/下章方向/未回填钩子)\n"]
1991
+ ];
1992
+ for (const [key, content] of files) {
1993
+ const f = join(dir, CREATION_FILES[key]);
1994
+ if (!existsSync(f)) {
1995
+ await writeFile(f, content, "utf8");
1996
+ } else if (key === "bible") {
1997
+ // v3.1.0:设定陈旧化修复——bible 已存在时只更新【用户原创设定】段(保留用户手写部分;无设定则移除旧段)
1998
+ const cur = await readTextFile(f, exec);
1999
+ const next = upsertCreationUserSection(cur, buildCreationUserSection(book));
2000
+ if (next !== cur) await writeFile(f, next, "utf8");
2001
+ }
2002
+ // v3.1.0:主要人物设定.md 角色段同步——首次创建(写模板后)与已存在(upsert)统一执行,角色设定不丢
2003
+ if (key === "characters-main") {
2004
+ const cs2 = buildCreationCharacterSection(book);
2005
+ if (cs2 !== "") {
2006
+ const ccur2 = await readTextFile(f, exec);
2007
+ const cnext2 = upsertCreationSection(ccur2, cs2, "【用户角色设定】");
2008
+ if (cnext2 !== ccur2) await writeFile(f, cnext2, "utf8");
2009
+ }
2010
+ }
2011
+ }
2012
+ return { book, action, message: "创作资料已初始化(" + files.length + " 个文件)" };
2013
+ }
2014
+
2015
+ if (action === "read") {
2016
+ const key = String(args?.file ?? "status");
2017
+ if (!CREATION_FILES[key]) throw new Error("未知文件:" + key + "(可用 bible/characters-main/characters-minor/outline/hooks/status)");
2018
+ const f = join(dir, CREATION_FILES[key]);
2019
+ let content = "";
2020
+ try { content = await readTextFile(f, exec); } catch { throw new Error("创作资料尚未初始化,请先 novel_outline init(或在创作文件夹自行创建)"); }
2021
+ // 强制机制:读状态卡/大纲时检查未回填钩子
2022
+ let remind = "";
2023
+ if (key === "status" || key === "outline") {
2024
+ try {
2025
+ // v3.1.0:大纲章节号从大纲文件提取(status 文件只有快照,不含章节行)
2026
+ const outlineContent = key === "status" ? await readTextFile(join(dir, CREATION_FILES.outline), exec) : content;
2027
+ const hooks = await readTextFile(join(dir, CREATION_FILES.hooks), exec);
2028
+ const outlined = (outlineContent.match(/^[-*] (\d+)/gm) || []).map((m) => parseInt(m.slice(2), 10));
2029
+ const hooked = (hooks.match(/^[-*] (\d+)/gm) || []).map((m) => parseInt(m.slice(2), 10));
2030
+ const missing = outlined.filter((n) => !hooked.includes(n));
2031
+ if (missing.length > 0) remind = "\n\n⚠ 未回填钩子章节:" + missing.join("、") + "——写完的章节必须回填钩子(novel_outline hook),下一章开头从钩子接续";
2032
+ } catch { /* 未初始化 */ }
2033
+ }
2034
+ return { book, action, file: key, content: content + remind };
2035
+ }
2036
+
2037
+ if (action === "bible") {
2038
+ const content = requiredString(args, "content");
2039
+ await mkdir(dir, { recursive: true });
2040
+ await writeFile(join(dir, CREATION_FILES.bible), content, "utf8");
2041
+ return { book, action, message: "创作设定已更新(" + content.length + " 字)" };
2042
+ }
2043
+
2044
+ if (action === "character") {
2045
+ const role = args?.role === "main" ? "main" : "minor";
2046
+ const name = requiredString(args, "name");
2047
+ const description = String(args?.description ?? "").replace(/\s*\n+\s*/g, ";").trim(); // v3.1.0:换行转分号,防断行伪造条目
2048
+ const f = join(dir, CREATION_FILES[role === "main" ? "characters-main" : "characters-minor"]);
2049
+ let cur = "";
2050
+ try { cur = await readTextFile(f, exec); } catch { cur = "" + (role === "main" ? "# 主要人物设定\n" : "# 次要人物设定\n") + "\n"; await mkdir(dir, { recursive: true }); }
2051
+ const cur2 = cur.startsWith("# 主") || cur.startsWith("# 次") ? cur : "# " + (role === "main" ? "主要人物设定\n" : "次要人物设定\n") + "\n" + cur;
2052
+ const line = "- " + name + (description ? ":" + description : "") + "\n";
2053
+ if (cur2.includes("- " + name)) return { book, action, message: "人物“" + name + "”已存在(未重复登记)" };
2054
+ await mkdir(dir, { recursive: true });
2055
+ await writeFile(f, cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line, "utf8");
2056
+ return { book, action, message: "已登记人物“" + name + "”到" + (role === "main" ? "主要" : "次要") + "人物设定" };
2057
+ }
2058
+
2059
+ if (action === "chapter") {
2060
+ const number = parseInt(args?.number, 10);
2061
+ const title = String(args?.title ?? "").replace(/\s*\n+\s*/g, " ").trim(); // v3.1.0:单行化
2062
+ if (isNaN(number)) throw new Error("chapter 需要 number");
2063
+ const f = join(dir, CREATION_FILES.outline);
2064
+ let cur = "";
2065
+ try { cur = await readTextFile(f, exec); } catch { cur = "# 剧情大纲\n\n"; await mkdir(dir, { recursive: true }); }
2066
+ const cur2 = cur.startsWith("# 剧") ? cur : "# 剧情大纲\n\n" + cur;
2067
+ const line = "- " + number + " " + title + "\n";
2068
+ if (cur2.includes("- " + number + " ")) {
2069
+ return { book, action, message: "第 " + number + " 章已在纲(未重复追加)" };
2070
+ }
2071
+ await mkdir(dir, { recursive: true });
2072
+ await writeFile(f, cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line, "utf8");
2073
+ return { book, action, message: "大纲已补:第 " + number + " 章 " + title };
2074
+ }
2075
+
2076
+ if (action === "hook") {
2077
+ const number = parseInt(args?.number, 10);
2078
+ const body = String(args?.body ?? "").replace(/\s*\n+\s*/g, " ").trim(); // v3.1.0:单行化,防换行伪造钩子条目
2079
+ if (isNaN(number)) throw new Error("hook 需要 number");
2080
+ if (body === "") throw new Error("hook 需要 body(本章结尾状态/悬念/时间场景)");
2081
+ const f = join(dir, CREATION_FILES.hooks);
2082
+ let cur = "";
2083
+ try { cur = await readTextFile(f, exec); } catch { cur = "# 钩子记录\n\n"; await mkdir(dir, { recursive: true }); }
2084
+ const cur2 = cur.startsWith("# 钩") ? cur : "# 钩子记录\n\n" + cur;
2085
+ // 替换已有章钩子(更新),否则追加
2086
+ const re = new RegExp("- " + number + " [\\s\\S]*?(?=\\n- |\\n$)");
2087
+ const line = "- " + number + " " + body + "\n";
2088
+ const next = re.test(cur2) ? cur2.replace(re, line) : (cur2.endsWith("\n") ? cur2 + line : cur2 + "\n" + line);
2089
+ await mkdir(dir, { recursive: true });
2090
+ await writeFile(f, next, "utf8");
2091
+ return { book, action, message: "第 " + number + " 章钩子已回填" };
2092
+ }
2093
+
2094
+ if (action === "status") {
2095
+ const content = requiredString(args, "content");
2096
+ await mkdir(dir, { recursive: true });
2097
+ await writeFile(join(dir, CREATION_FILES.status), content, "utf8");
2098
+ return { book, action, message: "创作状态卡已刷新" };
2099
+ }
2100
+
2101
+ throw new Error("未知 action:" + action);
2102
+ }
2103
+ };
2104
+ }
2105
+
1740
2106
  /** v0.6.0 伏笔/剧情线登记表。 */
1741
2107
  /** v0.8.0:书库统一数据目录(<root>/.novel-writer/),子目录:plots/settings/summaries/analysis/audits。 */
1742
2108
  function novelDataDir(root) {
@@ -3037,6 +3403,8 @@ function registerNovelBooks(ctx, config) {
3037
3403
  const books = [];
3038
3404
  for (const entry of entries) {
3039
3405
  if (!entry.isDirectory()) continue;
3406
+ // v3.1.0:粗大纲/创作资料目录(novels/创作资料/<书>/)不算作品
3407
+ if (entry.name === "创作资料") continue;
3040
3408
  const bookPath = join(dir, entry.name);
3041
3409
  let chapters;
3042
3410
  try {
@@ -3356,6 +3724,10 @@ function formatKeywords(value) {
3356
3724
  return lines.join("\n");
3357
3725
  }
3358
3726
 
3727
+ function registerNovelOutline(ctx, config) {
3728
+ ctx.tools.register(createOutlineTool(config));
3729
+ }
3730
+
3359
3731
  function registerNovelNewChapter(ctx, config) {
3360
3732
  ctx.tools.register({
3361
3733
  name: "novel_new_chapter",
@@ -3442,6 +3814,7 @@ function apply(ctx, config = {}) {
3442
3814
  registerNovelSummary(ctx, config);
3443
3815
  registerNovelContinuityCheck(ctx, config);
3444
3816
  registerNovelSemanticSearch(ctx, config);
3817
+ registerNovelOutline(ctx, config);
3445
3818
  // 注册 UI 开关状态路由(可选注入,headless 自动跳过)
3446
3819
  registerStyleConfigRoute(ctx, config);
3447
3820
  ctx.systemPrompt.section({
@@ -3449,6 +3822,7 @@ function apply(ctx, config = {}) {
3449
3822
  order: 150,
3450
3823
  text: WORKFLOW_TEXT
3451
3824
  });
3825
+ // v3.1.0 原创模式:不做动态 section(DSH 0.1.1-rc.2 对 section 返回 null 会导致对话失败——改为 novel_outline init 时把用户原创设定预填进 创作设定.md,经工具链注入)
3452
3826
  // v2.0.0 非净化模式:动态 section(text 为函数,组装提示词时实时读开关)
3453
3827
  ctx.systemPrompt.section({
3454
3828
  name: "novel-writing:raw-writing",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-novel-writer",
3
- "version": "3.0.0",
4
- "description": "小说写作助手插件(v3.0.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带与原创风格自检。",
3
+ "version": "3.1.0",
4
+ "description": "小说写作助手 / Novel Writing Assistant for DeepSeek Harness — sentence/emotion/imagery analysis, 6-dim writing baseline band, 12-axis vibe spectrum, style report, original-creation mode with per-book profiles, local semantic search (0 token), plot & settings management.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -40,5 +40,26 @@
40
40
  "license": "MIT",
41
41
  "engines": {
42
42
  "node": ">=22.3"
43
- }
44
- }
43
+ },
44
+ "keywords": [
45
+ "deepseek-harness",
46
+ "dsh",
47
+ "dsh-plugin",
48
+ "novel-writing",
49
+ "chinese-novel",
50
+ "writing-assistant",
51
+ "creative-writing",
52
+ "fiction",
53
+ "webnovel",
54
+ "ai-writing",
55
+ "text-analysis",
56
+ "semantic-search",
57
+ "style-analysis",
58
+ "sentence-analysis",
59
+ "emotion-analysis",
60
+ "vibe",
61
+ "quantification",
62
+ "story-plot",
63
+ "writing-tool"
64
+ ]
65
+ }
@@ -119,7 +119,34 @@ novel_sentence_analysis 的 emotion.quantification.semanticImplicit(semanticEm
119
119
  容差带(允许低于/高于基线的百分比)可在侧边栏「写作助手功能 → 风格基线」调整,默认 ±15%;主题/情节/人物完全自由,基线只管写法层面。
120
120
  - AI 结合数据给出风格气质判断,通过 aiJudgment 参数回传(插件存入 .novel-writer/style-reports/,后续 action=get 读取);续写时参考已保存判断保持风格一致。
121
121
 
122
- ## 网文信号层(v2.2.0)
122
+ ## 原创模式(v3.1.0)
123
+
124
+ 原创前先确认书名——侧边栏「原创模式」是**设定库**(每本书一份设定,含未建目录的新书预配置;没在库里的新书用「默认设定」);模型原创时用**该书专属设定**(novel_outline init 自动预填),绝不混用其他书的设定。先读侧边栏「写作助手功能 → 原创模式」的创作设定(世界观/角色设定/不允许的事件/主线目的/题材偏好/额外要求,留空项由模型自行设定);随后用 novel_outline 建立创作资料(init 时自动把用户设定预填为创作设定.md 的「【用户原创设定】」段),强制走「设定书 → 大纲 → 钩子」链,避免剧情跳跃与角色 OOC。
125
+
126
+ ### 创作资料结构(novels/创作资料/<书名>/)
127
+ - 创作设定.md:世界观规则/主线冲突/分卷目的/禁忌;
128
+ - 主要人物设定.md:主角团卡(目标→动机→弱点→说话方式→关系);
129
+ - 次要人物设定.md:出场即登记(谁/作用/特征),不再出场标注;
130
+ - 剧情大纲.md:批次大纲(每章方向行,**不写钩子**);
131
+ - 钩子记录.md:每章写完**回填**结尾钩子(状态/悬念/时间场景);
132
+ - 创作状态卡.md:几十字快照(进度/上一章结尾/时间线/活跃角色/下章方向/未回填钩子)。
133
+
134
+ ### 设定来源声明(v3.1.0)
135
+ - 用户在侧边栏「原创模式·设定库」保存的设定,保存时会**自动同步**为创作设定.md 顶部的【用户原创设定】段(含未建目录的新书——保存即创建 novels/创作资料/<书>/创作设定.md);删除设定则自动移除该段。
136
+ - 模型写书时以 **创作设定.md 的【用户原创设定】段**为准(该书专属;无专属时 init 回退「默认设定」)——**无需怀疑设定来源**,文件就是唯一事实;用户改设定后文件自动更新,不用重新 init。
137
+ - 用户填写的「角色设定」还会同步到 **主要人物设定.md 的【用户角色设定】段**(init 首次创建与已存在均生效)——写人物时以该段为准,登记/提升人物不要与它重复冲突。
138
+
139
+ ### 档位与动态批次
140
+ - 按目标字数选档:超短篇(≤1万字/一次列全) / 短篇(1-10万/10章批) / 中篇(10-30万/20章批) / 长篇(30-80万/30章批) / 超长篇(80万+/30章批);用户没报字数 → 从 10 章/批起步;
141
+ - 一批写完后若用户想继续 → 先回填更新创作设定(角色/线络变化),再列下一批;批次可升档(10→20→30)。
142
+
143
+ ### 强制流程(原创/续写都适用)
144
+ 1. 动笔前:novel_outline read status(几十字快照)→ 再 read outline(本章方向行)→ 动笔;不够时按需读人物/设定;
145
+ 2. 写完一章:novel_outline hook(number/body)回填结尾钩子;新出场且有戏份的角色 → novel_outline character(minor 登记,升咖改 main);
146
+ 3. 每章写完可刷新 status(进度/下章方向);下一章开头从上一章钩子接续(时间/场景变化必须写过渡句);
147
+ 4. read status/outline 会提示「未回填钩子章节」——先补钩子再写下一章;
148
+ 5. 大纲只定方向(核心事件),**结尾钩子不预写**——写完回填,创作自由且衔接有据。
123
149
 
150
+ ## 网文信号层(v2.2.0)
124
151
  - 网文动作/套路词群(含 genre-tropes 现成词)、题材联动(豪门总裁→甜宠等)、情感直给词密度——三层信号只加分不换引擎,权重封顶防污染;
125
152
  - 三组回归(公版文学/手写样本/网文库)验证无污染。