@waterwx/dsh-novel-forge 1.3.2-alpha → 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.
- package/lib/client.js +555 -466
- package/lib/index.js +531 -93
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +0 -2
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +8 -3
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/docx.d.ts +0 -2
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/views.d.ts +1 -33
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +15 -3
- package/lib/types/llm-live.d.ts +0 -5
- package/lib/types/market-radar-scan.d.ts +0 -1
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/protocol.d.ts +21 -5
- package/lib/types/routes.d.ts +5 -3
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/stage-contract.d.ts +1 -3
- package/package.json +13 -11
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +0 -5
- package/src/author-assets.ts +4 -22
- package/src/bookshelf.ts +13 -7
- package/src/client/api.ts +2 -2
- package/src/client/docx.ts +0 -5
- package/src/client/locales.ts +1 -0
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AuthorAssetsView.tsx +1 -1
- package/src/client/panel/ImportModal.tsx +1 -1
- package/src/client/panel/MarketRadarView.tsx +55 -17
- package/src/client/panel/SettingsView.tsx +2 -1
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +4 -4
- package/src/client/panel/views.tsx +1 -86
- package/src/client/sidebar-entry.ts +3 -3
- package/src/engine.ts +103 -16
- package/src/global-assets.ts +3 -4
- package/src/home.ts +33 -0
- package/src/index.ts +136 -51
- package/src/llm-live.ts +0 -15
- package/src/market-radar-scan.ts +2 -8
- package/src/model-capability.ts +215 -0
- package/src/output-budget.ts +147 -0
- package/src/protocol.ts +22 -5
- package/src/routes.ts +34 -11
- package/src/settings-persist.ts +114 -0
- package/src/stage-contract.ts +1 -4
- package/src/review-policy.ts +0 -161
package/src/market-radar-scan.ts
CHANGED
|
@@ -18,12 +18,6 @@ export interface MarketScanResult {
|
|
|
18
18
|
groups: MarketRadarGroup[]
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
let lastScan: MarketScanResult | null = null
|
|
22
|
-
|
|
23
|
-
export function getLastMarketScan(): MarketScanResult | null {
|
|
24
|
-
return lastScan
|
|
25
|
-
}
|
|
26
|
-
|
|
27
21
|
export async function scanMarketRanking(platforms?: string[]): Promise<MarketScanResult> {
|
|
28
22
|
const wanted = new Set<string>(platforms?.length ? platforms : ['fanqie', 'qidian', 'jinjiang'])
|
|
29
23
|
const sources = MARKET_RADAR_SOURCES.filter(s => wanted.has(s.platform))
|
|
@@ -36,6 +30,6 @@ export async function scanMarketRanking(platforms?: string[]): Promise<MarketSca
|
|
|
36
30
|
groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'error', error: (error as Error).message, items: [] })
|
|
37
31
|
}
|
|
38
32
|
}))
|
|
39
|
-
|
|
40
|
-
return
|
|
33
|
+
const result: MarketScanResult = { scannedAt: new Date().toISOString(), groups }
|
|
34
|
+
return result
|
|
41
35
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型参数能力兼容层。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:**界面上保存的温度/思考档位是用户偏好,不是最终请求值。** 构造请求前
|
|
5
|
+
* 必须经过本层收敛到厂商允许的范围 —— 否则换模型、走自定义中转、或厂商改了约束,
|
|
6
|
+
* 就会出现「换个 provider 就报参数错误」这类故障。
|
|
7
|
+
*
|
|
8
|
+
* 两条硬要求:
|
|
9
|
+
* 1. **按模型身份判断,不按 provider 判断**。同一个模型经由自定义中转调用时,
|
|
10
|
+
* provider 名可能完全自定义,但参数约束属于模型本身;把判断写成
|
|
11
|
+
* `provider === '某个厂商' && ...` 会让走中转的调用绕过约束。
|
|
12
|
+
* 2. **约束要有出处**。每条规则的来源写在规则上,不靠猜。下列规则来自厂商官方
|
|
13
|
+
* 《模型参数参考》(https://platform.kimi.com/docs/api/models-overview.md):
|
|
14
|
+
* · kimi-k3 : temperature 固定 1.0;支持顶层 reasoning_effort
|
|
15
|
+
* · kimi-k2.7-code : temperature 固定 1.0;不支持 reasoning_effort(用 thinking,且不可禁用)
|
|
16
|
+
* · kimi-k2.6 : thinking 固定 1.0 / 非 thinking 固定 0.6;不支持 reasoning_effort
|
|
17
|
+
* 官方同时建议:这类模型**不要显式传入 temperature**。本层因此优先「不传」,
|
|
18
|
+
* 仅在调用方无法省略该参数时才钳到允许值。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 一次请求的参数约束。 */
|
|
22
|
+
export interface ModelCapability {
|
|
23
|
+
/** 该模型只接受这一个温度值(如 Kimi 全系),缺省表示不约束。 */
|
|
24
|
+
fixedTemperature?: number
|
|
25
|
+
/** 温度允许的下界(默认 0)。 */
|
|
26
|
+
minTemperature?: number
|
|
27
|
+
/** 温度允许的上界(默认 2,主流 OpenAI 兼容区间)。 */
|
|
28
|
+
maxTemperature?: number
|
|
29
|
+
/**
|
|
30
|
+
* 是否支持 OpenAI 兼容的 `reasoning_effort`。
|
|
31
|
+
* false 表示**不得下发**该参数(如 Kimi K2.x 用的是 `thinking`),下发会被厂商拒绝。
|
|
32
|
+
*/
|
|
33
|
+
supportsReasoning?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* 推理控制方式:`reasoning_effort`(OpenAI 兼容顶层参数)或 `thinking`(Kimi K2.x 专属)。
|
|
36
|
+
* 运行时若适配层支持,应按此选择参数名;仅为诊断与后续适配保留。
|
|
37
|
+
*/
|
|
38
|
+
reasoningControl?: 'reasoning-effort' | 'thinking' | 'none'
|
|
39
|
+
/** 严格结构化输出必须关闭思考(覆盖用户偏好)。 */
|
|
40
|
+
reasoningForcedOff?: boolean
|
|
41
|
+
/** 命中的规则名,用于日志与测试断言。 */
|
|
42
|
+
rule: string
|
|
43
|
+
/** 约束出处(文档 URL 或说明),便于复核与更新。 */
|
|
44
|
+
source: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 一道匹配规则:先命中先生效。 */
|
|
48
|
+
interface CapabilityRule {
|
|
49
|
+
/** 规则名。 */
|
|
50
|
+
rule: string
|
|
51
|
+
/** 约束出处。 */
|
|
52
|
+
source: string
|
|
53
|
+
/** 模型 id 归一化后是否命中。 */
|
|
54
|
+
matches: (canonical: string) => boolean
|
|
55
|
+
capability: Omit<ModelCapability, 'rule' | 'source'>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const KIMI_SOURCE = 'https://platform.kimi.com/docs/api/models-overview.md'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 把模型 id 归一化为「模型身份」。
|
|
62
|
+
*
|
|
63
|
+
* 处理:去空白与首尾斜杠、小写、剥掉供应商前缀(`kimi/kimi-k3` → `kimi-k3`)。
|
|
64
|
+
* @param model - 原始模型 id。
|
|
65
|
+
* @returns 归一化后的身份(小写、无前缀)。
|
|
66
|
+
*/
|
|
67
|
+
export function canonicalModelId(model: string | undefined): string {
|
|
68
|
+
if (typeof model !== 'string') return ''
|
|
69
|
+
const trimmed = model.trim().toLowerCase().replace(/^\/+|\/+$/g, '')
|
|
70
|
+
if (trimmed === '') return ''
|
|
71
|
+
const segments = trimmed.split('/')
|
|
72
|
+
return segments[segments.length - 1] ?? ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const RULES: CapabilityRule[] = [
|
|
76
|
+
{
|
|
77
|
+
rule: 'kimi-k3-fixed-temperature',
|
|
78
|
+
source: KIMI_SOURCE,
|
|
79
|
+
// 只命中 k3 本身;k2.6 / k2.7 另立规则(约束不同)。
|
|
80
|
+
matches: (m) => /^kimi[-_.]?k-?3(?:[-_.]|$)/.test(m),
|
|
81
|
+
capability: { fixedTemperature: 1, supportsReasoning: true, reasoningControl: 'reasoning-effort' },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
rule: 'kimi-k2.7-code-fixed-temperature',
|
|
85
|
+
source: KIMI_SOURCE,
|
|
86
|
+
matches: (m) => /^kimi[-_.]?k-?2[-_.]?7(?:[-_.]|$)/.test(m),
|
|
87
|
+
capability: {
|
|
88
|
+
fixedTemperature: 1,
|
|
89
|
+
// 不支持 reasoning_effort;思考由 thinking 控制且不可禁用。
|
|
90
|
+
supportsReasoning: false,
|
|
91
|
+
reasoningControl: 'thinking',
|
|
92
|
+
reasoningForcedOff: true,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
rule: 'kimi-k2.6-temperature-by-thinking',
|
|
97
|
+
source: KIMI_SOURCE,
|
|
98
|
+
matches: (m) => /^kimi[-_.]?k-?2[-_.]?6(?:[-_.]|$)/.test(m),
|
|
99
|
+
capability: {
|
|
100
|
+
// 思考模式 1.0 / 非思考 0.6;本插件的结构化任务默认关闭思考,故取 0.6 不安全,
|
|
101
|
+
// 统一收敛到 1.0(思考档),避免"关思考却给 1.0"这类组合出错。
|
|
102
|
+
fixedTemperature: 1,
|
|
103
|
+
supportsReasoning: false,
|
|
104
|
+
reasoningControl: 'thinking',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
rule: 'deepseek-reasoner-fixed-temperature',
|
|
109
|
+
source: 'OpenAI 兼容约定:DeepSeek R1/reasoner 不接受自定义 temperature',
|
|
110
|
+
matches: (m) => /^deepseek[-_.]?(r1|reasoner)(?:[-_.]|$)/.test(m),
|
|
111
|
+
capability: { fixedTemperature: 1, supportsReasoning: true, reasoningControl: 'reasoning-effort' },
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 解析一条模型的能力约束。
|
|
117
|
+
* @param model - 模型 id(可能带供应商前缀)。
|
|
118
|
+
* @returns 命中的能力;无约束时返回 undefined。
|
|
119
|
+
*/
|
|
120
|
+
export function resolveModelCapability(model: string | undefined): ModelCapability | undefined {
|
|
121
|
+
const canonical = canonicalModelId(model)
|
|
122
|
+
if (canonical === '') return undefined
|
|
123
|
+
for (const rule of RULES) {
|
|
124
|
+
if (rule.matches(canonical)) return { ...rule.capability, rule: rule.rule, source: rule.source }
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 一次待收敛的请求参数。 */
|
|
130
|
+
export interface RequestParams {
|
|
131
|
+
temperature: number
|
|
132
|
+
/** 请求方(含用户偏好)希望使用的思考档位;undefined 表示不涉及。 */
|
|
133
|
+
reasoningEffort?: 'off' | 'low' | 'high' | 'max'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 收敛结果:最终请求值 + 发生了哪些调整(便于日志与诊断)。 */
|
|
137
|
+
export interface ConvergedParams {
|
|
138
|
+
/**
|
|
139
|
+
* 最终要下发的温度;`undefined` 表示**应当省略该参数**。
|
|
140
|
+
* 厂商建议这类模型不要显式传 temperature 时,本层返回 undefined。
|
|
141
|
+
*/
|
|
142
|
+
temperature: number | undefined
|
|
143
|
+
reasoningEffort?: 'off' | 'low' | 'high' | 'max'
|
|
144
|
+
/** 被本层改掉的项,形如 `temperature: 0.3 -> 省略`。 */
|
|
145
|
+
adjustments: string[]
|
|
146
|
+
/** 命中的规则名。 */
|
|
147
|
+
rule?: string
|
|
148
|
+
/** 约束出处。 */
|
|
149
|
+
source?: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 把请求参数收敛到模型允许的范围。
|
|
154
|
+
*
|
|
155
|
+
* @param params - 调用方希望使用的参数。
|
|
156
|
+
* @param model - 实际要调用的模型 id(应传**实际模型**;换备用模型后需重算)。
|
|
157
|
+
* @param options - `structured: true` 表示这是严格结构化输出任务;
|
|
158
|
+
* `omitFixedTemperature: true` 表示调用方可以不传温度(推荐,厂商建议如此),
|
|
159
|
+
* 此时固定温度会收敛为 undefined 而不是钳到固定值。
|
|
160
|
+
* @returns 最终请求值与被调整项。
|
|
161
|
+
*/
|
|
162
|
+
export function convergeRequestParams(
|
|
163
|
+
params: RequestParams,
|
|
164
|
+
model: string | undefined,
|
|
165
|
+
options: { structured?: boolean; omitFixedTemperature?: boolean } = {},
|
|
166
|
+
): ConvergedParams {
|
|
167
|
+
const capability = resolveModelCapability(model)
|
|
168
|
+
const adjustments: string[] = []
|
|
169
|
+
let temperature: number | undefined = params.temperature
|
|
170
|
+
let reasoningEffort = params.reasoningEffort
|
|
171
|
+
|
|
172
|
+
if (capability?.fixedTemperature !== undefined) {
|
|
173
|
+
const fixed = capability.fixedTemperature
|
|
174
|
+
if (options.omitFixedTemperature === true) {
|
|
175
|
+
if (temperature !== undefined) {
|
|
176
|
+
adjustments.push(`temperature: ${temperature} -> 省略(${capability.rule} 固定 ${fixed},厂商建议不要显式传入)`)
|
|
177
|
+
}
|
|
178
|
+
temperature = undefined
|
|
179
|
+
} else if (temperature !== fixed) {
|
|
180
|
+
adjustments.push(`temperature: ${temperature} -> ${fixed}(${capability.rule} 固定值)`)
|
|
181
|
+
temperature = fixed
|
|
182
|
+
}
|
|
183
|
+
} else if (capability !== undefined) {
|
|
184
|
+
const min = capability.minTemperature ?? 0
|
|
185
|
+
const max = capability.maxTemperature ?? 2
|
|
186
|
+
const clamped = Math.min(max, Math.max(min, temperature ?? min))
|
|
187
|
+
if (clamped !== temperature) {
|
|
188
|
+
adjustments.push(`temperature: ${temperature} -> ${clamped}(${capability.rule} 取值范围)`)
|
|
189
|
+
temperature = clamped
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 不支持 reasoning_effort 的模型:不得下发该参数,否则厂商直接报错。
|
|
194
|
+
if (capability?.supportsReasoning === false && reasoningEffort !== undefined) {
|
|
195
|
+
if (reasoningEffort !== 'off') {
|
|
196
|
+
adjustments.push(`reasoningEffort: ${reasoningEffort} -> 省略(${capability.rule} 不支持该参数)`)
|
|
197
|
+
}
|
|
198
|
+
reasoningEffort = undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 严格结构化任务:能力档案要求关闭思考时,覆盖用户偏好(输出稳定性优先)。
|
|
202
|
+
if (options.structured === true && capability?.reasoningForcedOff === true && reasoningEffort !== undefined) {
|
|
203
|
+
adjustments.push(`reasoningEffort: ${reasoningEffort} -> off(结构化任务强制关闭思考)`)
|
|
204
|
+
reasoningEffort = 'off'
|
|
205
|
+
// 该模型通过 thinking 控制且不可禁用:等价于不传任何推理参数。
|
|
206
|
+
if (capability.reasoningControl === 'thinking') reasoningEffort = undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
temperature,
|
|
211
|
+
reasoningEffort,
|
|
212
|
+
adjustments,
|
|
213
|
+
...(capability === undefined ? {} : { rule: capability.rule, source: capability.source }),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构化输出预算与字段长度合同。
|
|
3
|
+
*
|
|
4
|
+
* 背景:本插件此前给每个 LLM 调用写死一个 `Math.max(config.maxTokens, N)`。短任务
|
|
5
|
+
* 与长任务拿到同一个额度,长任务(分卷、拆章、逐章规划)**额度不随规模增长**,
|
|
6
|
+
* 截断后只能抛「请增大 maxTokens」。这里把额度改成按规模推算,并给结构化字段
|
|
7
|
+
* 定长度上限,让预算花在结构上而不是被自由发挥吃光。
|
|
8
|
+
*
|
|
9
|
+
* 设计取舍:结构化输出的额度应当**随本次要产出的条目数增长**,并由硬上限兜住,
|
|
10
|
+
* 否则要么短任务浪费额度,要么长任务写到一半被截断。字段长度上限同时写进提示词
|
|
11
|
+
* 与解析端,两处共用一张表,避免漂移。
|
|
12
|
+
*
|
|
13
|
+
* 一条硬纪律:**不要用 JSON repair 去补被截断的结构**。repair 只能修已有结构,
|
|
14
|
+
* 补不出尚未输出的条目;在服务层手工补齐缺失项,等于把不完整的规划伪装成可用
|
|
15
|
+
* 资产。预算给够 + 输出够紧凑,才是正解。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 一次调用的输出规模描述:给出能决定输出体量的那个量。 */
|
|
19
|
+
export interface OutputScope {
|
|
20
|
+
/** 预期卷数(分卷/卷战略类)。 */
|
|
21
|
+
volumes?: number
|
|
22
|
+
/** 预期章节数(章节列表/批量拆章类)。 */
|
|
23
|
+
chapters?: number
|
|
24
|
+
/** 预期角色数(角色阵容/资产提炼类)。 */
|
|
25
|
+
characters?: number
|
|
26
|
+
/** 预期条目数(伏笔、题材、资产等清单类)。 */
|
|
27
|
+
items?: number
|
|
28
|
+
/** 需要覆盖的正文规模(字),用于拆书/反推这类按输入体量走的任务。 */
|
|
29
|
+
sourceChars?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 一类结构化任务的预算规格。 */
|
|
33
|
+
export interface OutputBudgetSpec {
|
|
34
|
+
/** 无视规模也要给到的下限。 */
|
|
35
|
+
floor: number
|
|
36
|
+
/** 规模为 0 时的起步额度。 */
|
|
37
|
+
base: number
|
|
38
|
+
/** 每单位规模追加的额度。 */
|
|
39
|
+
perUnit: number
|
|
40
|
+
/** 硬上限:超过它只会让模型啰嗦,不会让结构更完整。 */
|
|
41
|
+
ceiling: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 结构化任务分组——按"输出里每个单位要写多少字段"分档,而不是按业务名。 */
|
|
45
|
+
export type OutputBudgetKind =
|
|
46
|
+
| 'volume-strategy'
|
|
47
|
+
| 'chapter-list'
|
|
48
|
+
| 'character-cast'
|
|
49
|
+
| 'item-list'
|
|
50
|
+
| 'analysis'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 数值口径:以**本插件真实产出字段的体积**折算,不是照搬参考项目的数字
|
|
54
|
+
* (它的卷战略字段与本插件的章节字段结构不同,直接套用会造成新的截断)。
|
|
55
|
+
*
|
|
56
|
+
* 折算假设:中文约 1.5-2 token/字;账本式 JSON 的键名与括号再占约 15%。
|
|
57
|
+
* 例如单章条目 = 标题(≤40) + 摘要(≤300) + 本章目标(≤200) + beats + 最多 4 条
|
|
58
|
+
* payoff 指令 → 约 250-400 字 → **约 350 token/章**。若字段合同调整,这里必须同步。
|
|
59
|
+
*/
|
|
60
|
+
const SPECS: Record<OutputBudgetKind, OutputBudgetSpec> = {
|
|
61
|
+
// 每卷:roleLabel(≤32) + coreReward(≤64) + escalationFocus(≤64) + uncertainty,
|
|
62
|
+
// 再加书级阶梯字段(readerRewardLadder/escalationLadder/midpointShift/notes)。
|
|
63
|
+
'volume-strategy': { floor: 1_800, base: 1_200, perUnit: 220, ceiling: 6_000 },
|
|
64
|
+
// 每章 ≈350 token(见上方折算说明),另留写作余量;上限对齐单次调用能力。
|
|
65
|
+
'chapter-list': { floor: 2_500, base: 2_000, perUnit: 350, ceiling: 20_000 },
|
|
66
|
+
// 每角色:名字 + roleLabel + 定位 + 关系 + 弧线 ≈150 token。
|
|
67
|
+
'character-cast': { floor: 1_500, base: 1_000, perUnit: 150, ceiling: 8_000 },
|
|
68
|
+
// 清单类,每条几个短字段。
|
|
69
|
+
'item-list': { floor: 1_000, base: 800, perUnit: 60, ceiling: 4_000 },
|
|
70
|
+
// 拆书/反推:按输入正文体量走,每千字约 120 token。
|
|
71
|
+
analysis: { floor: 3_000, base: 2_000, perUnit: 12, ceiling: 16_000 },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 取出与任务类型相关的那一个规模量。
|
|
76
|
+
* @param kind - 任务类型。
|
|
77
|
+
* @param scope - 规模描述。
|
|
78
|
+
* @returns 单位数;分析类返回千字单位(向上取整)。
|
|
79
|
+
*/
|
|
80
|
+
export function scopeUnits(kind: OutputBudgetKind, scope: OutputScope): number {
|
|
81
|
+
const positive = (value: number | undefined): number =>
|
|
82
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
|
|
83
|
+
switch (kind) {
|
|
84
|
+
case 'volume-strategy':
|
|
85
|
+
return positive(scope.volumes)
|
|
86
|
+
case 'chapter-list':
|
|
87
|
+
return positive(scope.chapters)
|
|
88
|
+
case 'character-cast':
|
|
89
|
+
return positive(scope.characters)
|
|
90
|
+
case 'item-list':
|
|
91
|
+
return positive(scope.items)
|
|
92
|
+
case 'analysis':
|
|
93
|
+
// 输入正文每 1000 字算一个单位。
|
|
94
|
+
return Math.ceil(positive(scope.sourceChars) / 1_000)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 按规模推算输出额度。
|
|
100
|
+
* @param kind - 结构化任务类型。
|
|
101
|
+
* @param scope - 本次任务的规模(缺省时按最小规模处理)。
|
|
102
|
+
* @returns 建议的 `maxTokens`,落在该类型的 [floor, ceiling] 内。
|
|
103
|
+
*/
|
|
104
|
+
export function resolveOutputBudget(kind: OutputBudgetKind, scope: OutputScope = {}): number {
|
|
105
|
+
const spec = SPECS[kind]
|
|
106
|
+
const raw = spec.base + scopeUnits(kind, scope) * spec.perUnit
|
|
107
|
+
return Math.min(spec.ceiling, Math.max(spec.floor, Math.round(raw)))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 读取某类任务的规格(供诊断与测试断言)。 */
|
|
111
|
+
export function outputBudgetSpec(kind: OutputBudgetKind): OutputBudgetSpec {
|
|
112
|
+
return { ...SPECS[kind] }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 结构化字段长度合同 —— 与 prompt 里的长度声明必须逐项一致。
|
|
117
|
+
*
|
|
118
|
+
* 两边同步的理由:只写进 schema,模型不知道就会超长被拒;只写进 prompt,模型
|
|
119
|
+
* 偶尔超长没人拦。本表是单一数据源。
|
|
120
|
+
*/
|
|
121
|
+
export const STRUCTURED_FIELD_LIMITS = {
|
|
122
|
+
/** 分卷战略:书级阶梯类字段。 */
|
|
123
|
+
volumeLadder: 160,
|
|
124
|
+
/** 分卷战略:每卷定位标签。 */
|
|
125
|
+
volumeRoleLabel: 32,
|
|
126
|
+
/** 分卷战略:每卷核心回报 / 升级焦点。 */
|
|
127
|
+
volumeReward: 64,
|
|
128
|
+
/** 分卷战略:不确定项原因。 */
|
|
129
|
+
volumeUncertaintyReason: 64,
|
|
130
|
+
/** 章节计划:单章标题。 */
|
|
131
|
+
chapterTitle: 40,
|
|
132
|
+
/** 章节计划:单章摘要。 */
|
|
133
|
+
chapterSummary: 300,
|
|
134
|
+
/** 章节计划:本章目标。 */
|
|
135
|
+
chapterGoal: 200,
|
|
136
|
+
} as const
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 把长度合同渲染成可直接塞进 system prompt 的一句话,避免 prompt 与表漂移。
|
|
140
|
+
* @param entries - 要声明的字段(描述 + 上限),按声明顺序渲染。
|
|
141
|
+
* @returns 形如「为保证输出完整:A 不超过 160 字;B 不超过 64 字。」的中文说明。
|
|
142
|
+
*/
|
|
143
|
+
export function renderLengthContract(entries: ReadonlyArray<readonly [string, number]>): string {
|
|
144
|
+
if (entries.length === 0) return ''
|
|
145
|
+
const parts = entries.map(([label, limit]) => `${label}不超过 ${limit} 字`)
|
|
146
|
+
return `为保证输出完整:${parts.join(';')}。每个字段只保留完成本阶段决策所需的信息,写完后立即结束输出,不要展开细纲或重复说明。`
|
|
147
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -229,7 +229,7 @@ export interface BookImportTextRequest {
|
|
|
229
229
|
text?: string
|
|
230
230
|
/** 模式二:原文件名(txt/md),用于推断书名与显示。 */
|
|
231
231
|
fileName?: string
|
|
232
|
-
/**
|
|
232
|
+
/** 输出目录;缺省为「输出目录」设置下的书名子目录。 */
|
|
233
233
|
outputDir?: string
|
|
234
234
|
}
|
|
235
235
|
|
|
@@ -350,7 +350,7 @@ export interface AuthorReview {
|
|
|
350
350
|
reviewedAt: string
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
/**
|
|
353
|
+
/** 审稿维度(结构化定位问题类别)。权威清单是 reviewSystemPrompt 里的九条维度。 */
|
|
354
354
|
export type ReviewDimension = 'character' | 'setting' | 'redline' | 'writing' | 'pacing' | 'logic' | 'anti-ai' | 'presentation' | 'compliance'
|
|
355
355
|
|
|
356
356
|
/** One review finding. */
|
|
@@ -531,6 +531,13 @@ export interface MarketRadarResult {
|
|
|
531
531
|
productionFoundation: ProductionFoundation
|
|
532
532
|
/** 开书创意简报(可选;用「用信号创作」接口单独生成)。 */
|
|
533
533
|
creativeBrief?: MarketCreativeBrief
|
|
534
|
+
/**
|
|
535
|
+
* 本次分析的报告 id(服务端生成)。
|
|
536
|
+
*
|
|
537
|
+
* 前端据此判断「是否换了一份新报告」:只有报告变了才重置信号勾选,否则重渲染/
|
|
538
|
+
* 轮询会把作者手选的信号冲掉。缺省(旧版产物)时前端按「首次看到」处理。
|
|
539
|
+
*/
|
|
540
|
+
reportId?: string
|
|
534
541
|
}
|
|
535
542
|
|
|
536
543
|
/** POST /market-radar request。 */
|
|
@@ -1828,6 +1835,16 @@ export interface ConfigPatch {
|
|
|
1828
1835
|
savedModels?: SavedModel[]
|
|
1829
1836
|
}
|
|
1830
1837
|
|
|
1838
|
+
/** POST /config response: the config the host will read next, plus persistence caveats. */
|
|
1839
|
+
export interface ConfigResponse {
|
|
1840
|
+
config: NovelConfig
|
|
1841
|
+
/**
|
|
1842
|
+
* 已生效但**未持久化**时的说明(宿主 settings 段不可寻址:段 id 未知或宿主
|
|
1843
|
+
* 未登记)。有值时设置只在本次进程内有效,重启回退,面板照此提示用户。
|
|
1844
|
+
*/
|
|
1845
|
+
settingsWarning?: string
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1831
1848
|
// ------------------------------------------------------------ assistant
|
|
1832
1849
|
|
|
1833
1850
|
/** One assistant conversation message (persisted per project). */
|
|
@@ -2339,7 +2356,7 @@ export interface AdaptSaveRequest {
|
|
|
2339
2356
|
text: string
|
|
2340
2357
|
/** 新书名(缺省为「改编新书」;建议用「<原著>·改编版」)。 */
|
|
2341
2358
|
bookName?: string
|
|
2342
|
-
/**
|
|
2359
|
+
/** 新书输出目录(缺省为「输出目录」设置下的书名子目录)。 */
|
|
2343
2360
|
outputDir?: string
|
|
2344
2361
|
/** 可附带反推大纲(写进新项目,便于后续续写/编辑)。 */
|
|
2345
2362
|
outline?: string
|
|
@@ -2365,7 +2382,7 @@ export interface AdaptMaterializeRequest {
|
|
|
2365
2382
|
text: string
|
|
2366
2383
|
/** 新书名(缺省为「<源书名>·改编版」)。 */
|
|
2367
2384
|
bookName?: string
|
|
2368
|
-
/**
|
|
2385
|
+
/** 新书输出目录(缺省为「输出目录」设置下的书名子目录)。 */
|
|
2369
2386
|
outputDir?: string
|
|
2370
2387
|
/** 反推大纲(来自分析;缺少时用源文章题兜底)。 */
|
|
2371
2388
|
outline?: string
|
|
@@ -2401,7 +2418,7 @@ export interface AdaptMaterializeResponse {
|
|
|
2401
2418
|
export interface AdaptMaterializeSaveRequest {
|
|
2402
2419
|
/** 新书名(缺省为「改编版」)。 */
|
|
2403
2420
|
bookName?: string
|
|
2404
|
-
/**
|
|
2421
|
+
/** 新书输出目录(缺省为「输出目录」设置下的书名子目录)。 */
|
|
2405
2422
|
outputDir?: string
|
|
2406
2423
|
/** 改编后总纲。 */
|
|
2407
2424
|
outline: string
|
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
13
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join, basename, extname } from 'node:path'
|
|
15
|
-
import { homedir } from 'node:os'
|
|
16
15
|
import { randomBytes } from 'node:crypto'
|
|
17
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -55,6 +54,8 @@ import {
|
|
|
55
54
|
type ChapterSaveResponse,
|
|
56
55
|
type ChapterTextRequest,
|
|
57
56
|
type ConfigPatch,
|
|
57
|
+
type ConfigResponse,
|
|
58
|
+
type GenerateRequest,
|
|
58
59
|
type MoveOutputDirRequest,
|
|
59
60
|
type MoveOutputDirResponse,
|
|
60
61
|
type BookSettings,
|
|
@@ -219,6 +220,7 @@ import { detectTimelineIssues } from './timeline.ts'
|
|
|
219
220
|
import { buildCurveData, detectTensionIssues, TENSION_MAX, TENSION_MIN, TENSION_PRESETS, clampTension } from './tension.ts'
|
|
220
221
|
import { PROMPT_SLOTS, normalizeSlotValue, readPromptSlots } from './prompt-slots.ts'
|
|
221
222
|
import { buildRevisionPlan, describeRevisionPlan, REVISION_ITEM_CAP } from './revision.ts'
|
|
223
|
+
import { dshHomePath } from './home.ts'
|
|
222
224
|
|
|
223
225
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
224
226
|
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
|
|
@@ -286,15 +288,29 @@ export interface NovelRoutesDeps {
|
|
|
286
288
|
ctx: Context
|
|
287
289
|
/** Resolve the live plugin config (settings-aware). */
|
|
288
290
|
getConfig: () => NovelConfig
|
|
289
|
-
/** Persist a config patch through the settings seam. */
|
|
290
|
-
patchConfig: (patch: ConfigPatch) => Promise<
|
|
291
|
+
/** Persist a config patch through the settings seam (memory fallback included). */
|
|
292
|
+
patchConfig: (patch: ConfigPatch) => Promise<ConfigResponse>
|
|
291
293
|
/** Raw settings config(未与激活书合并)——目录迁移判断默认目录是否需同步用。 */
|
|
292
294
|
rawConfig?: () => { outputDir?: string }
|
|
295
|
+
/** Loader entry id the settings section is addressed by (undefined when unknown). */
|
|
296
|
+
settingsNs?: string
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
/** Default chapter count for planning when the request omits it. */
|
|
296
300
|
const DEFAULT_PLAN_COUNT = 30
|
|
297
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The profile this host booted (`--profile <name>` on the dsh CLI invocation).
|
|
304
|
+
* Profile-scoped paths must not assume a name: the shipped template is `web`,
|
|
305
|
+
* but a custom profile (`web-alpha`) is the one actually running.
|
|
306
|
+
* @returns the profile name, or undefined when the flag is absent.
|
|
307
|
+
*/
|
|
308
|
+
function argvProfile(): string | undefined {
|
|
309
|
+
const flag = process.argv.indexOf('--profile')
|
|
310
|
+
const value = flag === -1 ? undefined : process.argv[flag + 1]
|
|
311
|
+
return value !== undefined && !value.startsWith('-') ? value : undefined
|
|
312
|
+
}
|
|
313
|
+
|
|
298
314
|
/**
|
|
299
315
|
* Build every /api/dsh-novel-forge route.
|
|
300
316
|
* @param deps - context, config resolver, config patcher.
|
|
@@ -303,6 +319,13 @@ const DEFAULT_PLAN_COUNT = 30
|
|
|
303
319
|
export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
304
320
|
const { ctx, getConfig, patchConfig } = deps
|
|
305
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
|
|
324
|
+
* outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
|
|
325
|
+
* 回退到 `<DSH_HOME>/novels`。
|
|
326
|
+
*/
|
|
327
|
+
const defaultBookDir = (bookName: string): string => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir)
|
|
328
|
+
|
|
306
329
|
/** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
|
|
307
330
|
let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
|
|
308
331
|
|
|
@@ -585,7 +608,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
585
608
|
handler: async (req, res) => {
|
|
586
609
|
if (!guard(req, res, 'POST')) return
|
|
587
610
|
const config0 = getConfig()
|
|
588
|
-
const body = await readJsonBody<
|
|
611
|
+
const body = await readJsonBody<GenerateRequest & { bookId?: string }>(req)
|
|
589
612
|
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
590
613
|
const config = { ...config0, outputDir }
|
|
591
614
|
const project = loadProject(outputDir)
|
|
@@ -2065,7 +2088,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2065
2088
|
}
|
|
2066
2089
|
const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
|
|
2067
2090
|
? body.outputDir
|
|
2068
|
-
:
|
|
2091
|
+
: defaultBookDir(bookName)
|
|
2069
2092
|
const book = createBook(bookName, outputDir)
|
|
2070
2093
|
// 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
|
|
2071
2094
|
const outline = body?.outline?.trim()
|
|
@@ -2188,7 +2211,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2188
2211
|
const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
|
|
2189
2212
|
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2190
2213
|
? body.outputDir.trim()
|
|
2191
|
-
:
|
|
2214
|
+
: defaultBookDir(bookName)
|
|
2192
2215
|
const result = importBookTextFromText(body.text, outDir, bookName)
|
|
2193
2216
|
const { book } = importDir(outDir)
|
|
2194
2217
|
writeJson(res, 200, { ...result, book })
|
|
@@ -2207,7 +2230,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2207
2230
|
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
|
|
2208
2231
|
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2209
2232
|
? body.outputDir.trim()
|
|
2210
|
-
:
|
|
2233
|
+
: defaultBookDir(bookName)
|
|
2211
2234
|
const result = importBookText(filePath, outDir)
|
|
2212
2235
|
const { book } = importDir(outDir)
|
|
2213
2236
|
writeJson(res, 200, { ...result, book })
|
|
@@ -2894,8 +2917,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2894
2917
|
return
|
|
2895
2918
|
}
|
|
2896
2919
|
try {
|
|
2897
|
-
const
|
|
2898
|
-
writeJson(res, 200,
|
|
2920
|
+
const result = await patchConfig(body)
|
|
2921
|
+
writeJson(res, 200, result satisfies ConfigResponse)
|
|
2899
2922
|
} catch (error) {
|
|
2900
2923
|
writeJson(res, 400, { error: (error as Error).message })
|
|
2901
2924
|
}
|
|
@@ -2933,7 +2956,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2933
2956
|
path: NOVEL_API.pluginUpdate,
|
|
2934
2957
|
handler: async (req, res) => {
|
|
2935
2958
|
if (!guard(req, res, 'POST')) return
|
|
2936
|
-
const profileDir =
|
|
2959
|
+
const profileDir = dshHomePath('profiles', argvProfile() ?? 'web')
|
|
2937
2960
|
const response: PluginUpdateResponse = await new Promise((resolve) => {
|
|
2938
2961
|
const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
|
|
2939
2962
|
let acc = ''
|
|
@@ -3619,7 +3642,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
3619
3642
|
}
|
|
3620
3643
|
|
|
3621
3644
|
// ------------------------------------------ theme custom background (upload + serve)
|
|
3622
|
-
const THEME_BG_DIR =
|
|
3645
|
+
const THEME_BG_DIR = dshHomePath('dsh-novel-forge-assets')
|
|
3623
3646
|
const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
3624
3647
|
|
|
3625
3648
|
const themeBackgroundUploadRoute: WebRoute = {
|