Files
ai-novel-studio/server/utils/prompts.ts
T
jocay 3c9b65d982 AI 小说工坊:主编 + 作家多模型协作写作应用
- 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>
2026-10-18 02:04:40 +08:00

256 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 条"]
}`)
)
}