Files
jocay 283f40892a 优化 API 配置:一个弹窗完成接口、Key 与模型选择
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key
- 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名
- 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头
- 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取
- 单模型测试与参数设置;手机端布局优化

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:23:09 +08:00

132 lines
6.9 KiB
JavaScript
Raw Permalink 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}`)
// 与真实接口一样要求 /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`))