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:
+210
@@ -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)
|
||||
@@ -0,0 +1,129 @@
|
||||
// 模拟 OpenAI 兼容接口,用于本地联调和端到端测试(不消耗真实 token)
|
||||
// 用法:node scripts/mock-llm.mjs [port]
|
||||
// - 模型 id 含 "trunc" 时,作家第一轮输出会以 finish_reason=length 结束,用于测试自动续写
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const port = Number(process.argv[2] || process.env.MOCK_PORT || 4010)
|
||||
const log = (...a) => console.log('[mock]', ...a)
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function completion(model, message, finish = 'stop') {
|
||||
return {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: null, ...message }, finish_reason: finish }],
|
||||
usage: { prompt_tokens: 1200, completion_tokens: 300, total_tokens: 1500 }
|
||||
}
|
||||
}
|
||||
|
||||
const toolCall = (name, args) => ({
|
||||
id: `call_${name}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: 'function',
|
||||
function: { name, arguments: JSON.stringify(args) }
|
||||
})
|
||||
|
||||
const text = m => (typeof m.content === 'string' ? m.content : (m.content || []).map(p => p.text || '').join(''))
|
||||
|
||||
function handleTools(body) {
|
||||
const names = body.tools.map(t => t.function.name)
|
||||
const hasToolResult = body.messages.some(m => m.role === 'tool')
|
||||
const prompt = body.messages.filter(m => m.role === 'user').map(text).join('\n')
|
||||
|
||||
if (names.includes('set_chapter_plan')) {
|
||||
if (hasToolResult) return completion(body.model, { content: '整体采用三幕结构,前三章完成人物登场与核心冲突的建立。' })
|
||||
const m = prompt.match(/从第(\d+)章开始的 (\d+) 个章节/)
|
||||
const from = m ? Number(m[1]) : 1
|
||||
const count = Math.min(m ? Number(m[2]) : 3, 3)
|
||||
const chapters = Array.from({ length: count }, (_, i) => ({
|
||||
index: from + i,
|
||||
title: ['雨季', '旧城', '回声'][i] ?? `第${from + i}章`,
|
||||
synopsis: `第${from + i}章梗概:主角在雨中重逢旧识,埋下关于旧城往事的悬念。`
|
||||
}))
|
||||
return completion(body.model, { tool_calls: [toolCall('set_chapter_plan', { chapters })] }, 'tool_calls')
|
||||
}
|
||||
|
||||
if (names.includes('write_brief')) {
|
||||
if (hasToolResult) return completion(body.model, { content: '本章以环境开篇,节奏先慢后快。' })
|
||||
const writerId = prompt.match(/id=([a-z0-9]+)|/)?.[1]
|
||||
const calls = [toolCall('write_brief', {
|
||||
title: '雨落昆明',
|
||||
goal: '让主角与简琳琅在雨中初遇,建立两人之间的张力',
|
||||
plotPoints: ['午后雷声,雨季开始', '主角在骑楼下避雨', '简琳琅撑伞经过,两人短暂交谈', '她留下一个意味深长的问题'],
|
||||
characters: ['简琳琅:表面强势,内心戒备'],
|
||||
setting: '2011 年 5 月底,昆明老街',
|
||||
mood: '潮湿、暧昧、略带不安',
|
||||
targetWords: 1200,
|
||||
endingHook: '她问:你还记得百鸟县吗?'
|
||||
})]
|
||||
if (writerId && !prompt.includes('用户已为本章指定作家')) calls.push(toolCall('assign_writer', { writerId, reason: '擅长氛围描写' }))
|
||||
return completion(body.model, { tool_calls: calls }, 'tool_calls')
|
||||
}
|
||||
return completion(body.model, { content: '好的。' })
|
||||
}
|
||||
|
||||
function handleText(body) {
|
||||
const system = body.messages.filter(m => m.role === 'system').map(text).join('\n')
|
||||
if (system.includes('资料员')) {
|
||||
return completion(body.model, {
|
||||
content: '```json\n' + JSON.stringify({
|
||||
summary: '雨季的第一声雷后,主角在骑楼下避雨,与撑伞经过的简琳琅短暂交谈。她看似随意,却在离开前提起了百鸟县。',
|
||||
characterStates: [{ name: '简琳琅', state: '刚与主角重逢,情绪克制,似乎有所隐瞒' }],
|
||||
newForeshadowing: ['简琳琅提起百鸟县的真正用意'],
|
||||
resolvedForeshadowing: [],
|
||||
events: ['主角与简琳琅雨中重逢']
|
||||
}) + '\n```'
|
||||
})
|
||||
}
|
||||
return completion(body.model, { content: '## 审稿意见\n\n1. 情节点全部完成。\n2. 结尾钩子可以更含蓄。\n\n**总评分:8/10**' })
|
||||
}
|
||||
|
||||
function streamChapter(res, body) {
|
||||
const userTurns = body.messages.filter(m => m.role === 'user').length
|
||||
const truncate = body.model.includes('trunc') && userTurns === 1
|
||||
const paragraphs = truncate
|
||||
? ['午后,一声闷雷从天边滚过来。', '\n\n雨点砸在青石板上,溅起细碎的水花。']
|
||||
: userTurns > 1
|
||||
? ['\n\n她收起伞,抬眼看他:“你还记得百鸟县吗?”']
|
||||
: ['午后,一声闷雷从天边滚过来。', '\n\n雨来得又急又密,他退到骑楼下。', '\n\n简琳琅撑着一把黑伞从街角走来,高跟鞋踩过积水。', '\n\n“借个地方躲雨。”她说。', '\n\n她收起伞,抬眼看他:“你还记得百鸟县吗?”']
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' })
|
||||
const base = { id: `chatcmpl-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model }
|
||||
const send = obj => res.write(`data: ${JSON.stringify({ ...base, ...obj })}\n\n`)
|
||||
send({ choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] })
|
||||
const chunks = paragraphs.flatMap(p => p.match(/.{1,4}/gs))
|
||||
let i = 0
|
||||
const timer = setInterval(() => {
|
||||
if (res.destroyed) return clearInterval(timer)
|
||||
if (i < chunks.length) {
|
||||
send({ choices: [{ index: 0, delta: { content: chunks[i++] }, finish_reason: null }] })
|
||||
return
|
||||
}
|
||||
clearInterval(timer)
|
||||
send({ choices: [{ index: 0, delta: {}, finish_reason: truncate ? 'length' : 'stop' }] })
|
||||
send({ choices: [], usage: { prompt_tokens: 2000, completion_tokens: 400, total_tokens: 2400 } })
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
}, 15)
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${port}`)
|
||||
if (req.method === 'GET' && url.pathname.endsWith('/models')) {
|
||||
return json(res, 200, { object: 'list', data: [{ id: 'mock-editor' }, { id: 'mock-writer-trunc' }] })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname.endsWith('/chat/completions')) {
|
||||
let raw = ''
|
||||
for await (const c of req) raw += c
|
||||
const body = JSON.parse(raw)
|
||||
if (req.headers.authorization !== 'Bearer test-key') return json(res, 401, { error: { message: 'invalid api key' } })
|
||||
log(body.model, body.stream ? 'stream' : 'json', body.tools ? `tools=${body.tools.map(t => t.function.name).join(',')}` : '')
|
||||
if (body.stream) return streamChapter(res, body)
|
||||
return json(res, 200, body.tools?.length ? handleTools(body) : handleText(body))
|
||||
}
|
||||
json(res, 404, { error: { message: 'not found' } })
|
||||
}).listen(port, () => log(`listening on http://localhost:${port}/v1`))
|
||||
Reference in New Issue
Block a user