feat: 实现协议双向映射,支持 Live 模式调用 OpenAI API

核心改动:
- 新增 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 测试通过
This commit is contained in:
carry
2026-06-09 15:02:10 +08:00
parent 6dcc8c62c2
commit 4c384fe566
9 changed files with 1565 additions and 24 deletions
+231
View File
@@ -0,0 +1,231 @@
/**
* 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,
}
}