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 }
|
||||
})
|
||||
Reference in New Issue
Block a user