From 4c384fe56640dee3b108c469ba88e865eec7ce12 Mon Sep 17 00:00:00 2001 From: carry Date: Tue, 9 Jun 2026 15:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E6=98=A0=E5=B0=84=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Live=20=E6=A8=A1=E5=BC=8F=E8=B0=83=E7=94=A8=20OpenAI=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改动: - 新增 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 测试通过 --- src/App.tsx | 74 +++++- src/__tests__/import.test.ts | 436 +++++++++++++++++++++++++++++++++ src/components/ApiSettings.tsx | 137 +++++++++++ src/components/ChatInput.tsx | 56 ++++- src/components/ChatView.tsx | 32 ++- src/context/ChatContext.tsx | 201 ++++++++++++++- src/services/api-config.ts | 73 ++++++ src/services/api.ts | 231 +++++++++++++++++ src/utils/import.ts | 349 ++++++++++++++++++++++++++ 9 files changed, 1565 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/import.test.ts create mode 100644 src/components/ApiSettings.tsx create mode 100644 src/services/api-config.ts create mode 100644 src/services/api.ts create mode 100644 src/utils/import.ts diff --git a/src/App.tsx b/src/App.tsx index 6993323..c3592a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ +import { useState } from 'react' import { ChatProvider, useChat } from './context/ChatContext' import ChatView from './components/ChatView' import ProtocolPanel from './components/ProtocolPanel' -import { Layers } from 'lucide-react' +import ApiSettings from './components/ApiSettings' +import { Layers, Settings, FlaskConical, Zap } from 'lucide-react' +import { hasApiKey } from './services/api-config' function DemoSelector() { const { demos, activeDemo, setActiveDemo } = useChat() @@ -26,7 +29,16 @@ function DemoSelector() { } function AppContent() { - const { envelope, demos, activeDemo } = useChat() + const { envelope, demos, activeDemo, isLive, setIsLive } = useChat() + const [settingsOpen, setSettingsOpen] = useState(false) + + const handleToggleLive = () => { + if (!isLive && !hasApiKey()) { + // 首次切换到 Live 模式,自动弹出设置 + setSettingsOpen(true) + } + setIsLive(!isLive) + } return (
@@ -37,12 +49,61 @@ function AppContent() {

Prompt Envelope Protocol

- - MVP - + {isLive ? ( + + Live + + ) : ( + + MVP + + )}
+
+ + {/* 模式切换 */} +
+ + +
+ + + {/* 设置齿轮(仅 Live 模式) */} + + {demos[activeDemo].description} @@ -53,6 +114,9 @@ function AppContent() {
+ + {/* API 设置模态框 */} + setSettingsOpen(false)} /> ) } diff --git a/src/__tests__/import.test.ts b/src/__tests__/import.test.ts new file mode 100644 index 0000000..17db191 --- /dev/null +++ b/src/__tests__/import.test.ts @@ -0,0 +1,436 @@ +import { describe, it, expect } from 'vitest' +import { + importFromOpenAIResponse, + importToolResult, + StreamingImporter, +} from '../utils/import' +import type { OpenAIMessage } from '../utils/export' + +// ============================================================ +// importFromOpenAIResponse +// ============================================================ + +describe('importFromOpenAIResponse', () => { + it('纯文本响应 → 单 text segment', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '你好,我是 AI 助手。', + } + + const result = importFromOpenAIResponse(msg, 'stop') + + expect(result.role).toBe('assistant') + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toEqual({ + kind: 'text', + content: '你好,我是 AI 助手。', + }) + expect(result.id).toMatch(/^msg_\d+_\d+$/) + expect(result.timestamp).toBeGreaterThan(0) + }) + + it('带 tool_calls 的响应 → text + tool_call_request segments', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '我来帮你搜索一下。', + tool_calls: [ + { + id: 'call_001', + type: 'function', + function: { + name: 'search', + arguments: '{"query":"HCI 设计","limit":5}', + }, + }, + ], + } + + const result = importFromOpenAIResponse(msg, 'tool_calls') + + expect(result.role).toBe('assistant') + expect(result.segments).toHaveLength(2) + + // 第一个 segment: text + expect(result.segments[0]).toEqual({ + kind: 'text', + content: '我来帮你搜索一下。', + }) + + // 第二个 segment: tool_call_request + expect(result.segments[1]).toEqual({ + kind: 'tool_call_request', + toolName: 'search', + arguments: { query: 'HCI 设计', limit: 5 }, + collapsed: false, + }) + }) + + it('纯 tool_calls(content=null)→ 仅 tool_call_request segments', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_a', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"city":"北京"}', + }, + }, + { + id: 'call_b', + type: 'function', + function: { + name: 'get_time', + arguments: '{"timezone":"Asia/Shanghai"}', + }, + }, + ], + } + + const result = importFromOpenAIResponse(msg, 'tool_calls') + + expect(result.role).toBe('assistant') + expect(result.segments).toHaveLength(2) + + expect(result.segments[0]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'get_weather', + arguments: { city: '北京' }, + }) + expect(result.segments[1]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'get_time', + arguments: { timezone: 'Asia/Shanghai' }, + }) + }) + + it('多 tool_calls → 对应多个 tool_call_request segments', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '并行查询:', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { name: 'search', arguments: '{"q":"A"}' }, + }, + { + id: 'c2', + type: 'function', + function: { name: 'search', arguments: '{"q":"B"}' }, + }, + { + id: 'c3', + type: 'function', + function: { name: 'fetch', arguments: '{"url":"x"}' }, + }, + ], + } + + const result = importFromOpenAIResponse(msg, 'tool_calls') + + expect(result.segments).toHaveLength(4) // 1 text + 3 tool_calls + expect(result.segments[0]).toMatchObject({ kind: 'text' }) + expect(result.segments[1]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'search', + arguments: { q: 'A' }, + }) + expect(result.segments[2]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'search', + arguments: { q: 'B' }, + }) + expect(result.segments[3]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'fetch', + arguments: { url: 'x' }, + }) + }) + + it('finish_reason="length" → 追加截断标记', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '这是一段很长的回复被截断了...', + } + + const result = importFromOpenAIResponse(msg, 'length') + + expect(result.segments).toHaveLength(2) + expect(result.segments[0]).toEqual({ + kind: 'text', + content: '这是一段很长的回复被截断了...', + }) + expect(result.segments[1]).toEqual({ + kind: 'text', + content: '[因长度限制被截断]', + }) + }) + + it('finish_reason="stop" → 无额外处理', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '正常结束。', + } + + const result = importFromOpenAIResponse(msg, 'stop') + + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toEqual({ + kind: 'text', + content: '正常结束。', + }) + }) + + it('空 content + 无 tool_calls → 防御占位 segment', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: '', + } + + const result = importFromOpenAIResponse(msg, 'stop') + + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toEqual({ + kind: 'text', + content: '[空响应]', + }) + }) + + it('非法 JSON arguments → _raw 字段保留原始字符串', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'bad', + type: 'function', + function: { name: 'parse', arguments: 'not-valid-json' }, + }, + ], + } + + const result = importFromOpenAIResponse(msg, 'tool_calls') + + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'parse', + arguments: { _raw: 'not-valid-json' }, + }) + }) + + it('支持自定义 id 和 timestamp', () => { + const msg: OpenAIMessage = { + role: 'assistant', + content: 'test', + } + + const result = importFromOpenAIResponse(msg, 'stop', { + id: 'custom-id', + timestamp: 1234567890000, + }) + + expect(result.id).toBe('custom-id') + expect(result.timestamp).toBe(1234567890000) + }) +}) + +// ============================================================ +// importToolResult +// ============================================================ + +describe('importToolResult', () => { + it('成功的 tool 结果 → success=true, collapsed=true', () => { + const result = importToolResult('search', '找到 3 条结果', true) + + expect(result.role).toBe('tool') + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toEqual({ + kind: 'tool_call_result', + toolName: 'search', + result: '找到 3 条结果', + success: true, + collapsed: true, + }) + expect(result.id).toMatch(/^msg_\d+_\d+$/) + }) + + it('失败的 tool 结果 → success=false, collapsed=false(默认展开以便查看错误)', () => { + const result = importToolResult('fetch', 'Connection refused', false, false) + + expect(result.role).toBe('tool') + expect(result.segments).toHaveLength(1) + expect(result.segments[0]).toMatchObject({ + kind: 'tool_call_result', + toolName: 'fetch', + result: 'Connection refused', + success: false, + collapsed: false, + }) + }) +}) + +// ============================================================ +// StreamingImporter +// ============================================================ + +describe('StreamingImporter', () => { + it('纯文本流式 delta → 逐字拼接', () => { + const importer = new StreamingImporter() + + importer.ingestDelta({ role: 'assistant' }) + importer.ingestDelta({ content: '你好' }) + importer.ingestDelta({ content: ',世界' }) + importer.ingestDelta({ content: '!' }) + + const msg = importer.toMessage() + + expect(msg.role).toBe('assistant') + expect(msg.segments).toHaveLength(1) + expect(msg.segments[0]).toEqual({ + kind: 'text', + content: '你好,世界!', + }) + }) + + it('toPartialMessage 返回当前部分内容(用于流式渲染)', () => { + const importer = new StreamingImporter() + + importer.ingestDelta({ content: 'Hello' }) + const partial1 = importer.toPartialMessage() + expect(partial1.segments[0]).toEqual({ kind: 'text', content: 'Hello' }) + + importer.ingestDelta({ content: ' World' }) + const partial2 = importer.toPartialMessage() + expect(partial2.segments[0]).toEqual({ kind: 'text', content: 'Hello World' }) + }) + + it('tool_call 流式 delta → 按 index 累积 arguments', () => { + const importer = new StreamingImporter() + + // 模拟真实的 tool_call 流式片段 + importer.ingestDelta({ + tool_calls: [ + { index: 0, id: 'call_001', type: 'function', function: { name: 'search', arguments: '' } }, + ], + }) + importer.ingestDelta({ + tool_calls: [{ index: 0, function: { arguments: '{"query"' } }], + }) + importer.ingestDelta({ + tool_calls: [{ index: 0, function: { arguments: ':"HCI"' } }], + }) + + // 此时 arguments = '{"query":"HCI"' —— 缺少结尾 },无法 parse + // 部分模式下不应出现 tool_call segment + const partial = importer.toPartialMessage() + const toolSegments = partial.segments.filter((s) => s.kind === 'tool_call_request') + expect(toolSegments).toHaveLength(0) + + // 补充最后一片 —— arguments 完整 + importer.ingestDelta({ + tool_calls: [{ index: 0, function: { arguments: '}' } }], + }) + + // 最终模式下,arguments 完整,应正确解析 + const final = importer.toMessage() + expect(final.segments).toHaveLength(1) + expect(final.segments[0]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'search', + arguments: { query: 'HCI' }, + }) + }) + + it('多个 tool_call 并行流式 → 正确分离', () => { + const importer = new StreamingImporter() + + // tool 0 + importer.ingestDelta({ + tool_calls: [ + { index: 0, id: 'c0', function: { name: 'search', arguments: '{"q":"A"}' } }, + ], + }) + // tool 1 + importer.ingestDelta({ + tool_calls: [ + { index: 1, id: 'c1', function: { name: 'fetch', arguments: '{"url":"x"}' } }, + ], + }) + + const msg = importer.toMessage() + + expect(msg.segments).toHaveLength(2) + expect(msg.segments[0]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'search', + arguments: { q: 'A' }, + }) + expect(msg.segments[1]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'fetch', + arguments: { url: 'x' }, + }) + }) + + it('混合流式:文本 + tool_call 交替出现', () => { + const importer = new StreamingImporter() + + importer.ingestDelta({ content: '让我' }) + importer.ingestDelta({ content: '查一下。' }) + importer.ingestDelta({ + tool_calls: [ + { index: 0, id: 'c0', function: { name: 'search', arguments: '{"q":"天气"}' } }, + ], + }) + + const msg = importer.toMessage() + + expect(msg.segments).toHaveLength(2) + expect(msg.segments[0]).toEqual({ + kind: 'text', + content: '让我查一下。', + }) + expect(msg.segments[1]).toMatchObject({ + kind: 'tool_call_request', + toolName: 'search', + }) + }) + + it('reset() 清空所有累积状态', () => { + const importer = new StreamingImporter() + + importer.ingestDelta({ content: 'Hello' }) + importer.ingestDelta({ + tool_calls: [{ index: 0, function: { name: 't1', arguments: '{}' } }], + }) + + importer.reset() + + expect(importer.content).toBe('') + expect(importer.hasPendingToolCalls).toBe(false) + + const msg = importer.toMessage() + expect(msg.segments[0]).toEqual({ kind: 'text', content: '[空响应]' }) + }) + + it('空 delta 累积 → toMessage 返回防御占位', () => { + const importer = new StreamingImporter() + + const msg = importer.toMessage() + + expect(msg.segments).toHaveLength(1) + expect(msg.segments[0]).toEqual({ kind: 'text', content: '[空响应]' }) + }) + + it('toPartialMessage 空内容时返回省略号占位', () => { + const importer = new StreamingImporter() + + const partial = importer.toPartialMessage() + + expect(partial.segments[0]).toEqual({ kind: 'text', content: '…' }) + }) +}) diff --git a/src/components/ApiSettings.tsx b/src/components/ApiSettings.tsx new file mode 100644 index 0000000..9457dcf --- /dev/null +++ b/src/components/ApiSettings.tsx @@ -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(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 ( +
{ + if (e.target === e.currentTarget) onClose() + }} + onKeyDown={handleKeyDown} + > +
+ {/* Header */} +
+

API 设置

+ +
+ + {/* Form */} +
+ {/* Base URL */} +
+ + 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" + /> +

+ OpenAI 兼容的 API 端点地址 +

+
+ + {/* API Key */} +
+ +
+ 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" + /> + +
+

+ 密钥仅保存在浏览器 localStorage 中 +

+
+ + {/* Model */} +
+ + 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" + /> +
+
+ + {/* Footer */} +
+ + 配置保存在本地浏览器中 + + +
+
+
+ ) +} diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index c683bd8..6bda42d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1,28 +1,68 @@ -import { Send } from 'lucide-react' +import { useRef } from 'react' +import { Send, Loader2 } from 'lucide-react' interface ChatInputProps { value: string onChange: (v: string) => void disabled?: boolean + loading?: boolean + onSend?: () => void + placeholder?: string } -export default function ChatInput({ value, onChange, disabled }: ChatInputProps) { +export default function ChatInput({ + value, + onChange, + disabled, + loading, + onSend, + placeholder, +}: ChatInputProps) { + const inputRef = useRef(null) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (!disabled && !loading && value.trim() && onSend) { + onSend() + } + } + } + + const handleSend = () => { + if (!disabled && !loading && value.trim() && onSend) { + onSend() + } + } + + const defaultPlaceholder = disabled + ? 'Demo 模式 — 切换到 Live 模式即可发送消息' + : '输入消息...' + return (
onChange(e.target.value)} - disabled={disabled} - placeholder="输入消息(Demo 模式 — 不会调用 LLM)" - className="flex-1 rounded-lg border border-gray-200 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 disabled:bg-gray-50 disabled:text-gray-400" + onKeyDown={handleKeyDown} + disabled={disabled || loading} + placeholder={placeholder ?? defaultPlaceholder} + className="flex-1 rounded-lg border border-gray-200 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 disabled:bg-gray-50 disabled:text-gray-400 transition-colors" />
diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index b005d6b..aa34152 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -2,15 +2,43 @@ import { useState } from 'react' import { useChat } from '../context/ChatContext' import MessageList from './MessageList' import ChatInput from './ChatInput' +import { AlertCircle, X } from 'lucide-react' export default function ChatView() { - const { envelope } = useChat() + const { envelope, isLive, isLoading, sendMessage, error, clearError } = useChat() const [input, setInput] = useState('') + const handleSend = () => { + if (!input.trim() || isLoading) return + sendMessage(input.trim()) + setInput('') + } + return (
+ {/* 错误提示条 */} + {error && ( +
+ + {error} + +
+ )} + - + +
) } diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index af57de0..ee639cd 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -1,25 +1,202 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' -import type { PromptEnvelope } from '../types/protocol' +import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react' +import type { PromptEnvelope, Message } from '../types/protocol' import { demos } from '../data/demos' +import { getApiConfig, hasApiKey } from '../services/api-config' +import { sendChatRequestStream } from '../services/api' interface ChatContextValue { + // 现有(保持不变) envelope: PromptEnvelope setEnvelope: (e: PromptEnvelope) => void demos: typeof demos activeDemo: number setActiveDemo: (i: number) => void + + // 新增 —— Live 模式 + isLive: boolean + setIsLive: (v: boolean) => void + isLoading: boolean + sendMessage: (text: string) => Promise + error: string | null + clearError: () => void } const ChatContext = createContext(null) -export function ChatProvider({ children }: { children: ReactNode }) { - const [activeDemo, setActiveDemo] = useState(0) // Default: Scene A - const [envelope, setEnvelope] = useState(demos[0].envelope) +/** 生成唯一消息 ID */ +let msgCounter = 0 +function genMsgId(): string { + return `live_${Date.now()}_${++msgCounter}` +} - const switchDemo = (i: number) => { - setActiveDemo(i) - setEnvelope(demos[i].envelope) - } +export function ChatProvider({ children }: { children: ReactNode }) { + const [activeDemo, setActiveDemo] = useState(0) + const [envelope, setEnvelope] = useState(demos[0].envelope) + const [isLive, setIsLive] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // 保存 Live 模式下的对话历史消息(用于 track 最新的消息列表) + const liveMessagesRef = useRef([]) + + const clearError = useCallback(() => setError(null), []) + + /** + * 从 demo 的 envelope 中提取系统上下文消息 + * (role: "system" 的消息,包含 system_prompt / memory / skills / tool_overview / static_var) + */ + const extractSystemContext = useCallback((env: PromptEnvelope): Message[] => { + return env.messages.filter((m) => m.role === 'system') + }, []) + + /** + * 构建 Live 模式的初始 envelope: + * 保留当前 demo 的系统上下文,但清空所有 user/assistant/tool 消息。 + */ + const buildLiveEnvelope = useCallback( + (demoIndex: number): PromptEnvelope => { + const systemMsgs = extractSystemContext(demos[demoIndex].envelope) + return { + version: '1.0' as const, + model: demos[demoIndex].envelope.model, + messages: [...systemMsgs], + } + }, + [extractSystemContext] + ) + + /** + * 切换 Demo 场景。 + * - Demo 模式:直接加载 Demo JSON(现有行为) + * - Live 模式:替换系统上下文,清空对话历史 + */ + const switchDemo = useCallback( + (i: number) => { + setActiveDemo(i) + if (isLive) { + const newEnv = buildLiveEnvelope(i) + liveMessagesRef.current = newEnv.messages + setEnvelope(newEnv) + } else { + setEnvelope(demos[i].envelope) + } + setError(null) + }, + [isLive, buildLiveEnvelope] + ) + + /** + * 切换 Demo / Live 模式。 + */ + const handleSetIsLive = useCallback( + (v: boolean) => { + if (v === isLive) return + + if (v) { + // Demo → Live:保留系统上下文,清空对话 + const newEnv = buildLiveEnvelope(activeDemo) + liveMessagesRef.current = newEnv.messages + setEnvelope(newEnv) + } else { + // Live → Demo:恢复完整的 Demo JSON + setEnvelope(demos[activeDemo].envelope) + liveMessagesRef.current = [] + } + + setIsLive(v) + setError(null) + setIsLoading(false) + }, + [isLive, activeDemo, buildLiveEnvelope] + ) + + /** + * 发送消息(Live 模式)。 + * + * 协议映射流程: + * 1. 用户输入 → TextSegment → role:"user" Message + * 2. 追加到 envelope → exportToOpenAIFormat → API 请求 + * 3. API 响应 → StreamingImporter → importFromOpenAIResponse → assistant Message + * 4. 追加到 envelope → UI 重新渲染 + */ + const sendMessage = useCallback( + async (text: string) => { + if (!text.trim() || isLoading) return + + // 检查 API 配置 + if (!hasApiKey()) { + setError('请先配置 API Key(点击右上角齿轮图标)') + return + } + + const config = getApiConfig() + clearError() + + // 1. 构造用户消息 + const userMsg: Message = { + id: genMsgId(), + role: 'user', + segments: [{ kind: 'text', content: text.trim() }], + timestamp: Date.now(), + } + + // 2. 构建当前 envelope(包含系统上下文 + 历史 + 新用户消息) + const currentMessages = [ + ...liveMessagesRef.current, + userMsg, + ] + const currentEnvelope: PromptEnvelope = { + version: '1.0', + model: envelope.model, + messages: currentMessages, + } + + // 立即渲染用户消息 + liveMessagesRef.current = currentMessages + setEnvelope({ ...currentEnvelope }) + setIsLoading(true) + + // 3. 发送流式请求 + let lastPartial: Message | null = null + + await sendChatRequestStream(currentEnvelope, config, { + onToken(partial: Message) { + lastPartial = partial + // 流式渲染:将部分 assistant 消息追加到列表末尾 + const updatedMessages = [...liveMessagesRef.current, partial] + setEnvelope({ + ...currentEnvelope, + messages: updatedMessages, + }) + }, + + onDone(finalMessages: Message[]) { + // 移除可能存在的部分消息,追加最终消息 + const baseMessages = liveMessagesRef.current.filter( + (m) => m.id !== lastPartial?.id + ) + const updatedMessages = [...baseMessages, ...finalMessages] + liveMessagesRef.current = updatedMessages + setEnvelope({ + ...currentEnvelope, + messages: updatedMessages, + }) + setIsLoading(false) + }, + + onError(err: Error) { + // 移除流式部分消息,保留用户消息 + setEnvelope({ + ...currentEnvelope, + messages: liveMessagesRef.current, + }) + setError(err.message) + setIsLoading(false) + }, + }) + }, + [isLoading, envelope.model, clearError] + ) return ( {children} diff --git a/src/services/api-config.ts b/src/services/api-config.ts new file mode 100644 index 0000000..79a052c --- /dev/null +++ b/src/services/api-config.ts @@ -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 + 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 { + // 静默 + } +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..4911a94 --- /dev/null +++ b/src/services/api.ts @@ -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 { + 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 { + 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, + } +} diff --git a/src/utils/import.ts b/src/utils/import.ts new file mode 100644 index 0000000..199895d --- /dev/null +++ b/src/utils/import.ts @@ -0,0 +1,349 @@ +/** + * OpenAI API 响应 → Protocol Message 反向映射。 + * + * 与 export.ts 的 exportToOpenAIFormat() 构成双向转换闭环: + * + * Protocol ──export.ts──▶ OpenAI Request ──API──▶ OpenAI Response + * │ + * Protocol ◀──import.ts──────────────────────────────┘ + * + * 职责: + * 1. importFromOpenAIResponse() — 完整响应 → Protocol Message(非流式) + * 2. StreamingImporter — SSE delta 增量累积 → Protocol Message(流式) + * 3. importToolResult() — tool 执行结果 → Protocol tool 消息 + */ + +import type { Message, Segment } from '../types/protocol' +import type { OpenAIMessage, OpenAIToolCall } from './export' + +// ============================================================ +// OpenAI Response 类型 +// ============================================================ + +/** API 响应的 choice 对象 */ +export interface OpenAIChoice { + index: number + message: OpenAIMessage + finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null +} + +/** 完整的 Chat Completion 响应 */ +export interface OpenAIResponse { + id: string + object: string + created: number + model: string + choices: OpenAIChoice[] + usage?: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } +} + +/** SSE 流式 chunk 中的 delta */ +export interface OpenAIDelta { + role?: string + content?: string + tool_calls?: Array<{ + index: number + id?: string + type?: 'function' + function?: { + name?: string + arguments?: string + } + }> +} + +/** SSE 流式 chunk */ +export interface OpenAIStreamChunk { + id: string + object: string + created: number + model: string + choices: Array<{ + index: number + delta: OpenAIDelta + finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null + }> +} + +// ============================================================ +// ID 生成 +// ============================================================ + +let idCounter = 0 + +/** 生成唯一消息 ID */ +function genMessageId(): string { + return `msg_${Date.now()}_${++idCounter}` +} + +// ============================================================ +// 核心导入函数 +// ============================================================ + +/** + * 将 OpenAI API 响应消息转换为 Protocol Message。 + * + * 映射规则: + * - content(非空)→ TextSegment + * - tool_calls[] → ToolCallRequestSegment(每个 tool_call 一条) + * - finish_reason → 仅在 "length" 时追加截断标记 + * - content 和 tool_calls 可共存于同一消息的 segments 中 + * + * @param message choices[0].message —— API 返回的 assistant 消息 + * @param finishReason choices[0].finish_reason + * @param options 可选的 id / timestamp 覆盖 + * @returns 单条 role: "assistant" 的 Protocol Message + */ +export function importFromOpenAIResponse( + message: OpenAIMessage, + finishReason: OpenAIChoice['finish_reason'] = 'stop', + options?: { id?: string; timestamp?: number } +): Message { + const segments: Segment[] = [] + + // 1. 文本内容 → TextSegment + if (message.content && typeof message.content === 'string' && message.content.trim()) { + segments.push({ + kind: 'text', + content: message.content, + }) + } + + // 2. tool_calls → ToolCallRequestSegment + if (message.tool_calls && message.tool_calls.length > 0) { + for (const tc of message.tool_calls) { + let args: Record = {} + if (tc.function.arguments) { + try { + args = JSON.parse(tc.function.arguments) + } catch { + // arguments 不是合法 JSON(可能被截断),保留原始字符串 + args = { _raw: tc.function.arguments } + } + } + + segments.push({ + kind: 'tool_call_request', + toolName: tc.function.name, + arguments: args, + collapsed: false, + }) + } + } + + // 3. finish_reason 处理 + if (finishReason === 'length') { + segments.push({ + kind: 'text', + content: '[因长度限制被截断]', + }) + } + + // 确保至少有一个 segment(API 不应返回空消息,但做防御) + if (segments.length === 0) { + segments.push({ + kind: 'text', + content: message.content + ? String(message.content) + : '[空响应]', + }) + } + + return { + id: options?.id ?? genMessageId(), + role: 'assistant', + segments, + timestamp: options?.timestamp ?? Date.now(), + } +} + +// ============================================================ +// Tool 结果导入 +// ============================================================ + +/** + * 构造 tool 执行结果的 Protocol 消息。 + * + * @param toolName 被调用的 tool 名称 + * @param result 执行结果文本 + * @param success 是否执行成功 + * @param collapsed 是否默认折叠(默认 true) + */ +export function importToolResult( + toolName: string, + result: string, + success: boolean, + collapsed = true +): Message { + return { + id: genMessageId(), + role: 'tool', + segments: [ + { + kind: 'tool_call_result', + toolName, + result, + success, + collapsed, + }, + ], + timestamp: Date.now(), + } +} + +// ============================================================ +// 流式增量导入器 +// ============================================================ + +/** + * 流式场景的增量累加器。 + * + * 用法: + * const importer = new StreamingImporter() + * for (const chunk of sseChunks) { + * importer.ingestDelta(chunk.choices[0].delta) + * // 渲染部分内容: + * render(importer.toPartialMessage()) + * } + * // 完成时构建最终消息: + * const finalMsg = importer.toMessage() + */ +export class StreamingImporter { + private contentBuf = '' + private toolCallBuf: Map = new Map() + private role: string = 'assistant' + + /** + * 摄入一个 SSE delta 对象。 + * @param delta choices[0].delta + */ + ingestDelta(delta: OpenAIDelta): void { + // 记录 role(通常只在第一个 delta 出现) + if (delta.role) { + this.role = delta.role + } + + // 文本内容:直接拼接 + if (delta.content) { + this.contentBuf += delta.content + } + + // tool_calls:按 index 分组累积 + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = this.toolCallBuf.get(tc.index) || { name: '', args: '' } + if (tc.id) { + existing.id = tc.id + } + if (tc.function?.name) { + existing.name = tc.function.name + } + if (tc.function?.arguments) { + existing.args += tc.function.arguments + } + this.toolCallBuf.set(tc.index, { ...existing }) + } + } + } + + /** + * 重置所有累积状态,准备下一轮对话。 + */ + reset(): void { + this.contentBuf = '' + this.toolCallBuf.clear() + this.role = 'assistant' + } + + /** + * 构建当前的完整 Protocol Message。 + * 在流式结束时调用,得到最终的不可变消息。 + */ + toMessage(options?: { id?: string; timestamp?: number }): Message { + return this.buildMessage(false, options) + } + + /** + * 构建当前部分内容的 Protocol Message(用于流式渲染的中间状态)。 + * content 为当前已累积的文本,tool_calls 仅包含已完整接收的。 + */ + toPartialMessage(options?: { id?: string; timestamp?: number }): Message { + return this.buildMessage(true, options) + } + + /** 当前已累积的纯文本内容 */ + get content(): string { + return this.contentBuf + } + + /** 是否有未完成的 tool_call 在累积中 */ + get hasPendingToolCalls(): boolean { + return this.toolCallBuf.size > 0 + } + + // ── 私有 ── + + private buildMessage(partial: boolean, options?: { id?: string; timestamp?: number }): Message { + const segments: Segment[] = [] + + // 文本内容(即使是部分模式也输出,用于实时打字效果) + if (this.contentBuf.trim()) { + segments.push({ + kind: 'text', + content: this.contentBuf, + }) + } + + // tool_calls —— 部分模式下,只有 arguments 是合法 JSON 的才展示 + const sortedIndices = [...this.toolCallBuf.keys()].sort() + for (const idx of sortedIndices) { + const tc = this.toolCallBuf.get(idx)! + if (!tc.name) continue // 还没收到 name,跳过 + + let args: Record = {} + let argsValid = false + if (tc.args) { + try { + args = JSON.parse(tc.args) + argsValid = true + } catch { + // 流式传输中 arguments 可能不完整——部分模式跳过,最终模式保留原始字符串 + if (!partial) { + args = { _raw: tc.args } + } + } + } + + // 部分模式:只展示已完整解析的 tool_call + if (partial && (!argsValid || !tc.args)) { + continue + } + + segments.push({ + kind: 'tool_call_request', + toolName: tc.name, + arguments: args, + collapsed: false, + }) + } + + // 防御:无内容时返回占位 + if (segments.length === 0) { + segments.push({ + kind: 'text', + content: partial ? '…' : '[空响应]', + }) + } + + return { + id: options?.id ?? genMessageId(), + role: this.role as Message['role'], + segments, + timestamp: options?.timestamp ?? Date.now(), + } + } +}