/** * API 调用服务 —— 薄封装层,负责 HTTP 通信和格式转换。 * * 数据流: * envelope → exportToOpenAIFormat() → POST JSON → API * API → SSE/JSON response → importFromOpenAIResponse() → Message[] * * 依赖: * - src/utils/export.ts (Protocol → OpenAI) * - src/utils/import.ts (OpenAI → Protocol) * - src/services/api-config.ts (配置) */ import type { PromptEnvelope, Message } from '../types/protocol' import type { ApiConfig } from './api-config' import { exportToOpenAIFormat, type OpenAIExport, type OpenAIMessage } from '../utils/export' import { importFromOpenAIResponse, StreamingImporter, type OpenAIResponse, type OpenAIStreamChunk, } from '../utils/import' // ============================================================ // 非流式请求 // ============================================================ /** * 发送非流式 Chat Completion 请求。 * @returns 导入后的 Protocol Message 数组(通常只有一条 assistant 消息) */ export async function sendChatRequest( envelope: PromptEnvelope, config: ApiConfig ): Promise { const body = buildRequestBody(envelope, config) const response = await fetch(`${config.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify(body), }) if (!response.ok) { const errorText = await response.text().catch(() => '无法读取错误详情') let errorMsg: string try { const errJson = JSON.parse(errorText) errorMsg = errJson.error?.message || errorText } catch { errorMsg = errorText || `HTTP ${response.status}` } throw new Error(`API 请求失败 (${response.status}): ${errorMsg}`) } const data: OpenAIResponse = await response.json() const choice = data.choices?.[0] if (!choice) { throw new Error('API 响应中没有有效的 choices') } const msg = importFromOpenAIResponse(choice.message, choice.finish_reason, { timestamp: Date.now(), }) return [msg] } // ============================================================ // 流式请求 // ============================================================ export interface StreamCallbacks { /** 每收到 text delta 时回调部分 Message(用于实时打字效果) */ onToken?: (partial: Message) => void /** tool_call delta 变化时回调(用于展示即将执行的 tool) */ onToolCall?: (partial: Message) => void /** 流式完成时回调最终 Message 数组 */ onDone: (final: Message[]) => void /** 发生错误时回调 */ onError: (err: Error) => void } /** * 发送流式 Chat Completion 请求(SSE)。 * * 使用 StreamingImporter 逐 chunk 累积 delta, * 通过 onToken/onToolCall 回调部分 Message 供 UI 实时渲染。 */ export async function sendChatRequestStream( envelope: PromptEnvelope, config: ApiConfig, callbacks: StreamCallbacks ): Promise { const body = buildRequestBody(envelope, config) body.stream = true // 流式模式下需要 stream_options 来获取 usage(可选) body.stream_options = { include_usage: true } let response: Response try { response = await fetch(`${config.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify(body), }) } catch (err) { callbacks.onError( new Error(`网络请求失败: ${err instanceof Error ? err.message : String(err)}`) ) return } if (!response.ok) { const errorText = await response.text().catch(() => '无法读取错误详情') let errorMsg: string try { const errJson = JSON.parse(errorText) errorMsg = errJson.error?.message || errorText } catch { errorMsg = errorText || `HTTP ${response.status}` } callbacks.onError(new Error(`API 请求失败 (${response.status}): ${errorMsg}`)) return } const reader = response.body?.getReader() if (!reader) { callbacks.onError(new Error('响应体不可读(不支持流式)')) return } const decoder = new TextDecoder() const importer = new StreamingImporter() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // 按 SSE 协议解析:event 以 \n\n 为界 const lines = buffer.split('\n') // 最后一个可能不完整,保留在 buffer 中 buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith(':')) { // 空行、注释行 —— 跳过 continue } if (trimmed.startsWith('data: ')) { const dataStr = trimmed.slice(6) // 结束标记 if (dataStr === '[DONE]') { break } try { const chunk: OpenAIStreamChunk = JSON.parse(dataStr) const delta = chunk.choices?.[0]?.delta if (delta) { const prevContent = importer.content const prevToolCount = importer.hasPendingToolCalls importer.ingestDelta(delta) // 文本有变化 → 回调当前部分消息 if (importer.content !== prevContent || importer.hasPendingToolCalls !== prevToolCount) { const partial = importer.toPartialMessage() callbacks.onToken?.(partial) } } } catch { // JSON 解析失败 —— 跳过此 chunk(某些 API 可能返回非标准格式) console.warn('[api] SSE chunk 解析失败:', dataStr.slice(0, 80)) } } } } // 构建最终消息 const finalMsg = importer.toMessage({ timestamp: Date.now() }) callbacks.onDone([finalMsg]) } catch (err) { // 流式中断时,尝试保留已累积的内容 if (importer.content.trim() || importer.hasPendingToolCalls) { const partial = importer.toPartialMessage({ timestamp: Date.now() }) // 用部分内容构建最终消息,但追加截断标记 const truncated = importer.toMessage({ timestamp: Date.now() }) if (truncated.segments.length > 0 && truncated.segments[0].kind === 'text') { callbacks.onDone([truncated]) } else { callbacks.onDone([partial]) } } callbacks.onError( new Error(`流式连接中断: ${err instanceof Error ? err.message : String(err)}`) ) } } // ============================================================ // 请求构造 // ============================================================ /** * 构建 OpenAI Chat Completions 请求体。 * 从 envelope 导出并注入 model 配置。 */ function buildRequestBody( envelope: PromptEnvelope, config: ApiConfig ): OpenAIExport & { stream?: boolean; stream_options?: { include_usage: boolean } } { const exported = exportToOpenAIFormat(envelope) return { ...exported, model: config.model || exported.model, } }