Files
Prompt-Envelope-Protocol/src/services/api.ts
T
carry 4c384fe566 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 测试通过
2026-06-09 15:02:10 +08:00

232 lines
7.1 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.
/**
* 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,
}
}