@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
|
@@ -8,7 +8,7 @@ import { tt } from './panel/helpers.ts'
|
|
|
8
8
|
import css from './panel/panel.module.css'
|
|
9
9
|
|
|
10
10
|
/** Stable data attribute identifying the injected entry row. */
|
|
11
|
-
|
|
11
|
+
const ENTRY_SELECTOR = '[data-dsh-novelforge-entry]'
|
|
12
12
|
|
|
13
13
|
/** Inline icon: an open book / writing glyph. */
|
|
14
14
|
const ICON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.67 19a2 2 0 0 0 1.416-.588l6.154-6.172a6 6 0 0 0-8.49-8.49L5.586 9.914A2 2 0 0 0 5 11.328V18a1 1 0 0 0 1 1z"/><path d="M16 8 2 22"/><path d="M17.5 15H9"/></svg>'
|
|
@@ -35,7 +35,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
35
35
|
function createEntry(controller: PanelController): HTMLButtonElement {
|
|
36
36
|
const entry = document.createElement('button')
|
|
37
37
|
entry.type = 'button'
|
|
38
|
-
entry.
|
|
38
|
+
entry.setAttribute(ENTRY_SELECTOR.slice(1, -1), 'true')
|
|
39
39
|
entry.className = css.entry
|
|
40
40
|
entry.setAttribute('aria-label', tt('entry.label'))
|
|
41
41
|
entry.setAttribute('title', tt('entry.tooltip'))
|
|
@@ -52,7 +52,7 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
|
52
52
|
const row = button.closest('[class*="logoRow"]')
|
|
53
53
|
const base = (row !== null && row.parentElement === root) ? row : button
|
|
54
54
|
const family = Array.from(root.children).filter(
|
|
55
|
-
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(
|
|
55
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(`[data-dsh-taskboard-entry], [data-dsh-ssh-entry], ${ENTRY_SELECTOR}`),
|
|
56
56
|
)
|
|
57
57
|
const last = family.length > 0 ? family[family.length - 1] : undefined
|
|
58
58
|
const anchor = last !== undefined ? last.nextElementSibling : base.nextElementSibling
|
package/src/engine.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
|
|
|
23
23
|
'9. 不得出现法律法规禁止的其他内容。',
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
|
|
27
27
|
const REVIEW_DIMENSION_IDS = new Set(['character', 'setting', 'redline', 'writing', 'pacing', 'logic', 'anti-ai', 'presentation', 'compliance'])
|
|
28
28
|
|
|
29
29
|
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, rmSync, renameSync } from 'node:fs'
|
|
@@ -35,6 +35,8 @@ import { BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, emptyProjectAssets, r
|
|
|
35
35
|
import { scanAiFlavor } from './ai-scan.ts'
|
|
36
36
|
import { beginLiveCall, endLiveCall, markFirstToken } from './llm-live.ts'
|
|
37
37
|
import { shouldSwitchModel, withModelFallback } from './llm-retry.ts'
|
|
38
|
+
import { convergeRequestParams } from './model-capability.ts'
|
|
39
|
+
import { renderLengthContract, resolveOutputBudget, STRUCTURED_FIELD_LIMITS } from './output-budget.ts'
|
|
38
40
|
import { renderTensionBlock } from './tension.ts'
|
|
39
41
|
import { renderPromptSlots } from './prompt-slots.ts'
|
|
40
42
|
import { normalizeTimelineEvent, renderTimelineBlock, sortTimeline } from './timeline.ts'
|
|
@@ -347,11 +349,64 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
|
|
|
347
349
|
|
|
348
350
|
// ------------------------------------------------------------------- llm
|
|
349
351
|
|
|
352
|
+
/** 一次非流式 LLM 调用的入参。 */
|
|
353
|
+
interface CompleteOptions {
|
|
354
|
+
system: string
|
|
355
|
+
user: string
|
|
356
|
+
temperature?: number
|
|
357
|
+
maxTokens?: number
|
|
358
|
+
reasoning?: 'off' | 'low' | 'high' | 'max'
|
|
359
|
+
model?: string
|
|
360
|
+
liveLabel?: string
|
|
361
|
+
/**
|
|
362
|
+
* 该调用是否要求严格结构化输出(JSON)。为 true 时,模型参数能力层可以在
|
|
363
|
+
* 结构化稳定性优先的前提下覆盖用户的思考档位。
|
|
364
|
+
*/
|
|
365
|
+
structured?: boolean
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 把已经拼好的请求过一遍模型参数能力层。
|
|
370
|
+
*
|
|
371
|
+
* **凡是自己构造 `GenerateOptions`、不走 {@link complete} 的调用点都必须经过它**,
|
|
372
|
+
* 否则那条路径会绕过厂商参数约束(固定温度、不支持的 reasoning 等),对 Kimi
|
|
373
|
+
* 这类模型直接报参数错误 —— 而"测试连通"恰恰是用户用来判断配置对不对的地方。
|
|
374
|
+
*
|
|
375
|
+
* 抽成函数而不是在每个调用点内联:流式调用点有 5 处,还要各自处理备用模型切换,
|
|
376
|
+
* 内联五遍就是同一件事写五份,必然漂移。
|
|
377
|
+
* @param base - 已拼好的请求(调用方已确定 temperature / reasoningEffort)。
|
|
378
|
+
* @param model - **本次实际要调用的模型**(切换备用模型后必须重算,故在循环内调用)。
|
|
379
|
+
* @param options - `structured` 表示严格结构化输出任务。
|
|
380
|
+
* @returns 收敛后的请求(固定温度的模型会省略 temperature / 不支持的参数会被移除)。
|
|
381
|
+
*/
|
|
382
|
+
function applyModelCapability(
|
|
383
|
+
base: GenerateOptions,
|
|
384
|
+
model: string,
|
|
385
|
+
options: { structured?: boolean } = {},
|
|
386
|
+
): GenerateOptions {
|
|
387
|
+
const converged = convergeRequestParams(
|
|
388
|
+
{
|
|
389
|
+
temperature: base.temperature ?? 0.7,
|
|
390
|
+
reasoningEffort: base.reasoningEffort === undefined
|
|
391
|
+
? undefined
|
|
392
|
+
: String(base.reasoningEffort) as 'off' | 'low' | 'high' | 'max',
|
|
393
|
+
},
|
|
394
|
+
model,
|
|
395
|
+
{ structured: options.structured === true, omitFixedTemperature: true },
|
|
396
|
+
)
|
|
397
|
+
return {
|
|
398
|
+
...base,
|
|
399
|
+
model,
|
|
400
|
+
...(converged.temperature === undefined ? { temperature: undefined } : { temperature: converged.temperature }),
|
|
401
|
+
...(converged.reasoningEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
350
405
|
/** One complete non-streaming LLM call. */
|
|
351
406
|
async function complete(
|
|
352
407
|
ctx: Context,
|
|
353
408
|
config: NovelConfig,
|
|
354
|
-
options:
|
|
409
|
+
options: CompleteOptions,
|
|
355
410
|
): Promise<string> {
|
|
356
411
|
const liveLabel = options.liveLabel ?? 'LLM 调用'
|
|
357
412
|
const messages: Message[] = [createUserMessage({
|
|
@@ -361,14 +416,31 @@ async function complete(
|
|
|
361
416
|
/** 用指定模型跑一次完整调用(含实况打点、token 记账与诊断日志)。 */
|
|
362
417
|
const runOnce = async (model: string): Promise<string> => {
|
|
363
418
|
const live = beginLiveCall({ label: liveLabel, model, system: options.system, user: options.user })
|
|
419
|
+
// 用户偏好先成型,再经能力层按**实际模型**收敛:同一模型走不同 provider/中转
|
|
420
|
+
// 时约束不变,换备用模型后约束随新模型重算。
|
|
421
|
+
const converged = convergeRequestParams(
|
|
422
|
+
{
|
|
423
|
+
temperature: options.temperature ?? 0.7,
|
|
424
|
+
reasoningEffort: options.reasoning ?? config.reasoningEffort ?? 'off',
|
|
425
|
+
},
|
|
426
|
+
model,
|
|
427
|
+
{
|
|
428
|
+
structured: options.structured === true,
|
|
429
|
+
// 厂商建议固定温度的模型不要显式传入,优先省略,省得多余参数被拒。
|
|
430
|
+
omitFixedTemperature: true,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
if (converged.adjustments.length > 0) {
|
|
434
|
+
ctx.logger('novel-forge').info('模型参数收敛(%s @ %s):%s', liveLabel, model, converged.adjustments.join(';'))
|
|
435
|
+
}
|
|
364
436
|
const request: GenerateOptions = {
|
|
365
437
|
provider: config.provider,
|
|
366
438
|
model,
|
|
367
439
|
messages,
|
|
368
440
|
system: options.system,
|
|
369
441
|
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
370
|
-
temperature:
|
|
371
|
-
reasoningEffort: ReasoningEffortId(
|
|
442
|
+
...(converged.temperature === undefined ? {} : { temperature: converged.temperature }),
|
|
443
|
+
...(converged.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
|
|
372
444
|
}
|
|
373
445
|
const assembler = new BlockAssembler()
|
|
374
446
|
for await (const chunk of ctx.llm.stream(request)) {
|
|
@@ -421,7 +493,7 @@ function textFromAssembler(assembler: BlockAssembler): string {
|
|
|
421
493
|
async function completeJsonArray(
|
|
422
494
|
ctx: Context,
|
|
423
495
|
config: NovelConfig,
|
|
424
|
-
options:
|
|
496
|
+
options: CompleteOptions,
|
|
425
497
|
parse: (text: string) => unknown[],
|
|
426
498
|
): Promise<unknown[]> {
|
|
427
499
|
let parsed = parse(await complete(ctx, config, options))
|
|
@@ -671,13 +743,21 @@ function volumeSystemPrompt(): string {
|
|
|
671
743
|
'[{"no": 1, "title": "卷名", "summary": "卷定位与剧情重心", "chapterStart": 1, "chapterEnd": 80}]',
|
|
672
744
|
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
673
745
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
746
|
+
// 长度合同与 resolve 端的截断口径共用一份声明,避免 prompt 与解析漂移。
|
|
747
|
+
renderLengthContract([
|
|
748
|
+
['卷名', STRUCTURED_FIELD_LIMITS.chapterTitle],
|
|
749
|
+
['每卷定位', STRUCTURED_FIELD_LIMITS.chapterSummary],
|
|
750
|
+
]),
|
|
674
751
|
].join('\n')
|
|
675
752
|
}
|
|
676
753
|
|
|
677
754
|
/** Plan volumes from an outline. */
|
|
678
755
|
export async function planVolumes(ctx: Context, config: NovelConfig, outline: string): Promise<Volume[]> {
|
|
679
756
|
const user = `请为下面这部小说划分卷:\n\n${outline}`
|
|
680
|
-
|
|
757
|
+
// 卷数由大纲决定、此处未知,按「大纲暗示的规模」估算:约每 1200 字大纲对应一卷,
|
|
758
|
+
// 夹在 3-24 卷之间(与卷规划自身的 3-8 卷建议不冲突,只是给预算一个量级)。
|
|
759
|
+
const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1_200)))
|
|
760
|
+
const text = await complete(ctx, config, { system: volumeSystemPrompt(), user, temperature: 0.4, maxTokens: resolveOutputBudget('volume-strategy', { volumes: expectedVolumes }) })
|
|
681
761
|
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
682
762
|
const volumes: Volume[] = []
|
|
683
763
|
for (let i = 0; i < parsed.length; i++) {
|
|
@@ -690,8 +770,8 @@ export async function planVolumes(ctx: Context, config: NovelConfig, outline: st
|
|
|
690
770
|
const end = typeof entry.chapterEnd === 'number' ? entry.chapterEnd : undefined
|
|
691
771
|
volumes.push({
|
|
692
772
|
no,
|
|
693
|
-
title: title.slice(0,
|
|
694
|
-
summary: summary.slice(0,
|
|
773
|
+
title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
|
|
774
|
+
summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
|
|
695
775
|
chapterStart: start ?? 1,
|
|
696
776
|
chapterEnd: end ?? 1,
|
|
697
777
|
})
|
|
@@ -981,7 +1061,7 @@ export async function planChapters(
|
|
|
981
1061
|
: '')
|
|
982
1062
|
const parsed = (await completeJsonArray(
|
|
983
1063
|
ctx, config,
|
|
984
|
-
{ system, user, temperature: 0.7, maxTokens:
|
|
1064
|
+
{ system, user, temperature: 0.7, maxTokens: resolveOutputBudget('chapter-list', { chapters: chapterCount }), liveLabel: '章节规划' },
|
|
985
1065
|
t => parseJsonArray<Record<string, unknown>>(t),
|
|
986
1066
|
)) as Record<string, unknown>[]
|
|
987
1067
|
const chapters: ChapterPlan[] = []
|
|
@@ -1561,7 +1641,8 @@ export async function extractRoles(
|
|
|
1561
1641
|
: '',
|
|
1562
1642
|
'只输出 JSON 数组。',
|
|
1563
1643
|
].join('\n\n')
|
|
1564
|
-
|
|
1644
|
+
// 角色阵容:一次要产出 8-20 个角色,预算按预期角色数推算。
|
|
1645
|
+
let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 20 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
|
|
1565
1646
|
let raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1566
1647
|
const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
|
|
1567
1648
|
const tooFew = raw.length > 0 && raw.length < 6
|
|
@@ -1572,7 +1653,7 @@ export async function extractRoles(
|
|
|
1572
1653
|
: tooFew
|
|
1573
1654
|
? '\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。'
|
|
1574
1655
|
: '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
|
|
1575
|
-
text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens:
|
|
1656
|
+
text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 24 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
|
|
1576
1657
|
raw = parseJsonArray<Record<string, unknown>>(text)
|
|
1577
1658
|
}
|
|
1578
1659
|
const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
|
|
@@ -2029,13 +2110,16 @@ export async function testLlmModel(ctx: Context, provider: string, model: string
|
|
|
2029
2110
|
content: [{ type: 'text', text: '只回复两个字:OK' }],
|
|
2030
2111
|
source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
|
|
2031
2112
|
})]
|
|
2032
|
-
|
|
2113
|
+
// 这里自己构造请求(为省 token 拿到首块即停),所以**必须显式过一遍能力层**:
|
|
2114
|
+
// 否则「测试连通」会原样下发参数,对固定温度的模型必然报错 —— 而测试连通
|
|
2115
|
+
// 正是用户用来判断配置对不对的地方。
|
|
2116
|
+
const request = applyModelCapability({
|
|
2033
2117
|
provider,
|
|
2034
2118
|
model,
|
|
2035
2119
|
messages,
|
|
2036
2120
|
maxTokens: 16,
|
|
2037
2121
|
temperature: 0,
|
|
2038
|
-
}
|
|
2122
|
+
}, model)
|
|
2039
2123
|
// 真实流式调用 + 30 秒超时(GenerateOptions.signal 由适配器响应并中止)。
|
|
2040
2124
|
const controller = new AbortController()
|
|
2041
2125
|
const timer = setTimeout(() => controller.abort(), 30_000)
|
|
@@ -2467,7 +2551,7 @@ export async function* rewriteChapterStream(
|
|
|
2467
2551
|
})
|
|
2468
2552
|
const assembler = new BlockAssembler()
|
|
2469
2553
|
let produced = 0
|
|
2470
|
-
for await (const chunk of ctx.llm.stream(
|
|
2554
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2471
2555
|
assembler.push(chunk)
|
|
2472
2556
|
if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
|
|
2473
2557
|
}
|
|
@@ -2578,7 +2662,7 @@ export async function* polishChapterStream(
|
|
|
2578
2662
|
})
|
|
2579
2663
|
const assembler = new BlockAssembler()
|
|
2580
2664
|
let produced = 0
|
|
2581
|
-
for await (const chunk of ctx.llm.stream(
|
|
2665
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2582
2666
|
assembler.push(chunk)
|
|
2583
2667
|
if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
|
|
2584
2668
|
}
|
|
@@ -2754,7 +2838,7 @@ export async function* generateChapterStream(
|
|
|
2754
2838
|
})
|
|
2755
2839
|
const assembler = new BlockAssembler()
|
|
2756
2840
|
let produced = 0
|
|
2757
|
-
for await (const chunk of ctx.llm.stream(
|
|
2841
|
+
for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
|
|
2758
2842
|
assembler.push(chunk)
|
|
2759
2843
|
if (chunk.type === 'text-delta') {
|
|
2760
2844
|
produced += chunk.text.length
|
|
@@ -4152,6 +4236,9 @@ export async function runMarketRadar(
|
|
|
4152
4236
|
return {
|
|
4153
4237
|
signals: signalAs(raw.signals),
|
|
4154
4238
|
productionFoundation,
|
|
4239
|
+
// 报告 id 让前端能区分「同一份报告的重渲染」与「换了新报告」:只有后者才重置
|
|
4240
|
+
// 作者手选的信号。用短随机 id 即可,无需持久化。
|
|
4241
|
+
reportId: `mr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
4155
4242
|
}
|
|
4156
4243
|
}
|
|
4157
4244
|
|
package/src/global-assets.ts
CHANGED
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
* 全局资源库(跨书可复用的自定义题材/推进模式)。
|
|
3
3
|
* 雷达「同步底座到全局资源库」写入此处;/assets 返回时与内置库合并展示。
|
|
4
4
|
*/
|
|
5
|
-
import { homedir } from 'node:os'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
5
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
6
|
import type { GenreNode, ProgressionMode } from './protocol.ts'
|
|
7
|
+
import { dshHome, dshHomePath } from './home.ts'
|
|
9
8
|
|
|
10
9
|
interface GlobalAssets {
|
|
11
10
|
genres: GenreNode[]
|
|
12
11
|
modes: ProgressionMode[]
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
const FILE =
|
|
14
|
+
const FILE = dshHomePath('novel-forge-global-assets.json')
|
|
16
15
|
let cache: GlobalAssets | null = null
|
|
17
16
|
|
|
18
17
|
function load(): GlobalAssets {
|
|
@@ -29,7 +28,7 @@ function load(): GlobalAssets {
|
|
|
29
28
|
|
|
30
29
|
function persist(): void {
|
|
31
30
|
if (cache === null) return
|
|
32
|
-
mkdirSync(
|
|
31
|
+
mkdirSync(dshHome(), { recursive: true })
|
|
33
32
|
writeFileSync(FILE, JSON.stringify(cache, null, 2), 'utf8')
|
|
34
33
|
}
|
|
35
34
|
|
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
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
* changes.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
13
11
|
import type {} from '@deepseek-ai/dsh-settings'
|
|
14
12
|
import z from 'schemastery'
|
|
15
13
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
16
14
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
17
15
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
18
|
-
import type { ConfigPatch, NovelConfig } from './protocol.ts'
|
|
16
|
+
import type { ConfigPatch, NovelConfig, ConfigResponse } from './protocol.ts'
|
|
19
17
|
import { makeRoutes } from './routes.ts'
|
|
20
18
|
import { activeBookOutputDir } from './bookshelf.ts'
|
|
21
19
|
import { loadProject } from './engine.ts'
|
|
20
|
+
import { dshHomePath } from './home.ts'
|
|
21
|
+
import { novelSettingsNamespace, persistConfigPatch, type SettingsLike } from './settings-persist.ts'
|
|
22
22
|
|
|
23
23
|
/** Stable cordis plugin name. */
|
|
24
24
|
export const name = 'novel-forge'
|
|
@@ -28,11 +28,18 @@ export const inject = ['webServer', 'llm', 'systemPrompt', 'settings']
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Settings namespace of the novel-forge capability — the section the web
|
|
31
|
-
* settings surface edits.
|
|
32
|
-
*
|
|
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.
|
|
33
39
|
*/
|
|
34
40
|
export const NOVEL_SETTINGS_NAMESPACE = 'dsh-novel-forge' as const
|
|
35
41
|
|
|
42
|
+
|
|
36
43
|
/** Plugin config, validated by the same-named schemastery schema. */
|
|
37
44
|
export interface Config {
|
|
38
45
|
/** When true (default), a system-prompt section announces the plugin to every agent. */
|
|
@@ -83,36 +90,70 @@ export interface Config {
|
|
|
83
90
|
enableAdaptMode?: boolean
|
|
84
91
|
}
|
|
85
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
|
+
*/
|
|
86
127
|
export const Config: z<Config> = z.object({
|
|
87
|
-
announceToAgent: z.boolean().default(true),
|
|
88
|
-
enabled: z.boolean().default(true),
|
|
89
|
-
outlinePath: z.string().default(''),
|
|
90
|
-
outputDir: z.string().default(
|
|
91
|
-
provider: z.string().default('deepseek-official'),
|
|
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),
|
|
98
|
-
reasoningEffort: z.union(['off', 'low', 'high', 'max']).default('off'),
|
|
99
|
-
analysisReasoning: z.union(['off', 'low', 'high', 'max']).default('low'),
|
|
100
|
-
chapterChars: z.number().default(3500),
|
|
101
|
-
maxTokens: z.number().default(12000),
|
|
102
|
-
reviewPassScore: z.number().default(70),
|
|
103
|
-
autoReview: z.boolean().default(true),
|
|
104
|
-
autoAuthorReview: z.boolean().default(true),
|
|
105
|
-
autoReviewAfterRevise: z.boolean().default(true),
|
|
106
|
-
themeBackground: z.string().default(''),
|
|
107
|
-
themeBackgroundBlur: z.number().default(0),
|
|
108
|
-
themeOpacity: z.number().default(100),
|
|
109
|
-
enableAdaptMode: z.boolean().default(true),
|
|
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)),
|
|
110
151
|
})
|
|
111
152
|
|
|
112
153
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
113
154
|
const DEFAULT_ANNOUNCE = true
|
|
114
155
|
const DEFAULT_OUTLINE_PATH = ''
|
|
115
|
-
const DEFAULT_OUTPUT_DIR =
|
|
156
|
+
const DEFAULT_OUTPUT_DIR = dshHomePath('novels')
|
|
116
157
|
const DEFAULT_PROVIDER = 'deepseek-official'
|
|
117
158
|
const DEFAULT_MODEL = 'deepseek-flash'
|
|
118
159
|
const DEFAULT_REASONING_EFFORT = 'off' as const
|
|
@@ -130,7 +171,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true
|
|
|
130
171
|
const SECTION_ORDER = 160
|
|
131
172
|
|
|
132
173
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
133
|
-
export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown
|
|
174
|
+
export const NOVEL_GUIDANCE = '本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲、粘贴大纲文本或把 txt 全本拆章导入;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线)与大世界(境界/区域/势力);生成卷计划与结构化章节计划(本章目标/剧情要点/必达项/义务合约/章末钩子);逐章生成 3000-4000 字正文并保存为 Markdown(默认输出到插件设置里「输出目录」指定的位置,未配置时落到 Harness 数据目录下的 novels/书名);每章自动生成摘要与编年录事实、抽取故事时间线(故事内时间/地点/在场角色/事件)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑/反 AI/呈现/合规九维)与作者复盘;支持按审稿意见合并修订(审稿 high + 时间线矛盾 + 张力偏差合成一轮,每章最多两轮后转人工)、去 AI 味润色、章节历史版本回滚、暗线(伏笔)管理、剧情线与角色库、张力曲线、全书一致性质检与敏感词扫描、分范围导出与项目备份、批量连写(生产单,可暂停/续跑)。限制:所有 AI 操作消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度;张力/时间线问题默认只是建议(写日志与待办),不会自动改正文。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。'
|
|
134
175
|
|
|
135
176
|
/** Resolve a config-like value into the full runtime config. */
|
|
136
177
|
export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
|
|
@@ -165,9 +206,13 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
|
|
|
165
206
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
166
207
|
*/
|
|
167
208
|
export function apply(ctx: Context, config?: Config): void {
|
|
168
|
-
// The live source the routes read
|
|
169
|
-
//
|
|
170
|
-
|
|
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 }
|
|
171
216
|
const resolve = (): NovelConfig => {
|
|
172
217
|
const resolved = resolveConfig(current())
|
|
173
218
|
// 书架激活的书优先决定输出目录(settings 仍可改默认值)。
|
|
@@ -190,7 +235,25 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
190
235
|
return book
|
|
191
236
|
}
|
|
192
237
|
|
|
193
|
-
|
|
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> => {
|
|
194
257
|
const next: ConfigPatch = {}
|
|
195
258
|
if (patch.outlinePath !== undefined) next.outlinePath = patch.outlinePath
|
|
196
259
|
if (patch.outputDir !== undefined) next.outputDir = patch.outputDir
|
|
@@ -213,16 +276,24 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
213
276
|
if (patch.themeBackgroundBlur !== undefined) next.themeBackgroundBlur = patch.themeBackgroundBlur
|
|
214
277
|
if (patch.themeOpacity !== undefined) next.themeOpacity = patch.themeOpacity
|
|
215
278
|
if (patch.enableAdaptMode !== undefined) next.enableAdaptMode = patch.enableAdaptMode
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// pattern
|
|
219
|
-
const settings = ctx.get('settings')
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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)
|
|
224
295
|
}
|
|
225
|
-
return resolve()
|
|
296
|
+
return { config: resolve(), ...(persisted.settingsWarning === undefined ? {} : { settingsWarning: persisted.settingsWarning }) }
|
|
226
297
|
}
|
|
227
298
|
|
|
228
299
|
let disposeSection: (() => void) | undefined
|
|
@@ -246,7 +317,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
246
317
|
text: NOVEL_GUIDANCE,
|
|
247
318
|
})
|
|
248
319
|
}
|
|
249
|
-
const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig, rawConfig: () => current() })
|
|
320
|
+
const routes = makeRoutes({ ctx, getConfig: resolve, patchConfig, rawConfig: () => current(), settingsNs })
|
|
250
321
|
disposeRoutes = ctx.effect(
|
|
251
322
|
() => {
|
|
252
323
|
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
@@ -257,13 +328,27 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
257
328
|
void value
|
|
258
329
|
}
|
|
259
330
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
267
352
|
|
|
268
353
|
// 运行时 skill:章节批量生产与值守处理(只在本插件环境可用)。
|
|
269
354
|
// 通过 ctx.get('skills') 获取注册表(服务存在时注册,缺失则跳过,不影响其他能力)。
|
package/src/llm-live.ts
CHANGED
|
@@ -19,8 +19,6 @@ import type {
|
|
|
19
19
|
|
|
20
20
|
export type { LlmLiveFrame }
|
|
21
21
|
|
|
22
|
-
export type LlmLivePhase = 'requesting' | 'streaming' | 'completed' | 'failed'
|
|
23
|
-
|
|
24
22
|
/** 帧缓冲上限(新连接重放用)。 */
|
|
25
23
|
const MAX_BUFFER = 400
|
|
26
24
|
/** Prompt 记录上限(环形,避免长跑吃内存)。 */
|
|
@@ -68,14 +66,6 @@ export function emitLive(frame: LlmLiveFrame): void {
|
|
|
68
66
|
for (const l of listeners) l(frame)
|
|
69
67
|
}
|
|
70
68
|
|
|
71
|
-
export function liveLatest(): LlmLiveFrame[] {
|
|
72
|
-
return [...buffer]
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function clearLiveFeed(): void {
|
|
76
|
-
buffer = []
|
|
77
|
-
}
|
|
78
|
-
|
|
79
69
|
// ------------------------------------------------------------------ 用量账本
|
|
80
70
|
|
|
81
71
|
/** 把 dsh-llm 的 usage 转成线上形状。 */
|
|
@@ -183,11 +173,6 @@ export function livePrompt(sessionId: string): LlmPromptRecord | undefined {
|
|
|
183
173
|
return prompts.get(sessionId)
|
|
184
174
|
}
|
|
185
175
|
|
|
186
|
-
/** 清空 Prompt 记录。 */
|
|
187
|
-
export function clearLivePrompts(): void {
|
|
188
|
-
prompts.clear()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
176
|
// --------------------------------------------------------------- 调用打点
|
|
192
177
|
|
|
193
178
|
/** 一次调用的打点句柄。 */
|