AI 小说工坊:主编 + 作家多模型协作写作应用
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const id = param(event)
|
||||
return { ...getProject(id), running: getActiveRun(id) }
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 }))
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler((event) => {
|
||||
return { stopped: stopRun(param(event)) }
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 内置角色提示词(只读展示,让用户了解自定义提示词会覆盖什么) */
|
||||
export default defineEventHandler(() => ({
|
||||
editor: EDITOR_BUILTIN,
|
||||
writer: WRITER_BUILTIN,
|
||||
summarizer: SUMMARIZER_BUILTIN
|
||||
}))
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)}` })
|
||||
}
|
||||
})
|
||||
@@ -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) }
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)) }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1792257891167,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)])
|
||||
@@ -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: '需要访问密码' })
|
||||
})
|
||||
@@ -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()}`)
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
export const newId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 条"]
|
||||
}`)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user