Files
F9Web/openprint/src/ai/client.ts
T

151 lines
5.0 KiB
TypeScript
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.
/**
* AI 客户端 —— 纯前端直连 OpenAI 兼容 /chat/completionsSSE 流式)。
* 不依赖任何后端;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,直接返回单次 JSONchoices[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
}