@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.
- package/README.md +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- 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 {
|
|
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 =
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
|
|
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('
|
|
73
|
-
outputDir: z.string().default('
|
|
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-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 = '
|
|
91
|
-
const DEFAULT_OUTPUT_DIR = '
|
|
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-
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
161
|
-
if (patch.
|
|
162
|
-
if (patch.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
'# 小说章节批量生产与值守处理',
|
package/src/llm-live.ts
ADDED
|
@@ -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
|
+
}
|
package/src/llm-retry.ts
ADDED
|
@@ -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
|
+
}
|