151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
/**
|
||
* AI 客户端 —— 纯前端直连 OpenAI 兼容 /chat/completions(SSE 流式)。
|
||
* 不依赖任何后端;baseURL / apiKey / model 全部由用户在前端设置里提供。
|
||
*/
|
||
|
||
export interface ChatMessage {
|
||
role: 'system' | 'user' | 'assistant'
|
||
content: string
|
||
}
|
||
|
||
export interface StreamOptions {
|
||
baseURL: string
|
||
apiKey: string
|
||
model: string
|
||
messages: ChatMessage[]
|
||
onToken: (delta: string) => void
|
||
signal?: AbortSignal
|
||
}
|
||
|
||
/** LLM 调用错误(携带 HTTP 状态码,便于前端给出友好提示) */
|
||
export class AiRequestError extends Error {
|
||
status: number
|
||
constructor(status: number, message: string) {
|
||
super(message)
|
||
this.name = 'AiRequestError'
|
||
this.status = status
|
||
}
|
||
}
|
||
|
||
function friendlyError(status: number, body: string): string {
|
||
if (status === 401) return 'API Key 无效或已过期,请检查设置中的 Key。'
|
||
if (status === 404) return '接口地址不正确(请确认 baseURL 含 /v1 且路径为 /chat/completions)。'
|
||
if (status === 429) return '请求过于频繁或额度不足(429)。'
|
||
if (status >= 500) return `模型服务异常(HTTP ${status})。`
|
||
// 跨域 / 网络层:fetch 抛 TypeError,没有 status
|
||
const lower = body.toLowerCase()
|
||
if (lower.includes('cors') || lower.includes('cross-origin')) {
|
||
return '跨域(CORS)被拦截:浏览器直连该地址受限。可将 baseURL 改为你自己的代理(如 Cloudflare Worker / Vercel Edge),或在支持浏览器直连的模型服务上使用。'
|
||
}
|
||
return body ? `请求失败(HTTP ${status}):${body.slice(0, 200)}` : `请求失败(HTTP ${status})`
|
||
}
|
||
|
||
/**
|
||
* 流式对话(SSE)。逐 token 回调 onToken,最终返回完整文本。
|
||
* 兼容 OpenAI / DeepSeek / 通义 等 OpenAI 格式(data: {choices:[{delta:{content}}]})。
|
||
*/
|
||
export async function streamChat(opts: StreamOptions): Promise<string> {
|
||
const url = `${opts.baseURL.replace(/\/+$/, '')}/chat/completions`
|
||
|
||
let res: Response
|
||
try {
|
||
res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${opts.apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: opts.model,
|
||
messages: opts.messages,
|
||
stream: true,
|
||
temperature: 0.6,
|
||
}),
|
||
signal: opts.signal,
|
||
})
|
||
} catch (e) {
|
||
// 网络层错误(含 CORS):fetch 抛 TypeError,无 status
|
||
const reason = e instanceof Error ? e.message : String(e)
|
||
if (reason.toLowerCase().includes('cors') || reason.toLowerCase().includes('cross-origin')) {
|
||
throw new AiRequestError(
|
||
0,
|
||
'跨域(CORS)被拦截:浏览器直连该地址受限。可将 baseURL 改为你自己的代理(如 Cloudflare Worker / Vercel Edge),或在支持浏览器直连的模型服务上使用。',
|
||
)
|
||
}
|
||
if (e instanceof DOMException && e.name === 'AbortError') throw e
|
||
throw new AiRequestError(0, `无法连接模型服务:${reason}`)
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => '')
|
||
throw new AiRequestError(res.status, friendlyError(res.status, txt))
|
||
}
|
||
|
||
if (!res.body) {
|
||
throw new AiRequestError(0, '响应缺少流式数据体。')
|
||
}
|
||
|
||
const reader = res.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let full = ''
|
||
let buffer = ''
|
||
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
|
||
let nl: number
|
||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||
const line = buffer.slice(0, nl).trim()
|
||
buffer = buffer.slice(nl + 1)
|
||
if (!line || line.startsWith(':')) continue
|
||
if (line === 'data: [DONE]') continue
|
||
if (!line.startsWith('data:')) continue
|
||
const payload = line.slice(5).trim()
|
||
if (!payload) continue
|
||
try {
|
||
const obj = JSON.parse(payload)
|
||
const delta: string | undefined = obj.choices?.[0]?.delta?.content
|
||
if (delta) {
|
||
full += delta
|
||
opts.onToken(delta)
|
||
}
|
||
} catch {
|
||
// 忽略不完整的分片
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
// 确保 reader 被释放(中断时也清理)
|
||
try {
|
||
await reader.cancel()
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
// 兜底:部分 OpenAI 兼容端点忽略 stream:true,直接返回单次 JSON(choices[0].message.content)。
|
||
// 这种情况下上面没有触发 onToken,这里做一次补偿,保证仍能解析出模板文本。
|
||
if (!full) {
|
||
const trimmed = buffer.trim()
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||
try {
|
||
const obj = JSON.parse(trimmed) as {
|
||
choices?: Array<{ message?: { content?: string }; text?: string }>
|
||
}
|
||
const content = obj.choices?.[0]?.message?.content ?? obj.choices?.[0]?.text
|
||
if (content) {
|
||
full = content
|
||
opts.onToken(content)
|
||
}
|
||
} catch {
|
||
/* 不是纯 JSON,忽略 */
|
||
}
|
||
}
|
||
}
|
||
|
||
return full
|
||
}
|