4c384fe566
核心改动: - 新增 src/utils/import.ts:OpenAI 响应 → Protocol Message 反向映射 + StreamingImporter 流式增量累加器 - 新增 src/services/api.ts:薄封装层,exportToOpenAIFormat → fetch → importFromOpenAIResponse - 新增 src/services/api-config.ts:API 配置 localStorage 持久化 - 新增 src/components/ApiSettings.tsx:API 设置模态框 - 改造 ChatContext:新增 isLive / sendMessage / 流式状态管理 - 改造 ChatView/ChatInput:Live 模式下启用输入,支持回车发送和 loading 动画 - 改造 App.tsx:Demo/Live 模式切换 + 设置入口 - 新增 19 个 import.test.ts 测试用例,全部 48 测试通过
232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
/**
|
||
* 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<Message[]> {
|
||
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<void> {
|
||
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,
|
||
}
|
||
}
|