// 模拟 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`))