Files
ai-novel-studio/scripts/e2e.mjs
T
jocay 3c9b65d982 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>
2026-10-18 02:04:40 +08:00

211 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 端到端测试:配合 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)