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
+10
View File
@@ -0,0 +1,10 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const agent = getAgent(param(event))
if (!agent) return { ok: true }
if (agent.role !== 'writer') throw createError({ statusCode: 400, message: '主编和摘要员不能删除,可以修改其模型和提示词' })
useDb().delete(tables.agents).where(eq(tables.agents.id, agent.id)).run()
emitProject(agent.projectId, { type: 'agents' })
return { ok: true }
})
+9
View File
@@ -0,0 +1,9 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const { role: _role, ...body } = await readPatch(event, agentSchema)
const row = useDb().update(tables.agents).set(body).where(eq(tables.agents.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '成员不存在' })
emitProject(row.projectId, { type: 'agents' })
return row
})
@@ -0,0 +1,13 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler((event) => {
const projectId = param(event, 'projectId')
const file = param(event, 'file')
if (!/^[\w-]+\.png$/.test(file) || !/^[\w-]+$/.test(projectId)) throw createError({ statusCode: 400, message: '非法文件名' })
const p = join(projectDir(projectId), 'avatars', file)
if (!existsSync(p)) throw createError({ statusCode: 404, message: '头像不存在' })
setHeader(event, 'Content-Type', 'image/png')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
return readFileSync(p)
})
+13
View File
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (getActiveRun(chapter.projectId)) throw createError({ statusCode: 409, message: '有任务正在运行,请先停止再删除章节' })
const db = useDb()
db.transaction(() => {
db.delete(tables.chapters).where(eq(tables.chapters.id, chapter.id)).run()
reindexAfterDelete(chapter.projectId, chapter.index)
})
emitProject(chapter.projectId, { type: 'chapters' })
return { ok: true }
})
+11
View File
@@ -0,0 +1,11 @@
import { desc, eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const versions = useDb().select().from(tables.chapterVersions)
.where(eq(tables.chapterVersions.chapterId, chapter.id))
.orderBy(desc(tables.chapterVersions.version)).all()
const active = versions.find(v => v.id === chapter.activeVersionId)
const content = active ? stripHeading(await readChapterFile(active.filePath)) : ''
return { ...chapter, versions, content }
})
+24
View File
@@ -0,0 +1,24 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
title: z.string(),
synopsis: z.string(),
brief: z.string(),
writerAgentId: z.string().nullable(),
writerLocked: z.boolean(),
summary: z.string(),
reviewNotes: z.string()
})
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const body = await readPatch(event, schema)
const patch: Partial<typeof tables.chapters.$inferInsert> = { ...body }
// 手动填写简报后,章节进入“已出简报”状态
if (body.brief !== undefined && chapter.status === 'planned' && body.brief.trim()) patch.status = 'briefed'
if (body.brief !== undefined && chapter.status === 'briefed' && !body.brief.trim()) patch.status = 'planned'
const row = useDb().update(tables.chapters).set(patch).where(eq(tables.chapters.id, chapter.id)).returning().get()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return row
})
+19
View File
@@ -0,0 +1,19 @@
import { and, eq } from 'drizzle-orm'
import { z } from 'zod'
/** 切换章节的当前版本 */
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const { versionId } = await readJson(event, z.object({ versionId: z.string() }))
const v = useDb().select().from(tables.chapterVersions)
.where(and(eq(tables.chapterVersions.id, versionId), eq(tables.chapterVersions.chapterId, chapter.id))).get()
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
if (v.status === 'writing') throw createError({ statusCode: 400, message: '该版本仍在写作中' })
useDb().update(tables.chapters).set({
activeVersionId: v.id,
// 切换版本后需要重新确认,以便重新生成摘要
status: chapter.status === 'approved' ? 'written' : chapter.status === 'writing' ? chapter.status : 'written'
}).where(eq(tables.chapters.id, chapter.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod'
/** 确认章节:生成摘要、更新故事记忆;continue=true 时接着写下一章 */
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const { continue: next } = await readJson(event, z.object({ continue: z.boolean().default(false) }))
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文' })
runExclusive(chapter.projectId, next ? `确认第${chapter.index}章并继续` : `确认第${chapter.index}章`, async (signal) => {
await approveChapter(chapter.projectId, chapter.id, signal)
if (next) await runPipeline(chapter.projectId, signal)
})
return { started: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 让主编为本章生成简报并指派作家 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (chapter.status === 'writing') throw createError({ statusCode: 409, message: '本章正在写作中' })
runExclusive(chapter.projectId, `第${chapter.index}章简报`, signal => generateBrief(chapter.projectId, chapter.id, signal))
return { started: true }
})
+10
View File
@@ -0,0 +1,10 @@
import { eq } from 'drizzle-orm'
/** 撤销确认:已确认的章节回到“待确认”状态 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (chapter.status !== 'approved') return { ok: true }
useDb().update(tables.chapters).set({ status: 'written' }).where(eq(tables.chapters.id, chapter.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
/** 主编审稿 */
export default defineEventHandler((event) => {
const chapter = getChapter(param(event))
if (!chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有正文' })
runExclusive(chapter.projectId, `审阅第${chapter.index}章`, signal => reviewChapter(chapter.projectId, chapter.id, signal))
return { started: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod'
const schema = z.object({
/** 指定作家;不传则使用章节已指派的作家 */
writerAgentId: z.string().nullable().optional(),
/** 修改意见 */
feedback: z.string().optional(),
/** true:在当前版本基础上按意见修改;false:重新写一版 */
revise: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
const chapter = getChapter(param(event))
const body = await readJson(event, schema)
if (body.revise && !chapter.activeVersionId) throw createError({ statusCode: 400, message: '本章还没有可修改的版本' })
runExclusive(chapter.projectId, `第${chapter.index}章写作`, signal => writeChapter(chapter.projectId, chapter.id, signal, body))
return { started: true }
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.characters).where(eq(tables.characters.id, param(event))).run()
return { ok: true }
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, characterSchema)
const row = useDb().update(tables.characters).set(body).where(eq(tables.characters.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '角色不存在' })
return row
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
/** 任务详情(含完整提示词,用于“提示词检视器”) */
export default defineEventHandler((event) => {
const job = useDb().select().from(tables.jobs).where(eq(tables.jobs.id, param(event))).get()
if (!job) throw createError({ statusCode: 404, message: '任务不存在' })
return job
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.loreEntries).where(eq(tables.loreEntries.id, param(event))).run()
return { ok: true }
})
+8
View File
@@ -0,0 +1,8 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, loreSchema)
const row = useDb().update(tables.loreEntries).set(body).where(eq(tables.loreEntries.id, param(event))).returning().get()
if (!row) throw createError({ statusCode: 404, message: '条目不存在' })
return row
})
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.models).where(eq(tables.models.id, param(event))).run()
return { ok: true }
})
+7
View File
@@ -0,0 +1,7 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const body = await readPatch(event, modelSchema)
useDb().update(tables.models).set(body).where(eq(tables.models.id, param(event))).run()
return { ok: true }
})
+14
View File
@@ -0,0 +1,14 @@
import { generateText } from 'ai'
/** 发送一条极短的请求,验证模型可用 */
export default defineEventHandler(async (event) => {
const { model, label } = resolveModel(param(event))
const started = Date.now()
try {
const res = await generateText({ model, prompt: '请只回复“连接成功”四个字。', maxOutputTokens: 512, abortSignal: AbortSignal.timeout(60_000) })
return { ok: true, label, text: res.text.slice(0, 200), ms: Date.now() - started }
}
catch (err) {
return { ok: false, label, error: describeError(err), ms: Date.now() - started }
}
})
+18
View File
@@ -0,0 +1,18 @@
import { asc, eq } from 'drizzle-orm'
export default defineEventHandler(() => {
return useDb().select({
id: tables.models.id,
providerId: tables.models.providerId,
providerName: tables.providers.name,
providerType: tables.providers.type,
label: tables.models.label,
modelId: tables.models.modelId,
temperature: tables.models.temperature,
topP: tables.models.topP,
maxOutputTokens: tables.models.maxOutputTokens
}).from(tables.models)
.innerJoin(tables.providers, eq(tables.models.providerId, tables.providers.id))
.orderBy(asc(tables.providers.createdAt), asc(tables.models.createdAt))
.all()
})
+5
View File
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const body = await readJson(event, modelSchema)
const row = useDb().insert(tables.models).values({ id: newId(), ...body, label: body.label || body.modelId }).returning().get()
return { id: row.id }
})
+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 }
})
+6
View File
@@ -0,0 +1,6 @@
/** 内置角色提示词(只读展示,让用户了解自定义提示词会覆盖什么) */
export default defineEventHandler(() => ({
editor: EDITOR_BUILTIN,
writer: WRITER_BUILTIN,
summarizer: SUMMARIZER_BUILTIN
}))
+6
View File
@@ -0,0 +1,6 @@
import { eq } from 'drizzle-orm'
export default defineEventHandler((event) => {
useDb().delete(tables.providers).where(eq(tables.providers.id, param(event))).run()
return { ok: true }
})
+22
View File
@@ -0,0 +1,22 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1).optional(),
type: z.enum(['openai-compatible', 'anthropic', 'google']).optional(),
baseUrl: z.string().optional(),
// 不传或传空字符串表示保留原有密钥
apiKey: z.string().optional()
})
export default defineEventHandler(async (event) => {
const id = param(event)
const body = await readJson(event, schema)
const patch: Partial<typeof tables.providers.$inferInsert> = {}
if (body.name !== undefined) patch.name = body.name
if (body.type !== undefined) patch.type = body.type
if (body.baseUrl !== undefined) patch.baseUrl = body.baseUrl.trim()
if (body.apiKey) patch.apiKeyEnc = encryptSecret(body.apiKey.trim())
useDb().update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
return { ok: true }
})
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
/** 拉取提供方的可用模型列表 */
export default defineEventHandler(async (event) => {
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, param(event))).get()
if (!p) throw createError({ statusCode: 404, message: '提供方不存在' })
try {
return await listRemoteModels(p.type, p.baseUrl, decryptSecret(p.apiKeyEnc))
}
catch (err) {
throw createError({ statusCode: 502, message: `获取模型列表失败:${describeError(err)}` })
}
})
+11
View File
@@ -0,0 +1,11 @@
import { asc } from 'drizzle-orm'
export default defineEventHandler(() => {
const rows = useDb().select().from(tables.providers).orderBy(asc(tables.providers.createdAt)).all()
return rows.map(({ apiKeyEnc, ...p }) => {
let masked = ''
try { masked = maskSecret(decryptSecret(apiKeyEnc)) }
catch { masked = '(无法解密,请重新填写)' }
return { ...p, apiKeyMasked: masked, hasKey: Boolean(apiKeyEnc) }
})
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1),
type: z.enum(['openai-compatible', 'anthropic', 'google']),
baseUrl: z.string().default(''),
apiKey: z.string().default('')
})
export default defineEventHandler(async (event) => {
const body = await readJson(event, schema)
const row = useDb().insert(tables.providers).values({
id: newId(),
name: body.name,
type: body.type,
baseUrl: body.baseUrl.trim(),
apiKeyEnc: encryptSecret(body.apiKey.trim())
}).returning().get()
return { id: row.id }
})
+5
View File
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const v = getVersion(param(event))
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
return { ...v, content: stripHeading(await readChapterFile(v.filePath)) }
})
+16
View File
@@ -0,0 +1,16 @@
import { writeFile } from 'node:fs/promises'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
/** 手动编辑版本正文(直接改写文件) */
export default defineEventHandler(async (event) => {
const v = getVersion(param(event))
if (!v) throw createError({ statusCode: 404, message: '版本不存在' })
if (v.status === 'writing') throw createError({ statusCode: 409, message: '该版本正在写作中,无法编辑' })
const { content } = await readJson(event, z.object({ content: z.string() }))
const chapter = getChapter(v.chapterId)
await writeFile(absPath(v.filePath), `# 第${chapter.index}章 ${chapter.title}\n\n${content}`, 'utf8')
useDb().update(tables.chapterVersions).set({ wordCount: countWords(content), status: 'done' }).where(eq(tables.chapterVersions.id, v.id)).run()
emitProject(chapter.projectId, { type: 'chapter', chapterId: chapter.id })
return { ok: true }
})
+143
View File
@@ -0,0 +1,143 @@
CREATE TABLE `agents` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`role` text NOT NULL,
`name` text NOT NULL,
`model_id` text,
`system_prompt` text DEFAULT '' NOT NULL,
`style_notes` text DEFAULT '' NOT NULL,
`created_by` text DEFAULT 'user' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`model_id`) REFERENCES `models`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `agents_project_idx` ON `agents` (`project_id`);--> statement-breakpoint
CREATE TABLE `chapter_versions` (
`id` text PRIMARY KEY NOT NULL,
`chapter_id` text NOT NULL,
`version` integer NOT NULL,
`file_path` text NOT NULL,
`word_count` integer DEFAULT 0 NOT NULL,
`writer_agent_id` text,
`writer_name` text DEFAULT '' NOT NULL,
`model_label` text DEFAULT '' NOT NULL,
`feedback` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'writing' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`chapter_id`) REFERENCES `chapters`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `versions_chapter_idx` ON `chapter_versions` (`chapter_id`);--> statement-breakpoint
CREATE TABLE `chapters` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`index` integer NOT NULL,
`title` text DEFAULT '' NOT NULL,
`synopsis` text DEFAULT '' NOT NULL,
`brief` text DEFAULT '' NOT NULL,
`writer_agent_id` text,
`writer_locked` integer DEFAULT false NOT NULL,
`editor_notes` text DEFAULT '' NOT NULL,
`review_notes` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'planned' NOT NULL,
`active_version_id` text,
`summary` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`writer_agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `chapters_project_idx` ON `chapters` (`project_id`,`index`);--> statement-breakpoint
CREATE TABLE `characters` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`identity` text DEFAULT '' NOT NULL,
`description` text DEFAULT '' NOT NULL,
`personality` text DEFAULT '' NOT NULL,
`speech_examples` text DEFAULT '' NOT NULL,
`notes` text DEFAULT '' NOT NULL,
`avatar` text DEFAULT '' NOT NULL,
`source_card` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `characters_project_idx` ON `characters` (`project_id`);--> statement-breakpoint
CREATE TABLE `jobs` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`chapter_id` text,
`type` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`agent_name` text DEFAULT '' NOT NULL,
`model_label` text DEFAULT '' NOT NULL,
`input_tokens` integer DEFAULT 0 NOT NULL,
`output_tokens` integer DEFAULT 0 NOT NULL,
`error` text DEFAULT '' NOT NULL,
`log` text DEFAULT '' NOT NULL,
`prompt` text,
`created_at` integer NOT NULL,
`finished_at` integer,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `jobs_project_idx` ON `jobs` (`project_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `lore_entries` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`title` text DEFAULT '' NOT NULL,
`keys` text NOT NULL,
`content` text DEFAULT '' NOT NULL,
`constant` integer DEFAULT false NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`source` text DEFAULT 'user' NOT NULL,
`sort_order` integer DEFAULT 100 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `lore_project_idx` ON `lore_entries` (`project_id`);--> statement-breakpoint
CREATE TABLE `models` (
`id` text PRIMARY KEY NOT NULL,
`provider_id` text NOT NULL,
`label` text NOT NULL,
`model_id` text NOT NULL,
`temperature` real,
`top_p` real,
`max_output_tokens` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`provider_id`) REFERENCES `providers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`world_setting` text DEFAULT '' NOT NULL,
`story_outline` text DEFAULT '' NOT NULL,
`requirements` text NOT NULL,
`settings` text NOT NULL,
`memory` text NOT NULL,
`user_name` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `providers` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`base_url` text DEFAULT '' NOT NULL,
`api_key_enc` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1792257891167,
"tag": "0000_init",
"breakpoints": true
}
]
}
+142
View File
@@ -0,0 +1,142 @@
import { sqliteTable, text, integer, real, index } from 'drizzle-orm/sqlite-core'
import type {
ProviderType, ProjectRequirements, ProjectSettings, ProjectMemory,
AgentRole, ChapterStatus, VersionStatus, JobType, JobStatus, LoreEntrySource
} from '../../shared/types'
const id = () => text('id').primaryKey()
const createdAt = () => integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
const updatedAt = () => integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date())
/** API 提供方(OpenAI 兼容 / Anthropic / Google) */
export const providers = sqliteTable('providers', {
id: id(),
name: text('name').notNull(),
type: text('type').$type<ProviderType>().notNull(),
baseUrl: text('base_url').notNull().default(''),
apiKeyEnc: text('api_key_enc').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
})
/** 模型预设:某个提供方下的具体模型 + 采样参数 */
export const models = sqliteTable('models', {
id: id(),
providerId: text('provider_id').notNull().references(() => providers.id, { onDelete: 'cascade' }),
label: text('label').notNull(),
modelId: text('model_id').notNull(),
temperature: real('temperature'),
topP: real('top_p'),
maxOutputTokens: integer('max_output_tokens'),
createdAt: createdAt(),
updatedAt: updatedAt()
})
export const projects = sqliteTable('projects', {
id: id(),
title: text('title').notNull(),
description: text('description').notNull().default(''),
worldSetting: text('world_setting').notNull().default(''),
storyOutline: text('story_outline').notNull().default(''),
requirements: text('requirements', { mode: 'json' }).$type<ProjectRequirements>().notNull(),
settings: text('settings', { mode: 'json' }).$type<ProjectSettings>().notNull(),
memory: text('memory', { mode: 'json' }).$type<ProjectMemory>().notNull(),
userName: text('user_name').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
})
export const characters = sqliteTable('characters', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
identity: text('identity').notNull().default(''),
description: text('description').notNull().default(''),
personality: text('personality').notNull().default(''),
speechExamples: text('speech_examples').notNull().default(''),
notes: text('notes').notNull().default(''),
avatar: text('avatar').notNull().default(''),
sourceCard: text('source_card', { mode: 'json' }).$type<Record<string, unknown> | null>(),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('characters_project_idx').on(t.projectId)])
/** 世界书条目:常驻条目每次注入,关键词条目在章节简报/大纲命中时注入 */
export const loreEntries = sqliteTable('lore_entries', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
title: text('title').notNull().default(''),
keys: text('keys', { mode: 'json' }).$type<string[]>().notNull(),
content: text('content').notNull().default(''),
constant: integer('constant', { mode: 'boolean' }).notNull().default(false),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
source: text('source').$type<LoreEntrySource>().notNull().default('user'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('lore_project_idx').on(t.projectId)])
/** AI 成员:主编 / 作家 / 摘要员 */
export const agents = sqliteTable('agents', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
role: text('role').$type<AgentRole>().notNull(),
name: text('name').notNull(),
modelId: text('model_id').references(() => models.id, { onDelete: 'set null' }),
systemPrompt: text('system_prompt').notNull().default(''),
styleNotes: text('style_notes').notNull().default(''),
createdBy: text('created_by').$type<'user' | 'editor'>().notNull().default('user'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('agents_project_idx').on(t.projectId)])
export const chapters = sqliteTable('chapters', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
index: integer('index').notNull(),
title: text('title').notNull().default(''),
synopsis: text('synopsis').notNull().default(''),
brief: text('brief').notNull().default(''),
writerAgentId: text('writer_agent_id').references(() => agents.id, { onDelete: 'set null' }),
writerLocked: integer('writer_locked', { mode: 'boolean' }).notNull().default(false),
editorNotes: text('editor_notes').notNull().default(''),
reviewNotes: text('review_notes').notNull().default(''),
status: text('status').$type<ChapterStatus>().notNull().default('planned'),
activeVersionId: text('active_version_id'),
summary: text('summary').notNull().default(''),
createdAt: createdAt(),
updatedAt: updatedAt()
}, t => [index('chapters_project_idx').on(t.projectId, t.index)])
export const chapterVersions = sqliteTable('chapter_versions', {
id: id(),
chapterId: text('chapter_id').notNull().references(() => chapters.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
filePath: text('file_path').notNull(),
wordCount: integer('word_count').notNull().default(0),
writerAgentId: text('writer_agent_id'),
writerName: text('writer_name').notNull().default(''),
modelLabel: text('model_label').notNull().default(''),
feedback: text('feedback').notNull().default(''),
status: text('status').$type<VersionStatus>().notNull().default('writing'),
createdAt: createdAt()
}, t => [index('versions_chapter_idx').on(t.chapterId)])
export const jobs = sqliteTable('jobs', {
id: id(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
chapterId: text('chapter_id'),
type: text('type').$type<JobType>().notNull(),
status: text('status').$type<JobStatus>().notNull().default('running'),
agentName: text('agent_name').notNull().default(''),
modelLabel: text('model_label').notNull().default(''),
inputTokens: integer('input_tokens').notNull().default(0),
outputTokens: integer('output_tokens').notNull().default(0),
error: text('error').notNull().default(''),
log: text('log').notNull().default(''),
prompt: text('prompt', { mode: 'json' }).$type<{ system: string, prompt: string } | null>(),
createdAt: createdAt(),
finishedAt: integer('finished_at', { mode: 'timestamp_ms' })
}, t => [index('jobs_project_idx').on(t.projectId, t.createdAt)])
+19
View File
@@ -0,0 +1,19 @@
import { timingSafeEqual } from 'node:crypto'
/**
* 可选的访问密码(HTTP Basic Auth)。
* 设置环境变量 NOVEL_PASSWORD 后启用,用户名任意;本机使用可以不设置。
*/
export default defineEventHandler((event) => {
const password = process.env.NOVEL_PASSWORD
if (!password) return
const header = getHeader(event, 'authorization') || ''
if (header.startsWith('Basic ')) {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const given = Buffer.from(decoded.slice(decoded.indexOf(':') + 1))
const expected = Buffer.from(password)
if (given.length === expected.length && timingSafeEqual(given, expected)) return
}
setHeader(event, 'WWW-Authenticate', 'Basic realm="AI Novel Studio", charset="UTF-8"')
throw createError({ statusCode: 401, message: '需要访问密码' })
})
+16
View File
@@ -0,0 +1,16 @@
import { eq, inArray } from 'drizzle-orm'
/** 启动时初始化数据库,并修复上次异常退出遗留的“进行中”状态 */
export default defineNitroPlugin(() => {
const db = useDb()
db.update(tables.jobs).set({ status: 'error', error: '服务重启,任务中断', finishedAt: new Date() })
.where(eq(tables.jobs.status, 'running')).run()
db.update(tables.chapterVersions).set({ status: 'error' })
.where(eq(tables.chapterVersions.status, 'writing')).run()
const stuck = db.select().from(tables.chapters).where(inArray(tables.chapters.status, ['writing'])).all()
for (const c of stuck) {
db.update(tables.chapters).set({ status: c.activeVersionId ? 'written' : (c.brief ? 'briefed' : 'planned') })
.where(eq(tables.chapters.id, c.id)).run()
}
console.log(`[novel] 数据目录:${dataDir()}`)
})
+120
View File
@@ -0,0 +1,120 @@
import { inflateSync } from 'node:zlib'
import type { ParsedCard } from '#shared/types'
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
/** 读取 PNG 中所有文本块(tEXt / zTXt / iTXt),返回 关键字 → 文本 */
export function readPngTextChunks(buf: Buffer): Record<string, string> {
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是有效的 PNG 文件')
const out: Record<string, string> = {}
let off = 8
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off)
const type = buf.toString('ascii', off + 4, off + 8)
const data = buf.subarray(off + 8, off + 8 + len)
if (type === 'tEXt') {
const sep = data.indexOf(0)
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = data.toString('latin1', sep + 1)
}
else if (type === 'zTXt') {
const sep = data.indexOf(0)
if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = inflateSync(data.subarray(sep + 2)).toString('latin1')
}
else if (type === 'iTXt') {
// keyword\0 compressionFlag compressionMethod languageTag\0 translatedKeyword\0 text
const sep = data.indexOf(0)
if (sep > 0) {
const keyword = data.toString('latin1', 0, sep).toLowerCase()
const compressed = data[sep + 1] === 1
const langEnd = data.indexOf(0, sep + 3)
const transEnd = data.indexOf(0, langEnd + 1)
const body = data.subarray(transEnd + 1)
out[keyword] = (compressed ? inflateSync(body) : body).toString('utf8')
}
}
if (type === 'IEND') break
off += 12 + len
}
return out
}
function decodeCardText(text: string): Record<string, unknown> {
const trimmed = text.trim()
if (trimmed.startsWith('{')) return JSON.parse(trimmed)
return JSON.parse(Buffer.from(trimmed, 'base64').toString('utf8'))
}
/** 从 PNG 或 JSON 中提取原始角色卡对象 */
export function extractRawCard(buf: Buffer, filename = ''): Record<string, unknown> {
const isPng = buf.subarray(0, 8).equals(PNG_SIGNATURE)
if (isPng) {
const chunks = readPngTextChunks(buf)
// V3 卡优先读 ccv3,同时兼容只写了 chara 的 V2/V1 卡
const raw = chunks.ccv3 ?? chunks.chara
if (!raw) throw new Error('这张 PNG 里没有角色卡数据(缺少 chara / ccv3 文本块)。如果图片经过截图、压缩或聊天软件转发,元数据会丢失,请使用原始文件。')
return decodeCardText(raw)
}
const text = buf.toString('utf8').replace(/^/, '')
try {
return JSON.parse(text)
}
catch {
throw new Error(`无法解析文件 ${filename}:既不是 PNG 角色卡也不是 JSON`)
}
}
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v))
const strArr = (v: unknown) => (Array.isArray(v) ? v.map(str).filter(Boolean) : [])
/** 将 V1 / V2 / V3 角色卡统一成内部结构 */
export function normalizeCard(raw: Record<string, unknown>): ParsedCard {
const specRaw = str(raw.spec)
const hasData = raw.data && typeof raw.data === 'object'
const d = (hasData ? raw.data : raw) as Record<string, unknown>
const spec: ParsedCard['spec'] = specRaw === 'chara_card_v3' ? 'v3' : specRaw === 'chara_card_v2' || hasData ? 'v2' : 'v1'
const ext = (d.extensions ?? {}) as Record<string, unknown>
const book = (d.character_book ?? null) as { entries?: Record<string, unknown>[] } | null
const lore = (book?.entries ?? []).map((e, i) => {
const extE = (e.extensions ?? {}) as Record<string, unknown>
return {
title: str(e.comment) || str(e.name) || `条目 ${i + 1}`,
keys: [...strArr(e.keys), ...strArr(e.secondary_keys)],
content: str(e.content),
constant: Boolean(e.constant),
enabled: e.enabled !== false && extE.disable !== true,
order: Number(e.insertion_order ?? e.priority ?? 100) || 100
}
}).filter(e => e.content.trim())
return {
spec,
name: str(d.name) || str(raw.name) || '未命名角色',
description: str(d.description),
personality: str(d.personality),
scenario: str(d.scenario),
firstMes: str(d.first_mes),
mesExample: str(d.mes_example),
systemPrompt: str(d.system_prompt),
postHistoryInstructions: str(d.post_history_instructions),
creatorNotes: str(d.creator_notes) || str(raw.creatorcomment),
alternateGreetings: strArr(d.alternate_greetings),
tags: strArr(d.tags),
world: typeof ext.world === 'string' ? ext.world : '',
lore,
raw
}
}
/** 替换酒馆宏:{{char}} / {{user}} / <BOT> / <USER> */
export function replaceMacros(text: string, charName: string, userName: string) {
if (!text) return text
return text
.replace(/\{\{\s*char\s*\}\}|<BOT>/gi, charName)
.replace(/\{\{\s*user\s*\}\}|<USER>/gi, userName || '主角')
.replace(/\r\n/g, '\n')
}
export function parseCardFile(buf: Buffer, filename = '') {
return normalizeCard(extractRawCard(buf, filename))
}
+58
View File
@@ -0,0 +1,58 @@
import { and, asc, eq } from 'drizzle-orm'
export type ProjectRow = typeof tables.projects.$inferSelect
export type CharacterRow = typeof tables.characters.$inferSelect
export type LoreRow = typeof tables.loreEntries.$inferSelect
export type AgentRow = typeof tables.agents.$inferSelect
export type ChapterRow = typeof tables.chapters.$inferSelect
export type VersionRow = typeof tables.chapterVersions.$inferSelect
export type ModelRow = typeof tables.models.$inferSelect
export function getProject(projectId: string): ProjectRow {
const p = useDb().select().from(tables.projects).where(eq(tables.projects.id, projectId)).get()
if (!p) throw createError({ statusCode: 404, message: '项目不存在' })
return p
}
export function getChapter(chapterId: string): ChapterRow {
const c = useDb().select().from(tables.chapters).where(eq(tables.chapters.id, chapterId)).get()
if (!c) throw createError({ statusCode: 404, message: '章节不存在' })
return c
}
export function getAgent(agentId: string | null | undefined): AgentRow | undefined {
if (!agentId) return undefined
return useDb().select().from(tables.agents).where(eq(tables.agents.id, agentId)).get()
}
export function getRoleAgent(projectId: string, role: 'editor' | 'summarizer'): AgentRow | undefined {
return useDb().select().from(tables.agents)
.where(and(eq(tables.agents.projectId, projectId), eq(tables.agents.role, role)))
.get()
}
export function listChapters(projectId: string) {
return useDb().select().from(tables.chapters).where(eq(tables.chapters.projectId, projectId)).orderBy(asc(tables.chapters.index)).all()
}
export function getVersion(versionId: string | null | undefined): VersionRow | undefined {
if (!versionId) return undefined
return useDb().select().from(tables.chapterVersions).where(eq(tables.chapterVersions.id, versionId)).get()
}
/** 一次性加载生成提示词所需的全部项目数据 */
export function loadProjectContext(projectId: string) {
const db = useDb()
const project = getProject(projectId)
return {
project,
characters: db.select().from(tables.characters).where(eq(tables.characters.projectId, projectId)).orderBy(asc(tables.characters.sortOrder), asc(tables.characters.createdAt)).all(),
lore: db.select().from(tables.loreEntries).where(eq(tables.loreEntries.projectId, projectId)).orderBy(asc(tables.loreEntries.sortOrder)).all(),
agents: db.select().from(tables.agents).where(eq(tables.agents.projectId, projectId)).orderBy(asc(tables.agents.createdAt)).all(),
chapters: listChapters(projectId),
models: db.select({ id: tables.models.id, label: tables.models.label, modelId: tables.models.modelId, providerName: tables.providers.name })
.from(tables.models).innerJoin(tables.providers, eq(tables.models.providerId, tables.providers.id)).all()
}
}
export type ProjectContext = ReturnType<typeof loadProjectContext>
+49
View File
@@ -0,0 +1,49 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
let _key: Buffer | null = null
function getKey() {
if (_key) return _key
const configured = process.env.NOVEL_SECRET_KEY || useRuntimeConfig().secretKey
if (configured) {
const buf = Buffer.from(configured, 'hex')
if (buf.length !== 32) throw new Error('NOVEL_SECRET_KEY 必须是 64 位十六进制字符串(32 字节)')
_key = buf
return _key
}
const file = join(dataDir(), 'secret.key')
if (!existsSync(file)) writeFileSync(file, randomBytes(32).toString('hex'), { mode: 0o600 })
_key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex')
return _key
}
/** AES-256-GCM 加密,输出 iv.tag.ciphertext(base64) */
export function encryptSecret(plain: string) {
if (!plain) return ''
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
return [iv, cipher.getAuthTag(), enc].map(b => b.toString('base64')).join('.')
}
export function decryptSecret(payload: string) {
if (!payload) return ''
const [iv, tag, enc] = payload.split('.').map(s => Buffer.from(s, 'base64'))
if (!iv || !tag || !enc) throw new Error('密钥数据损坏')
try {
const decipher = createDecipheriv('aes-256-gcm', getKey(), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
}
catch {
throw createError({ statusCode: 400, message: 'API Key 无法解密:加密密钥(data/secret.key 或 NOVEL_SECRET_KEY)已变更。请在「模型与 API」中重新填写该提供方的 API Key。' })
}
}
export function maskSecret(plain: string) {
if (!plain) return ''
if (plain.length <= 8) return '••••'
return `${plain.slice(0, 3)}••••${plain.slice(-4)}`
}
+38
View File
@@ -0,0 +1,38 @@
import { mkdirSync, existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import * as schema from '../db/schema'
export { schema }
export const tables = schema
let _db: BetterSQLite3Database<typeof schema> | null = null
export function dataDir() {
// 运行时读取环境变量(runtimeConfig 的默认值在构建时就已固定)
const dir = resolve(process.env.NOVEL_DATA_DIR || useRuntimeConfig().dataDir || './data')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return dir
}
function migrationsFolder() {
const candidates = [
resolve(process.cwd(), 'server/db/migrations'),
resolve(process.cwd(), '../server/db/migrations')
]
const found = candidates.find(p => existsSync(join(p, 'meta', '_journal.json')))
if (!found) throw new Error('找不到数据库迁移目录 server/db/migrations,请在项目根目录启动应用')
return found
}
export function useDb() {
if (_db) return _db
const sqlite = new Database(join(dataDir(), 'app.db'))
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
_db = drizzle({ client: sqlite, schema })
migrate(_db, { migrationsFolder: migrationsFolder() })
return _db
}
+24
View File
@@ -0,0 +1,24 @@
import type { ProjectEvent } from '#shared/types'
type Listener = (e: ProjectEvent) => void
const listeners = new Map<string, Set<Listener>>()
export function subscribeProject(projectId: string, fn: Listener) {
let set = listeners.get(projectId)
if (!set) listeners.set(projectId, set = new Set())
set.add(fn)
return () => {
set!.delete(fn)
if (!set!.size) listeners.delete(projectId)
}
}
export function emitProject(projectId: string, e: ProjectEvent) {
const set = listeners.get(projectId)
if (!set) return
for (const fn of set) {
try { fn(e) }
catch (err) { console.error('[events] listener error', err) }
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { H3Event } from 'h3'
import type { output, ZodObject, ZodType } from 'zod'
async function readRawJson(event: H3Event): Promise<Record<string, unknown>> {
const raw = await readRawBody(event, 'utf8')
if (!raw) return {}
try { return JSON.parse(raw) }
catch { throw createError({ statusCode: 400, message: '请求体不是合法的 JSON' }) }
}
function validationError(issues: { path: PropertyKey[], message: string }[]) {
const first = issues[0]
return createError({ statusCode: 400, message: `参数错误:${first?.path.map(String).join('.') || ''} ${first?.message || ''}`.trim() })
}
export async function readJson<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
const parsed = schema.safeParse(await readRawJson(event))
if (!parsed.success) throw validationError(parsed.error.issues)
return parsed.data
}
/**
* 读取部分更新的请求体:只保留请求中实际出现的字段
* (zod 4 中 optional 包裹的 default 仍会生效,直接 partial() 会把缺省字段覆盖成默认值)
*/
export async function readPatch<S extends ZodObject>(event: H3Event, schema: S): Promise<Partial<output<S>>> {
const data = await readRawJson(event)
const parsed = schema.partial().safeParse(data)
if (!parsed.success) throw validationError(parsed.error.issues)
const result = parsed.data as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const k of Object.keys(data)) {
if (k in result) out[k] = result[k]
}
return out as Partial<output<S>>
}
export function param(event: H3Event, name = 'id') {
const v = getRouterParam(event, name)
if (!v) throw createError({ statusCode: 400, message: `缺少参数 ${name}` })
return v
}
+3
View File
@@ -0,0 +1,3 @@
import { customAlphabet } from 'nanoid'
export const newId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
+118
View File
@@ -0,0 +1,118 @@
import { eq } from 'drizzle-orm'
import type { JobInfo, JobType, ProjectEvent } from '#shared/types'
type JobRow = typeof tables.jobs.$inferSelect
export function toJobInfo(row: JobRow): JobInfo {
const { prompt: _p, projectId: _pid, ...rest } = row
return rest
}
/** 一次 AI 调用(或一组调用)的记录:持久化到数据库,并实时推送给前端 */
export class Job {
readonly id = newId()
private row: JobRow
constructor(readonly projectId: string, type: JobType, opts: { chapterId?: string | null, agentName?: string, modelLabel?: string } = {}) {
const db = useDb()
this.row = db.insert(tables.jobs).values({
id: this.id,
projectId,
type,
chapterId: opts.chapterId ?? null,
agentName: opts.agentName ?? '',
modelLabel: opts.modelLabel ?? ''
}).returning().get()
this.emit()
}
private update(patch: Partial<JobRow>) {
this.row = useDb().update(tables.jobs).set(patch).where(eq(tables.jobs.id, this.id)).returning().get()!
this.emit()
}
private emit() {
emitProject(this.projectId, { type: 'job', job: toJobInfo(this.row) })
}
log(message: string) {
const line = `[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`
this.update({ log: this.row.log ? `${this.row.log}\n${line}` : line })
}
setPrompt(system: string, prompt: string) {
this.update({ prompt: { system, prompt } })
}
addUsage(usage: { inputTokens?: number, outputTokens?: number } | undefined) {
if (!usage) return
this.update({
inputTokens: this.row.inputTokens + (usage.inputTokens ?? 0),
outputTokens: this.row.outputTokens + (usage.outputTokens ?? 0)
})
}
done() {
this.update({ status: 'done', finishedAt: new Date() })
}
fail(err: unknown) {
const cancelled = isAbort(err)
this.update({ status: cancelled ? 'cancelled' : 'error', error: cancelled ? '' : describeError(err), finishedAt: new Date() })
}
}
export function isAbort(err: unknown) {
const e = err as { name?: string } | undefined
return e?.name === 'AbortError' || e?.name === 'ResponseAborted' || (err instanceof Error && /aborted|abort/i.test(err.message) && /signal|user/i.test(err.message))
}
/* ---------------- 项目级互斥运行 ---------------- */
interface ActiveRun {
label: string
controller: AbortController
startedAt: number
}
const activeRuns = new Map<string, ActiveRun>()
export function getActiveRun(projectId: string) {
const run = activeRuns.get(projectId)
return run ? { label: run.label, startedAt: run.startedAt } : null
}
/**
* 每个项目同一时间只允许一个生成任务(保证章节连贯、避免状态竞争)。
* 任务在后台执行,接口立即返回;进度通过 SSE 推送。
*/
export function runExclusive(projectId: string, label: string, fn: (signal: AbortSignal) => Promise<void>) {
if (activeRuns.has(projectId)) {
throw createError({ statusCode: 409, message: `当前项目正在执行「${activeRuns.get(projectId)!.label}」,请等待完成或先停止` })
}
const controller = new AbortController()
activeRuns.set(projectId, { label, controller, startedAt: Date.now() })
emitProject(projectId, { type: 'pipeline', running: true, message: label })
fn(controller.signal)
.catch((err) => {
if (!isAbort(err)) {
console.error(`[run:${label}]`, err)
emitProject(projectId, { type: 'log', level: 'error', message: `${label}失败:${describeError(err)}` })
}
})
.finally(() => {
activeRuns.delete(projectId)
emitProject(projectId, { type: 'pipeline', running: false })
})
}
export function stopRun(projectId: string) {
const run = activeRuns.get(projectId)
if (!run) return false
run.controller.abort()
return true
}
export function emitAll(projectId: string, ...events: ProjectEvent[]) {
for (const e of events) emitProject(projectId, e)
}
+97
View File
@@ -0,0 +1,97 @@
import { createAnthropic } from '@ai-sdk/anthropic'
import { createGoogle } from '@ai-sdk/google'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import type { LanguageModel } from 'ai'
import { eq } from 'drizzle-orm'
import type { ProviderType } from '#shared/types'
type ProviderRow = typeof tables.providers.$inferSelect
type ModelRow = typeof tables.models.$inferSelect
export const DEFAULT_BASE_URL: Record<ProviderType, string> = {
'openai-compatible': 'https://api.openai.com/v1',
'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta'
}
function trimUrl(url: string) {
return url.trim().replace(/\/+$/, '')
}
export function createLanguageModel(provider: ProviderRow, modelId: string): LanguageModel {
const apiKey = decryptSecret(provider.apiKeyEnc)
const baseURL = trimUrl(provider.baseUrl) || DEFAULT_BASE_URL[provider.type]
switch (provider.type) {
case 'anthropic':
return createAnthropic({ apiKey, baseURL })(modelId)
case 'google':
return createGoogle({ apiKey, baseURL })(modelId)
default:
return createOpenAICompatible({ name: 'openai-compatible', apiKey, baseURL, includeUsage: true })(modelId)
}
}
export interface ResolvedModel {
model: LanguageModel
label: string
settings: { temperature?: number, topP?: number, maxOutputTokens?: number }
}
export function resolveModel(modelRowId: string | null | undefined): ResolvedModel {
if (!modelRowId) throw createError({ statusCode: 400, message: '该成员尚未指定模型,请先在「AI 团队」里选择模型' })
const db = useDb()
const row = db.select().from(tables.models).where(eq(tables.models.id, modelRowId)).get()
if (!row) throw createError({ statusCode: 400, message: '指定的模型不存在,可能已被删除' })
const provider = db.select().from(tables.providers).where(eq(tables.providers.id, row.providerId)).get()
if (!provider) throw createError({ statusCode: 400, message: '模型所属的 API 提供方不存在' })
return {
model: createLanguageModel(provider, row.modelId),
label: `${provider.name} / ${row.label || row.modelId}`,
settings: modelSettings(row)
}
}
export function modelSettings(row: ModelRow) {
const s: ResolvedModel['settings'] = {}
if (row.temperature != null) s.temperature = row.temperature
if (row.topP != null) s.topP = row.topP
if (row.maxOutputTokens != null) s.maxOutputTokens = row.maxOutputTokens
return s
}
/** 从提供方拉取可用模型列表 */
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
if (type === 'anthropic') {
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
query: { limit: 1000 }
})
return res.data.map(m => m.id)
}
if (type === 'google') {
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
headers: { 'x-goog-api-key': apiKey },
query: { pageSize: 1000 }
})
return res.models
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
.map(m => m.name.replace(/^models\//, ''))
}
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
headers: { Authorization: `Bearer ${apiKey}` }
})
return res.data.map(m => m.id).sort()
}
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
export function describeError(err: unknown): string {
if (!err) return '未知错误'
const e = err as { name?: string, message?: string, statusCode?: number, responseBody?: string, data?: unknown, cause?: unknown }
if (e.name === 'AbortError') return '已取消'
let msg = e.message || String(err)
if (e.statusCode) msg = `[HTTP ${e.statusCode}] ${msg}`
if (e.responseBody) msg += `\n${String(e.responseBody).slice(0, 800)}`
else if (e.data) msg += `\n${JSON.stringify(e.data).slice(0, 800)}`
return msg
}
+513
View File
@@ -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()
}
+255
View File
@@ -0,0 +1,255 @@
import type { ProjectMemory, ProjectRequirements } from '#shared/types'
import type { AgentRow, ChapterRow, CharacterRow, LoreRow, ProjectContext } from './context'
/* ============================================================
* 系统提示词分层:用户自定义(最高优先级)> 角色内置 > 写作要求
* ============================================================ */
export function composeSystem(customPrompt: string, builtin: string, extra = '') {
const parts: string[] = []
if (customPrompt.trim()) {
parts.push([
'<highest_priority_instructions>',
'以下是用户自定义的最高优先级指令。它们的优先级高于本提示中其他所有内容;如有任何冲突,一律以此为准。',
'',
customPrompt.trim(),
'</highest_priority_instructions>'
].join('\n'))
}
parts.push(builtin.trim())
if (extra.trim()) parts.push(extra.trim())
return parts.join('\n\n')
}
const section = (tag: string, body: string) => (body.trim() ? `<${tag}>\n${body.trim()}\n</${tag}>` : '')
const join = (...parts: string[]) => parts.filter(Boolean).join('\n\n')
export function formatRequirements(r: ProjectRequirements) {
const lines = [
r.language && `- 语言:${r.language}`,
r.style && `- 文风:${r.style}`,
r.pov && `- 叙事视角:${r.pov}`,
r.narration && `- 叙述方式:${r.narration}`,
r.wordsPerChapter && `- 每章目标字数:约 ${r.wordsPerChapter} 字`,
r.extra && `- 其他要求:${r.extra}`
].filter(Boolean)
return lines.join('\n')
}
export function formatCharacter(c: CharacterRow, full = true) {
const lines = [`## ${c.name}${c.identity ? `(${c.identity})` : ''}`]
if (c.description) lines.push(full ? c.description : truncate(c.description, 300))
if (c.personality) lines.push(`性格:${c.personality}`)
if (full && c.speechExamples) lines.push(`说话风格示例:\n${truncate(c.speechExamples, 1500)}`)
if (full && c.notes) lines.push(`备注:${c.notes}`)
return lines.join('\n')
}
/** 根据文本挑出出场人物;一个都没匹配到时返回全部角色 */
export function pickCharacters(chars: CharacterRow[], scanText: string) {
const hit = chars.filter(c => scanText.includes(c.name))
return hit.length ? hit : chars
}
function keyMatches(key: string, text: string) {
const m = key.match(/^\/(.+)\/([a-z]*)$/)
if (m) {
try { return new RegExp(m[1]!, m[2]).test(text) }
catch { return false }
}
return key.trim() !== '' && text.toLowerCase().includes(key.toLowerCase())
}
/** 世界书:常驻条目 + 关键词命中的条目 */
export function selectLore(lore: LoreRow[], scanText: string) {
return lore.filter(l => l.enabled && (l.constant || l.keys.some(k => keyMatches(k, scanText))))
}
export function formatLore(entries: LoreRow[]) {
return entries.map(e => `### ${e.title || e.keys.slice(0, 3).join('/') || '设定'}\n${e.content}`).join('\n\n')
}
export function formatMemory(m: ProjectMemory) {
const parts: string[] = []
if (m.characterStates.length) parts.push(`人物当前状态:\n${m.characterStates.map(s => `- ${s.name}:${s.state}`).join('\n')}`)
const open = m.foreshadowing.filter(f => f.status === 'open')
if (open.length) parts.push(`未回收的伏笔:\n${open.map(f => `- (第${f.chapter}章埋下)${f.text}`).join('\n')}`)
return parts.join('\n\n')
}
export function formatChapterPlan(chapters: ChapterRow[], highlightIndex?: number) {
return chapters.map((c) => {
const mark = c.index === highlightIndex ? ' ← 当前章节' : ''
return `第${c.index}章 ${c.title || '(未命名)'}${mark}${c.synopsis ? `:${c.synopsis}` : ''}`
}).join('\n')
}
/** 前情提要:最近 N 章给完整摘要,更早的章节只给梗概 */
export function formatPreviousSummaries(chapters: ChapterRow[], beforeIndex: number, recent: number) {
const prev = chapters.filter(c => c.index < beforeIndex && (c.summary || c.synopsis))
const older = prev.slice(0, Math.max(0, prev.length - recent))
const near = prev.slice(-recent)
const parts: string[] = []
if (older.length) parts.push(`更早章节梗概:\n${older.map(c => `- 第${c.index}章 ${c.title}:${truncate(c.summary || c.synopsis, 120)}`).join('\n')}`)
if (near.length) parts.push(near.map(c => `### 第${c.index}章 ${c.title}\n${c.summary || c.synopsis}`).join('\n\n'))
return parts.join('\n\n')
}
export function truncate(s: string, n: number) {
return s.length > n ? `${s.slice(0, n)}…` : s
}
export function tail(s: string, n: number) {
return s.length > n ? `…${s.slice(-n)}` : s
}
function storyBible(ctx: ProjectContext, scanText: string, opts: { fullCharacters: boolean }) {
const { project, characters, lore } = ctx
const chars = opts.fullCharacters ? characters : pickCharacters(characters, scanText)
return join(
section('story_title', project.title),
section('world_setting', project.worldSetting),
section('characters', chars.map(c => formatCharacter(c, true)).join('\n\n')),
section('lore', formatLore(selectLore(lore, scanText))),
section('story_outline', project.storyOutline),
section('writing_requirements', formatRequirements(project.requirements))
)
}
/* ============================================================
* 主编 Editor
* ============================================================ */
export const EDITOR_BUILTIN = `你是一部长篇小说的主编,负责把控整部作品的走向、结构与质量。
你的职责:
1. 依据故事背景、人物设定和故事大纲,规划全书的章节结构,保证起承转合、节奏张弛有度、伏笔有埋有收。
2. 在每一章开写之前,为作家撰写一份清晰、可执行的「章节简报」:本章要完成的剧情目标、关键情节点(按顺序)、出场人物及其状态与动机、场景、情绪基调、节奏、文风要求、目标字数、需要埋下或回收的伏笔、结尾钩子。
3. 从作家团队中挑选最适合本章风格的作家(或在允许时创建新作家)来执行写作。
4. 你不亲自写正文。你看到的是前文的摘要而不是全文,请据此保证连续性。
工作原则:简报要具体,避免空泛的形容词;尊重既定设定和人物性格,不要擅自改动核心设定;输出使用简体中文(除非写作要求另有规定)。`
export function buildEditorBriefPrompt(ctx: ProjectContext, chapter: ChapterRow, writers: AgentRow[], allowCreate: boolean) {
const { project, chapters } = ctx
const scan = `${chapter.title}\n${chapter.synopsis}\n${project.storyOutline}`
const roster = writers.length
? writers.map((w) => {
const model = ctx.models.find(m => m.id === w.modelId)
return `- id=${w.id}|${w.name}|模型:${model ? `${model.providerName}/${model.label || model.modelId}` : '未配置'}${w.styleNotes ? `|擅长:${w.styleNotes}` : ''}`
}).join('\n')
: '(暂无作家)'
const modelList = ctx.models.map(m => `- modelId=${m.id}|${m.providerName} / ${m.label || m.modelId}`).join('\n')
const prompt = join(
storyBible(ctx, scan, { fullCharacters: true }),
section('chapter_plan', formatChapterPlan(chapters, chapter.index)),
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
section('story_memory', formatMemory(project.memory)),
section('writers', roster),
allowCreate ? section('available_models', modelList) : '',
chapter.brief ? section('previous_brief_draft', chapter.brief) : '',
section('task', [
`请为第${chapter.index}章${chapter.title ? `「${chapter.title}」` : ''}制定章节简报。`,
chapter.synopsis ? `本章既定梗概:${chapter.synopsis}` : '本章尚无梗概,请根据大纲和前情自行确定本章内容。',
'步骤:',
'1. 调用 write_brief 工具提交章节简报(必须调用)。',
chapter.writerLocked
? '2. 用户已为本章指定作家,无需再选择作家。'
: allowCreate
? '2. 调用 assign_writer 从作家团队中选择一位作家;如果没有合适的作家,可以调用 create_writer 用 available_models 中的模型创建一位新作家(创建后自动指派)。'
: '2. 调用 assign_writer 从作家团队中选择一位作家。',
'3. 完成后用一两句话说明你的安排思路。'
].join('\n'))
)
return prompt
}
export function buildEditorOutlinePrompt(ctx: ProjectContext, count: number, fromIndex: number) {
const { project, chapters } = ctx
return join(
storyBible(ctx, project.storyOutline, { fullCharacters: true }),
section('existing_chapter_plan', formatChapterPlan(chapters)),
section('previous_chapters', formatPreviousSummaries(chapters, fromIndex, project.settings.recentSummaryCount)),
section('story_memory', formatMemory(project.memory)),
section('task', [
`请规划从第${fromIndex}章开始的 ${count} 个章节(第${fromIndex}章 ~ 第${fromIndex + count - 1}章)。`,
'要求:每章给出标题和 80~200 字的梗概,梗概需写明本章的核心事件、冲突与转折、结尾落点;章节之间要有因果递进,节奏有张有弛。',
'已确认(approved)或已写完的章节不要改动;之后的章节可以根据需要重新规划。',
'请调用 set_chapter_plan 工具一次性提交规划结果,然后用一两句话概括你的整体设计思路。'
].join('\n'))
)
}
export function buildEditorReviewPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
return join(
section('writing_requirements', formatRequirements(ctx.project.requirements)),
section('characters', pickCharacters(ctx.characters, text).map(c => formatCharacter(c, false)).join('\n\n')),
section('story_memory', formatMemory(ctx.project.memory)),
section('chapter_brief', chapter.brief),
section('chapter_text', text),
section('task', [
`请以主编身份审阅第${chapter.index}章正文。`,
'从以下维度给出评价:简报完成度(逐条对照情节点)、人物是否 OOC、与设定/前文是否矛盾、文风与要求是否一致、节奏与可读性、语言问题。',
'最后给出 1~10 分的总评分,并列出可以直接交给作家执行的修改意见(编号列表)。用 Markdown 输出。'
].join('\n'))
)
}
/* ============================================================
* 作家 Writer
* ============================================================ */
export const WRITER_BUILTIN = `你是一位专业的小说作家,正在为一部长篇小说执笔其中一章。
你会收到故事设定、人物资料、前情提要和主编给出的「章节简报」。你的任务是把简报写成完整、生动、可直接发表的小说正文。
写作原则:
- 严格完成简报中的全部情节点,按顺序推进,不要跳过,也不要擅自写到下一章的内容。
- 人物言行必须符合其性格与当前状态;对话要有个性。
- 多用具体的动作、细节、感官描写和对白来展现,少用概括性的叙述。
- 与上一章结尾自然衔接,不要重复上一章已经写过的内容。
- 只输出小说正文本身:不要输出章节标题、不要写“好的”“以下是”等说明,不要在结尾加总结或注释。`
export function buildWriterPrompt(ctx: ProjectContext, chapter: ChapterRow, prevTail: string, feedback: string, previousDraft: string) {
const { project, chapters } = ctx
const scan = `${chapter.brief}\n${chapter.title}\n${chapter.synopsis}`
const plan = chapters.filter(c => Math.abs(c.index - chapter.index) <= 2)
return join(
storyBible(ctx, scan, { fullCharacters: false }),
section('nearby_chapter_plan', formatChapterPlan(plan, chapter.index)),
section('previous_chapters', formatPreviousSummaries(chapters, chapter.index, project.settings.recentSummaryCount)),
section('story_memory', formatMemory(project.memory)),
section('previous_chapter_ending', prevTail),
section('chapter_brief', `第${chapter.index}章 ${chapter.title}\n\n${chapter.brief || chapter.synopsis}`),
previousDraft ? section('previous_draft', previousDraft) : '',
feedback ? section('revision_feedback', feedback) : '',
section('task', previousDraft
? `请根据 revision_feedback 修改 previous_draft,输出修改后的第${chapter.index}章完整正文。`
: `请开始写第${chapter.index}章正文,目标约 ${project.requirements.wordsPerChapter} 字。`)
)
}
export function writerExtra(r: ProjectRequirements) {
const req = formatRequirements(r)
return req ? `写作要求:\n${req}` : ''
}
/* ============================================================
* 摘要员 Summarizer
* ============================================================ */
export const SUMMARIZER_BUILTIN = `你是小说编辑部的资料员,负责在每章定稿后整理“故事记忆”,供主编和后续作家保持连贯。
你只输出一个 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。`
export function buildSummaryPrompt(ctx: ProjectContext, chapter: ChapterRow, text: string) {
return join(
section('character_names', ctx.characters.map(c => c.name).join('、')),
section('story_memory_before', formatMemory(ctx.project.memory)),
section('chapter_text', `第${chapter.index}章 ${chapter.title}\n\n${text}`),
section('task', `请阅读本章正文,输出如下 JSON:
{
"summary": "本章剧情摘要,300~500 字,按时间顺序写清楚发生了什么、人物做出了什么选择、关系和局势有什么变化、结尾停在哪里",
"characterStates": [{ "name": "人物名", "state": "该人物在本章结束时的处境、情绪、位置、身体状况、关键持有物等(只列本章有变化的人物)" }],
"newForeshadowing": ["本章新埋下的伏笔或悬念"],
"resolvedForeshadowing": ["本章回收/揭晓的伏笔(尽量引用 story_memory_before 中的原文)"],
"events": ["本章关键事件,一句话一条,最多 5 条"]
}`)
)
}
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod'
export const modelSchema = z.object({
providerId: z.string().min(1),
label: z.string().default(''),
modelId: z.string().min(1),
temperature: z.number().min(0).max(2).nullable().default(null),
topP: z.number().min(0).max(1).nullable().default(null),
maxOutputTokens: z.number().int().positive().nullable().default(null)
})
export const requirementsSchema = z.object({
style: z.string(),
language: z.string(),
pov: z.string(),
narration: z.string(),
wordsPerChapter: z.number().int().min(100).max(50000),
extra: z.string()
})
export const settingsSchema = z.object({
autoMode: z.boolean(),
pauseAfterBrief: z.boolean(),
allowEditorCreateWriters: z.boolean(),
prevTailChars: z.number().int().min(0).max(20000),
recentSummaryCount: z.number().int().min(0).max(50),
maxContinuations: z.number().int().min(0).max(10),
autoChapterLimit: z.number().int().min(1).max(200)
})
export const memorySchema = z.object({
characterStates: z.array(z.object({ name: z.string(), state: z.string() })),
foreshadowing: z.array(z.object({
text: z.string(),
status: z.enum(['open', 'resolved']),
chapter: z.number(),
resolvedChapter: z.number().optional()
})),
timeline: z.array(z.object({ chapter: z.number(), event: z.string() }))
})
export const characterSchema = z.object({
name: z.string().min(1),
identity: z.string().default(''),
description: z.string().default(''),
personality: z.string().default(''),
speechExamples: z.string().default(''),
notes: z.string().default(''),
sortOrder: z.number().int().default(0)
})
export const loreSchema = z.object({
title: z.string().default(''),
keys: z.array(z.string()).default([]),
content: z.string().default(''),
constant: z.boolean().default(false),
enabled: z.boolean().default(true),
sortOrder: z.number().int().default(100)
})
export const agentSchema = z.object({
role: z.enum(['editor', 'writer', 'summarizer']),
name: z.string().min(1),
modelId: z.string().nullable().default(null),
systemPrompt: z.string().default(''),
styleNotes: z.string().default(''),
enabled: z.boolean().default(true)
})
+52
View File
@@ -0,0 +1,52 @@
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, type WriteStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
export function projectDir(projectId: string) {
return join(dataDir(), 'projects', projectId)
}
/** 章节文件相对 data 目录的路径,例如 projects/xxx/chapters/0003-v2.md */
export function chapterFileRel(projectId: string, index: number, version: number) {
return ['projects', projectId, 'chapters', `${String(index).padStart(4, '0')}-v${version}.md`].join('/')
}
export function absPath(rel: string) {
const root = dataDir()
const p = resolve(root, rel)
if (p !== root && !p.startsWith(root + sep)) throw new Error('非法路径')
return p
}
export function openChapterWriter(rel: string): WriteStream {
const p = absPath(rel)
mkdirSync(dirname(p), { recursive: true })
return createWriteStream(p, { encoding: 'utf8' })
}
export async function readChapterFile(rel: string) {
const p = absPath(rel)
if (!existsSync(p)) return ''
return readFile(p, 'utf8')
}
export function readChapterFileSync(rel: string) {
const p = absPath(rel)
return existsSync(p) ? readFileSync(p, 'utf8') : ''
}
export function removeProjectFiles(projectId: string) {
const dir = projectDir(projectId)
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
}
export function relToData(abs: string) {
return relative(dataDir(), abs).split(sep).join('/')
}
/** 统计字数:中日韩字符按 1 字,英文按单词计 */
export function countWords(text: string) {
const cjk = text.match(/[㐀-鿿豈-﫿぀-ヿ가-힯]/g)?.length ?? 0
const latin = text.replace(/[㐀-鿿豈-﫿぀-ヿ가-힯]/g, ' ').match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g)?.length ?? 0
return cjk + latin
}