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:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const id = param(event)
stopRun(id)
useDb().delete(tables.projects).where(eq(tables.projects.id, id)).run()
removeProjectFiles(id)
return { ok: true }
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler((event) => {
const id = param(event)
return { ...getProject(id), running: getActiveRun(id) }
})
+21
View File
@@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1),
description: z.string(),
worldSetting: z.string(),
storyOutline: z.string(),
userName: z.string(),
requirements: requirementsSchema,
settings: settingsSchema,
memory: memorySchema
})
export default defineEventHandler(async (event) => {
const id = param(event)
getProject(id)
const body = await readPatch(event, schema)
useDb().update(tables.projects).set(body).where(eq(tables.projects.id, id)).run()
return getProject(id)
})
+7
View File
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.agents)
.where(eq(tables.agents.projectId, param(event)))
.orderBy(asc(tables.agents.createdAt)).all()
})
+11
View File
@@ -0,0 +1,11 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, agentSchema)
if (body.role !== 'writer' && getRoleAgent(projectId, body.role)) {
throw createError({ statusCode: 400, message: '每个项目只能有一个主编和一个摘要员' })
}
const row = useDb().insert(tables.agents).values({ id: newId(), projectId, ...body }).returning().get()
emitProject(projectId, { type: 'agents' })
return row
})
@@ -0,0 +1,93 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { eq, sql } from 'drizzle-orm'
import { z } from 'zod'
const optionsSchema = z.object({
/** 把卡片的 scenario 追加到故事背景 */
scenarioToWorld: z.boolean().default(true),
/** 导入角色卡内置世界书 */
importLore: z.boolean().default(true),
/** 把开场白(first_mes)作为文风/说话示例 */
firstMesAsSample: z.boolean().default(true),
/** 把卡片的 system_prompt 写入角色备注 */
systemPromptToNotes: z.boolean().default(false),
/** {{user}} 的替换名,留空则使用项目设置 */
userName: z.string().default(''),
/** 角色身份标签,如“女主角” */
identity: z.string().default('')
})
export default defineEventHandler(async (event) => {
const projectId = param(event)
const project = getProject(projectId)
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'file')
if (!file?.data?.length) throw createError({ statusCode: 400, message: '请上传角色卡文件' })
const optRaw = form?.find(f => f.name === 'options')?.data.toString('utf8') || '{}'
const opts = optionsSchema.parse(JSON.parse(optRaw))
let card
try { card = parseCardFile(file.data, file.filename) }
catch (err) { throw createError({ statusCode: 400, message: (err as Error).message }) }
const userName = opts.userName || project.userName
const m = (s: string) => replaceMacros(s, card.name, userName).trim()
const db = useDb()
const charId = newId()
const samples: string[] = []
if (card.mesExample.trim()) samples.push(m(card.mesExample))
if (opts.firstMesAsSample && card.firstMes.trim()) samples.push(`【开场白示例】\n${m(card.firstMes)}`)
const notes: string[] = []
if (!opts.scenarioToWorld && card.scenario.trim()) notes.push(`【场景】${m(card.scenario)}`)
if (opts.systemPromptToNotes && card.systemPrompt.trim()) notes.push(`【卡片系统提示】${m(card.systemPrompt)}`)
if (card.postHistoryInstructions.trim() && opts.systemPromptToNotes) notes.push(`【补充指令】${m(card.postHistoryInstructions)}`)
let avatar = ''
if (file.data[0] === 0x89) {
const dir = join(projectDir(projectId), 'avatars')
mkdirSync(dir, { recursive: true })
avatar = `${charId}.png`
writeFileSync(join(dir, avatar), file.data)
}
const count = db.select({ n: sql<number>`count(*)` }).from(tables.characters).where(eq(tables.characters.projectId, projectId)).get()?.n ?? 0
db.transaction((tx) => {
tx.insert(tables.characters).values({
id: charId,
projectId,
name: card.name,
identity: opts.identity,
description: m(card.description),
personality: m(card.personality),
speechExamples: samples.join('\n\n'),
notes: notes.join('\n\n'),
avatar,
sourceCard: card.raw,
sortOrder: count
}).run()
if (opts.importLore && card.lore.length) {
tx.insert(tables.loreEntries).values(card.lore.map(l => ({
id: newId(),
projectId,
title: `${card.name}·${l.title}`,
keys: l.keys,
content: m(l.content),
constant: l.constant,
enabled: l.enabled,
source: 'card' as const,
sortOrder: l.order
}))).run()
}
if (opts.scenarioToWorld && card.scenario.trim()) {
const world = [project.worldSetting.trim(), `【${card.name}·场景】\n${m(card.scenario)}`].filter(Boolean).join('\n\n')
tx.update(tables.projects).set({ worldSetting: world }).where(eq(tables.projects.id, projectId)).run()
}
})
return { id: charId, name: card.name, loreCount: opts.importLore ? card.lore.length : 0 }
})
@@ -0,0 +1,15 @@
/** 解析角色卡并返回预览(不写入数据库) */
export default defineEventHandler(async (event) => {
getProject(param(event))
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'file')
if (!file?.data?.length) throw createError({ statusCode: 400, message: '请上传角色卡文件(PNG 或 JSON)' })
try {
const card = parseCardFile(file.data, file.filename)
const { raw: _raw, ...rest } = card
return { ...rest, isPng: file.data[0] === 0x89, filename: file.filename ?? '' }
}
catch (err) {
throw createError({ statusCode: 400, message: (err as Error).message })
}
})
+24
View File
@@ -0,0 +1,24 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const projectId = param(event)
const db = useDb()
return db.select({
id: tables.chapters.id,
index: tables.chapters.index,
title: tables.chapters.title,
synopsis: tables.chapters.synopsis,
status: tables.chapters.status,
writerAgentId: tables.chapters.writerAgentId,
writerLocked: tables.chapters.writerLocked,
activeVersionId: tables.chapters.activeVersionId,
hasBrief: tables.chapters.brief,
wordCount: tables.chapterVersions.wordCount,
updatedAt: tables.chapters.updatedAt
}).from(tables.chapters)
.leftJoin(tables.chapterVersions, eq(tables.chapterVersions.id, tables.chapters.activeVersionId))
.where(eq(tables.chapters.projectId, projectId))
.orderBy(asc(tables.chapters.index))
.all()
.map(c => ({ ...c, hasBrief: Boolean(c.hasBrief?.trim()), wordCount: c.wordCount ?? 0 }))
})
+27
View File
@@ -0,0 +1,27 @@
import { and, eq, gte, sql } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string().default(''),
synopsis: z.string().default(''),
/** 插入位置;不传则追加到末尾 */
index: z.number().int().positive().optional()
})
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, schema)
const db = useDb()
const max = db.select({ m: sql<number>`coalesce(max(${tables.chapters.index}), 0)` }).from(tables.chapters).where(eq(tables.chapters.projectId, projectId)).get()?.m ?? 0
const index = body.index && body.index <= max ? body.index : max + 1
const row = db.transaction((tx) => {
if (index <= max) {
tx.update(tables.chapters).set({ index: sql`${tables.chapters.index} + 1` })
.where(and(eq(tables.chapters.projectId, projectId), gte(tables.chapters.index, index))).run()
}
return tx.insert(tables.chapters).values({ id: newId(), projectId, index, title: body.title, synopsis: body.synopsis }).returning().get()
})
emitProject(projectId, { type: 'chapters' })
return row
})
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.characters)
.where(eq(tables.characters.projectId, param(event)))
.orderBy(asc(tables.characters.sortOrder), asc(tables.characters.createdAt)).all()
})
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, characterSchema)
return useDb().insert(tables.characters).values({ id: newId(), projectId, ...body }).returning().get()
})
+19
View File
@@ -0,0 +1,19 @@
import type { ProjectEvent } from '#shared/types'
/** SSE:推送任务进度、写作增量、章节变更 */
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const stream = createEventStream(event)
const send = (e: ProjectEvent) => { stream.push(JSON.stringify(e)).catch(() => {}) }
const unsubscribe = subscribeProject(projectId, send)
const run = getActiveRun(projectId)
send({ type: 'pipeline', running: Boolean(run), message: run?.label })
const heartbeat = setInterval(() => { stream.push({ event: 'ping', data: String(Date.now()) }).catch(() => {}) }, 20_000)
stream.onClosed(async () => {
clearInterval(heartbeat)
unsubscribe()
await stream.close()
})
return stream.send()
})
+23
View File
@@ -0,0 +1,23 @@
/** 导出整本小说:format=txt|md,scope=approved|all */
export default defineEventHandler(async (event) => {
const project = getProject(param(event))
const q = getQuery(event)
const format = q.format === 'md' ? 'md' : 'txt'
const scope = q.scope === 'approved' ? 'approved' : 'all'
const chapters = listChapters(project.id).filter(c => c.activeVersionId && (scope === 'all' || c.status === 'approved'))
const parts: string[] = []
parts.push(format === 'md' ? `# ${project.title}` : project.title)
if (project.description) parts.push(project.description)
for (const c of chapters) {
const v = getVersion(c.activeVersionId)
if (!v) continue
const body = stripHeading(await readChapterFile(v.filePath)).trim()
const heading = `第${c.index}章 ${c.title}`
parts.push(format === 'md' ? `## ${heading}\n\n${body}` : `${heading}\n\n${body}`)
}
const filename = encodeURIComponent(`${project.title}.${format}`)
setHeader(event, 'Content-Type', format === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8')
setHeader(event, 'Content-Disposition', `attachment; filename*=UTF-8''${filename}`)
return parts.join('\n\n\n')
})
+10
View File
@@ -0,0 +1,10 @@
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const limit = Math.min(Number(getQuery(event).limit) || 100, 500)
return useDb().select().from(tables.jobs)
.where(eq(tables.jobs.projectId, param(event)))
.orderBy(desc(tables.jobs.createdAt))
.limit(limit).all()
.map(toJobInfo)
})
+7
View File
@@ -0,0 +1,7 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
return useDb().select().from(tables.loreEntries)
.where(eq(tables.loreEntries.projectId, param(event)))
.orderBy(asc(tables.loreEntries.sortOrder), asc(tables.loreEntries.createdAt)).all()
})
+6
View File
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, loreSchema)
return useDb().insert(tables.loreEntries).values({ id: newId(), projectId, ...body }).returning().get()
})
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
/** 主编规划章节大纲 */
export default defineEventHandler(async (event) => {
const projectId = param(event)
getProject(projectId)
const body = await readJson(event, z.object({
count: z.number().int().min(1).max(100).default(10),
fromIndex: z.number().int().positive().optional()
}))
runExclusive(projectId, '规划章节大纲', signal => planOutline(projectId, signal, body))
return { started: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 继续写作:推进流水线 */
export default defineEventHandler((event) => {
const projectId = param(event)
const project = getProject(projectId)
runExclusive(projectId, project.settings.autoMode ? '自动写作' : '继续下一章', signal => runPipeline(projectId, signal))
return { started: true }
})
+3
View File
@@ -0,0 +1,3 @@
export default defineEventHandler((event) => {
return { stopped: stopRun(param(event)) }
})
+20
View File
@@ -0,0 +1,20 @@
import { desc, sql } from 'drizzle-orm'
export default defineEventHandler(() => {
const db = useDb()
const rows = db.select().from(tables.projects).orderBy(desc(tables.projects.updatedAt)).all()
const stats = db.select({
projectId: tables.chapters.projectId,
total: sql<number>`count(*)`,
approved: sql<number>`sum(case when ${tables.chapters.status} = 'approved' then 1 else 0 end)`,
words: sql<number>`coalesce(sum((select word_count from chapter_versions v where v.id = ${tables.chapters.activeVersionId})), 0)`
}).from(tables.chapters).groupBy(tables.chapters.projectId).all()
return rows.map(p => ({
id: p.id,
title: p.title,
description: p.description,
updatedAt: p.updatedAt,
stats: stats.find(s => s.projectId === p.id) ?? { total: 0, approved: 0, words: 0 },
running: getActiveRun(p.id)
}))
})
+32
View File
@@ -0,0 +1,32 @@
import { asc } from 'drizzle-orm'
import { z } from 'zod'
import { DEFAULT_REQUIREMENTS, DEFAULT_SETTINGS, EMPTY_MEMORY } from '#shared/types'
const schema = z.object({
title: z.string().min(1),
description: z.string().default('')
})
export default defineEventHandler(async (event) => {
const body = await readJson(event, schema)
const db = useDb()
const id = newId()
const firstModel = db.select().from(tables.models).orderBy(asc(tables.models.createdAt)).get()
const modelId = firstModel?.id ?? null
db.transaction((tx) => {
tx.insert(tables.projects).values({
id,
title: body.title,
description: body.description,
requirements: { ...DEFAULT_REQUIREMENTS },
settings: { ...DEFAULT_SETTINGS },
memory: structuredClone(EMPTY_MEMORY)
}).run()
tx.insert(tables.agents).values([
{ id: newId(), projectId: id, role: 'editor', name: '主编', modelId },
{ id: newId(), projectId: id, role: 'summarizer', name: '摘要员', modelId },
{ id: newId(), projectId: id, role: 'writer', name: '作家·默认', modelId, styleNotes: '通用叙事' }
]).run()
})
return { id }
})