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>
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user