@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

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.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 全局资源库(跨书可复用的自定义题材/推进模式)。
3
+ * 雷达「同步底座到全局资源库」写入此处;/assets 返回时与内置库合并展示。
4
+ */
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import type { GenreNode, ProgressionMode } from './protocol.ts'
9
+
10
+ interface GlobalAssets {
11
+ genres: GenreNode[]
12
+ modes: ProgressionMode[]
13
+ }
14
+
15
+ const FILE = join(homedir(), '.dsh', 'novel-forge-global-assets.json')
16
+ let cache: GlobalAssets | null = null
17
+
18
+ function load(): GlobalAssets {
19
+ if (cache !== null) return cache
20
+ try {
21
+ const raw = readFileSync(FILE, 'utf8')
22
+ const parsed = JSON.parse(raw) as Partial<GlobalAssets>
23
+ cache = { genres: Array.isArray(parsed.genres) ? parsed.genres : [], modes: Array.isArray(parsed.modes) ? parsed.modes : [] }
24
+ } catch {
25
+ cache = { genres: [], modes: [] }
26
+ }
27
+ return cache
28
+ }
29
+
30
+ function persist(): void {
31
+ if (cache === null) return
32
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
33
+ writeFileSync(FILE, JSON.stringify(cache, null, 2), 'utf8')
34
+ }
35
+
36
+ export function globalGenreLibrary(): GenreNode[] {
37
+ return load().genres
38
+ }
39
+
40
+ export function globalProgressionLibrary(): ProgressionMode[] {
41
+ return load().modes
42
+ }
43
+
44
+ /** 新增/已有则跳过;返回 true=新增。 */
45
+ export function addGlobalGenre(g: GenreNode): boolean {
46
+ const lib = load()
47
+ if (lib.genres.some(x => x.name === g.name)) return false
48
+ lib.genres.push({ ...g, children: g.children ?? [] })
49
+ persist()
50
+ return true
51
+ }
52
+
53
+ export function addGlobalMode(m: ProgressionMode): boolean {
54
+ const lib = load()
55
+ if (lib.modes.some(x => x.name === m.name)) return false
56
+ lib.modes.push({ ...m, payoffs: m.payoffs ?? [], risks: m.risks ?? [], primary: m.primary ?? false })
57
+ persist()
58
+ return true
59
+ }
package/src/index.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  * changes.
8
8
  */
9
9
 
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
10
12
  import type { Context } from '@deepseek-ai/cordis'
11
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
13
+ import type {} from '@deepseek-ai/dsh-settings'
12
14
  import z from 'schemastery'
13
15
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
16
  import type {} from '@deepseek-ai/dsh-llm'
@@ -16,19 +18,20 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
16
18
  import type { ConfigPatch, NovelConfig } from './protocol.ts'
17
19
  import { makeRoutes } from './routes.ts'
18
20
  import { activeBookOutputDir } from './bookshelf.ts'
21
+ import { loadProject } from './engine.ts'
19
22
 
20
23
  /** Stable cordis plugin name. */
21
24
  export const name = 'novel-forge'
22
25
 
23
26
  /** Services required before the novel-forge surfaces can mount. */
24
- export const inject = ['webServer', 'llm', 'systemPrompt']
27
+ export const inject = ['webServer', 'llm', 'systemPrompt', 'settings']
25
28
 
26
29
  /**
27
30
  * Settings namespace of the novel-forge capability — the section the web
28
31
  * settings surface edits. Spelled here rather than imported: the browser half
29
32
  * spells the same value and must not depend on a Host package.
30
33
  */
31
- export const NOVEL_SETTINGS_NAMESPACE = settingsNamespace('dsh-novel-forge')
34
+ export const NOVEL_SETTINGS_NAMESPACE = 'dsh-novel-forge' as const
32
35
 
33
36
  /** Plugin config, validated by the same-named schemastery schema. */
34
37
  export interface Config {
@@ -44,8 +47,20 @@ export interface Config {
44
47
  provider?: string
45
48
  /** LLM model id. */
46
49
  model?: string
50
+ /** 任务级模型路由:正文生成模型(留空则跟随 model)。 */
51
+ generateModel?: string
52
+ /** 任务级模型路由:审稿模型(留空则跟随 model)。 */
53
+ reviewModel?: string
54
+ /** 任务级模型路由:质检模型(留空则跟随 model)。 */
55
+ auditModel?: string
56
+ /** 备用模型:主模型失败时自动切换重试一次(留空 = 不切换)。 */
57
+ fallbackModel?: string
58
+ /** 出章后自动抽取故事时间线(默认开)。 */
59
+ autoTimeline?: boolean
47
60
  /** LLM reasoning effort (off/low/high/max). */
48
61
  reasoningEffort?: 'off' | 'low' | 'high' | 'max'
62
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low。 */
63
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max'
49
64
  /** Target characters per chapter. */
50
65
  chapterChars?: number
51
66
  /** Max output tokens per chapter call. */
@@ -58,40 +73,52 @@ export interface Config {
58
73
  autoAuthorReview?: boolean
59
74
  /** 修订/润色产出草稿后自动附带一次 AI 审查(默认开,可在设置页关闭省 token)。 */
60
75
  autoReviewAfterRevise?: boolean
61
- /** 豆包/Seedream 生图 API Key。 */
62
- imageApiKey?: string
63
- /** 豆包/Seedream 生图模型 ID。 */
64
- imageApiModel?: string
65
- /** 是否启用豆包生图(默认关)。 */
66
- imageApiEnabled?: boolean
76
+ /** 自定义背景图(URL / dataURL / 服务端路径引用)。 */
77
+ themeBackground?: string
78
+ /** 自定义背景遮罩/模糊强度 0-80。 */
79
+ themeBackgroundBlur?: number
80
+ /** 玻璃透明度 0-100(100=当前原样)。 */
81
+ themeOpacity?: number
82
+ /** 是否启用改编模式(默认关闭)。 */
83
+ enableAdaptMode?: boolean
67
84
  }
68
85
 
69
86
  export const Config: z<Config> = z.object({
70
87
  announceToAgent: z.boolean().default(true),
71
88
  enabled: z.boolean().default(true),
72
- outlinePath: z.string().default('D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx'),
73
- outputDir: z.string().default('D:\\ryan work\\harness\\novels\\归墟玉主'),
89
+ outlinePath: z.string().default(''),
90
+ outputDir: z.string().default(join(homedir(), '.dsh', 'novels')),
74
91
  provider: z.string().default('deepseek-official'),
75
- model: z.string().default('deepseek-v4-flash'),
92
+ model: z.string().default('deepseek-flash'),
93
+ generateModel: z.string().default(''),
94
+ reviewModel: z.string().default(''),
95
+ auditModel: z.string().default(''),
96
+ fallbackModel: z.string().default(''),
97
+ autoTimeline: z.boolean().default(true),
76
98
  reasoningEffort: z.union(['off', 'low', 'high', 'max']).default('off'),
99
+ analysisReasoning: z.union(['off', 'low', 'high', 'max']).default('low'),
77
100
  chapterChars: z.number().default(3500),
78
101
  maxTokens: z.number().default(12000),
79
102
  reviewPassScore: z.number().default(70),
80
103
  autoReview: z.boolean().default(true),
81
104
  autoAuthorReview: z.boolean().default(true),
82
105
  autoReviewAfterRevise: z.boolean().default(true),
83
- imageApiKey: z.string().default(''),
84
- imageApiModel: z.string().default(''),
85
- imageApiEnabled: z.boolean().default(false),
106
+ themeBackground: z.string().default(''),
107
+ themeBackgroundBlur: z.number().default(0),
108
+ themeOpacity: z.number().default(100),
109
+ enableAdaptMode: z.boolean().default(true),
86
110
  })
87
111
 
88
112
  /** Schema defaults, re-read for hand-built test contexts. */
89
113
  const DEFAULT_ANNOUNCE = true
90
- const DEFAULT_OUTLINE_PATH = 'D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx'
91
- const DEFAULT_OUTPUT_DIR = 'D:\\ryan work\\harness\\novels\\归墟玉主'
114
+ const DEFAULT_OUTLINE_PATH = ''
115
+ const DEFAULT_OUTPUT_DIR = join(homedir(), '.dsh', 'novels')
92
116
  const DEFAULT_PROVIDER = 'deepseek-official'
93
- const DEFAULT_MODEL = 'deepseek-v4-flash'
117
+ const DEFAULT_MODEL = 'deepseek-flash'
94
118
  const DEFAULT_REASONING_EFFORT = 'off' as const
119
+ const DEFAULT_ANALYSIS_REASONING = 'low' as const
120
+ const DEFAULT_FALLBACK_MODEL = ''
121
+ const DEFAULT_AUTO_TIMELINE = true
95
122
  const DEFAULT_CHAPTER_CHARS = 3500
96
123
  const DEFAULT_MAX_TOKENS = 12000
97
124
  const DEFAULT_REVIEW_PASS_SCORE = 70
@@ -103,7 +130,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true
103
130
  const SECTION_ORDER = 160
104
131
 
105
132
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
106
- export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认 D:\\ryan work\\harness\\novels\\归墟玉主 大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 D:\\ryan work\\harness\\novels\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。'
133
+ export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要与编年录事实、抽取故事时间线(故事内时间/地点/在场角色/事件)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑/反 AI/呈现/合规九维)与作者复盘;支持按审稿意见合并修订(审稿 high + 时间线矛盾 + 张力偏差合成一轮,每章最多两轮后转人工)、去 AI 味润色、章节历史版本回滚、暗线(伏笔)管理、剧情线与角色库、张力曲线、全书一致性质检与敏感词扫描、分范围导出与项目备份、批量连写(生产单,可暂停/续跑)。限制:所有 AI 操作消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度;张力/时间线问题默认只是建议(写日志与待办),不会自动改正文。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。'
107
134
 
108
135
  /** Resolve a config-like value into the full runtime config. */
109
136
  export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
@@ -112,16 +139,23 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
112
139
  outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
113
140
  provider: value?.provider ?? DEFAULT_PROVIDER,
114
141
  model: value?.model ?? DEFAULT_MODEL,
142
+ generateModel: value?.generateModel,
143
+ reviewModel: value?.reviewModel,
144
+ auditModel: value?.auditModel,
145
+ fallbackModel: value?.fallbackModel ?? DEFAULT_FALLBACK_MODEL,
146
+ autoTimeline: value?.autoTimeline ?? DEFAULT_AUTO_TIMELINE,
115
147
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
148
+ analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
116
149
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
117
150
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
118
151
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
119
152
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
120
153
  autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
121
154
  autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
122
- imageApiKey: value?.imageApiKey,
123
- imageApiModel: value?.imageApiModel,
124
- imageApiEnabled: value?.imageApiEnabled ?? false,
155
+ themeBackground: value?.themeBackground ?? '',
156
+ themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
157
+ themeOpacity: value?.themeOpacity ?? 100,
158
+ enableAdaptMode: value?.enableAdaptMode ?? true,
125
159
  }
126
160
  }
127
161
 
@@ -138,10 +172,22 @@ export function apply(ctx: Context, config?: Config): void {
138
172
  const resolved = resolveConfig(current())
139
173
  // 书架激活的书优先决定输出目录(settings 仍可改默认值)。
140
174
  const shelfDir = activeBookOutputDir()
141
- if (shelfDir !== undefined) {
142
- return { ...resolved, outputDir: shelfDir }
143
- }
144
- return resolved
175
+ if (shelfDir === undefined) return resolved
176
+ // 本书参数覆盖(novel-project.json.bookSettings;未设字段回退全局)。
177
+ const bs = loadProject(shelfDir)?.bookSettings
178
+ const book: NovelConfig = { ...resolved, outputDir: shelfDir }
179
+ if (bs === undefined) return book
180
+ if (bs.provider !== undefined) book.provider = bs.provider
181
+ if (bs.model !== undefined) book.model = bs.model
182
+ if (bs.reasoningEffort !== undefined) book.reasoningEffort = bs.reasoningEffort
183
+ if (bs.analysisReasoning !== undefined) book.analysisReasoning = bs.analysisReasoning
184
+ if (bs.chapterChars !== undefined) book.chapterChars = bs.chapterChars
185
+ if (bs.maxTokens !== undefined) book.maxTokens = bs.maxTokens
186
+ if (bs.reviewPassScore !== undefined) book.reviewPassScore = bs.reviewPassScore
187
+ if (bs.autoReview !== undefined) book.autoReview = bs.autoReview
188
+ if (bs.autoAuthorReview !== undefined) book.autoAuthorReview = bs.autoAuthorReview
189
+ if (bs.autoReviewAfterRevise !== undefined) book.autoReviewAfterRevise = bs.autoReviewAfterRevise
190
+ return book
145
191
  }
146
192
 
147
193
  const patchConfig = async (patch: ConfigPatch): Promise<NovelConfig> => {
@@ -150,16 +196,23 @@ export function apply(ctx: Context, config?: Config): void {
150
196
  if (patch.outputDir !== undefined) next.outputDir = patch.outputDir
151
197
  if (patch.provider !== undefined) next.provider = patch.provider
152
198
  if (patch.model !== undefined) next.model = patch.model
199
+ if (patch.generateModel !== undefined) next.generateModel = patch.generateModel
200
+ if (patch.reviewModel !== undefined) next.reviewModel = patch.reviewModel
201
+ if (patch.auditModel !== undefined) next.auditModel = patch.auditModel
202
+ if (patch.fallbackModel !== undefined) next.fallbackModel = patch.fallbackModel
203
+ if (patch.autoTimeline !== undefined) next.autoTimeline = patch.autoTimeline
153
204
  if (patch.reasoningEffort !== undefined) next.reasoningEffort = patch.reasoningEffort
205
+ if (patch.analysisReasoning !== undefined) next.analysisReasoning = patch.analysisReasoning
154
206
  if (patch.chapterChars !== undefined) next.chapterChars = patch.chapterChars
155
207
  if (patch.maxTokens !== undefined) next.maxTokens = patch.maxTokens
156
208
  if (patch.reviewPassScore !== undefined) next.reviewPassScore = patch.reviewPassScore
157
209
  if (patch.autoReview !== undefined) next.autoReview = patch.autoReview
158
210
  if (patch.autoAuthorReview !== undefined) next.autoAuthorReview = patch.autoAuthorReview
159
211
  if (patch.autoReviewAfterRevise !== undefined) next.autoReviewAfterRevise = patch.autoReviewAfterRevise
160
- if (patch.imageApiKey !== undefined) next.imageApiKey = patch.imageApiKey
161
- if (patch.imageApiModel !== undefined) next.imageApiModel = patch.imageApiModel
162
- if (patch.imageApiEnabled !== undefined) next.imageApiEnabled = patch.imageApiEnabled
212
+ if (patch.themeBackground !== undefined) next.themeBackground = patch.themeBackground
213
+ if (patch.themeBackgroundBlur !== undefined) next.themeBackgroundBlur = patch.themeBackgroundBlur
214
+ if (patch.themeOpacity !== undefined) next.themeOpacity = patch.themeOpacity
215
+ if (patch.enableAdaptMode !== undefined) next.enableAdaptMode = patch.enableAdaptMode
163
216
  // Persist through the settings seam when available; otherwise keep in memory.
164
217
  // (ctx.get is the non-strict service access — no inject requirement, same
165
218
  // pattern installSettingsSection itself uses.)
@@ -193,7 +246,7 @@ export function apply(ctx: Context, config?: Config): void {
193
246
  text: NOVEL_GUIDANCE,
194
247
  })
195
248
  }
196
- const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig })
249
+ const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig, rawConfig: () => current() })
197
250
  disposeRoutes = ctx.effect(
198
251
  () => {
199
252
  const disposers = routes.map(route => ctx.webServer.register(route))
@@ -204,7 +257,7 @@ export function apply(ctx: Context, config?: Config): void {
204
257
  void value
205
258
  }
206
259
 
207
- installSettingsSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
260
+ ctx.settings.installSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
208
261
  setSource: (source) => {
209
262
  current = source
210
263
  sync()
@@ -214,10 +267,13 @@ export function apply(ctx: Context, config?: Config): void {
214
267
 
215
268
  // 运行时 skill:章节批量生产与值守处理(只在本插件环境可用)。
216
269
  // 通过 ctx.get('skills') 获取注册表(服务存在时注册,缺失则跳过,不影响其他能力)。
217
- const skillsService = ctx.get('skills') as { register?: (skill: { name: string; description: string; content: string }) => () => void } | undefined
218
- if (skillsService?.register !== undefined) {
270
+ const skillsService = ctx.get('skills') as { register?: (skill: { name: string; provider: string; description: string; content: string }) => () => void } | undefined
271
+ // [disabled 2026-09-05] 运行时 skill 注册按用户要求暂时关闭(排查 .length 报错)。
272
+ const enableRuntimeSkill: boolean = false
273
+ if (enableRuntimeSkill && skillsService !== undefined && skillsService.register !== undefined) {
219
274
  const disposeSkill = skillsService.register({
220
275
  name: 'novel-forge-chapter-batch',
276
+ provider: 'dsh-novel-forge',
221
277
  description: '批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。',
222
278
  content: [
223
279
  '# 小说章节批量生产与值守处理',
@@ -0,0 +1,260 @@
1
+ /**
2
+ * LLM 实况 feed:以「每次 LLM 调用」为粒度,把生命周期事件(开始/流式正文/阶段变化/完成)
3
+ * 推给订阅者,并提供环形缓冲供新连上的客户端重放。
4
+ *
5
+ * 同时承担三件与用量有关的事(进程内,不落盘):
6
+ * 1) **用量账本**:每次调用结束后按用途累计 token / 耗时,供 /status 展示;
7
+ * 2) **Prompt 记录**:保存每次调用实际发送的 system + user(环形,按需查询);
8
+ * 3) **首字耗时**:调用方在拿到第一个文本增量时打点,用于观察模型响应速度。
9
+ */
10
+
11
+ import type { BlockAssembler, TokenUsage } from '@deepseek-ai/dsh-llm'
12
+ import type {
13
+ LlmLiveFrame,
14
+ LlmPromptRecord,
15
+ LlmTokenUsage,
16
+ LlmUsageBucket,
17
+ LlmUsageSummary,
18
+ } from './protocol.ts'
19
+
20
+ export type { LlmLiveFrame }
21
+
22
+ export type LlmLivePhase = 'requesting' | 'streaming' | 'completed' | 'failed'
23
+
24
+ /** 帧缓冲上限(新连接重放用)。 */
25
+ const MAX_BUFFER = 400
26
+ /** Prompt 记录上限(环形,避免长跑吃内存)。 */
27
+ const MAX_PROMPTS = 40
28
+ /** 单条 Prompt 记录的最大字符数(system + user 合计),超出截断并标记。 */
29
+ const MAX_PROMPT_CHARS = 60_000
30
+
31
+ let buffer: LlmLiveFrame[] = []
32
+ type Listener = (frame: LlmLiveFrame) => void
33
+ let listeners = new Set<Listener>()
34
+ let seq = 0
35
+
36
+ /** 进程内用量账本。 */
37
+ const usageTotals = {
38
+ calls: 0,
39
+ failed: 0,
40
+ inputTokens: 0,
41
+ outputTokens: 0,
42
+ reasoningTokens: 0,
43
+ totalTokens: 0,
44
+ elapsedMs: 0,
45
+ callsWithoutUsage: 0,
46
+ byLabel: new Map<string, LlmUsageBucket>(),
47
+ }
48
+
49
+ /** Prompt 记录(按 sessionId,保留最近 MAX_PROMPTS 条)。 */
50
+ const prompts = new Map<string, LlmPromptRecord>()
51
+
52
+ export function nextSessionId(): string {
53
+ seq++
54
+ return `ll-${Date.now().toString(36)}-${seq}`
55
+ }
56
+
57
+ export function subscribeLiveFeed(listener: Listener): () => void {
58
+ // 先重放现有缓冲(新连接能立即看到最近调用),再进入实时。
59
+ for (const frame of buffer) listener(frame)
60
+ listeners.add(listener)
61
+ return () => { listeners.delete(listener) }
62
+ }
63
+
64
+ export function emitLive(frame: LlmLiveFrame): void {
65
+ if (frame.type === 'session_completed') recordUsage(frame)
66
+ buffer.push(frame)
67
+ if (buffer.length > MAX_BUFFER) buffer = buffer.slice(-MAX_BUFFER)
68
+ for (const l of listeners) l(frame)
69
+ }
70
+
71
+ export function liveLatest(): LlmLiveFrame[] {
72
+ return [...buffer]
73
+ }
74
+
75
+ export function clearLiveFeed(): void {
76
+ buffer = []
77
+ }
78
+
79
+ // ------------------------------------------------------------------ 用量账本
80
+
81
+ /** 把 dsh-llm 的 usage 转成线上形状。 */
82
+ export function toLiveUsage(usage: TokenUsage | undefined): LlmTokenUsage | undefined {
83
+ if (usage === undefined) return undefined
84
+ return {
85
+ inputTokens: usage.inputTokens ?? 0,
86
+ outputTokens: usage.outputTokens ?? 0,
87
+ totalTokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
88
+ reasoningTokens: usage.reasoningTokens ?? 0,
89
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
90
+ }
91
+ }
92
+
93
+ /** 记一次已完成的调用(由 emitLive 内部调用)。 */
94
+ function recordUsage(frame: LlmLiveFrame): void {
95
+ const failed = frame.phase === 'failed' ? 1 : 0
96
+ const usage = frame.usage
97
+ usageTotals.calls += 1
98
+ usageTotals.failed += failed
99
+ usageTotals.elapsedMs += frame.elapsedMs ?? 0
100
+ if (usage === undefined) {
101
+ usageTotals.callsWithoutUsage += 1
102
+ } else {
103
+ usageTotals.inputTokens += usage.inputTokens
104
+ usageTotals.outputTokens += usage.outputTokens
105
+ usageTotals.reasoningTokens += usage.reasoningTokens ?? 0
106
+ usageTotals.totalTokens += usage.totalTokens ?? usage.inputTokens + usage.outputTokens
107
+ }
108
+ const label = frame.label ?? '未标注调用'
109
+ const bucket = usageTotals.byLabel.get(label) ?? { label, calls: 0, failed: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, elapsedMs: 0 }
110
+ bucket.calls += 1
111
+ bucket.failed += failed
112
+ bucket.elapsedMs += frame.elapsedMs ?? 0
113
+ if (usage !== undefined) {
114
+ bucket.inputTokens += usage.inputTokens
115
+ bucket.outputTokens += usage.outputTokens
116
+ bucket.reasoningTokens += usage.reasoningTokens ?? 0
117
+ }
118
+ usageTotals.byLabel.set(label, bucket)
119
+ }
120
+
121
+ /** 本次运行的用量汇总(按耗时降序排列用途,便于看谁最贵)。 */
122
+ export function liveUsage(): LlmUsageSummary {
123
+ return {
124
+ calls: usageTotals.calls,
125
+ failed: usageTotals.failed,
126
+ inputTokens: usageTotals.inputTokens,
127
+ outputTokens: usageTotals.outputTokens,
128
+ reasoningTokens: usageTotals.reasoningTokens,
129
+ totalTokens: usageTotals.totalTokens,
130
+ elapsedMs: usageTotals.elapsedMs,
131
+ callsWithoutUsage: usageTotals.callsWithoutUsage,
132
+ byLabel: [...usageTotals.byLabel.values()].sort((a, b) =>
133
+ (b.inputTokens + b.outputTokens + b.reasoningTokens) - (a.inputTokens + a.outputTokens + a.reasoningTokens)),
134
+ }
135
+ }
136
+
137
+ /** 清零用量账本(面板「重新计数」用)。 */
138
+ export function resetLiveUsage(): void {
139
+ usageTotals.calls = 0
140
+ usageTotals.failed = 0
141
+ usageTotals.inputTokens = 0
142
+ usageTotals.outputTokens = 0
143
+ usageTotals.reasoningTokens = 0
144
+ usageTotals.totalTokens = 0
145
+ usageTotals.elapsedMs = 0
146
+ usageTotals.callsWithoutUsage = 0
147
+ usageTotals.byLabel.clear()
148
+ }
149
+
150
+ // ------------------------------------------------------------- Prompt 记录
151
+
152
+ /** 记一条 Prompt(超长截断并标记)。 */
153
+ export function recordPrompt(input: { sessionId: string; label?: string; model?: string; system?: string; user?: string }): LlmPromptRecord {
154
+ const system = input.system ?? ''
155
+ const user = input.user ?? ''
156
+ const chars = system.length + user.length
157
+ let record: LlmPromptRecord = {
158
+ sessionId: input.sessionId,
159
+ label: input.label,
160
+ model: input.model,
161
+ at: new Date().toISOString(),
162
+ system,
163
+ user,
164
+ chars,
165
+ }
166
+ if (chars > MAX_PROMPT_CHARS) {
167
+ // 优先保留 user(真正随调用变化的输入),system 按剩余预算截断。
168
+ const userPart = user.slice(0, MAX_PROMPT_CHARS)
169
+ const rest = MAX_PROMPT_CHARS - userPart.length
170
+ record = { ...record, user: userPart, system: system.slice(0, Math.max(0, rest)), truncated: true }
171
+ }
172
+ prompts.set(record.sessionId, record)
173
+ while (prompts.size > MAX_PROMPTS) {
174
+ const oldest = prompts.keys().next().value
175
+ if (oldest === undefined) break
176
+ prompts.delete(oldest)
177
+ }
178
+ return record
179
+ }
180
+
181
+ /** 取一条 Prompt 记录(不存在返回 undefined)。 */
182
+ export function livePrompt(sessionId: string): LlmPromptRecord | undefined {
183
+ return prompts.get(sessionId)
184
+ }
185
+
186
+ /** 清空 Prompt 记录。 */
187
+ export function clearLivePrompts(): void {
188
+ prompts.clear()
189
+ }
190
+
191
+ // --------------------------------------------------------------- 调用打点
192
+
193
+ /** 一次调用的打点句柄。 */
194
+ export interface LiveCallHandle {
195
+ sessionId: string
196
+ startedAt: number
197
+ /** 用途(用于 session_completed 的用量分组)。 */
198
+ label: string
199
+ /** 实际使用的模型。 */
200
+ model: string
201
+ /** 首字耗时(毫秒),由 markFirstToken 写入。 */
202
+ firstTokenMs?: number
203
+ }
204
+
205
+ /**
206
+ * 开始一次 LLM 调用:发 session_started、记 Prompt、起计时。
207
+ *
208
+ * @param input 用途/模型/实际发送的 system 与 user。
209
+ * @returns 打点句柄(结束时交给 endLiveCall)。
210
+ */
211
+ export function beginLiveCall(input: { label: string; model: string; system?: string; user?: string }): LiveCallHandle {
212
+ const handle: LiveCallHandle = { sessionId: nextSessionId(), startedAt: Date.now(), label: input.label, model: input.model }
213
+ const record = recordPrompt({ sessionId: handle.sessionId, label: input.label, model: input.model, system: input.system, user: input.user })
214
+ emitLive({
215
+ type: 'session_started',
216
+ sessionId: handle.sessionId,
217
+ label: input.label,
218
+ model: input.model,
219
+ at: new Date().toISOString(),
220
+ context: { interactionId: handle.sessionId },
221
+ hasPrompt: true,
222
+ promptChars: record.chars,
223
+ })
224
+ emitLive({ type: 'phase_changed', sessionId: handle.sessionId, phase: 'streaming', phaseMessage: '模型正在返回内容', at: new Date().toISOString() })
225
+ return handle
226
+ }
227
+
228
+ /** 首字打点(调用方在收到第一个文本增量时调用,只记第一次)。 */
229
+ export function markFirstToken(handle: LiveCallHandle): void {
230
+ if (handle.firstTokenMs === undefined) handle.firstTokenMs = Date.now() - handle.startedAt
231
+ }
232
+
233
+ /**
234
+ * 结束一次 LLM 调用:从 assembler 取用量,发 session_completed(含 token/耗时/首字)。
235
+ *
236
+ * @param handle beginLiveCall 的句柄。
237
+ * @param assembler 本次调用的 BlockAssembler。
238
+ * @param extra 字符数/预览/失败信息等。
239
+ */
240
+ export function endLiveCall(
241
+ handle: LiveCallHandle,
242
+ assembler: BlockAssembler,
243
+ extra: { chars?: number; preview?: string; phase?: 'completed' | 'failed'; error?: string } = {},
244
+ ): void {
245
+ const usage = toLiveUsage(assembler.usage)
246
+ emitLive({
247
+ type: 'session_completed',
248
+ sessionId: handle.sessionId,
249
+ label: handle.label,
250
+ model: handle.model,
251
+ at: new Date().toISOString(),
252
+ totalChars: extra.chars,
253
+ preview: extra.preview,
254
+ phase: extra.phase ?? 'completed',
255
+ error: extra.error,
256
+ usage,
257
+ elapsedMs: Date.now() - handle.startedAt,
258
+ firstTokenMs: handle.firstTokenMs,
259
+ })
260
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 模型失败恢复规则(纯逻辑,可离线单测)。
3
+ *
4
+ * 解决的问题:批量连写时,主模型偶发限流/超时/额度耗尽会让**整批中断**,
5
+ * 而换一个模型重试一次往往就能过去。规则:
6
+ * 1) 只对「可重试」错误切换模型——用户主动取消、参数/契约类错误不重试;
7
+ * 2) 备用模型为空或与主模型相同则不切换;
8
+ * 3) 备用模型也失败时,抛**原始错误**(保留主因,不掩盖现场);
9
+ * 4) 每次调用最多切换一次。
10
+ */
11
+
12
+ /** 可重试错误特征(网络/超时/限流/服务端/额度类)。 */
13
+ const RETRYABLE_PATTERNS: readonly RegExp[] = [
14
+ /失败/, /超时/, /timeout/i,
15
+ /network|fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|socket hang up|terminated/i,
16
+ /频繁|限流|rate\s*limit|too many requests|\b429\b|\b5\d\d\b/i,
17
+ /overload|unavailable|capacity|服务不可用|繁忙/i,
18
+ /quota|balance|credit|insufficient|额度|余额/i,
19
+ ]
20
+
21
+ /** 明确不可重试的特征(用户取消 / 契约类)。 */
22
+ const NON_RETRYABLE_PATTERNS: readonly RegExp[] = [
23
+ /\baborted\b|已取消|用户取消/i,
24
+ /不在计划中|尚未生成|参数|非法|无效|需要/i,
25
+ ]
26
+
27
+ /**
28
+ * 判断一次 LLM 失败是否值得换模型重试。
29
+ *
30
+ * @param error 捕获到的异常。
31
+ * @returns 可重试时为 true。
32
+ */
33
+ export function isRetryableLlmError(error: unknown): boolean {
34
+ const message = error instanceof Error ? error.message : String(error)
35
+ if (NON_RETRYABLE_PATTERNS.some(re => re.test(message))) return false
36
+ return RETRYABLE_PATTERNS.some(re => re.test(message))
37
+ }
38
+
39
+ /**
40
+ * 判断是否应当切换到备用模型。
41
+ *
42
+ * @param fallbackModel 配置的备用模型(空串 = 未配置)。
43
+ * @param primaryModel 本次实际使用的主模型。
44
+ * @param error 捕获到的异常。
45
+ * @returns 应当切换时为 true。
46
+ */
47
+ export function shouldSwitchModel(fallbackModel: string | undefined, primaryModel: string, error: unknown): boolean {
48
+ const fallback = (fallbackModel ?? '').trim()
49
+ if (fallback === '' || fallback === primaryModel) return false
50
+ return isRetryableLlmError(error)
51
+ }
52
+
53
+ /**
54
+ * 用主模型执行;失败且满足切换条件时,用备用模型再执行一次。
55
+ *
56
+ * @param input 主模型 / 备用模型 / 实际执行函数(接收本次要用的模型 id)。
57
+ * @returns 执行结果(备用模型成功时返回其结果)。
58
+ * @throws 主模型失败且未切换时抛原错误;切换后仍失败时同样抛**主模型的原始错误**。
59
+ */
60
+ export async function withModelFallback<T>(
61
+ input: { model: string; fallbackModel?: string; onSwitch?: (from: string, to: string, error: unknown) => void },
62
+ run: (model: string) => Promise<T>,
63
+ ): Promise<T> {
64
+ try {
65
+ return await run(input.model)
66
+ } catch (error) {
67
+ const fallback = (input.fallbackModel ?? '').trim()
68
+ if (fallback === '' || fallback === input.model || !isRetryableLlmError(error)) throw error
69
+ input.onSwitch?.(input.model, fallback, error)
70
+ try {
71
+ return await run(fallback)
72
+ } catch {
73
+ // 备用模型也失败:抛主模型的原始错误,避免掩盖第一现场。
74
+ throw error
75
+ }
76
+ }
77
+ }