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,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