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) { 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, 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 /* ---------------- 主编:规划大纲 ---------------- */ 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((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(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(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() }