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:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* API 配置管理 —— localStorage 持久化存储。
|
||||
*
|
||||
* 存储的字段:
|
||||
* - baseUrl: API 端点地址(默认 OpenAI 官方)
|
||||
* - apiKey: API 密钥
|
||||
* - model: 模型名称(默认 envelope.model ?? 'gpt-4-turbo')
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'hci-api-config'
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
}
|
||||
|
||||
const DEFAULTS: ApiConfig = {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: '',
|
||||
model: 'gpt-4-turbo',
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取 API 配置。
|
||||
* 若某项不存在,使用默认值填充。
|
||||
*/
|
||||
export function getApiConfig(): ApiConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<ApiConfig>
|
||||
return {
|
||||
baseUrl: parsed.baseUrl || DEFAULTS.baseUrl,
|
||||
apiKey: parsed.apiKey || DEFAULTS.apiKey,
|
||||
model: parsed.model || DEFAULTS.model,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用或数据损坏,回退默认值
|
||||
}
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 API 配置到 localStorage。
|
||||
*/
|
||||
export function setApiConfig(config: ApiConfig): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch {
|
||||
console.warn('[api-config] localStorage 写入失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已配置 API Key。
|
||||
*/
|
||||
export function hasApiKey(): boolean {
|
||||
const config = getApiConfig()
|
||||
return config.apiKey.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除已保存的 API 配置。
|
||||
*/
|
||||
export function clearApiConfig(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user