Files
ai-novel-studio/scripts/mock-llm.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

130 lines
6.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.
// 模拟 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`))