Files
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

514 lines
22 KiB
TypeScript
Raw Permalink 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 { generateText, isStepCount, streamText, tool, type ModelMessage } from 'ai'
import { and, desc, eq, gt } from 'drizzle-orm'
import { z } from 'zod'
import type { ProjectMemory } from '#shared/types'
import type { ChapterRow } from './context'
/* ---------------- 工具函数 ---------------- */
function updateChapter(projectId: string, chapterId: string, patch: Partial<ChapterRow>) {
const row = useDb().update(tables.chapters).set(patch).where(eq(tables.chapters.id, chapterId)).returning().get()
emitProject(projectId, { type: 'chapter', chapterId })
return row!
}
function updateMemory(projectId: string, memory: ProjectMemory) {
useDb().update(tables.projects).set({ memory }).where(eq(tables.projects.id, projectId)).run()
}
/** 去掉章节文件开头的 “# 第N章 标题” 行,得到纯正文 */
export function stripHeading(text: string) {
return text.replace(/^#\s[^\n]*\n+/, '')
}
export async function readActiveBody(chapter: ChapterRow) {
const v = getVersion(chapter.activeVersionId)
if (!v) return ''
return stripHeading(await readChapterFile(v.filePath))
}
function requireModelAgent(agent: ReturnType<typeof getAgent>, roleName: string) {
if (!agent) throw createError({ statusCode: 400, message: `项目缺少${roleName},请在「AI 团队」中配置` })
return { agent, ...resolveModel(agent.modelId) }
}
function formatBriefMarkdown(b: BriefInput) {
const lines: string[] = []
lines.push(`**本章目标**:${b.goal}`)
if (b.plotPoints.length) lines.push('', '**情节要点**:', ...b.plotPoints.map((p, i) => `${i + 1}. ${p}`))
if (b.characters.length) lines.push('', `**出场人物**:${b.characters.join(';')}`)
if (b.setting) lines.push('', `**场景**:${b.setting}`)
if (b.mood) lines.push('', `**情绪基调**:${b.mood}`)
if (b.pacing) lines.push('', `**节奏**:${b.pacing}`)
if (b.style) lines.push('', `**文风要求**:${b.style}`)
if (b.targetWords) lines.push('', `**目标字数**:约 ${b.targetWords} 字`)
if (b.foreshadowing) lines.push('', `**伏笔**:${b.foreshadowing}`)
if (b.endingHook) lines.push('', `**结尾钩子**:${b.endingHook}`)
if (b.notes) lines.push('', `**注意事项**:${b.notes}`)
return lines.join('\n')
}
const briefSchema = z.object({
title: z.string().describe('本章标题(不含“第N章”)'),
goal: z.string().describe('本章要完成的剧情目标,一两句话'),
plotPoints: z.array(z.string()).describe('按发生顺序排列的关键情节点,每条要具体可执行,通常 4~8 条'),
characters: z.array(z.string()).describe('出场人物及其在本章的状态/动机,如“林晚:刚失业,强撑体面”'),
setting: z.string().describe('时间与场景'),
mood: z.string().describe('情绪基调'),
pacing: z.string().optional().describe('节奏安排,如“前慢后快,结尾留白”'),
style: z.string().optional().describe('针对本章的文风/写法要求'),
targetWords: z.number().optional().describe('目标字数'),
foreshadowing: z.string().optional().describe('需要埋下或回收的伏笔'),
endingHook: z.string().optional().describe('结尾钩子/悬念'),
notes: z.string().optional().describe('其他注意事项,例如需要避免的内容')
})
type BriefInput = z.infer<typeof briefSchema>
/* ---------------- 主编:规划大纲 ---------------- */
export async function planOutline(projectId: string, signal: AbortSignal, opts: { count: number, fromIndex?: number }) {
const ctx = loadProjectContext(projectId)
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
const locked = ctx.chapters.filter(c => c.status !== 'planned' && c.status !== 'briefed')
const fromIndex = opts.fromIndex ?? ((locked.at(-1)?.index ?? 0) + 1)
const job = new Job(projectId, 'outline', { agentName: agent.name, modelLabel: label })
try {
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
const prompt = buildEditorOutlinePrompt(ctx, opts.count, fromIndex)
job.setPrompt(system, prompt)
job.log(`主编开始规划第${fromIndex}~${fromIndex + opts.count - 1}章`)
let saved = 0
const result = await generateText({
model, ...settings, abortSignal: signal,
instructions: system,
prompt,
stopWhen: isStepCount(4),
tools: {
set_chapter_plan: tool({
description: '提交章节规划。会覆盖对应序号上尚未开写的章节。',
inputSchema: z.object({
chapters: z.array(z.object({
index: z.number().int().describe('章节序号'),
title: z.string(),
synopsis: z.string().describe('本章梗概')
}))
}),
execute: async ({ chapters }) => {
const db = useDb()
const existing = listChapters(projectId)
for (const c of chapters) {
const cur = existing.find(e => e.index === c.index)
if (cur && cur.status !== 'planned' && cur.status !== 'briefed') continue
if (cur) {
db.update(tables.chapters).set({ title: c.title, synopsis: c.synopsis }).where(eq(tables.chapters.id, cur.id)).run()
}
else {
db.insert(tables.chapters).values({ id: newId(), projectId, index: c.index, title: c.title, synopsis: c.synopsis }).run()
}
saved++
}
emitProject(projectId, { type: 'chapters' })
return { ok: true, saved }
}
})
}
})
job.addUsage(result.totalUsage)
if (!saved) throw new Error(`主编没有提交章节规划(模型可能不支持工具调用)。模型回复:${truncate(result.text, 500)}`)
job.log(`已保存 ${saved} 个章节规划。${result.text ? `主编说明:${result.text}` : ''}`)
job.done()
}
catch (err) {
job.fail(err)
throw err
}
}
/* ---------------- 主编:章节简报 + 指派作家 ---------------- */
export async function generateBrief(projectId: string, chapterId: string, signal: AbortSignal) {
const ctx = loadProjectContext(projectId)
const chapter = getChapter(chapterId)
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
const writers = ctx.agents.filter(a => a.role === 'writer' && a.enabled)
const allowCreate = ctx.project.settings.allowEditorCreateWriters && ctx.models.length > 0
const job = new Job(projectId, 'brief', { chapterId, agentName: agent.name, modelLabel: label })
try {
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
const prompt = buildEditorBriefPrompt(ctx, chapter, writers, allowCreate)
job.setPrompt(system, prompt)
job.log(`主编正在为第${chapter.index}章撰写简报`)
let brief: string | null = null
let title = chapter.title
let writerId = chapter.writerLocked ? chapter.writerAgentId : null
let reason = ''
const tools = {
write_brief: tool({
description: '提交本章的章节简报',
inputSchema: briefSchema,
execute: async (input) => {
brief = formatBriefMarkdown(input)
if (input.title) title = input.title
job.log('简报已提交')
return { ok: true }
}
}),
assign_writer: tool({
description: '从作家团队中指派一位作家来写本章',
inputSchema: z.object({ writerId: z.string().describe('作家 id'), reason: z.string().describe('选择理由') }),
execute: async (input) => {
if (chapter.writerLocked) return { ok: false, error: '用户已锁定本章作家,无需指派' }
const w = writers.find(x => x.id === input.writerId)
if (!w) return { ok: false, error: `不存在 id 为 ${input.writerId} 的作家,请从 writers 列表中选择` }
writerId = w.id
reason = input.reason
job.log(`指派作家:${w.name}(${input.reason})`)
return { ok: true }
}
}),
...(allowCreate && !chapter.writerLocked
? {
create_writer: tool({
description: '创建一位新作家并指派其写本章。仅在现有作家都不合适时使用。',
inputSchema: z.object({
name: z.string().describe('作家名称,体现其风格,如“冷峻悬疑派”'),
modelId: z.string().describe('available_models 中的 modelId'),
systemPrompt: z.string().describe('该作家的风格设定提示词'),
styleNotes: z.string().describe('一句话描述其擅长的风格'),
reason: z.string()
}),
execute: async (input) => {
if (!ctx.models.some(m => m.id === input.modelId)) return { ok: false, error: '无效的 modelId,请从 available_models 中选择' }
const id = newId()
useDb().insert(tables.agents).values({
id, projectId, role: 'writer', name: input.name, modelId: input.modelId,
systemPrompt: input.systemPrompt, styleNotes: input.styleNotes, createdBy: 'editor'
}).run()
emitProject(projectId, { type: 'agents' })
writerId = id
reason = input.reason
job.log(`主编创建了新作家:${input.name}(${input.reason})`)
return { ok: true, writerId: id }
}
})
}
: {})
}
const result = await generateText({
model, ...settings, abortSignal: signal,
instructions: system,
prompt,
tools,
stopWhen: isStepCount(6)
})
job.addUsage(result.totalUsage)
if (!brief) throw new Error(`主编没有提交简报(模型可能不支持工具调用)。模型回复:${truncate(result.text, 500)}`)
if (!writerId) {
writerId = writers[0]?.id ?? null
if (writerId) job.log(`主编未指派作家,默认使用:${writers[0]!.name}`)
}
updateChapter(projectId, chapterId, {
brief,
title,
writerAgentId: writerId,
editorNotes: [reason && `选派理由:${reason}`, result.text].filter(Boolean).join('\n'),
status: chapter.status === 'planned' ? 'briefed' : chapter.status
})
job.done()
}
catch (err) {
job.fail(err)
throw err
}
}
/* ---------------- 作家:写章节(流式写入文件 + 同步推送到前端) ---------------- */
export async function writeChapter(
projectId: string,
chapterId: string,
signal: AbortSignal,
opts: { writerAgentId?: string | null, feedback?: string, revise?: boolean } = {}
) {
const db = useDb()
const ctx = loadProjectContext(projectId)
const chapter = getChapter(chapterId)
const writer = getAgent(opts.writerAgentId || chapter.writerAgentId) ?? ctx.agents.find(a => a.role === 'writer' && a.enabled)
const { agent, model, label, settings } = requireModelAgent(writer, '作家')
const prev = ctx.chapters.filter(c => c.index < chapter.index && c.activeVersionId).at(-1)
const prevTail = prev ? tail(await readActiveBody(prev), ctx.project.settings.prevTailChars) : ''
const previousDraft = opts.revise ? await readActiveBody(chapter) : ''
const feedback = opts.feedback?.trim() ?? ''
const last = db.select().from(tables.chapterVersions).where(eq(tables.chapterVersions.chapterId, chapterId)).orderBy(desc(tables.chapterVersions.version)).get()
const version = (last?.version ?? 0) + 1
const filePath = chapterFileRel(projectId, chapter.index, version)
const versionId = newId()
db.insert(tables.chapterVersions).values({
id: versionId, chapterId, version, filePath,
writerAgentId: agent.id, writerName: agent.name, modelLabel: label, feedback
}).run()
const prevStatus = chapter.status
updateChapter(projectId, chapterId, { status: 'writing', writerAgentId: agent.id })
const job = new Job(projectId, 'write', { chapterId, agentName: agent.name, modelLabel: label })
const out = openChapterWriter(filePath)
let body = ''
let lastProgress = 0
try {
const system = composeSystem(agent.systemPrompt, WRITER_BUILTIN, writerExtra(ctx.project.requirements))
const prompt = buildWriterPrompt(ctx, chapter, prevTail, feedback, previousDraft)
job.setPrompt(system, prompt)
job.log(`${agent.name} 开始写第${chapter.index}章 → ${filePath}`)
const heading = `# 第${chapter.index}章 ${chapter.title}\n\n`
out.write(heading)
const messages: ModelMessage[] = [{ role: 'user', content: prompt }]
const maxRounds = 1 + Math.max(0, ctx.project.settings.maxContinuations)
for (let round = 0; round < maxRounds; round++) {
const result = streamText({ model, ...settings, abortSignal: signal, instructions: system, messages })
let segment = ''
for await (const part of result.stream) {
if (part.type === 'text-delta') {
segment += part.text
body += part.text
out.write(part.text)
emitProject(projectId, { type: 'delta', chapterId, versionId, text: part.text })
if (body.length - lastProgress > 2000) {
lastProgress = body.length
db.update(tables.chapterVersions).set({ wordCount: countWords(body) }).where(eq(tables.chapterVersions.id, versionId)).run()
}
}
else if (part.type === 'error') {
throw part.error
}
}
job.addUsage(await result.usage)
const finish = await result.finishReason
if (finish !== 'length') break
if (round + 1 >= maxRounds) {
job.log('输出达到长度上限,已达到最大续写次数')
break
}
job.log(`输出达到长度上限,自动续写(第${round + 1}次)`)
messages.push({ role: 'assistant', content: segment })
messages.push({ role: 'user', content: '请从上文中断处无缝继续写下去,不要重复已经写过的内容,不要添加任何说明。' })
}
await new Promise<void>((res, rej) => out.end((err?: Error | null) => (err ? rej(err) : res())))
if (!body.trim()) throw new Error('模型没有返回任何内容')
const wordCount = countWords(body)
db.update(tables.chapterVersions).set({ status: 'done', wordCount }).where(eq(tables.chapterVersions.id, versionId)).run()
updateChapter(projectId, chapterId, { status: 'written', activeVersionId: versionId, reviewNotes: '' })
job.log(`完成,共 ${wordCount} 字`)
job.done()
}
catch (err) {
out.end()
const cancelled = isAbort(err)
db.update(tables.chapterVersions).set({ status: cancelled ? 'cancelled' : 'error', wordCount: countWords(body) })
.where(eq(tables.chapterVersions.id, versionId)).run()
// 恢复为写作前的状态(保留之前的有效版本)
updateChapter(projectId, chapterId, { status: prevStatus === 'writing' ? (chapter.activeVersionId ? 'written' : 'briefed') : prevStatus })
job.fail(err)
throw err
}
}
/* ---------------- 摘要员:确认章节 → 摘要 + 更新故事记忆 ---------------- */
function parseJsonLoose<T>(text: string): T | null {
const cleaned = text.replace(/```(?:json)?/gi, '').trim()
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) return null
try { return JSON.parse(cleaned.slice(start, end + 1)) as T }
catch { return null }
}
interface SummaryResult {
summary?: string
characterStates?: { name: string, state: string }[]
newForeshadowing?: string[]
resolvedForeshadowing?: string[]
events?: string[]
}
function similar(a: string, b: string) {
const x = a.replace(/\s/g, '')
const y = b.replace(/\s/g, '')
if (!x || !y) return false
return x.includes(y) || y.includes(x) || (x.length >= 8 && y.includes(x.slice(0, 8)))
}
export async function approveChapter(projectId: string, chapterId: string, signal: AbortSignal) {
const ctx = loadProjectContext(projectId)
const chapter = getChapter(chapterId)
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文,无法确认' })
const summarizer = getRoleAgent(projectId, 'summarizer')
const agentRow = summarizer?.modelId ? summarizer : getRoleAgent(projectId, 'editor')
const { agent, model, label, settings } = requireModelAgent(agentRow, '摘要员')
const text = await readActiveBody(chapter)
const job = new Job(projectId, 'summarize', { chapterId, agentName: agent.name, modelLabel: label })
try {
const system = composeSystem(agent.role === 'summarizer' ? agent.systemPrompt : '', SUMMARIZER_BUILTIN)
const prompt = buildSummaryPrompt(ctx, chapter, text)
job.setPrompt(system, prompt)
job.log(`正在整理第${chapter.index}章摘要与故事记忆`)
const result = await generateText({ model, ...settings, abortSignal: signal, instructions: system, prompt })
job.addUsage(result.totalUsage)
const parsed = parseJsonLoose<SummaryResult>(result.text)
if (!parsed) job.log('摘要未能解析为 JSON,已将原文作为摘要保存')
const summary = parsed?.summary?.trim() || result.text.trim()
// 合并故事记忆;重新确认同一章节时先撤销该章之前的贡献
const mem: ProjectMemory = structuredClone(ctx.project.memory)
mem.timeline = mem.timeline.filter(t => t.chapter !== chapter.index)
mem.foreshadowing = mem.foreshadowing
.filter(f => f.chapter !== chapter.index)
.map(f => (f.resolvedChapter === chapter.index ? { text: f.text, status: 'open' as const, chapter: f.chapter } : f))
if (parsed) {
for (const s of parsed.characterStates ?? []) {
if (!s?.name || !s.state) continue
const cur = mem.characterStates.find(c => c.name === s.name)
if (cur) cur.state = s.state
else mem.characterStates.push({ name: s.name, state: s.state })
}
for (const r of parsed.resolvedForeshadowing ?? []) {
const f = mem.foreshadowing.find(x => x.status === 'open' && similar(x.text, r))
if (f) { f.status = 'resolved'; f.resolvedChapter = chapter.index }
}
for (const n of parsed.newForeshadowing ?? []) {
if (n?.trim()) mem.foreshadowing.push({ text: n.trim(), status: 'open', chapter: chapter.index })
}
for (const e of (parsed.events ?? []).slice(0, 5)) {
if (e?.trim()) mem.timeline.push({ chapter: chapter.index, event: e.trim() })
}
mem.timeline.sort((a, b) => a.chapter - b.chapter)
}
updateMemory(projectId, mem)
updateChapter(projectId, chapterId, { summary, status: 'approved' })
job.log('已确认章节并更新故事记忆')
job.done()
}
catch (err) {
job.fail(err)
throw err
}
}
/* ---------------- 主编:审稿 ---------------- */
export async function reviewChapter(projectId: string, chapterId: string, signal: AbortSignal) {
const ctx = loadProjectContext(projectId)
const chapter = getChapter(chapterId)
const text = await readActiveBody(chapter)
if (!text) throw createError({ statusCode: 400, message: '本章还没有正文' })
const { agent, model, label, settings } = requireModelAgent(getRoleAgent(projectId, 'editor'), '主编')
const job = new Job(projectId, 'review', { chapterId, agentName: agent.name, modelLabel: label })
try {
const system = composeSystem(agent.systemPrompt, EDITOR_BUILTIN)
const prompt = buildEditorReviewPrompt(ctx, chapter, text)
job.setPrompt(system, prompt)
job.log(`主编正在审阅第${chapter.index}章`)
const result = await generateText({ model, ...settings, abortSignal: signal, instructions: system, prompt })
job.addUsage(result.totalUsage)
updateChapter(projectId, chapterId, { reviewNotes: result.text.trim() })
job.done()
}
catch (err) {
job.fail(err)
throw err
}
}
/* ---------------- 流水线:继续写作 ---------------- */
/**
* 推进到下一章:
* - 没有待写章节时,主编先规划后续章节
* - 待写章节没有简报时,主编写简报(可选暂停等待确认)
* - 作家写作;逐章模式下写完即暂停,等用户确认
* - 自动模式下自动确认并继续,直到达到连续章节上限
*/
export async function runPipeline(projectId: string, signal: AbortSignal) {
const project = getProject(projectId)
const { autoMode, pauseAfterBrief, autoChapterLimit } = project.settings
const job = new Job(projectId, 'pipeline', { agentName: autoMode ? '自动模式' : '逐章模式' })
let written = 0
try {
for (let guard = 0; guard < 200; guard++) {
if (signal.aborted) throw new DOMException('aborted', 'AbortError')
const chapters = listChapters(projectId)
const pending = chapters.find(c => c.status === 'written')
if (pending) {
if (!autoMode) {
job.log(`第${pending.index}章已写完,等待你确认后再继续`)
break
}
job.log(`自动确认第${pending.index}章`)
await approveChapter(projectId, pending.id, signal)
continue
}
if (autoMode && written >= autoChapterLimit) {
job.log(`已连续写作 ${written} 章,达到自动模式上限,暂停`)
break
}
let next = chapters.find(c => c.status !== 'approved')
if (!next) {
job.log('没有待写章节,请主编规划后续章节')
await planOutline(projectId, signal, { count: 5 })
next = listChapters(projectId).find(c => c.status !== 'approved')
if (!next) throw new Error('主编未能规划出新的章节')
}
if (next.status === 'planned' || !next.brief.trim()) {
await generateBrief(projectId, next.id, signal)
if (pauseAfterBrief && !autoMode) {
job.log(`第${next.index}章简报已生成,请确认或修改后点击「继续」开始写作`)
break
}
next = getChapter(next.id)
}
await writeChapter(projectId, next.id, signal)
written++
if (!autoMode) {
job.log(`第${next.index}章已写完,请阅读并确认`)
break
}
if (written >= autoChapterLimit) {
// 最后一章留给用户审阅,下次启动自动模式时再确认
job.log(`已连续写作 ${written} 章,达到自动模式上限,暂停(第${next.index}章待确认)`)
break
}
}
job.done()
}
catch (err) {
job.fail(err)
throw err
}
}
/** 删除某章之后的章节号整体前移 */
export function reindexAfterDelete(projectId: string, index: number) {
const db = useDb()
const after = db.select().from(tables.chapters)
.where(and(eq(tables.chapters.projectId, projectId), gt(tables.chapters.index, index)))
.all()
for (const c of after) db.update(tables.chapters).set({ index: c.index - 1 }).where(eq(tables.chapters.id, c.id)).run()
}