283f40892a
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key - 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名 - 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头 - 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取 - 单模型测试与参数设置;手机端布局优化 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
6.9 KiB
JavaScript
132 lines
6.9 KiB
JavaScript
// 模拟 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}`)
|
||
// 与真实接口一样要求 /v1 前缀,用于测试 Base URL 自动补全
|
||
if (!url.pathname.startsWith('/v1/')) return json(res, 404, { error: { message: 'not found' } })
|
||
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`))
|