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,137 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Eye, EyeOff } from 'lucide-react'
|
||||
import { getApiConfig, setApiConfig, type ApiConfig } from '../services/api-config'
|
||||
|
||||
interface ApiSettingsProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ApiSettings({ open, onClose }: ApiSettingsProps) {
|
||||
const [config, setConfig] = useState<ApiConfig>(getApiConfig)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// 每次打开时从 localStorage 同步最新配置
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConfig(getApiConfig())
|
||||
setSaved(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSave = () => {
|
||||
setApiConfig(config)
|
||||
setSaved(true)
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">API 设置</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.baseUrl}
|
||||
onChange={(e) => setConfig({ ...config, baseUrl: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
OpenAI 兼容的 API 端点地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => setConfig({ ...config, apiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-gray-200 pl-3 pr-9 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={showKey ? '隐藏' : '显示'}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
密钥仅保存在浏览器 localStorage 中
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.model}
|
||||
onChange={(e) => setConfig({ ...config, model: e.target.value })}
|
||||
placeholder="gpt-4-turbo"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-gray-100 bg-gray-50">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
配置保存在本地浏览器中
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!config.apiKey.trim() || !config.baseUrl.trim()}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
saved
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{saved ? '已保存 ✓' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user