@waterwx/dsh-novel-forge 0.1.0 → 1.0.0
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 +51 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +3456 -1316
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -37
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +32 -2
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +25 -6
- package/lib/types/protocol.d.ts +82 -0
- package/package.json +1 -13
- package/src/assistant.ts +24 -3
- package/src/bookshelf.ts +11 -0
- package/src/client/api.ts +38 -3
- package/src/client/panel/AssetsTab.tsx +132 -17
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/NovelPanel.tsx +1341 -172
- package/src/client/panel/panel.module.css +863 -59
- package/src/engine.ts +235 -21
- package/src/protocol.ts +84 -0
- package/src/routes.ts +225 -8
package/src/engine.ts
CHANGED
|
@@ -12,12 +12,13 @@ import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOpti
|
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis'
|
|
13
13
|
import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
|
|
14
14
|
import type {
|
|
15
|
+
AuditIssue,
|
|
15
16
|
ChapterPlan,
|
|
16
|
-
CharacterCard,
|
|
17
17
|
Foreshadow,
|
|
18
18
|
NovelConfig,
|
|
19
19
|
ProjectState,
|
|
20
20
|
ReviewReport,
|
|
21
|
+
RoleStatusCard,
|
|
21
22
|
StoryBible,
|
|
22
23
|
Volume,
|
|
23
24
|
} from './protocol.ts'
|
|
@@ -66,6 +67,7 @@ export function loadProject(outputDir: string): ProjectState | undefined {
|
|
|
66
67
|
if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = []
|
|
67
68
|
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = []
|
|
68
69
|
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = []
|
|
70
|
+
if (!Array.isArray(raw.facts)) raw.facts = []
|
|
69
71
|
return raw
|
|
70
72
|
} catch {
|
|
71
73
|
return undefined
|
|
@@ -83,7 +85,7 @@ export function listChapterFiles(outputDir: string): string[] {
|
|
|
83
85
|
if (!existsSync(outputDir)) return []
|
|
84
86
|
try {
|
|
85
87
|
return readdirSync(outputDir)
|
|
86
|
-
.filter(name => /^第\d+章_.*\.md$/.test(name))
|
|
88
|
+
.filter(name => /^第\d+章_.*\.md$/.test(name) && !name.endsWith('.bak.md'))
|
|
87
89
|
.sort((a, b) => {
|
|
88
90
|
const na = Number(/^第(\d+)章/.exec(a)?.[1] ?? 0)
|
|
89
91
|
const nb = Number(/^第(\d+)章/.exec(b)?.[1] ?? 0)
|
|
@@ -129,6 +131,7 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
|
|
|
129
131
|
chapters: [],
|
|
130
132
|
foreshadows: [],
|
|
131
133
|
assets: emptyProjectAssets(),
|
|
134
|
+
facts: [],
|
|
132
135
|
createdAt: now,
|
|
133
136
|
updatedAt: now,
|
|
134
137
|
}
|
|
@@ -312,13 +315,13 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
|
|
|
312
315
|
}>(text)
|
|
313
316
|
const strArray = (value: unknown): string[] =>
|
|
314
317
|
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
315
|
-
const characters:
|
|
318
|
+
const characters: StoryBible['characters'] = Array.isArray(raw.characters)
|
|
316
319
|
? raw.characters
|
|
317
320
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
318
321
|
.map(entry => ({
|
|
319
322
|
name: typeof entry.name === 'string' ? entry.name.trim() : '未命名',
|
|
320
323
|
role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(entry.role as never)
|
|
321
|
-
? entry.role as
|
|
324
|
+
? entry.role as StoryBible['characters'][number]['role']
|
|
322
325
|
: 'other',
|
|
323
326
|
traits: strArray(entry.traits),
|
|
324
327
|
goals: typeof entry.goals === 'string' ? entry.goals : '',
|
|
@@ -409,8 +412,8 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
|
|
|
409
412
|
'2. 章节之间要衔接自然,前章结尾为后章埋下钩子。',
|
|
410
413
|
'3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
|
|
411
414
|
'4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
|
|
412
|
-
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "
|
|
413
|
-
'
|
|
415
|
+
'5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
|
|
416
|
+
'重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
|
|
414
417
|
'重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
|
|
415
418
|
volumeBlock,
|
|
416
419
|
].join('\n')
|
|
@@ -608,7 +611,7 @@ export async function* rewriteChapterStream(
|
|
|
608
611
|
chapterNo: number,
|
|
609
612
|
instructions: string,
|
|
610
613
|
target?: string,
|
|
611
|
-
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: '
|
|
614
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
612
615
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
613
616
|
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
614
617
|
const body = readChapterFile(outputDir, chapter)
|
|
@@ -711,16 +714,14 @@ export async function* rewriteChapterStream(
|
|
|
711
714
|
}
|
|
712
715
|
if (newBody.length < 100) throw new Error('修订结果过短,可能失败,请重试')
|
|
713
716
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
chapter.
|
|
718
|
-
chapter.chars = newBody.length
|
|
717
|
+
// Draft mode: do NOT overwrite the file yet. Store the new body as a
|
|
718
|
+
// pending draft; the user reviews the diff and decides to apply or
|
|
719
|
+
// discard. File overwrite + status change happen on draft/apply.
|
|
720
|
+
chapter.pendingDraft = newBody
|
|
719
721
|
chapter.error = undefined
|
|
720
|
-
chapter.review = undefined
|
|
721
722
|
project.updatedAt = new Date().toISOString()
|
|
722
723
|
saveProject(outputDir, project)
|
|
723
|
-
yield { frame: '
|
|
724
|
+
yield { frame: 'drafted', chars: newBody.length, draft: newBody }
|
|
724
725
|
}
|
|
725
726
|
|
|
726
727
|
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
@@ -738,14 +739,15 @@ function polishSystemPrompt(project: ProjectState): string {
|
|
|
738
739
|
].join('\n')
|
|
739
740
|
}
|
|
740
741
|
|
|
741
|
-
/** Stream a chapter polish (de-AI-ify).
|
|
742
|
+
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
743
|
+
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
742
744
|
export async function* polishChapterStream(
|
|
743
745
|
ctx: Context,
|
|
744
746
|
config: NovelConfig,
|
|
745
747
|
project: ProjectState,
|
|
746
748
|
outputDir: string,
|
|
747
749
|
chapterNo: number,
|
|
748
|
-
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: '
|
|
750
|
+
): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
|
|
749
751
|
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
750
752
|
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
751
753
|
const body = readChapterFile(outputDir, chapter)
|
|
@@ -786,13 +788,12 @@ export async function* polishChapterStream(
|
|
|
786
788
|
.trim()
|
|
787
789
|
if (streamError !== undefined) throw streamError
|
|
788
790
|
if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
chapter.
|
|
792
|
-
chapter.chars = newBody.length
|
|
791
|
+
|
|
792
|
+
// Draft mode: keep the original file untouched until the user decides.
|
|
793
|
+
chapter.pendingDraft = newBody
|
|
793
794
|
project.updatedAt = new Date().toISOString()
|
|
794
795
|
saveProject(outputDir, project)
|
|
795
|
-
yield { frame: '
|
|
796
|
+
yield { frame: 'drafted', chars: newBody.length, draft: newBody }
|
|
796
797
|
}
|
|
797
798
|
|
|
798
799
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
@@ -820,11 +821,16 @@ export async function* generateChapterStream(
|
|
|
820
821
|
}
|
|
821
822
|
}
|
|
822
823
|
const prevSummary = prev?.summary
|
|
824
|
+
// 事实库注入:最近 20 条已确立事实,约束本章不与既定状态矛盾。
|
|
825
|
+
const recentFacts = (project.facts ?? []).slice(-20).map(f => f.text)
|
|
823
826
|
|
|
824
827
|
const user = [
|
|
825
828
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
826
829
|
`本章剧情要点:${chapter.beats}`,
|
|
827
830
|
'',
|
|
831
|
+
recentFacts.length > 0
|
|
832
|
+
? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join('\n')}`
|
|
833
|
+
: '',
|
|
828
834
|
prevSummary !== undefined && prevSummary !== ''
|
|
829
835
|
? `上一章摘要:${prevSummary}`
|
|
830
836
|
: '',
|
|
@@ -915,6 +921,214 @@ export async function summarizeChapter(
|
|
|
915
921
|
return chapter.summary
|
|
916
922
|
}
|
|
917
923
|
|
|
924
|
+
/**
|
|
925
|
+
* 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
|
|
926
|
+
* 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
|
|
927
|
+
* @returns 新增事实条数(失败返回 0,调用方 best-effort)。
|
|
928
|
+
*/
|
|
929
|
+
export async function extractFacts(
|
|
930
|
+
ctx: Context,
|
|
931
|
+
config: NovelConfig,
|
|
932
|
+
project: ProjectState,
|
|
933
|
+
outputDir: string,
|
|
934
|
+
chapterNo: number,
|
|
935
|
+
): Promise<number> {
|
|
936
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
937
|
+
if (chapter === undefined) return 0
|
|
938
|
+
const body = readChapterFile(outputDir, chapter)
|
|
939
|
+
if (body === undefined) return 0
|
|
940
|
+
const system = [
|
|
941
|
+
'你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。',
|
|
942
|
+
'事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。',
|
|
943
|
+
'要求:',
|
|
944
|
+
'1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。',
|
|
945
|
+
'2. 每行一条事实,用客观陈述句,不含主观评价。',
|
|
946
|
+
'3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。',
|
|
947
|
+
].join('\n')
|
|
948
|
+
const user = body.replace(/^#\s+.*$/m, '').trim()
|
|
949
|
+
// v4-flash 推理模型:reasoning channel 也占 maxTokens,预算给足避免截断。
|
|
950
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 4000) })
|
|
951
|
+
const lines = text.split('\n')
|
|
952
|
+
.map(line => line.replace(/^[-*\d.\s]+/, '').trim())
|
|
953
|
+
.filter(line => line.length > 8)
|
|
954
|
+
.slice(0, 8)
|
|
955
|
+
if (lines.length === 0) return 0
|
|
956
|
+
const facts = project.facts ?? []
|
|
957
|
+
for (const line of lines) facts.push({ chapterNo, text: line.slice(0, 140) })
|
|
958
|
+
project.facts = facts.slice(-300)
|
|
959
|
+
project.updatedAt = new Date().toISOString()
|
|
960
|
+
saveProject(outputDir, project)
|
|
961
|
+
return lines.length
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ------------------------------------------------------------ book audit
|
|
965
|
+
|
|
966
|
+
/** 全书一致性质检:LLM 扫描已生成章节 + 设定 + 事实库,输出矛盾清单。 */
|
|
967
|
+
export async function auditBook(
|
|
968
|
+
ctx: Context,
|
|
969
|
+
config: NovelConfig,
|
|
970
|
+
project: ProjectState,
|
|
971
|
+
outputDir: string,
|
|
972
|
+
): Promise<AuditIssue[]> {
|
|
973
|
+
const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
|
|
974
|
+
if (written.length === 0) return []
|
|
975
|
+
const system = [
|
|
976
|
+
'你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和各章正文节选。',
|
|
977
|
+
'请找出全书的一致性矛盾,例如:',
|
|
978
|
+
'- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。',
|
|
979
|
+
'- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。',
|
|
980
|
+
'- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。',
|
|
981
|
+
'- 细节穿帮:人名/地名/物品/数字前后不一致。',
|
|
982
|
+
'要求:',
|
|
983
|
+
'1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。',
|
|
984
|
+
'2. 每条必须定位到具体章节号。',
|
|
985
|
+
'3. 输出必须是合法 JSON 数组,格式:[{"chapterNo": 章节号, "severity": "high|medium|low", "item": "矛盾描述", "suggestion": "修改建议"}]',
|
|
986
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
987
|
+
].join('\n')
|
|
988
|
+
const factsBlock = (project.facts ?? []).slice(-80).map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
989
|
+
const chapterBlocks = written.map(c => {
|
|
990
|
+
const body = readChapterFile(outputDir, c)
|
|
991
|
+
const excerpt = (body ?? '').replace(/^#\s+.*$/m, '').trim().slice(0, 700)
|
|
992
|
+
return `【第${c.no}章《${c.title}》】\n${excerpt}`
|
|
993
|
+
}).join('\n\n')
|
|
994
|
+
const user = [
|
|
995
|
+
'请对以下小说做全书一致性质检。',
|
|
996
|
+
project.bible !== undefined
|
|
997
|
+
? '设定圣经:\n' + [
|
|
998
|
+
project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map(r => `- ${r}`).join('\n')}` : '',
|
|
999
|
+
project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map(r => `- ${r}`).join('\n')}` : '',
|
|
1000
|
+
project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')}` : '',
|
|
1001
|
+
].filter(s => s !== '').join('\n')
|
|
1002
|
+
: '',
|
|
1003
|
+
factsBlock !== '' ? `已确立事实库:\n${factsBlock}` : '',
|
|
1004
|
+
`正文节选(每章前 700 字):\n${chapterBlocks}`,
|
|
1005
|
+
'只输出 JSON 数组。',
|
|
1006
|
+
].filter(s => s !== '').join('\n\n')
|
|
1007
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000) })
|
|
1008
|
+
const parsed = parseJsonArray<Record<string, unknown>>(text)
|
|
1009
|
+
const issues: AuditIssue[] = []
|
|
1010
|
+
for (const entry of parsed) {
|
|
1011
|
+
const item = typeof entry.item === 'string' ? entry.item : ''
|
|
1012
|
+
if (item === '') continue
|
|
1013
|
+
issues.push({
|
|
1014
|
+
chapterNo: Number(entry.chapterNo) || 0,
|
|
1015
|
+
severity: ['high', 'medium', 'low'].includes(entry.severity as string)
|
|
1016
|
+
? entry.severity as AuditIssue['severity']
|
|
1017
|
+
: 'medium',
|
|
1018
|
+
item,
|
|
1019
|
+
suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
return issues.slice(0, 30)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
|
|
1027
|
+
* @returns 回填的章节数。
|
|
1028
|
+
*/
|
|
1029
|
+
export async function backfillFacts(
|
|
1030
|
+
ctx: Context,
|
|
1031
|
+
config: NovelConfig,
|
|
1032
|
+
project: ProjectState,
|
|
1033
|
+
outputDir: string,
|
|
1034
|
+
): Promise<number> {
|
|
1035
|
+
const have = new Set((project.facts ?? []).map(f => f.chapterNo))
|
|
1036
|
+
let filled = 0
|
|
1037
|
+
for (const chapter of project.chapters) {
|
|
1038
|
+
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
1039
|
+
if (chapter.file === undefined || have.has(chapter.no)) continue
|
|
1040
|
+
try {
|
|
1041
|
+
const n = await extractFacts(ctx, config, project, outputDir, chapter.no)
|
|
1042
|
+
if (n > 0) filled++
|
|
1043
|
+
} catch { /* best-effort per chapter */ }
|
|
1044
|
+
have.add(chapter.no)
|
|
1045
|
+
}
|
|
1046
|
+
return filled
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
|
|
1051
|
+
* 最近出现章节),LLM 只负责聚合「当前状态」一句话。
|
|
1052
|
+
*/
|
|
1053
|
+
export async function refreshCharacters(
|
|
1054
|
+
ctx: Context,
|
|
1055
|
+
config: NovelConfig,
|
|
1056
|
+
project: ProjectState,
|
|
1057
|
+
outputDir: string,
|
|
1058
|
+
): Promise<RoleStatusCard[]> {
|
|
1059
|
+
const roster = project.bible?.characters ?? []
|
|
1060
|
+
const facts = project.facts ?? []
|
|
1061
|
+
if (roster.length === 0 && facts.length === 0) return []
|
|
1062
|
+
|
|
1063
|
+
// 服务端精确出场统计:遍历已写章节正文,统计每个角色名出现过的章节。
|
|
1064
|
+
const stat = new Map<string, { chapters: Set<number>; last: number }>()
|
|
1065
|
+
const known = roster.map(card => card.name)
|
|
1066
|
+
for (const chapter of project.chapters) {
|
|
1067
|
+
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
1068
|
+
const body = readChapterFile(outputDir, chapter)
|
|
1069
|
+
if (body === undefined) continue
|
|
1070
|
+
for (const name of known) {
|
|
1071
|
+
if (body.includes(name)) {
|
|
1072
|
+
const entry = stat.get(name) ?? { chapters: new Set<number>(), last: 0 }
|
|
1073
|
+
entry.chapters.add(chapter.no)
|
|
1074
|
+
if (chapter.no > entry.last) entry.last = chapter.no
|
|
1075
|
+
stat.set(name, entry)
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// LLM 只聚合状态:名单(含 traits)+ 事实库 → [{name, status}]
|
|
1081
|
+
let statuses = new Map<string, string>()
|
|
1082
|
+
if (facts.length > 0) {
|
|
1083
|
+
const system = [
|
|
1084
|
+
'你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。',
|
|
1085
|
+
'输出必须是合法 JSON 数组,格式:[{"name": "角色名", "status": "当前状态一句话"}]',
|
|
1086
|
+
'重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
1087
|
+
].join('\n')
|
|
1088
|
+
const rosterBlock = roster.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')
|
|
1089
|
+
const factsBlock = facts.map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
|
|
1090
|
+
const user = [
|
|
1091
|
+
`角色名单:\n${rosterBlock}`,
|
|
1092
|
+
`已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6000)}`,
|
|
1093
|
+
'只输出 JSON 数组。',
|
|
1094
|
+
].join('\n\n')
|
|
1095
|
+
try {
|
|
1096
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000) })
|
|
1097
|
+
for (const entry of parseJsonArray<Record<string, unknown>>(text)) {
|
|
1098
|
+
const name = typeof entry.name === 'string' ? entry.name : ''
|
|
1099
|
+
if (name !== '' && typeof entry.status === 'string') statuses.set(name, entry.status)
|
|
1100
|
+
}
|
|
1101
|
+
} catch { /* status 聚合失败则只给出场统计 */ }
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// 合并:出场统计(精确)+ 状态(LLM)+ 名单角色补全。
|
|
1105
|
+
const cards: RoleStatusCard[] = []
|
|
1106
|
+
const roleOf = (name: string): string => roster.find(c => c.name === name)?.role ?? 'other'
|
|
1107
|
+
for (const card of roster) {
|
|
1108
|
+
const entry = stat.get(card.name)
|
|
1109
|
+
cards.push({
|
|
1110
|
+
name: card.name,
|
|
1111
|
+
role: card.role,
|
|
1112
|
+
status: statuses.get(card.name) ?? '',
|
|
1113
|
+
lastChapter: entry?.last ?? 0,
|
|
1114
|
+
appearances: entry?.chapters.size ?? 0,
|
|
1115
|
+
})
|
|
1116
|
+
}
|
|
1117
|
+
// 名单外的角色(从事实库中识别到但不在设定圣经名单)仅当有出场统计时补充。
|
|
1118
|
+
for (const [name, entry] of stat) {
|
|
1119
|
+
if (!cards.some(c => c.name === name)) {
|
|
1120
|
+
cards.push({
|
|
1121
|
+
name,
|
|
1122
|
+
role: roleOf(name),
|
|
1123
|
+
status: statuses.get(name) ?? '',
|
|
1124
|
+
lastChapter: entry.last,
|
|
1125
|
+
appearances: entry.chapters.size,
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return cards
|
|
1130
|
+
}
|
|
1131
|
+
|
|
918
1132
|
// ------------------------------------------------------------- foreshadows
|
|
919
1133
|
|
|
920
1134
|
/** System prompt for foreshadow suggestions. */
|
package/src/protocol.ts
CHANGED
|
@@ -19,6 +19,10 @@ export const NOVEL_API = {
|
|
|
19
19
|
review: '/api/dsh-novel-forge/review',
|
|
20
20
|
rewrite: '/api/dsh-novel-forge/rewrite',
|
|
21
21
|
polish: '/api/dsh-novel-forge/polish',
|
|
22
|
+
/** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
|
|
23
|
+
draftApply: '/api/dsh-novel-forge/draft/apply',
|
|
24
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
25
|
+
draftDiscard: '/api/dsh-novel-forge/draft/discard',
|
|
22
26
|
summary: '/api/dsh-novel-forge/summary',
|
|
23
27
|
foreshadow: '/api/dsh-novel-forge/foreshadow',
|
|
24
28
|
exportBook: '/api/dsh-novel-forge/export',
|
|
@@ -26,6 +30,16 @@ export const NOVEL_API = {
|
|
|
26
30
|
assistant: '/api/dsh-novel-forge/assistant',
|
|
27
31
|
assistantHistory: '/api/dsh-novel-forge/assistant-history',
|
|
28
32
|
bookshelf: '/api/dsh-novel-forge/bookshelf',
|
|
33
|
+
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
34
|
+
reset: '/api/dsh-novel-forge/reset',
|
|
35
|
+
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
36
|
+
audit: '/api/dsh-novel-forge/audit',
|
|
37
|
+
/** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
|
|
38
|
+
charactersRefresh: '/api/dsh-novel-forge/characters/refresh',
|
|
39
|
+
/** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
|
|
40
|
+
factsBackfill: '/api/dsh-novel-forge/facts/backfill',
|
|
41
|
+
/** 设定圣经局部修补(如世界观规则编辑)。 */
|
|
42
|
+
biblePatch: '/api/dsh-novel-forge/bible/patch',
|
|
29
43
|
config: '/api/dsh-novel-forge/config',
|
|
30
44
|
openFolder: '/api/dsh-novel-forge/open-folder',
|
|
31
45
|
} as const
|
|
@@ -55,6 +69,14 @@ export interface BookshelfSnapshot {
|
|
|
55
69
|
export interface BookCreateRequest {
|
|
56
70
|
bookName: string
|
|
57
71
|
outputDir?: string
|
|
72
|
+
/** 开书向导:创建时直接导入的大纲文本(提供则立即建立项目)。 */
|
|
73
|
+
outline?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** POST /reset 请求:重置项目(可选更新大纲)。 */
|
|
77
|
+
export interface ResetRequest {
|
|
78
|
+
/** 新大纲文本;提供则替换 outline,否则保留原大纲。 */
|
|
79
|
+
outline?: string
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
/** POST /bookshelf/activate 请求:切换当前书。 */
|
|
@@ -101,6 +123,11 @@ export interface ChapterPlan {
|
|
|
101
123
|
summary?: string
|
|
102
124
|
/** Latest review report (present once reviewed). */
|
|
103
125
|
review?: ReviewReport
|
|
126
|
+
/**
|
|
127
|
+
* 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
|
|
128
|
+
* 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
|
|
129
|
+
*/
|
|
130
|
+
pendingDraft?: string
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
/** One review finding. */
|
|
@@ -185,6 +212,54 @@ export interface Foreshadow {
|
|
|
185
212
|
resolvedNote?: string
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
/** 一条已确立的叙事事实(事实库/时间线,注入后续章节生成)。 */
|
|
216
|
+
export interface ChapterFact {
|
|
217
|
+
/** 来源章节号。 */
|
|
218
|
+
chapterNo: number
|
|
219
|
+
/** 事实文本(人物状态/境界资源/关系变化/伏笔落地等)。 */
|
|
220
|
+
text: string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 一条全书质检发现的问题(一致性矛盾,定位到章)。 */
|
|
224
|
+
export interface AuditIssue {
|
|
225
|
+
/** 问题所在章节号(无法定位时 0)。 */
|
|
226
|
+
chapterNo: number
|
|
227
|
+
severity: 'high' | 'medium' | 'low'
|
|
228
|
+
/** 矛盾描述。 */
|
|
229
|
+
item: string
|
|
230
|
+
/** 修改建议。 */
|
|
231
|
+
suggestion: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** POST /audit 响应。 */
|
|
235
|
+
export interface AuditResponse {
|
|
236
|
+
issues: AuditIssue[]
|
|
237
|
+
/** 参与质检的章节数。 */
|
|
238
|
+
auditedChapters: number
|
|
239
|
+
/** 质检时间。 */
|
|
240
|
+
auditedAt: string
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 角色卡:角色当前状态(从事实库聚合)。 */
|
|
244
|
+
export interface RoleStatusCard {
|
|
245
|
+
name: string
|
|
246
|
+
/** protagonist / supporting / antagonist / other。 */
|
|
247
|
+
role: string
|
|
248
|
+
/** 当前状态一句话(境界/资源/伤势/心境)。 */
|
|
249
|
+
status: string
|
|
250
|
+
/** 最近出场章节。 */
|
|
251
|
+
lastChapter: number
|
|
252
|
+
/** 出场次数。 */
|
|
253
|
+
appearances: number
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** POST /bible/patch 请求:局部修补设定圣经。 */
|
|
257
|
+
export interface BiblePatchRequest {
|
|
258
|
+
worldRules?: string[]
|
|
259
|
+
redLines?: string[]
|
|
260
|
+
style?: string[]
|
|
261
|
+
}
|
|
262
|
+
|
|
188
263
|
/** The persisted project: outline + bible + plan + progress. */
|
|
189
264
|
export interface ProjectState {
|
|
190
265
|
/** Book title (first non-empty line of the outline, usually). */
|
|
@@ -203,6 +278,8 @@ export interface ProjectState {
|
|
|
203
278
|
foreshadows: Foreshadow[]
|
|
204
279
|
/** 写作资产(题材基底/推进模式/反AI规则/写法资产)。 */
|
|
205
280
|
assets?: ProjectAssets
|
|
281
|
+
/** 事实库/时间线:每章生成后抽取,注入后续章节保持一致性。 */
|
|
282
|
+
facts?: ChapterFact[]
|
|
206
283
|
/** ISO timestamps. */
|
|
207
284
|
createdAt: string
|
|
208
285
|
updatedAt: string
|
|
@@ -302,6 +379,8 @@ export type JobFrame =
|
|
|
302
379
|
| { type: 'done'; no: number; file: string; chars: number; title: string }
|
|
303
380
|
| { type: 'review'; no: number; report: ReviewReport }
|
|
304
381
|
| { type: 'rewritten'; no: number; file: string; chars: number }
|
|
382
|
+
/** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
|
|
383
|
+
| { type: 'drafted'; no: number; chars: number; draft: string }
|
|
305
384
|
| { type: 'error'; no: number; message: string }
|
|
306
385
|
|
|
307
386
|
/** POST /review request: review one written chapter. */
|
|
@@ -326,6 +405,11 @@ export interface PolishRequest {
|
|
|
326
405
|
chapterNo: number
|
|
327
406
|
}
|
|
328
407
|
|
|
408
|
+
/** POST /draft/apply | /draft/discard request: 采纳或放弃待确认草稿。 */
|
|
409
|
+
export interface DraftDecisionRequest {
|
|
410
|
+
chapterNo: number
|
|
411
|
+
}
|
|
412
|
+
|
|
329
413
|
/** POST /summary request: (re)generate a chapter summary. */
|
|
330
414
|
export interface SummaryRequest {
|
|
331
415
|
chapterNo: number
|