AI 小说工坊:主编 + 作家多模型协作写作应用

- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite
- 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端
- 逐章确认模式(默认)与自动模式;摘要与故事记忆
- 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入
- 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视
- 模拟 LLM 服务与端到端测试脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jocay
2026-10-18 02:04:40 +08:00
commit 3c9b65d982
106 changed files with 16286 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
// 端到端测试:配合 scripts/mock-llm.mjs 跑通完整写作流程
// 用法:node scripts/e2e.mjs <appBaseUrl> <mockBaseUrl> [角色卡.png]
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
const [app = 'http://localhost:3000', mock = 'http://localhost:4010/v1', cardPath] = process.argv.slice(2)
async function api(path, method = 'GET', body) {
const res = await fetch(app + path, {
method,
headers: body instanceof FormData || body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body)
})
const text = await res.text()
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${text}`)
try { return JSON.parse(text) }
catch { return text }
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function waitIdle(pid, timeout = 30000) {
const start = Date.now()
await sleep(150)
while (Date.now() - start < timeout) {
const p = await api(`/api/projects/${pid}`)
if (!p.running) return p
await sleep(200)
}
throw new Error('等待任务完成超时')
}
const step = msg => console.log(`✔ ${msg}`)
// 1. 提供方与模型
const provider = await api('/api/providers', 'POST', { name: 'Mock', type: 'openai-compatible', baseUrl: mock, apiKey: 'test-key' })
const providers = await api('/api/providers')
assert.ok(providers.find(p => p.id === provider.id).apiKeyMasked.length > 0)
assert.ok(!JSON.stringify(providers).includes('test-key'), '接口不应返回明文 Key')
const remote = await api(`/api/providers/${provider.id}/remote-models`)
assert.deepEqual(remote, ['mock-editor', 'mock-writer-trunc'])
const editorModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-editor', label: '模拟主编' })
const writerModel = await api('/api/models', 'POST', { providerId: provider.id, modelId: 'mock-writer-trunc', label: '模拟作家', maxOutputTokens: 4096 })
const test = await api(`/api/models/${editorModel.id}/test`, 'POST', {})
assert.equal(test.ok, true, JSON.stringify(test))
step('提供方/模型/连接测试')
// 2. 项目与设定
const { id: pid } = await api('/api/projects', 'POST', { title: 'E2E 测试作品' })
await api(`/api/projects/${pid}`, 'PUT', {
worldSetting: '2011 年的昆明,雨季。',
storyOutline: '主角回到昆明,与旧识简琳琅重逢,逐步揭开百鸟县的往事。',
userName: '陈默'
})
const agents = await api(`/api/projects/${pid}/agents`)
assert.equal(agents.length, 3)
const editor = agents.find(a => a.role === 'editor')
const writer = agents.find(a => a.role === 'writer')
await api(`/api/agents/${editor.id}`, 'PUT', { modelId: editorModel.id, systemPrompt: '你是一位苛刻的主编。' })
await api(`/api/agents/${writer.id}`, 'PUT', { modelId: writerModel.id, styleNotes: '氛围描写' })
await api(`/api/agents/${agents.find(a => a.role === 'summarizer').id}`, 'PUT', { modelId: editorModel.id })
const writer2 = await api(`/api/projects/${pid}/agents`, 'POST', { role: 'writer', name: '作家二号', modelId: writerModel.id })
step('项目/AI 团队配置')
// 3. 角色卡导入
if (cardPath) {
const buf = readFileSync(cardPath)
const fd = new FormData()
fd.append('file', new Blob([buf], { type: 'image/png' }), 'card.png')
const preview = await api(`/api/projects/${pid}/cards/preview`, 'POST', fd)
assert.ok(preview.name)
assert.ok(preview.lore.length > 0)
const fd2 = new FormData()
fd2.append('file', new Blob([buf], { type: 'image/png' }), 'card.png')
fd2.append('options', JSON.stringify({ identity: '女主角' }))
const imported = await api(`/api/projects/${pid}/cards/import`, 'POST', fd2)
const chars = await api(`/api/projects/${pid}/characters`)
assert.equal(chars.length, 1)
assert.equal(chars[0].name, preview.name)
assert.ok(chars[0].avatar)
const lore = await api(`/api/projects/${pid}/lore`)
assert.equal(lore.length, imported.loreCount)
const avatar = await fetch(`${app}/api/avatars/${pid}/${chars[0].avatar}`)
assert.equal(avatar.status, 200)
step(`角色卡导入:${preview.name}(${preview.spec},世界书 ${lore.length} 条)`)
}
// 4. SSE 监听
const events = []
const ac = new AbortController()
;(async () => {
try {
const res = await fetch(`${app}/api/projects/${pid}/events`, { signal: ac.signal })
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
let i
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i)
buf = buf.slice(i + 2)
const data = block.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).join('')
if (data && !block.includes('event: ping')) events.push(JSON.parse(data))
}
}
}
catch {}
})()
await sleep(300)
// 5. 继续下一章(逐章模式):规划 → 简报 → 写作 → 暂停
await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
let chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters.length, 3, '主编应规划 3 章')
assert.equal(chapters[0].status, 'written')
assert.equal(chapters[1].status, 'planned')
let ch1 = await api(`/api/chapters/${chapters[0].id}`)
assert.ok(ch1.brief.includes('情节要点'))
assert.equal(ch1.title, '雨落昆明')
assert.equal(ch1.writerAgentId, writer.id)
assert.ok(ch1.content.includes('百鸟县'), '自动续写后应包含续写内容')
assert.equal(ch1.versions.length, 1)
const deltas = events.filter(e => e.type === 'delta' && e.chapterId === ch1.id).map(e => e.text).join('')
assert.equal(deltas, ch1.content, 'SSE 推送的增量应与文件内容一致')
step(`逐章模式写完第1章(${ch1.versions[0].wordCount} 字,SSE 增量 ${events.filter(e => e.type === 'delta').length} 条)`)
// 6. 待确认时不能继续
const blocked = await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'planned', '第1章未确认时不应开始第2章')
assert.ok(blocked.started)
// 7. 确认并继续
await api(`/api/chapters/${ch1.id}/approve`, 'POST', { continue: true })
await waitIdle(pid)
ch1 = await api(`/api/chapters/${ch1.id}`)
assert.equal(ch1.status, 'approved')
assert.ok(ch1.summary.includes('百鸟县'))
let project = await api(`/api/projects/${pid}`)
assert.equal(project.memory.characterStates.length, 1)
assert.equal(project.memory.foreshadowing.length, 1)
assert.equal(project.memory.timeline.length, 1)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'written')
step('确认第1章 → 摘要与故事记忆已更新 → 第2章写完')
// 8. 手动锁定作家
await api(`/api/chapters/${chapters[2].id}`, 'PUT', { writerAgentId: writer2.id, writerLocked: true })
// 9. 审稿与修改
const ch2Id = chapters[1].id
await api(`/api/chapters/${ch2Id}/review`, 'POST', {})
await waitIdle(pid)
let ch2 = await api(`/api/chapters/${ch2Id}`)
assert.ok(ch2.reviewNotes.includes('总评分'))
await api(`/api/chapters/${ch2Id}/write`, 'POST', { feedback: ch2.reviewNotes, revise: true })
await waitIdle(pid)
ch2 = await api(`/api/chapters/${ch2Id}`)
assert.equal(ch2.versions.length, 2)
assert.equal(ch2.activeVersionId, ch2.versions[0].id)
const writeJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'write' && j.chapterId === ch2Id)
const writeJobDetail = await api(`/api/jobs/${writeJob.id}`)
assert.ok(writeJobDetail.prompt.prompt.includes('revision_feedback'))
assert.ok(writeJobDetail.prompt.prompt.includes('previous_draft'))
step('主编审稿 → 按意见修改生成 v2')
// 10. 自动模式
await api(`/api/projects/${pid}`, 'PUT', { settings: { ...project.settings, autoMode: true, autoChapterLimit: 1 } })
await api(`/api/projects/${pid}/run`, 'POST', {})
await waitIdle(pid)
chapters = await api(`/api/projects/${pid}/chapters`)
assert.equal(chapters[1].status, 'approved', '自动模式应自动确认第2章')
assert.equal(chapters[2].status, 'written')
const ch3 = await api(`/api/chapters/${chapters[2].id}`)
assert.equal(ch3.writerAgentId, writer2.id, '锁定的作家不应被主编更换')
const briefJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'brief' && j.chapterId === ch3.id)
const briefDetail = await api(`/api/jobs/${briefJob.id}`)
assert.ok(briefDetail.prompt.system.startsWith('<highest_priority_instructions>'), '用户自定义提示词应位于最前')
assert.ok(briefDetail.prompt.prompt.includes('简琳琅') || !cardPath)
step('自动模式:自动确认第2章并写完第3章(遵守章节上限与作家锁定)')
// 11. 停止
await api(`/api/chapters/${chapters[2].id}/write`, 'POST', {})
await sleep(100)
await api(`/api/projects/${pid}/stop`, 'POST', {})
await waitIdle(pid)
const ch3b = await api(`/api/chapters/${chapters[2].id}`)
assert.equal(ch3b.status, 'written', '取消后应恢复为之前的状态')
assert.equal(ch3b.versions[0].status, 'cancelled')
assert.equal(ch3b.activeVersionId, ch3.activeVersionId, '取消的版本不应成为当前版本')
step('写作中途停止 → 状态回滚,旧版本保留')
// 12. 导出
const txt = await api(`/api/projects/${pid}/export?format=txt&scope=approved`)
assert.ok(txt.includes('第1章 雨落昆明'))
assert.ok(!txt.includes('第3章'))
step('导出 TXT')
const jobs = await api(`/api/projects/${pid}/jobs`)
const errors = jobs.filter(j => j.status === 'error')
assert.equal(errors.length, 0, JSON.stringify(errors, null, 2))
step(`共 ${jobs.length} 个任务,无错误;token 统计:${jobs.reduce((s, j) => s + j.inputTokens + j.outputTokens, 0)}`)
ac.abort()
await api(`/api/projects/${pid}`, 'DELETE')
console.log('\n全部通过 ✅')
process.exit(0)