3c9b65d982
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
14 KiB
TypeScript
256 lines
14 KiB
TypeScript
import type { ProjectMemory, ProjectRequirements } from '#shared/types'
|
||
import type { AgentRow, ChapterRow, CharacterRow, LoreRow, ProjectContext } from './context'
|
||
|
||
/* ============================================================
|
||
* 系统提示词分层:用户自定义(最高优先级)> 角色内置 > 写作要求
|
||
* ============================================================ */
|
||
|
||
export function composeSystem(customPrompt: string, builtin: string, extra = '') {
|
||
const parts: string[] = []
|
||
if (customPrompt.trim()) {
|
||
parts.push([
|
||
'<highest_priority_instructions>',
|
||
'以下是用户自定义的最高优先级指令。它们的优先级高于本提示中其他所有内容;如有任何冲突,一律以此为准。',
|
||
'',
|
||
customPrompt.trim(),
|
||
'</highest_priority_instructions>'
|
||
].join('\n'))
|
||
}
|
||
parts.push(builtin.trim())
|
||
if (extra.trim()) parts.push(extra.trim())
|
||
return parts.join('\n\n')
|
||
}
|
||
|
||
const section = (tag: string, body: string) => (body.trim() ? `<${tag}>\n${body.trim()}\n</${tag}>` : '')
|
||
const join = (...parts: string[]) => parts.filter(Boolean).join('\n\n')
|
||
|
||
export function formatRequirements(r: ProjectRequirements) {
|
||
const lines = [
|
||
r.language && `- 语言:${r.language}`,
|
||
r.style && `- 文风:${r.style}`,
|
||
r.pov && `- 叙事视角:${r.pov}`,
|
||
r.narration && `- 叙述方式:${r.narration}`,
|
||
r.wordsPerChapter && `- 每章目标字数:约 ${r.wordsPerChapter} 字`,
|
||
r.extra && `- 其他要求:${r.extra}`
|
||
].filter(Boolean)
|
||
return lines.join('\n')
|
||
}
|
||
|
||
export function formatCharacter(c: CharacterRow, full = true) {
|
||
const lines = [`## ${c.name}${c.identity ? `(${c.identity})` : ''}`]
|
||
if (c.description) lines.push(full ? c.description : truncate(c.description, 300))
|
||
if (c.personality) lines.push(`性格:${c.personality}`)
|
||
if (full && c.speechExamples) lines.push(`说话风格示例:\n${truncate(c.speechExamples, 1500)}`)
|
||
if (full && c.notes) lines.push(`备注:${c.notes}`)
|
||
return lines.join('\n')
|
||
}
|
||
|
||
/** 根据文本挑出出场人物;一个都没匹配到时返回全部角色 */
|
||
export function pickCharacters(chars: CharacterRow[], scanText: string) {
|
||
const hit = chars.filter(c => scanText.includes(c.name))
|
||
return hit.length ? hit : chars
|
||
}
|
||
|
||
function keyMatches(key: string, text: string) {
|
||
const m = key.match(/^\/(.+)\/([a-z]*)$/)
|
||
if (m) {
|
||
try { return new RegExp(m[1]!, m[2]).test(text) }
|
||
catch { return false }
|
||
}
|
||
return key.trim() !== '' && text.toLowerCase().includes(key.toLowerCase())
|
||
}
|
||
|
||
/** 世界书:常驻条目 + 关键词命中的条目 */
|
||
export function selectLore(lore: LoreRow[], scanText: string) {
|
||
return lore.filter(l => l.enabled && (l.constant || l.keys.some(k => keyMatches(k, scanText))))
|
||
}
|
||
|
||
export function formatLore(entries: LoreRow[]) {
|
||
return entries.map(e => `### ${e.title || e.keys.slice(0, 3).join('/') || '设定'}\n${e.content}`).join('\n\n')
|
||
}
|
||
|
||
export function formatMemory(m: ProjectMemory) {
|
||
const parts: string[] = []
|
||
if (m.characterStates.length) parts.push(`人物当前状态:\n${m.characterStates.map(s => `- ${s.name}:${s.state}`).join('\n')}`)
|
||
const open = m.foreshadowing.filter(f => f.status === 'open')
|
||
if (open.length) parts.push(`未回收的伏笔:\n${open.map(f => `- (第${f.chapter}章埋下)${f.text}`).join('\n')}`)
|
||
return parts.join('\n\n')
|
||
}
|
||
|
||
export function formatChapterPlan(chapters: ChapterRow[], highlightIndex?: number) {
|
||
return chapters.map((c) => {
|
||
const mark = c.index === highlightIndex ? ' ← 当前章节' : ''
|
||
return `第${c.index}章 ${c.title || '(未命名)'}${mark}${c.synopsis ? `:${c.synopsis}` : ''}`
|
||
}).join('\n')
|
||
}
|
||
|
||
/** 前情提要:最近 N 章给完整摘要,更早的章节只给梗概 */
|
||
export function formatPreviousSummaries(chapters: ChapterRow[], beforeIndex: number, recent: number) {
|
||
const prev = chapters.filter(c => c.index < beforeIndex && (c.summary || c.synopsis))
|
||
const older = prev.slice(0, Math.max(0, prev.length - recent))
|
||
const near = prev.slice(-recent)
|
||
const parts: string[] = []
|
||
if (older.length) parts.push(`更早章节梗概:\n${older.map(c => `- 第${c.index}章 ${c.title}:${truncate(c.summary || c.synopsis, 120)}`).join('\n')}`)
|
||
if (near.length) parts.push(near.map(c => `### 第${c.index}章 ${c.title}\n${c.summary || c.synopsis}`).join('\n\n'))
|
||
return parts.join('\n\n')
|
||
}
|
||
|
||
export function truncate(s: string, n: number) {
|
||
return s.length > n ? `${s.slice(0, n)}…` : s
|
||
}
|
||
|
||
export function tail(s: string, n: number) {
|
||
return s.length > n ? `…${s.slice(-n)}` : s
|
||
}
|
||
|
||
function storyBible(ctx: ProjectContext, scanText: string, opts: { fullCharacters: boolean }) {
|
||
const { project, characters, lore } = ctx
|
||
const chars = opts.fullCharacters ? characters : pickCharacters(characters, scanText)
|
||
return join(
|
||
section('story_title', project.title),
|
||
section('world_setting', project.worldSetting),
|
||
section('characters', chars.map(c => formatCharacter(c, true)).join('\n\n')),
|
||
section('lore', formatLore(selectLore(lore, scanText))),
|
||
section('story_outline', project.storyOutline),
|
||
section('writing_requirements', formatRequirements(project.requirements))
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
* 主编 Editor
|
||
* ============================================================ */
|
||
|
||
export const EDITOR_BUILTIN = `你是一部长篇小说的主编,负责把控整部作品的走向、结构与质量。
|
||
你的职责:
|
||
1. 依据故事背景、人物设定和故事大纲,规划全书的章节结构,保证起承转合、节奏张弛有度、伏笔有埋有收。
|
||
2. 在每一章开写之前,为作家撰写一份清晰、可执行的「章节简报」:本章要完成的剧情目标、关键情节点(按顺序)、出场人物及其状态与动机、场景、情绪基调、节奏、文风要求、目标字数、需要埋下或回收的伏笔、结尾钩子。
|
||
3. 从作家团队中挑选最适合本章风格的作家(或在允许时创建新作家)来执行写作。
|
||
4. 你不亲自写正文。你看到的是前文的摘要而不是全文,请据此保证连续性。
|
||
工作原则:简报要具体,避免空泛的形容词;尊重既定设定和人物性格,不要擅自改动核心设定;输出使用简体中文(除非写作要求另有规定)。`
|
||
|
||
export function buildEditorBriefPrompt(ctx: ProjectContext, chapter: ChapterRow, writers: AgentRow[], allowCreate: boolean) {
|
||
const { project, chapters } = ctx
|
||
const scan = `${chapter.title}\n${chapter.synopsis}\n${project.storyOutline}`
|
||
const roster = writers.length
|
||
? writers.map((w) => {
|
||
const model = ctx.models.find(m => m.id === w.modelId)
|
||
return `- id=${w.id}|${w.name}|模型:${model ? `${model.providerName}/${model.label || model.modelId}` : '未配置'}${w.styleNotes ? `|擅长:${w.styleNotes}` : ''}`
|
||
}).join('\n')
|
||
: '(暂无作家)'
|
||
const modelList = ctx.models.map(m => `- modelId=${m.id}|${m.providerName} / ${m.label || m.modelId}`).join('\n')
|
||
|
||
const prompt = join(
|
||
storyBible(ctx, scan, { fullCharacters: true }),
|
||
section('chapter_plan', formatChapterPlan(chapters, chapter.index)),
|
||
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
|
||
section('story_memory', formatMemory(project.memory)),
|
||
section('writers', roster),
|
||
allowCreate ? section('available_models', modelList) : '',
|
||
chapter.brief ? section('previous_brief_draft', chapter.brief) : '',
|
||
section('task', [
|
||
`请为第${chapter.index}章${chapter.title ? `「${chapter.title}」` : ''}制定章节简报。`,
|
||
chapter.synopsis ? `本章既定梗概:${chapter.synopsis}` : '本章尚无梗概,请根据大纲和前情自行确定本章内容。',
|
||
'步骤:',
|
||
'1. 调用 write_brief 工具提交章节简报(必须调用)。',
|
||
chapter.writerLocked
|
||
? '2. 用户已为本章指定作家,无需再选择作家。'
|
||
: allowCreate
|
||
? '2. 调用 assign_writer 从作家团队中选择一位作家;如果没有合适的作家,可以调用 create_writer 用 available_models 中的模型创建一位新作家(创建后自动指派)。'
|
||
: '2. 调用 assign_writer 从作家团队中选择一位作家。',
|
||
'3. 完成后用一两句话说明你的安排思路。'
|
||
].join('\n'))
|
||
)
|
||
return prompt
|
||
}
|
||
|
||
export function buildEditorOutlinePrompt(ctx: ProjectContext, count: number, fromIndex: number) {
|
||
const { project, chapters } = ctx
|
||
return join(
|
||
storyBible(ctx, project.storyOutline, { fullCharacters: true }),
|
||
section('existing_chapter_plan', formatChapterPlan(chapters)),
|
||
section('previous_chapters', formatPreviousSummaries(chapters, fromIndex, project.settings.recentSummaryCount)),
|
||
section('story_memory', formatMemory(project.memory)),
|
||
section('task', [
|
||
`请规划从第${fromIndex}章开始的 ${count} 个章节(第${fromIndex}章 ~ 第${fromIndex + count - 1}章)。`,
|
||
'要求:每章给出标题和 80~200 字的梗概,梗概需写明本章的核心事件、冲突与转折、结尾落点;章节之间要有因果递进,节奏有张有弛。',
|
||
'已确认(approved)或已写完的章节不要改动;之后的章节可以根据需要重新规划。',
|
||
'请调用 set_chapter_plan 工具一次性提交规划结果,然后用一两句话概括你的整体设计思路。'
|
||
].join('\n'))
|
||
)
|
||
}
|
||
|
||
export function buildEditorReviewPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
|
||
return join(
|
||
section('writing_requirements', formatRequirements(ctx.project.requirements)),
|
||
section('characters', pickCharacters(ctx.characters, text).map(c => formatCharacter(c, false)).join('\n\n')),
|
||
section('story_memory', formatMemory(ctx.project.memory)),
|
||
section('chapter_brief', chapter.brief),
|
||
section('chapter_text', text),
|
||
section('task', [
|
||
`请以主编身份审阅第${chapter.index}章正文。`,
|
||
'从以下维度给出评价:简报完成度(逐条对照情节点)、人物是否 OOC、与设定/前文是否矛盾、文风与要求是否一致、节奏与可读性、语言问题。',
|
||
'最后给出 1~10 分的总评分,并列出可以直接交给作家执行的修改意见(编号列表)。用 Markdown 输出。'
|
||
].join('\n'))
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
* 作家 Writer
|
||
* ============================================================ */
|
||
|
||
export const WRITER_BUILTIN = `你是一位专业的小说作家,正在为一部长篇小说执笔其中一章。
|
||
你会收到故事设定、人物资料、前情提要和主编给出的「章节简报」。你的任务是把简报写成完整、生动、可直接发表的小说正文。
|
||
写作原则:
|
||
- 严格完成简报中的全部情节点,按顺序推进,不要跳过,也不要擅自写到下一章的内容。
|
||
- 人物言行必须符合其性格与当前状态;对话要有个性。
|
||
- 多用具体的动作、细节、感官描写和对白来展现,少用概括性的叙述。
|
||
- 与上一章结尾自然衔接,不要重复上一章已经写过的内容。
|
||
- 只输出小说正文本身:不要输出章节标题、不要写“好的”“以下是”等说明,不要在结尾加总结或注释。`
|
||
|
||
export function buildWriterPrompt(ctx: ProjectContext, chapter: ChapterRow, prevTail: string, feedback: string, previousDraft: string) {
|
||
const { project, chapters } = ctx
|
||
const scan = `${chapter.brief}\n${chapter.title}\n${chapter.synopsis}`
|
||
const plan = chapters.filter(c => Math.abs(c.index - chapter.index) <= 2)
|
||
return join(
|
||
storyBible(ctx, scan, { fullCharacters: false }),
|
||
section('nearby_chapter_plan', formatChapterPlan(plan, chapter.index)),
|
||
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
|
||
section('story_memory', formatMemory(project.memory)),
|
||
section('previous_chapter_ending', prevTail),
|
||
section('chapter_brief', `第${chapter.index}章 ${chapter.title}\n\n${chapter.brief || chapter.synopsis}`),
|
||
previousDraft ? section('previous_draft', previousDraft) : '',
|
||
feedback ? section('revision_feedback', feedback) : '',
|
||
section('task', previousDraft
|
||
? `请根据 revision_feedback 修改 previous_draft,输出修改后的第${chapter.index}章完整正文。`
|
||
: `请开始写第${chapter.index}章正文,目标约 ${project.requirements.wordsPerChapter} 字。`)
|
||
)
|
||
}
|
||
|
||
export function writerExtra(r: ProjectRequirements) {
|
||
const req = formatRequirements(r)
|
||
return req ? `写作要求:\n${req}` : ''
|
||
}
|
||
|
||
/* ============================================================
|
||
* 摘要员 Summarizer
|
||
* ============================================================ */
|
||
|
||
export const SUMMARIZER_BUILTIN = `你是小说编辑部的资料员,负责在每章定稿后整理“故事记忆”,供主编和后续作家保持连贯。
|
||
你只输出一个 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。`
|
||
|
||
export function buildSummaryPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
|
||
return join(
|
||
section('character_names', ctx.characters.map(c => c.name).join('、')),
|
||
section('story_memory_before', formatMemory(ctx.project.memory)),
|
||
section('chapter_text', `第${chapter.index}章 ${chapter.title}\n\n${text}`),
|
||
section('task', `请阅读本章正文,输出如下 JSON:
|
||
{
|
||
"summary": "本章剧情摘要,300~500 字,按时间顺序写清楚发生了什么、人物做出了什么选择、关系和局势有什么变化、结尾停在哪里",
|
||
"characterStates": [{ "name": "人物名", "state": "该人物在本章结束时的处境、情绪、位置、身体状况、关键持有物等(只列本章有变化的人物)" }],
|
||
"newForeshadowing": ["本章新埋下的伏笔或悬念"],
|
||
"resolvedForeshadowing": ["本章回收/揭晓的伏笔(尽量引用 story_memory_before 中的原文)"],
|
||
"events": ["本章关键事件,一句话一条,最多 5 条"]
|
||
}`)
|
||
)
|
||
}
|