@waterwx/dsh-novel-forge 1.3.2 → 1.3.3-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 (172) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56288 -12061
  3. package/lib/index.js +14655 -5573
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +22 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +23 -0
  9. package/lib/types/bookshelf.d.ts +18 -3
  10. package/lib/types/client/api.d.ts +187 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  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 +29 -67
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  18. package/lib/types/client/panel/AiTag.d.ts +1 -0
  19. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  20. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  21. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  22. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  23. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  24. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  25. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  26. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  27. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  28. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  29. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  30. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  31. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  32. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  33. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  34. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  35. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  36. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  37. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  38. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  39. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  40. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  41. package/lib/types/client/panel/SubPage.d.ts +27 -0
  42. package/lib/types/client/panel/TensionView.d.ts +18 -0
  43. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  44. package/lib/types/client/panel/helpers.d.ts +5 -3
  45. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  46. package/lib/types/client/panel/toast.d.ts +2 -0
  47. package/lib/types/client/panel/views.d.ts +21 -35
  48. package/lib/types/client/run-status.d.ts +21 -0
  49. package/lib/types/client/sidebar-entry.d.ts +0 -2
  50. package/lib/types/client/text.d.ts +9 -0
  51. package/lib/types/docx-core.d.ts +16 -0
  52. package/lib/types/docx.d.ts +2 -3
  53. package/lib/types/engine.d.ts +186 -45
  54. package/lib/types/global-assets.d.ts +6 -0
  55. package/lib/types/home.d.ts +11 -0
  56. package/lib/types/index.d.ts +36 -10
  57. package/lib/types/llm-live.d.ts +70 -0
  58. package/lib/types/llm-retry.d.ts +38 -0
  59. package/lib/types/market-radar-scan.d.ts +18 -0
  60. package/lib/types/market-radar-sources.d.ts +31 -0
  61. package/lib/types/migrate-output.d.ts +25 -0
  62. package/lib/types/model-capability.d.ts +93 -0
  63. package/lib/types/novel-context.d.ts +80 -0
  64. package/lib/types/output-budget.d.ts +86 -0
  65. package/lib/types/prompt-slots.d.ts +57 -0
  66. package/lib/types/prompt-utils.d.ts +35 -0
  67. package/lib/types/prompting.d.ts +10 -0
  68. package/lib/types/protocol.d.ts +1378 -166
  69. package/lib/types/review-policy.d.ts +97 -0
  70. package/lib/types/revision.d.ts +76 -0
  71. package/lib/types/routes.d.ts +9 -3
  72. package/lib/types/run.d.ts +23 -1
  73. package/lib/types/settings-persist.d.ts +59 -0
  74. package/lib/types/snapshots.d.ts +46 -0
  75. package/lib/types/stage-contract.d.ts +58 -0
  76. package/lib/types/tension.d.ts +53 -0
  77. package/lib/types/timeline.d.ts +37 -0
  78. package/package.json +54 -28
  79. package/src/action-guard.ts +127 -0
  80. package/src/ai-scan.ts +155 -0
  81. package/src/assets.ts +1801 -520
  82. package/src/assistant.ts +1098 -770
  83. package/src/author-assets.ts +131 -0
  84. package/src/bookshelf.ts +193 -151
  85. package/src/client/api.ts +380 -23
  86. package/src/client/chip-prefs.ts +121 -0
  87. package/src/client/docx.ts +32 -69
  88. package/src/client/index.ts +3 -1
  89. package/src/client/llmLive.ts +111 -0
  90. package/src/client/locales.ts +29 -253
  91. package/src/client/mount.tsx +4 -3
  92. package/src/client/panel/AdaptModeView.tsx +535 -0
  93. package/src/client/panel/AiTag.tsx +7 -0
  94. package/src/client/panel/AssetsTab.tsx +448 -458
  95. package/src/client/panel/AssistantTab.tsx +355 -301
  96. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  97. package/src/client/panel/AuthorHome.tsx +309 -0
  98. package/src/client/panel/BookAnalysisView.tsx +63 -0
  99. package/src/client/panel/CmdPalette.tsx +102 -0
  100. package/src/client/panel/CreateBookView.tsx +366 -348
  101. package/src/client/panel/DirectorView.tsx +200 -0
  102. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  103. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  104. package/src/client/panel/ImportModal.tsx +303 -0
  105. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  106. package/src/client/panel/LiveFeedLog.tsx +204 -0
  107. package/src/client/panel/MarketRadarView.tsx +415 -0
  108. package/src/client/panel/ModelManager.tsx +205 -0
  109. package/src/client/panel/NovelPanel.tsx +3013 -2500
  110. package/src/client/panel/PageHeader.tsx +37 -0
  111. package/src/client/panel/ProgressConsole.tsx +63 -0
  112. package/src/client/panel/PromptSlotsView.tsx +97 -0
  113. package/src/client/panel/ReaderView.tsx +306 -306
  114. package/src/client/panel/ReasoningSection.tsx +31 -0
  115. package/src/client/panel/RunPanel.tsx +64 -32
  116. package/src/client/panel/SettingsView.tsx +355 -0
  117. package/src/client/panel/ShelfView.tsx +266 -236
  118. package/src/client/panel/SubPage.tsx +42 -0
  119. package/src/client/panel/TensionView.tsx +212 -0
  120. package/src/client/panel/TimelineView.tsx +208 -0
  121. package/src/client/panel/WorldTab.tsx +202 -202
  122. package/src/client/panel/helpers.ts +7 -5
  123. package/src/client/panel/market-radar-state.ts +79 -0
  124. package/src/client/panel/panel.module.css +4481 -1655
  125. package/src/client/panel/toast.ts +51 -0
  126. package/src/client/panel/views.tsx +316 -314
  127. package/src/client/run-status.ts +171 -0
  128. package/src/client/sidebar-entry.ts +4 -4
  129. package/src/client/text.ts +28 -0
  130. package/src/docx-core.ts +53 -0
  131. package/src/docx.ts +44 -83
  132. package/src/engine.ts +4759 -2943
  133. package/src/global-assets.ts +58 -0
  134. package/src/home.ts +33 -0
  135. package/src/index.ts +206 -65
  136. package/src/llm-live.ts +245 -0
  137. package/src/llm-retry.ts +77 -0
  138. package/src/market-radar-scan.ts +35 -0
  139. package/src/market-radar-sources.ts +173 -0
  140. package/src/migrate-output.ts +76 -0
  141. package/src/model-capability.ts +215 -0
  142. package/src/novel-context.ts +286 -0
  143. package/src/output-budget.ts +147 -0
  144. package/src/prompt-slots.ts +125 -0
  145. package/src/prompting.ts +26 -0
  146. package/src/protocol.ts +1405 -158
  147. package/src/revision.ts +283 -0
  148. package/src/routes.ts +1959 -436
  149. package/src/run.ts +118 -21
  150. package/src/settings-persist.ts +114 -0
  151. package/src/snapshots.ts +174 -0
  152. package/src/stage-contract.ts +186 -0
  153. package/src/tension.ts +251 -0
  154. package/src/timeline.ts +159 -0
  155. package/lib/client.js.map +0 -1
  156. package/lib/index.js.map +0 -1
  157. package/lib/types/comic-presets.d.ts +0 -12
  158. package/scripts/assistant-frames.txt +0 -8
  159. package/scripts/diagnose-adapter.mts +0 -81
  160. package/scripts/diagnose-assistant.mjs +0 -58
  161. package/scripts/diagnose-bible.mjs +0 -72
  162. package/scripts/diagnose-harness.mts +0 -75
  163. package/scripts/novel-forge-restart.log +0 -10
  164. package/scripts/novel-forge-web.stderr.log +0 -2
  165. package/scripts/novel-forge-web.stdout.log +0 -1
  166. package/scripts/outline-sample.txt +0 -449
  167. package/scripts/probe.mjs +0 -6
  168. package/scripts/restart-web.ps1 +0 -88
  169. package/scripts/smoke.mts +0 -72
  170. package/scripts/upload-github.ps1 +0 -51
  171. package/src/client/panel/BookshelfBar.tsx +0 -170
  172. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 全局资源库(跨书可复用的自定义题材/推进模式)。
3
+ * 雷达「同步底座到全局资源库」写入此处;/assets 返回时与内置库合并展示。
4
+ */
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import type { GenreNode, ProgressionMode } from './protocol.ts'
7
+ import { dshHome, dshHomePath } from './home.ts'
8
+
9
+ interface GlobalAssets {
10
+ genres: GenreNode[]
11
+ modes: ProgressionMode[]
12
+ }
13
+
14
+ const FILE = dshHomePath('novel-forge-global-assets.json')
15
+ let cache: GlobalAssets | null = null
16
+
17
+ function load(): GlobalAssets {
18
+ if (cache !== null) return cache
19
+ try {
20
+ const raw = readFileSync(FILE, 'utf8')
21
+ const parsed = JSON.parse(raw) as Partial<GlobalAssets>
22
+ cache = { genres: Array.isArray(parsed.genres) ? parsed.genres : [], modes: Array.isArray(parsed.modes) ? parsed.modes : [] }
23
+ } catch {
24
+ cache = { genres: [], modes: [] }
25
+ }
26
+ return cache
27
+ }
28
+
29
+ function persist(): void {
30
+ if (cache === null) return
31
+ mkdirSync(dshHome(), { recursive: true })
32
+ writeFileSync(FILE, JSON.stringify(cache, null, 2), 'utf8')
33
+ }
34
+
35
+ export function globalGenreLibrary(): GenreNode[] {
36
+ return load().genres
37
+ }
38
+
39
+ export function globalProgressionLibrary(): ProgressionMode[] {
40
+ return load().modes
41
+ }
42
+
43
+ /** 新增/已有则跳过;返回 true=新增。 */
44
+ export function addGlobalGenre(g: GenreNode): boolean {
45
+ const lib = load()
46
+ if (lib.genres.some(x => x.name === g.name)) return false
47
+ lib.genres.push({ ...g, children: g.children ?? [] })
48
+ persist()
49
+ return true
50
+ }
51
+
52
+ export function addGlobalMode(m: ProgressionMode): boolean {
53
+ const lib = load()
54
+ if (lib.modes.some(x => x.name === m.name)) return false
55
+ lib.modes.push({ ...m, payoffs: m.payoffs ?? [], risks: m.risks ?? [], primary: m.primary ?? false })
56
+ persist()
57
+ return true
58
+ }
package/src/home.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * DeepSeek Harness home resolution for this plugin.
3
+ *
4
+ * Precedence mirrors `resolveDshHome` from `@deepseek-ai/dsh-home-paths`:
5
+ * `$DSH_HOME` when set and non-blank, else `~/.dsh`. That package's remaining
6
+ * input — the harness's own explicit override — is not reachable from a
7
+ * plugin, so this reproduces the part a plugin can observe instead of taking
8
+ * a dependency whose published range tracks a different harness line.
9
+ *
10
+ * State files must resolve through here rather than `~/.dsh` directly: the
11
+ * desktop build points `DSH_HOME` at its own root, and a hardcoded path made
12
+ * both builds share one store and lose it whenever that directory was cleaned.
13
+ */
14
+ import { homedir } from 'node:os'
15
+ import { join, resolve } from 'node:path'
16
+
17
+ /**
18
+ * Absolute Harness home for this process.
19
+ * @returns the normalized home directory.
20
+ */
21
+ export function dshHome(): string {
22
+ const configured = process.env.DSH_HOME?.trim()
23
+ return resolve(configured !== undefined && configured.length > 0 ? configured : join(homedir(), '.dsh'))
24
+ }
25
+
26
+ /**
27
+ * Join path segments onto the Harness home.
28
+ * @param segments - path segments appended to the home.
29
+ * @returns the normalized absolute joined path.
30
+ */
31
+ export function dshHomePath(...segments: string[]): string {
32
+ return join(dshHome(), ...segments)
33
+ }
package/src/index.ts CHANGED
@@ -8,27 +8,37 @@
8
8
  */
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
11
+ import type {} from '@deepseek-ai/dsh-settings'
12
12
  import z from 'schemastery'
13
13
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
14
  import type {} from '@deepseek-ai/dsh-llm'
15
15
  import type {} from '@deepseek-ai/dsh-system-prompt'
16
- import type { ConfigPatch, NovelConfig } from './protocol.ts'
16
+ import type { ConfigPatch, NovelConfig, ConfigResponse } from './protocol.ts'
17
17
  import { makeRoutes } from './routes.ts'
18
18
  import { activeBookOutputDir } from './bookshelf.ts'
19
+ import { loadProject } from './engine.ts'
20
+ import { dshHomePath } from './home.ts'
21
+ import { novelSettingsNamespace, persistConfigPatch, type SettingsLike } from './settings-persist.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
- * settings surface edits. Spelled here rather than imported: the browser half
29
- * spells the same value and must not depend on a Host package.
31
+ * settings surface edits.
32
+ *
33
+ * Legacy fallback only: from Harness 0.2 the Loader derives the section from this
34
+ * plugin's exported `Config`, and addresses it by the **entry id** it assigned in
35
+ * the active profile. That id is not guaranteed to be this name (bundle rows may
36
+ * be given a generated id), so the live id is read from the context instead —
37
+ * see {@link novelSettingsNamespace}. The constant stays for the browser half and
38
+ * for installations whose entry id really is this spelling.
30
39
  */
31
- export const NOVEL_SETTINGS_NAMESPACE = settingsNamespace('dsh-novel-forge')
40
+ export const NOVEL_SETTINGS_NAMESPACE = 'dsh-novel-forge' as const
41
+
32
42
 
33
43
  /** Plugin config, validated by the same-named schemastery schema. */
34
44
  export interface Config {
@@ -44,8 +54,20 @@ export interface Config {
44
54
  provider?: string
45
55
  /** LLM model id. */
46
56
  model?: string
57
+ /** 任务级模型路由:正文生成模型(留空则跟随 model)。 */
58
+ generateModel?: string
59
+ /** 任务级模型路由:审稿模型(留空则跟随 model)。 */
60
+ reviewModel?: string
61
+ /** 任务级模型路由:质检模型(留空则跟随 model)。 */
62
+ auditModel?: string
63
+ /** 备用模型:主模型失败时自动切换重试一次(留空 = 不切换)。 */
64
+ fallbackModel?: string
65
+ /** 出章后自动抽取故事时间线(默认开)。 */
66
+ autoTimeline?: boolean
47
67
  /** LLM reasoning effort (off/low/high/max). */
48
68
  reasoningEffort?: 'off' | 'low' | 'high' | 'max'
69
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low。 */
70
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max'
49
71
  /** Target characters per chapter. */
50
72
  chapterChars?: number
51
73
  /** Max output tokens per chapter call. */
@@ -58,40 +80,86 @@ export interface Config {
58
80
  autoAuthorReview?: boolean
59
81
  /** 修订/润色产出草稿后自动附带一次 AI 审查(默认开,可在设置页关闭省 token)。 */
60
82
  autoReviewAfterRevise?: boolean
61
- /** 豆包/Seedream 生图 API Key。 */
62
- imageApiKey?: string
63
- /** 豆包/Seedream 生图模型 ID。 */
64
- imageApiModel?: string
65
- /** 是否启用豆包生图(默认关)。 */
66
- imageApiEnabled?: boolean
83
+ /** 自定义背景图(URL / dataURL / 服务端路径引用)。 */
84
+ themeBackground?: string
85
+ /** 自定义背景遮罩/模糊强度 0-80。 */
86
+ themeBackgroundBlur?: number
87
+ /** 玻璃透明度 0-100(100=当前原样)。 */
88
+ themeOpacity?: number
89
+ /** 是否启用改编模式(默认关闭)。 */
90
+ enableAdaptMode?: boolean
67
91
  }
68
92
 
93
+ /**
94
+ * Mark a config field as live-editable.
95
+ *
96
+ * From Harness 0.2 the settings surface builds its editable form from this
97
+ * plugin's exported `Config` and only exposes fields whose nearest ancestor
98
+ * declares volatility (`SettingsForms.schema` + `volatileForm`). Without the
99
+ * marker the host refuses a write with `Plugin entry "…" has no volatile
100
+ * fields`, so the plugin's own 保存设置 route could never persist anything.
101
+ *
102
+ * The canonical form is schemastery's `.volatile()` builder, but that builder
103
+ * ships only in the Harness fork (`@deepseek-ai/schemastery`); the npm
104
+ * `schemastery` this package depends on exposes the same switch as metadata
105
+ * (`extra('volatile', true)`), which is what the fork's builder sets anyway.
106
+ * Prefer the builder when the loaded copy has it, fall back to the metadata so
107
+ * the schema is correct on either copy.
108
+ * @param schema - the field schema.
109
+ * @returns the same field marked live-editable.
110
+ */
111
+ function live<T>(schema: T): T {
112
+ const candidate = schema as unknown as {
113
+ volatile?: () => unknown
114
+ extra?: (key: string, value: boolean) => unknown
115
+ }
116
+ if (typeof candidate.volatile === 'function') return candidate.volatile() as T
117
+ if (typeof candidate.extra === 'function') return candidate.extra('volatile', true) as T
118
+ return schema
119
+ }
120
+
121
+ /**
122
+ * Plugin config schema.
123
+ *
124
+ * Every field is live-editable (see {@link live}): the plugin re-reads the
125
+ * settings section on every request, so a change must apply without remounting.
126
+ */
69
127
  export const Config: z<Config> = z.object({
70
- announceToAgent: z.boolean().default(true),
71
- 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\\归墟玉主'),
74
- provider: z.string().default('deepseek-official'),
75
- model: z.string().default('deepseek-v4-flash'),
76
- reasoningEffort: z.union(['off', 'low', 'high', 'max']).default('off'),
77
- chapterChars: z.number().default(3500),
78
- maxTokens: z.number().default(12000),
79
- reviewPassScore: z.number().default(70),
80
- autoReview: z.boolean().default(true),
81
- autoAuthorReview: z.boolean().default(true),
82
- autoReviewAfterRevise: z.boolean().default(true),
83
- imageApiKey: z.string().default(''),
84
- imageApiModel: z.string().default(''),
85
- imageApiEnabled: z.boolean().default(false),
128
+ announceToAgent: live(z.boolean().default(true)),
129
+ enabled: live(z.boolean().default(true)),
130
+ outlinePath: live(z.string().default('')),
131
+ outputDir: live(z.string().default(dshHomePath('novels'))),
132
+ provider: live(z.string().default('deepseek-official')),
133
+ model: live(z.string().default('deepseek-flash')),
134
+ generateModel: live(z.string().default('')),
135
+ reviewModel: live(z.string().default('')),
136
+ auditModel: live(z.string().default('')),
137
+ fallbackModel: live(z.string().default('')),
138
+ autoTimeline: live(z.boolean().default(true)),
139
+ reasoningEffort: live(z.union(['off', 'low', 'high', 'max']).default('off')),
140
+ analysisReasoning: live(z.union(['off', 'low', 'high', 'max']).default('low')),
141
+ chapterChars: live(z.number().default(3500)),
142
+ maxTokens: live(z.number().default(12000)),
143
+ reviewPassScore: live(z.number().default(70)),
144
+ autoReview: live(z.boolean().default(true)),
145
+ autoAuthorReview: live(z.boolean().default(true)),
146
+ autoReviewAfterRevise: live(z.boolean().default(true)),
147
+ themeBackground: live(z.string().default('')),
148
+ themeBackgroundBlur: live(z.number().default(0)),
149
+ themeOpacity: live(z.number().default(100)),
150
+ enableAdaptMode: live(z.boolean().default(true)),
86
151
  })
87
152
 
88
153
  /** Schema defaults, re-read for hand-built test contexts. */
89
154
  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\\归墟玉主'
155
+ const DEFAULT_OUTLINE_PATH = ''
156
+ const DEFAULT_OUTPUT_DIR = dshHomePath('novels')
92
157
  const DEFAULT_PROVIDER = 'deepseek-official'
93
- const DEFAULT_MODEL = 'deepseek-v4-flash'
158
+ const DEFAULT_MODEL = 'deepseek-flash'
94
159
  const DEFAULT_REASONING_EFFORT = 'off' as const
160
+ const DEFAULT_ANALYSIS_REASONING = 'low' as const
161
+ const DEFAULT_FALLBACK_MODEL = ''
162
+ const DEFAULT_AUTO_TIMELINE = true
95
163
  const DEFAULT_CHAPTER_CHARS = 3500
96
164
  const DEFAULT_MAX_TOKENS = 12000
97
165
  const DEFAULT_REVIEW_PASS_SCORE = 70
@@ -103,7 +171,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true
103
171
  const SECTION_ORDER = 160
104
172
 
105
173
  /** 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 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。'
174
+ export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown(默认输出到插件设置里「输出目录」指定的位置,未配置时落到 Harness 数据目录下的 novels/书名);每章自动生成摘要与编年录事实、抽取故事时间线(故事内时间/地点/在场角色/事件)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑/反 AI/呈现/合规九维)与作者复盘;支持按审稿意见合并修订(审稿 high + 时间线矛盾 + 张力偏差合成一轮,每章最多两轮后转人工)、去 AI 味润色、章节历史版本回滚、暗线(伏笔)管理、剧情线与角色库、张力曲线、全书一致性质检与敏感词扫描、分范围导出与项目备份、批量连写(生产单,可暂停/续跑)。限制:所有 AI 操作消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度;张力/时间线问题默认只是建议(写日志与待办),不会自动改正文。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。'
107
175
 
108
176
  /** Resolve a config-like value into the full runtime config. */
109
177
  export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
@@ -112,16 +180,23 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
112
180
  outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
113
181
  provider: value?.provider ?? DEFAULT_PROVIDER,
114
182
  model: value?.model ?? DEFAULT_MODEL,
183
+ generateModel: value?.generateModel,
184
+ reviewModel: value?.reviewModel,
185
+ auditModel: value?.auditModel,
186
+ fallbackModel: value?.fallbackModel ?? DEFAULT_FALLBACK_MODEL,
187
+ autoTimeline: value?.autoTimeline ?? DEFAULT_AUTO_TIMELINE,
115
188
  reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
189
+ analysisReasoning: value?.analysisReasoning ?? DEFAULT_ANALYSIS_REASONING,
116
190
  chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
117
191
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
118
192
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
119
193
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
120
194
  autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
121
195
  autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
122
- imageApiKey: value?.imageApiKey,
123
- imageApiModel: value?.imageApiModel,
124
- imageApiEnabled: value?.imageApiEnabled ?? false,
196
+ themeBackground: value?.themeBackground ?? '',
197
+ themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
198
+ themeOpacity: value?.themeOpacity ?? 100,
199
+ enableAdaptMode: value?.enableAdaptMode ?? true,
125
200
  }
126
201
  }
127
202
 
@@ -131,45 +206,94 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
131
206
  * @param config - resolved plugin config (schema defaults applied by the loader).
132
207
  */
133
208
  export function apply(ctx: Context, config?: Config): void {
134
- // The live source the routes read: the settings section once the web
135
- // settings surface is served, the composition entry otherwise.
136
- let current: () => Config = () => config ?? {}
209
+ // The live source the routes read. Held as an explicit state object, not a
210
+ // self-referential closure: `current = () => ({ ...current(), ...patch })`
211
+ // re-reads `current` inside the function it is assigning, so the first save
212
+ // recurses until the stack blows (`Maximum call stack size exceeded`).
213
+ let currentState: Config = config ?? {}
214
+ const current = (): Config => currentState
215
+ const setCurrent = (next: Config): void => { currentState = next }
137
216
  const resolve = (): NovelConfig => {
138
217
  const resolved = resolveConfig(current())
139
218
  // 书架激活的书优先决定输出目录(settings 仍可改默认值)。
140
219
  const shelfDir = activeBookOutputDir()
141
- if (shelfDir !== undefined) {
142
- return { ...resolved, outputDir: shelfDir }
143
- }
144
- return resolved
220
+ if (shelfDir === undefined) return resolved
221
+ // 本书参数覆盖(novel-project.json.bookSettings;未设字段回退全局)。
222
+ const bs = loadProject(shelfDir)?.bookSettings
223
+ const book: NovelConfig = { ...resolved, outputDir: shelfDir }
224
+ if (bs === undefined) return book
225
+ if (bs.provider !== undefined) book.provider = bs.provider
226
+ if (bs.model !== undefined) book.model = bs.model
227
+ if (bs.reasoningEffort !== undefined) book.reasoningEffort = bs.reasoningEffort
228
+ if (bs.analysisReasoning !== undefined) book.analysisReasoning = bs.analysisReasoning
229
+ if (bs.chapterChars !== undefined) book.chapterChars = bs.chapterChars
230
+ if (bs.maxTokens !== undefined) book.maxTokens = bs.maxTokens
231
+ if (bs.reviewPassScore !== undefined) book.reviewPassScore = bs.reviewPassScore
232
+ if (bs.autoReview !== undefined) book.autoReview = bs.autoReview
233
+ if (bs.autoAuthorReview !== undefined) book.autoAuthorReview = bs.autoAuthorReview
234
+ if (bs.autoReviewAfterRevise !== undefined) book.autoReviewAfterRevise = bs.autoReviewAfterRevise
235
+ return book
145
236
  }
146
237
 
147
- const patchConfig = async (patch: ConfigPatch): Promise<NovelConfig> => {
238
+ /**
239
+ * The settings section this instance is addressed by, resolved once at mount.
240
+ * Undefined when the host exposes neither an entry id nor a registered section,
241
+ * in which case config edits stay in memory (see {@link patchConfig}).
242
+ */
243
+ const settingsNs = novelSettingsNamespace(ctx)
244
+
245
+ /**
246
+ * Persist one config patch.
247
+ *
248
+ * Applies to the live config either way. Persistence goes through the host
249
+ * settings section when that section is addressable; when it is not (unknown
250
+ * entry id, or the section never registered), the patch is kept in memory and
251
+ * the caller is told it will not survive a restart — a config save must never
252
+ * fail the request just because the host does not offer persistence.
253
+ * @param patch - the subset to merge.
254
+ * @returns the config the host will read next, plus a persistence caveat.
255
+ */
256
+ const patchConfig = async (patch: ConfigPatch): Promise<ConfigResponse> => {
148
257
  const next: ConfigPatch = {}
149
258
  if (patch.outlinePath !== undefined) next.outlinePath = patch.outlinePath
150
259
  if (patch.outputDir !== undefined) next.outputDir = patch.outputDir
151
260
  if (patch.provider !== undefined) next.provider = patch.provider
152
261
  if (patch.model !== undefined) next.model = patch.model
262
+ if (patch.generateModel !== undefined) next.generateModel = patch.generateModel
263
+ if (patch.reviewModel !== undefined) next.reviewModel = patch.reviewModel
264
+ if (patch.auditModel !== undefined) next.auditModel = patch.auditModel
265
+ if (patch.fallbackModel !== undefined) next.fallbackModel = patch.fallbackModel
266
+ if (patch.autoTimeline !== undefined) next.autoTimeline = patch.autoTimeline
153
267
  if (patch.reasoningEffort !== undefined) next.reasoningEffort = patch.reasoningEffort
268
+ if (patch.analysisReasoning !== undefined) next.analysisReasoning = patch.analysisReasoning
154
269
  if (patch.chapterChars !== undefined) next.chapterChars = patch.chapterChars
155
270
  if (patch.maxTokens !== undefined) next.maxTokens = patch.maxTokens
156
271
  if (patch.reviewPassScore !== undefined) next.reviewPassScore = patch.reviewPassScore
157
272
  if (patch.autoReview !== undefined) next.autoReview = patch.autoReview
158
273
  if (patch.autoAuthorReview !== undefined) next.autoAuthorReview = patch.autoAuthorReview
159
274
  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
163
- // Persist through the settings seam when available; otherwise keep in memory.
164
- // (ctx.get is the non-strict service access — no inject requirement, same
165
- // pattern installSettingsSection itself uses.)
166
- const settings = ctx.get('settings')
167
- if (settings !== undefined) {
168
- await settings.update(NOVEL_SETTINGS_NAMESPACE, next as Record<string, unknown>)
169
- } else {
170
- current = () => ({ ...current(), ...next })
275
+ if (patch.themeBackground !== undefined) next.themeBackground = patch.themeBackground
276
+ if (patch.themeBackgroundBlur !== undefined) next.themeBackgroundBlur = patch.themeBackgroundBlur
277
+ if (patch.themeOpacity !== undefined) next.themeOpacity = patch.themeOpacity
278
+ if (patch.enableAdaptMode !== undefined) next.enableAdaptMode = patch.enableAdaptMode
279
+ // Apply + persist via the shared helper: the host section is addressed by the
280
+ // entry id the Loader assigned, never by a guessed constant. (ctx.get is the
281
+ // non-strict service access — same pattern the 0.1.x wiring used.)
282
+ const settings = ctx.get('settings') as SettingsLike | undefined
283
+ const persisted = await persistConfigPatch(
284
+ current() as unknown as Record<string, unknown>,
285
+ next as Record<string, unknown>,
286
+ settings,
287
+ settingsNs,
288
+ NOVEL_SETTINGS_NAMESPACE,
289
+ )
290
+ // Keep the live source in sync even when persistence went through the host:
291
+ // until the host reloads the plugin, the state object is still the old snapshot.
292
+ setCurrent(persisted.config as Config)
293
+ if (persisted.settingsWarning !== undefined) {
294
+ ctx.logger('novel-forge').warn('%s', persisted.settingsWarning)
171
295
  }
172
- return resolve()
296
+ return { config: resolve(), ...(persisted.settingsWarning === undefined ? {} : { settingsWarning: persisted.settingsWarning }) }
173
297
  }
174
298
 
175
299
  let disposeSection: (() => void) | undefined
@@ -193,7 +317,7 @@ export function apply(ctx: Context, config?: Config): void {
193
317
  text: NOVEL_GUIDANCE,
194
318
  })
195
319
  }
196
- const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig })
320
+ const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig, rawConfig: () => current(), settingsNs })
197
321
  disposeRoutes = ctx.effect(
198
322
  () => {
199
323
  const disposers = routes.map(route => ctx.webServer.register(route))
@@ -204,20 +328,37 @@ export function apply(ctx: Context, config?: Config): void {
204
328
  void value
205
329
  }
206
330
 
207
- installSettingsSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
208
- setSource: (source) => {
209
- current = source
210
- sync()
211
- },
212
- onChange: sync,
213
- })
331
+ // settings 接线做跨版本兼容(2026-09-30 适配 dsh 0.2.0-rc.2):
332
+ // 0.1.x 提供 installSection(注册配置段并回调 setSource/onChange),段 id 由
333
+ // 调用方指定,因此用声明的常量;
334
+ // 0.2.x 移除了它,改由 Loader 从本插件导出的 Config schema 推导配置段(段 id
335
+ // = 该 entry 的 id),settings 服务只剩 configure/describe/update/replace/mutate。
336
+ // 缺失时必须静默跳过,不能让整棵插件挂载失败(此前 novel-forge 因此整包不加载)。
337
+ const settingsService = ctx.get('settings') as unknown as
338
+ | { installSection?: (c: unknown, ns: string, schema: unknown, cfg: unknown, hooks: unknown) => void }
339
+ | undefined
340
+ if (settingsService !== undefined && typeof settingsService.installSection === 'function') {
341
+ settingsService.installSection(ctx, settingsNs ?? NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
342
+ setSource: (source: () => Config) => {
343
+ // Snapshot the host-provided source. The live source is one state object
344
+ // now, so a source that recomputed per call would have to be polled;
345
+ // 0.1.x wiring is legacy and the composition entry is authoritative here.
346
+ setCurrent(source())
347
+ sync()
348
+ },
349
+ onChange: sync,
350
+ })
351
+ }
214
352
 
215
353
  // 运行时 skill:章节批量生产与值守处理(只在本插件环境可用)。
216
354
  // 通过 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) {
355
+ const skillsService = ctx.get('skills') as { register?: (skill: { name: string; provider: string; description: string; content: string }) => () => void } | undefined
356
+ // [disabled 2026-09-05] 运行时 skill 注册按用户要求暂时关闭(排查 .length 报错)。
357
+ const enableRuntimeSkill: boolean = false
358
+ if (enableRuntimeSkill && skillsService !== undefined && skillsService.register !== undefined) {
219
359
  const disposeSkill = skillsService.register({
220
360
  name: 'novel-forge-chapter-batch',
361
+ provider: 'dsh-novel-forge',
221
362
  description: '批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。',
222
363
  content: [
223
364
  '# 小说章节批量生产与值守处理',