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>
514 lines
22 KiB
TypeScript
514 lines
22 KiB
TypeScript
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()
|
||
}
|