Update code
This commit is contained in:
13
test2/client/index.html
Normal file
13
test2/client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>语音通话 - 混合编排模式</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2632
test2/client/package-lock.json
generated
Normal file
2632
test2/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
test2/client/package.json
Normal file
26
test2/client/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "voice-chat-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@volcengine/rtc": "^4.62.1",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
175
test2/client/src/App.jsx
Normal file
175
test2/client/src/App.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Settings2, Zap, Mic, MessageSquare } from 'lucide-react';
|
||||
import VoicePanel from './components/VoicePanel';
|
||||
import ChatPanel from './components/ChatPanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import { getVoiceConfig } from './services/voiceApi';
|
||||
|
||||
export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [voiceConfig, setVoiceConfig] = useState(null);
|
||||
// 'voice' | 'chat'
|
||||
const [mode, setMode] = useState('voice');
|
||||
// 统一会话 ID(贯穿语音和文字模式,数据库用此 ID 关联所有消息)
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
// 语音转文字的交接数据(保留兼容)
|
||||
const [handoff, setHandoff] = useState(null);
|
||||
// 文字聊天消息(用于切回语音时注入上下文)
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [settings, setSettings] = useState({
|
||||
botName: '小智',
|
||||
systemRole: '你是一个友善的智能助手,名叫小智。你擅长帮用户解答各类问题。',
|
||||
speakingStyle: '请使用温和、清晰的口吻。',
|
||||
modelVersion: '1.2.1.0',
|
||||
speaker: 'zh_female_vv_jupiter_bigtts',
|
||||
enableWebSearch: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getVoiceConfig()
|
||||
.then(setVoiceConfig)
|
||||
.catch((err) => console.warn('Failed to load config:', err));
|
||||
}, []);
|
||||
|
||||
// 语音通话结束后,无缝切换到文字对话(携带同一个 sessionId)
|
||||
const handleVoiceEnd = useCallback((data) => {
|
||||
if (data?.sessionId) {
|
||||
const sid = data.sessionId;
|
||||
setCurrentSessionId(sid);
|
||||
setHandoff({
|
||||
sessionId: sid,
|
||||
subtitles: data.subtitles || [],
|
||||
});
|
||||
setMode('chat');
|
||||
console.log(`[App] Voice→Chat, sessionId=${sid}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 从文字模式返回语音模式(使用同一个 sessionId,数据库已有完整历史)
|
||||
const handleBackToVoice = useCallback(() => {
|
||||
console.log(`[App] Chat→Voice, sessionId=${currentSessionId}`);
|
||||
setMode('voice');
|
||||
}, [currentSessionId]);
|
||||
|
||||
// 直接进入文字模式(新会话)
|
||||
const handleStartChat = useCallback(() => {
|
||||
const newSid = `chat_${Date.now().toString(36)}`;
|
||||
setCurrentSessionId(newSid);
|
||||
setHandoff({
|
||||
sessionId: newSid,
|
||||
subtitles: [],
|
||||
});
|
||||
setMode('chat');
|
||||
console.log(`[App] New chat session: ${newSid}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="border-b border-slate-700/50 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-30">
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-4 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-white leading-tight">
|
||||
{mode === 'voice' ? '语音通话' : '文字对话'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-slate-400 leading-tight">
|
||||
{mode === 'voice'
|
||||
? '混合编排模式 · OutputMode=1'
|
||||
: handoff?.subtitles?.length > 0
|
||||
? '语音转接 · 上下文已延续'
|
||||
: '方舟 LLM · Function Calling'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Mode toggle buttons */}
|
||||
<div className="flex items-center bg-slate-800/60 rounded-lg border border-slate-700/40 p-0.5 mr-2">
|
||||
<button
|
||||
onClick={() => { if (mode !== 'voice') handleBackToVoice(); }}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs transition-all ${
|
||||
mode === 'voice'
|
||||
? 'bg-violet-500/20 text-violet-300 font-medium'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Mic className="w-3 h-3" /> 语音
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (mode !== 'chat') handleStartChat(); }}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs transition-all ${
|
||||
mode === 'chat'
|
||||
? 'bg-violet-500/20 text-violet-300 font-medium'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" /> 文字
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'voice' && (
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 rounded-lg hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors"
|
||||
title="语音设置"
|
||||
>
|
||||
<Settings2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
{/* Settings Panel */}
|
||||
{showSettings && mode === 'voice' && (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
onChange={setSettings}
|
||||
voiceConfig={voiceConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'voice' ? (
|
||||
<>
|
||||
{/* Voice Panel */}
|
||||
<VoicePanel settings={settings} onVoiceEnd={handleVoiceEnd} chatHistory={chatMessages} sessionId={currentSessionId} />
|
||||
|
||||
{/* Architecture Info */}
|
||||
<div className="mt-6 p-4 rounded-xl bg-slate-800/40 border border-slate-700/40">
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">方案B 混合编排架构</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-emerald-400 font-medium mb-1">闲聊场景</div>
|
||||
<div className="text-slate-400">端到端模型直接回复 · ~300-800ms</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-amber-400 font-medium mb-1">工具调用场景</div>
|
||||
<div className="text-slate-400">LLM 决策 + Function Calling · ~1-2s</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-700/30 border border-slate-600/30">
|
||||
<div className="text-violet-400 font-medium mb-1">自动切换</div>
|
||||
<div className="text-slate-400">系统自动判断走 S2S 或 LLM 分支</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Chat Panel */
|
||||
handoff && (
|
||||
<ChatPanel
|
||||
sessionId={handoff.sessionId}
|
||||
voiceSubtitles={handoff.subtitles}
|
||||
settings={settings}
|
||||
onBack={handleBackToVoice}
|
||||
onMessagesChange={setChatMessages}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
test2/client/src/components/ChatPanel.jsx
Normal file
304
test2/client/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Bot, User, Loader2, ArrowLeft, Sparkles, Wrench, StopCircle } from 'lucide-react';
|
||||
import { startChatSession, sendMessageStream } from '../services/chatApi';
|
||||
import { getSessionHistory } from '../services/voiceApi';
|
||||
|
||||
export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack, onMessagesChange }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [streamingId, setStreamingId] = useState(null);
|
||||
const [toolsInUse, setToolsInUse] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// 初始化:创建聊天会话,优先从数据库加载完整历史
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
// 启动后端聊天会话(后端会从 DB 加载历史注入 Coze 上下文)
|
||||
await startChatSession(sessionId, voiceSubtitles);
|
||||
setIsInitialized(true);
|
||||
|
||||
// 从数据库加载完整对话历史(包含语音通话中的工具结果)
|
||||
let historyMsgs = [];
|
||||
try {
|
||||
const historyData = await getSessionHistory(sessionId, 20);
|
||||
if (historyData?.messages?.length > 0) {
|
||||
historyMsgs = historyData.messages.map((m, i) => ({
|
||||
id: `history-${i}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
fromVoice: true,
|
||||
}));
|
||||
console.log(`[ChatPanel] Loaded ${historyMsgs.length} messages from DB`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ChatPanel] DB history load failed, falling back to subtitles:', e.message);
|
||||
}
|
||||
|
||||
// 如果数据库没有历史,回退到 voiceSubtitles
|
||||
if (historyMsgs.length === 0 && voiceSubtitles && voiceSubtitles.length > 0) {
|
||||
historyMsgs = voiceSubtitles.map((s, i) => ({
|
||||
id: `voice-${i}`,
|
||||
role: s.role === 'user' ? 'user' : 'assistant',
|
||||
content: s.text,
|
||||
fromVoice: true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (historyMsgs.length > 0) {
|
||||
setMessages(historyMsgs);
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
} catch (err) {
|
||||
console.error('[ChatPanel] Init failed:', err);
|
||||
setError('聊天会话初始化失败');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [sessionId, voiceSubtitles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamingId, toolsInUse]);
|
||||
|
||||
// 消息变化时通知父组件(用于文字→语音上下文传递)
|
||||
useEffect(() => {
|
||||
if (onMessagesChange) {
|
||||
const finalMessages = messages.filter(m => !m.streaming && m.content);
|
||||
// 仅当有实际消息时才同步,避免挂载时清空父组件的 chatMessages 状态
|
||||
if (finalMessages.length > 0) {
|
||||
onMessagesChange(finalMessages);
|
||||
}
|
||||
}
|
||||
}, [messages, onMessagesChange]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setInput('');
|
||||
setError(null);
|
||||
setToolsInUse(null);
|
||||
|
||||
const userMsg = { id: `user-${Date.now()}`, role: 'user', content: text };
|
||||
const assistantId = `assistant-${Date.now()}`;
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsLoading(true);
|
||||
setStreamingId(assistantId);
|
||||
|
||||
// 先插入一个空的 assistant 消息用于流式填充
|
||||
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', streaming: true }]);
|
||||
|
||||
const abort = sendMessageStream(sessionId, text, {
|
||||
onChunk: (chunk) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + chunk } : m))
|
||||
);
|
||||
},
|
||||
onToolCall: (tools) => {
|
||||
setToolsInUse(tools);
|
||||
},
|
||||
onDone: (fullContent) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent, streaming: false } : m))
|
||||
);
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
onError: (errMsg) => {
|
||||
setError(errMsg);
|
||||
setIsLoading(false);
|
||||
setStreamingId(null);
|
||||
setToolsInUse(null);
|
||||
// 移除空的流式消息
|
||||
setMessages((prev) => prev.filter((m) => m.id !== assistantId || m.content));
|
||||
},
|
||||
});
|
||||
|
||||
abortRef.current = abort;
|
||||
}, [input, isLoading, sessionId]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800/60 border border-slate-700/50 overflow-hidden flex flex-col h-[calc(100vh-8rem)] min-h-[400px] max-h-[700px]">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-700/40 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-700/50 text-slate-400 hover:text-white transition-colors"
|
||||
title="返回语音模式"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white leading-tight">{settings.botName}</h3>
|
||||
<p className="text-[10px] text-slate-500 leading-tight">文字对话模式 · 方舟 LLM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{voiceSubtitles?.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-500/10 border border-violet-500/20">
|
||||
<Sparkles className="w-3 h-3 text-violet-400" />
|
||||
<span className="text-[10px] text-violet-400">语音转接</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{/* Transition notice */}
|
||||
{voiceSubtitles?.length > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="px-3 py-1 rounded-full bg-slate-700/30 border border-slate-600/20 text-[11px] text-slate-500">
|
||||
以下是语音通话中的对话记录,已无缝接续
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex items-start gap-2.5 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role !== 'user' && (
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
msg.fromVoice ? 'bg-slate-600/40' : 'bg-violet-500/20'
|
||||
}`}>
|
||||
<Bot className={`w-3.5 h-3.5 ${msg.fromVoice ? 'text-slate-400' : 'text-violet-400'}`} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-3 py-2 rounded-xl text-sm max-w-[75%] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? msg.fromVoice
|
||||
? 'bg-indigo-500/10 text-indigo-300/70 rounded-tr-sm'
|
||||
: 'bg-indigo-500/20 text-indigo-200 rounded-tr-sm'
|
||||
: msg.fromVoice
|
||||
? 'bg-slate-700/30 text-slate-300/70 rounded-tl-sm'
|
||||
: 'bg-slate-700/50 text-slate-200 rounded-tl-sm'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.fromVoice && (
|
||||
<span className="ml-1.5 text-[9px] text-slate-600 align-middle">🎙</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
msg.fromVoice ? 'bg-slate-600/40' : 'bg-indigo-500/20'
|
||||
}`}>
|
||||
<User className={`w-3.5 h-3.5 ${msg.fromVoice ? 'text-slate-400' : 'text-indigo-400'}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Voice→Text divider */}
|
||||
{messages.length > 0 && messages[messages.length - 1]?.fromVoice && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<div className="px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-[11px] text-emerald-400">
|
||||
↓ 文字对话开始 ↓
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call indicator */}
|
||||
{toolsInUse && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-400">
|
||||
<Wrench className="w-3 h-3 animate-pulse" />
|
||||
正在调用工具:{toolsInUse.join('、')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{isLoading && !streamingId && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-violet-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-400" />
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-xl bg-slate-700/50 text-slate-400 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin inline mr-1.5" />
|
||||
思考中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline hover:text-red-300">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t border-slate-700/40 flex-shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isInitialized ? '输入消息,Enter 发送...' : '正在初始化...'}
|
||||
disabled={!isInitialized || isLoading}
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 rounded-xl bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 resize-none disabled:opacity-50"
|
||||
style={{ maxHeight: '80px' }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-9 h-9 rounded-xl bg-red-500/80 hover:bg-red-500 text-white flex items-center justify-center transition-all flex-shrink-0"
|
||||
title="停止生成"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isInitialized}
|
||||
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600 hover:from-violet-600 hover:to-indigo-700 text-white flex items-center justify-center transition-all disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
test2/client/src/components/SettingsPanel.jsx
Normal file
166
test2/client/src/components/SettingsPanel.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { X, Volume2, Bot, Mic2, Globe } from 'lucide-react';
|
||||
|
||||
export default function SettingsPanel({ settings, onChange, voiceConfig, onClose }) {
|
||||
const update = (key, value) => {
|
||||
onChange({ ...settings, [key]: value });
|
||||
};
|
||||
|
||||
const models = voiceConfig?.models || [
|
||||
{ value: '1.2.1.0', label: 'O2.0(推荐,精品音质)' },
|
||||
{ value: 'O', label: 'O(基础版)' },
|
||||
{ value: '2.2.0.0', label: 'SC2.0(推荐,声音复刻)' },
|
||||
{ value: 'SC', label: 'SC(基础版)' },
|
||||
];
|
||||
|
||||
const allSpeakers = voiceConfig?.speakers || [
|
||||
{ value: 'zh_female_vv_jupiter_bigtts', label: 'VV(活泼女声)', series: 'O' },
|
||||
{ value: 'zh_female_xiaohe_jupiter_bigtts', label: '小禾(甜美女声)', series: 'O' },
|
||||
{ value: 'zh_male_yunzhou_jupiter_bigtts', label: '云舟(沉稳男声)', series: 'O' },
|
||||
{ value: 'zh_male_xiaotian_jupiter_bigtts', label: '小天(磁性男声)', series: 'O' },
|
||||
];
|
||||
|
||||
// 根据模型系列过滤音色:O/O2.0 显示 O 系列,SC/SC2.0 显示对应系列
|
||||
const isOModel = settings.modelVersion === 'O' || settings.modelVersion.startsWith('1.');
|
||||
const speakers = allSpeakers.filter((s) => {
|
||||
if (!s.series) return true;
|
||||
if (isOModel) return s.series === 'O';
|
||||
if (settings.modelVersion === '2.2.0.0') return s.series === 'SC2.0' || s.series === 'SC';
|
||||
if (settings.modelVersion === 'SC') return s.series === 'SC' || s.series === 'SC2.0';
|
||||
return true;
|
||||
});
|
||||
|
||||
const tools = voiceConfig?.tools || [
|
||||
{ name: 'query_weather', description: '查询城市天气' },
|
||||
{ name: 'query_order', description: '查询订单状态' },
|
||||
{ name: 'search_knowledge', description: '知识库搜索' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-2xl bg-slate-800/70 border border-slate-700/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-700/40 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">语音通话设置</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* AI 角色 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
<Bot className="w-3.5 h-3.5" /> AI 角色设定
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.botName}
|
||||
onChange={(e) => update('botName', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">角色描述 (system_role)</label>
|
||||
<textarea
|
||||
value={settings.systemRole}
|
||||
onChange={(e) => update('systemRole', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">说话风格 (speaking_style)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.speakingStyle}
|
||||
onChange={(e) => update('speakingStyle', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色 & 模型 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||
<Volume2 className="w-3.5 h-3.5" /> 模型与音色
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">S2S 模型版本</label>
|
||||
<select
|
||||
value={settings.modelVersion}
|
||||
onChange={(e) => {
|
||||
const newModel = e.target.value;
|
||||
update('modelVersion', newModel);
|
||||
// 切换模型系列时自动选择第一个兼容音色
|
||||
const newIsO = newModel === 'O' || newModel.startsWith('1.');
|
||||
const currentIsO = settings.modelVersion === 'O' || settings.modelVersion.startsWith('1.');
|
||||
if (newIsO !== currentIsO) {
|
||||
const firstMatch = allSpeakers.find((s) => {
|
||||
if (newIsO) return s.series === 'O';
|
||||
if (newModel === '2.2.0.0') return s.series === 'SC2.0' || s.series === 'SC';
|
||||
return s.series === 'SC' || s.series === 'SC2.0';
|
||||
});
|
||||
if (firstMatch) onChange({ ...settings, modelVersion: newModel, speaker: firstMatch.value });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">音色</label>
|
||||
<select
|
||||
value={settings.speaker}
|
||||
onChange={(e) => update('speaker', e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600/40 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
>
|
||||
{speakers.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 联网搜索 */}
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400 mt-4">
|
||||
<Globe className="w-3.5 h-3.5" /> 功能开关
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableWebSearch}
|
||||
onChange={(e) => update('enableWebSearch', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-slate-600 bg-slate-700 text-violet-500 focus:ring-violet-500/30"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">启用联网搜索</span>
|
||||
</label>
|
||||
|
||||
{/* 已注册工具 */}
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-400 mt-4">
|
||||
<Mic2 className="w-3.5 h-3.5" /> 已注册工具 (Function Calling)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{tools.map((t) => (
|
||||
<div key={t.name} className="px-2.5 py-1.5 rounded-md bg-slate-700/30 border border-slate-600/20 text-xs">
|
||||
<span className="text-violet-400 font-mono">{t.name}</span>
|
||||
<span className="text-slate-500 ml-2">{t.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
test2/client/src/components/SubtitleDisplay.jsx
Normal file
82
test2/client/src/components/SubtitleDisplay.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageCircle, Bot, User } from 'lucide-react';
|
||||
|
||||
export default function SubtitleDisplay({ subtitles, isActive }) {
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [subtitles]);
|
||||
|
||||
const finalSubtitles = subtitles.filter((s) => s.isFinal);
|
||||
|
||||
if (!isActive && finalSubtitles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700/40">
|
||||
<div className="px-4 py-2 flex items-center gap-1.5">
|
||||
<MessageCircle className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-500">实时字幕</span>
|
||||
{finalSubtitles.length > 0 && (
|
||||
<span className="text-xs text-slate-600">({finalSubtitles.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="max-h-60 overflow-y-auto px-4 pb-4 space-y-2">
|
||||
{finalSubtitles.map((sub, i) => (
|
||||
<div
|
||||
key={`${sub.sequence}-${i}`}
|
||||
className={`flex items-start gap-2 ${
|
||||
sub.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{sub.role !== 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-violet-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-3 py-1.5 rounded-xl text-sm max-w-[75%] ${
|
||||
sub.role === 'user'
|
||||
? 'bg-indigo-500/20 text-indigo-200 rounded-tr-sm'
|
||||
: 'bg-slate-700/50 text-slate-200 rounded-tl-sm'
|
||||
}`}
|
||||
>
|
||||
{sub.text}
|
||||
</div>
|
||||
{sub.role === 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-indigo-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<User className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Partial (non-final) subtitle being transcribed */}
|
||||
{subtitles.filter((s) => !s.isFinal).map((sub, i) => (
|
||||
<div
|
||||
key={`partial-${i}`}
|
||||
className={`flex items-start gap-2 opacity-50 ${
|
||||
sub.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
{sub.role !== 'user' && (
|
||||
<div className="w-6 h-6 rounded-full bg-slate-600/30 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-xl text-sm max-w-[75%] bg-slate-700/30 text-slate-400 italic">
|
||||
{sub.text}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isActive && finalSubtitles.length === 0 && (
|
||||
<p className="text-xs text-slate-600 text-center py-2">对话字幕将在这里显示...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
test2/client/src/components/VoicePanel.jsx
Normal file
170
test2/client/src/components/VoicePanel.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Mic, MicOff, Phone, PhoneOff, Loader2, MessageSquare } from 'lucide-react';
|
||||
import { useVoiceChat } from '../hooks/useVoiceChat';
|
||||
import SubtitleDisplay from './SubtitleDisplay';
|
||||
|
||||
export default function VoicePanel({ settings, onVoiceEnd, chatHistory = [], sessionId: parentSessionId }) {
|
||||
const {
|
||||
isActive,
|
||||
isMuted,
|
||||
isConnecting,
|
||||
subtitles,
|
||||
connectionState,
|
||||
error,
|
||||
duration,
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
clearError,
|
||||
} = useVoiceChat();
|
||||
|
||||
const formatTime = (s) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
start({
|
||||
botName: settings.botName,
|
||||
systemRole: settings.systemRole,
|
||||
speakingStyle: settings.speakingStyle,
|
||||
modelVersion: settings.modelVersion,
|
||||
speaker: settings.speaker,
|
||||
enableWebSearch: settings.enableWebSearch,
|
||||
chatHistory: chatHistory.length > 0 ? chatHistory.slice(-10) : undefined,
|
||||
parentSessionId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-slate-800/60 border border-slate-700/50 overflow-hidden">
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2.5 border-b border-slate-700/40 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isActive ? 'bg-emerald-400 animate-pulse' : isConnecting ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{isActive
|
||||
? `通话中 · ${formatTime(duration)}`
|
||||
: isConnecting
|
||||
? '正在连接...'
|
||||
: '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>模型: {settings.modelVersion}</span>
|
||||
<span>·</span>
|
||||
<span>{settings.botName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visualization Area */}
|
||||
<div className="relative flex flex-col items-center justify-center py-12 px-4">
|
||||
{/* Voice Wave Animation */}
|
||||
<div className="relative w-32 h-32 mb-6">
|
||||
{/* Outer rings */}
|
||||
{isActive && !isMuted && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-violet-500/10 animate-ping" style={{ animationDuration: '2s' }} />
|
||||
<div className="absolute inset-3 rounded-full bg-violet-500/10 animate-ping" style={{ animationDuration: '2.5s' }} />
|
||||
</>
|
||||
)}
|
||||
{/* Center circle */}
|
||||
<div
|
||||
className={`absolute inset-4 rounded-full flex items-center justify-center transition-all duration-300 ${
|
||||
isActive
|
||||
? isMuted
|
||||
? 'bg-slate-600 shadow-lg shadow-slate-600/20'
|
||||
: 'bg-gradient-to-br from-violet-500 to-indigo-600 shadow-lg shadow-violet-500/30'
|
||||
: 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-10 h-10 text-white animate-spin" />
|
||||
) : isActive ? (
|
||||
isMuted ? (
|
||||
<MicOff className="w-10 h-10 text-slate-300" />
|
||||
) : (
|
||||
<Mic className="w-10 h-10 text-white" />
|
||||
)
|
||||
) : (
|
||||
<Phone className="w-10 h-10 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Info */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">{settings.botName}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-sm">
|
||||
{isActive
|
||||
? isMuted
|
||||
? '麦克风已静音'
|
||||
: '正在聆听...'
|
||||
: '点击下方按钮开始语音通话'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isActive ? (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
|
||||
isMuted
|
||||
? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
|
||||
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||
}`}
|
||||
title={isMuted ? '取消静音' : '静音'}
|
||||
>
|
||||
{isMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await stop();
|
||||
if (onVoiceEnd) onVoiceEnd(result);
|
||||
}}
|
||||
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center transition-all shadow-lg shadow-red-500/25"
|
||||
title="结束通话并转接文字对话"
|
||||
>
|
||||
<PhoneOff className="w-6 h-6" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isConnecting}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 hover:from-violet-600 hover:to-indigo-700 text-white flex items-center justify-center transition-all shadow-lg shadow-violet-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="开始通话"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<Phone className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-4 px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs max-w-md text-center">
|
||||
{error}
|
||||
<button onClick={clearError} className="ml-2 underline hover:text-red-300">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<SubtitleDisplay subtitles={subtitles} isActive={isActive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
test2/client/src/hooks/useVoiceChat.js
Normal file
198
test2/client/src/hooks/useVoiceChat.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import rtcService from '../services/rtcService';
|
||||
import { prepareVoiceChat, startVoiceChat, stopVoiceChat, executeToolCall, executeFcCallback, sendSubtitle, forwardRoomMessage } from '../services/voiceApi';
|
||||
|
||||
export function useVoiceChat() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [subtitles, setSubtitles] = useState([]);
|
||||
const [connectionState, setConnectionState] = useState('disconnected');
|
||||
const [error, setError] = useState(null);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const sessionRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
rtcService.on('onSubtitle', (subtitle) => {
|
||||
setSubtitles((prev) => {
|
||||
if (subtitle.isFinal) {
|
||||
return [...prev.filter((s) => s.sequence !== subtitle.sequence), subtitle];
|
||||
}
|
||||
const idx = prev.findIndex((s) => s.sequence === subtitle.sequence && !s.isFinal);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = subtitle;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, subtitle];
|
||||
});
|
||||
|
||||
// 方案B:将用户最终字幕转发到后端,供 FC 回调时作为知识库查询
|
||||
if (subtitle.isFinal && subtitle.role === 'user' && subtitle.text) {
|
||||
const session = sessionRef.current;
|
||||
if (session) {
|
||||
sendSubtitle({
|
||||
sessionId: session.sessionId,
|
||||
roomId: session.roomId,
|
||||
text: subtitle.text,
|
||||
role: 'user',
|
||||
definite: true,
|
||||
sequence: subtitle.sequence,
|
||||
}).catch((err) => console.warn('[useVoiceChat] Send subtitle failed:', err.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rtcService.on('onToolCall', async (toolCall) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) {
|
||||
console.warn('[useVoiceChat] Tool call received but no active session');
|
||||
return;
|
||||
}
|
||||
console.log(`[useVoiceChat] Tool call: ${toolCall.function_name}, session: ${session.sessionId}`);
|
||||
try {
|
||||
// 构建FC回调消息格式
|
||||
const message = JSON.stringify([{
|
||||
id: toolCall.tool_call_id,
|
||||
function: {
|
||||
name: toolCall.function_name,
|
||||
arguments: toolCall.arguments
|
||||
},
|
||||
seq: 1
|
||||
}]);
|
||||
|
||||
// 调用fc_callback端点,传递必要的参数
|
||||
const result = await executeFcCallback({
|
||||
roomId: session.roomId,
|
||||
taskId: session.taskId || session.sessionId,
|
||||
type: 'tool_calls',
|
||||
message: message
|
||||
});
|
||||
console.log('[useVoiceChat] FC callback result:', result);
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] FC callback failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 方案B:转发所有 RTC 房间消息到后端(可能包含 ASR/会话状态数据)
|
||||
rtcService.on('onRoomMessage', (msg) => {
|
||||
const session = sessionRef.current;
|
||||
if (session && msg.text) {
|
||||
forwardRoomMessage({
|
||||
roomId: session.roomId,
|
||||
uid: msg.uid,
|
||||
text: msg.text,
|
||||
}).catch(() => {}); // 静默失败,不影响主流程
|
||||
}
|
||||
});
|
||||
|
||||
rtcService.on('onConnectionStateChange', setConnectionState);
|
||||
rtcService.on('onError', (err) => setError(err?.message || 'RTC error'));
|
||||
|
||||
return () => {
|
||||
rtcService.destroy();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (options = {}) => {
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const userId = `user_${Date.now().toString(36)}`;
|
||||
const { parentSessionId, ...startOptions } = options;
|
||||
|
||||
// 第一步:准备房间,获取 token
|
||||
const prepareRes = await prepareVoiceChat({ userId });
|
||||
if (!prepareRes.success) throw new Error(prepareRes.error);
|
||||
|
||||
const { sessionId, roomId, taskId, rtcToken, rtcAppId } = prepareRes.data;
|
||||
sessionRef.current = { sessionId, roomId, taskId, parentSessionId };
|
||||
|
||||
// 第二步:用户先进房
|
||||
await rtcService.init(rtcAppId);
|
||||
await rtcService.joinRoom(roomId, userId, rtcToken);
|
||||
console.log('[useVoiceChat] User joined room, now starting AI...');
|
||||
|
||||
// 第三步:用户已在房间内,启动 AI 语音对话
|
||||
const startRes = await startVoiceChat({ sessionId, ...startOptions });
|
||||
if (!startRes.success) throw new Error(startRes.error);
|
||||
|
||||
setIsActive(true);
|
||||
setSubtitles([]);
|
||||
setDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((d) => d + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] Start failed:', err);
|
||||
setError(err.message || 'Failed to start voice chat');
|
||||
rtcService.destroy();
|
||||
if (sessionRef.current) {
|
||||
stopVoiceChat(sessionRef.current.sessionId).catch(() => {});
|
||||
sessionRef.current = null;
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
let result = { sessionId: null, subtitles: [] };
|
||||
try {
|
||||
// 在离开房间前,先从前端 state 中提取已确认的字幕
|
||||
const localFinalSubtitles = subtitles.filter((s) => s.isFinal);
|
||||
|
||||
await rtcService.leaveRoom();
|
||||
|
||||
if (sessionRef.current) {
|
||||
const sid = sessionRef.current.sessionId;
|
||||
const response = await stopVoiceChat(sid);
|
||||
const backendSubtitles = response?.data?.subtitles || [];
|
||||
|
||||
// 优先使用前端本地字幕(RTC 直接接收,更完整),后端字幕作为 fallback
|
||||
result = {
|
||||
sessionId: sid,
|
||||
subtitles: localFinalSubtitles.length > 0 ? localFinalSubtitles : backendSubtitles,
|
||||
};
|
||||
sessionRef.current = null;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
setIsActive(false);
|
||||
setIsMuted(false);
|
||||
setConnectionState('disconnected');
|
||||
} catch (err) {
|
||||
console.error('[useVoiceChat] Stop failed:', err);
|
||||
}
|
||||
return result;
|
||||
}, [subtitles]);
|
||||
|
||||
const toggleMute = useCallback(async () => {
|
||||
const next = !isMuted;
|
||||
await rtcService.setMuted(next);
|
||||
setIsMuted(next);
|
||||
}, [isMuted]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isMuted,
|
||||
isConnecting,
|
||||
subtitles,
|
||||
connectionState,
|
||||
error,
|
||||
duration,
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
19
test2/client/src/index.css
Normal file
19
test2/client/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
10
test2/client/src/main.jsx
Normal file
10
test2/client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
98
test2/client/src/services/chatApi.js
Normal file
98
test2/client/src/services/chatApi.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/chat',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
export async function startChatSession(sessionId, voiceSubtitles = [], systemPrompt = '') {
|
||||
const { data } = await api.post('/start', { sessionId, voiceSubtitles, systemPrompt });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(sessionId, message) {
|
||||
const { data } = await api.post('/send', { sessionId, message });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式发送消息,逐块回调
|
||||
* @param {string} sessionId
|
||||
* @param {string} message
|
||||
* @param {object} callbacks - { onChunk, onToolCall, onDone, onError }
|
||||
* @returns {function} abort - 调用可取消请求
|
||||
*/
|
||||
export function sendMessageStream(sessionId, message, { onChunk, onToolCall, onDone, onError }) {
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/chat/send-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, message }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ error: response.statusText }));
|
||||
onError?.(err.error || 'Request failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6));
|
||||
switch (data.type) {
|
||||
case 'chunk':
|
||||
onChunk?.(data.content);
|
||||
break;
|
||||
case 'tool_call':
|
||||
onToolCall?.(data.tools);
|
||||
break;
|
||||
case 'done':
|
||||
onDone?.(data.content);
|
||||
break;
|
||||
case 'error':
|
||||
onError?.(data.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed SSE
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
onError?.(err.message || 'Stream failed');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
export async function getChatHistory(sessionId) {
|
||||
const { data } = await api.get(`/history/${sessionId}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteChatSession(sessionId) {
|
||||
const { data } = await api.delete(`/${sessionId}`);
|
||||
return data;
|
||||
}
|
||||
323
test2/client/src/services/rtcService.js
Normal file
323
test2/client/src/services/rtcService.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 火山引擎 RTC SDK 封装
|
||||
* 负责 WebRTC 音频流的建立和管理
|
||||
*/
|
||||
class RTCService {
|
||||
constructor() {
|
||||
this.engine = null;
|
||||
this.joined = false;
|
||||
this.callbacks = {
|
||||
onSubtitle: null,
|
||||
onAudioStatus: null,
|
||||
onConnectionStateChange: null,
|
||||
onError: null,
|
||||
onUserJoined: null,
|
||||
onUserLeft: null,
|
||||
onToolCall: null,
|
||||
onRoomMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
async init(appId) {
|
||||
if (this.engine) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
try {
|
||||
const VERTC = await import('@volcengine/rtc');
|
||||
const createEngine = VERTC.default?.createEngine || VERTC.createEngine;
|
||||
const events = VERTC.default?.events || VERTC.events;
|
||||
|
||||
if (!createEngine) {
|
||||
throw new Error('Failed to load RTC SDK: createEngine not found');
|
||||
}
|
||||
|
||||
this.engine = createEngine(appId);
|
||||
this.events = events;
|
||||
|
||||
this.engine.on(events.onConnectionStateChanged, (state) => {
|
||||
console.log('[RTC] Connection state:', state);
|
||||
this.callbacks.onConnectionStateChange?.(state);
|
||||
});
|
||||
|
||||
if (events.onSubtitleStateChanged) {
|
||||
this.engine.on(events.onSubtitleStateChanged, (state) => {
|
||||
console.log('[RTC] Subtitle state changed:', state);
|
||||
});
|
||||
}
|
||||
|
||||
if (events.onSubtitleMessageReceived) {
|
||||
this.engine.on(events.onSubtitleMessageReceived, (subtitles) => {
|
||||
console.log('[RTC] Subtitle received:', subtitles.length, 'items');
|
||||
subtitles.forEach((sub) => {
|
||||
// bot 的 userId 以 'bot_' 开头,无 userId 或 bot_ 开头都是 assistant
|
||||
const isBot = !sub.userId || sub.userId.startsWith('bot_');
|
||||
this.callbacks.onSubtitle?.({
|
||||
text: sub.text,
|
||||
role: isBot ? 'assistant' : 'user',
|
||||
isFinal: sub.definite,
|
||||
sequence: sub.sequence,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.engine.on(events.onUserJoined, (info) => {
|
||||
console.log('[RTC] User joined:', info.userInfo?.userId);
|
||||
this.callbacks.onUserJoined?.(info);
|
||||
});
|
||||
|
||||
this.engine.on(events.onUserLeave, (info) => {
|
||||
console.log('[RTC] User left:', info.userInfo?.userId);
|
||||
this.callbacks.onUserLeft?.(info);
|
||||
});
|
||||
|
||||
this.engine.on(events.onError, (error) => {
|
||||
console.error('[RTC] Error:', error);
|
||||
this.callbacks.onError?.(error);
|
||||
});
|
||||
|
||||
// === Function Calling: 监听房间消息(SDK 回调参数是单个 event 对象) ===
|
||||
if (events.onRoomBinaryMessageReceived) {
|
||||
this.engine.on(events.onRoomBinaryMessageReceived, (event) => {
|
||||
try {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const raw = event.message;
|
||||
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
console.log('[RTC][FC] Room binary from', uid, ':', text.substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text });
|
||||
const parsed = JSON.parse(text);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] Room binary (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onRoomMessageReceived) {
|
||||
this.engine.on(events.onRoomMessageReceived, (event) => {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const msg = event.message || '';
|
||||
console.log('[RTC][FC] Room text from', uid, ':', String(msg).substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text: String(msg) });
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] Room text (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onUserBinaryMessageReceived) {
|
||||
this.engine.on(events.onUserBinaryMessageReceived, (event) => {
|
||||
try {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const raw = event.message;
|
||||
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
console.log('[RTC][FC] User binary from', uid, ':', text.substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text });
|
||||
const parsed = JSON.parse(text);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] User binary (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (events.onUserMessageReceived) {
|
||||
this.engine.on(events.onUserMessageReceived, (event) => {
|
||||
const uid = event.uid || event.userId || 'unknown';
|
||||
const msg = event.message || '';
|
||||
console.log('[RTC][FC] User text from', uid, ':', String(msg).substring(0, 500));
|
||||
this.callbacks.onRoomMessage?.({ uid, text: String(msg) });
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
this._handleRoomMessage(uid, parsed);
|
||||
} catch (e) {
|
||||
console.log('[RTC][FC] User text (non-JSON):', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === 诊断事件 ===
|
||||
if (events.onUserPublishStream) {
|
||||
this.engine.on(events.onUserPublishStream, (info) => {
|
||||
console.log('[RTC][DIAG] Remote user published stream:', info.userId, 'mediaType:', info.mediaType);
|
||||
});
|
||||
}
|
||||
if (events.onUserUnpublishStream) {
|
||||
this.engine.on(events.onUserUnpublishStream, (info) => {
|
||||
console.log('[RTC][DIAG] Remote user unpublished stream:', info.userId, 'mediaType:', info.mediaType);
|
||||
});
|
||||
}
|
||||
if (events.onAutoplayFailed) {
|
||||
this.engine.on(events.onAutoplayFailed, (info) => {
|
||||
console.error('[RTC][DIAG] ❌ Autoplay FAILED! Audio blocked by browser:', info);
|
||||
});
|
||||
}
|
||||
if (events.onPlayerEvent) {
|
||||
this.engine.on(events.onPlayerEvent, (info) => {
|
||||
console.log('[RTC][DIAG] Player event:', info);
|
||||
});
|
||||
}
|
||||
if (events.onRemoteStreamStats) {
|
||||
this.engine.on(events.onRemoteStreamStats, (stats) => {
|
||||
if (stats.audioRecvBytes > 0) {
|
||||
console.log('[RTC][DIAG] Receiving audio from:', stats.uid, 'bytes:', stats.audioRecvBytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启用音频属性报告,检测是否有远端音频
|
||||
try {
|
||||
this.engine.enableAudioPropertiesReport?.({ interval: 3000 });
|
||||
if (events.onRemoteAudioPropertiesReport) {
|
||||
this.engine.on(events.onRemoteAudioPropertiesReport, (infos) => {
|
||||
infos?.forEach((info) => {
|
||||
if (info.audioPropertiesInfo?.linearVolume > 0) {
|
||||
console.log('[RTC][DIAG] 🔊 Remote audio detected! user:', info.streamKey?.userId, 'volume:', info.audioPropertiesInfo.linearVolume);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (events.onLocalAudioPropertiesReport) {
|
||||
this.engine.on(events.onLocalAudioPropertiesReport, (infos) => {
|
||||
infos?.forEach((info) => {
|
||||
if (info.audioPropertiesInfo?.linearVolume > 0) {
|
||||
console.log('[RTC][DIAG] 🎤 Local mic active, volume:', info.audioPropertiesInfo.linearVolume);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[RTC][DIAG] enableAudioPropertiesReport not available:', e.message);
|
||||
}
|
||||
|
||||
console.log('[RTC] Engine initialized with diagnostic listeners');
|
||||
console.log('[RTC] Available events:', Object.keys(events).filter(k => k.startsWith('on')).join(', '));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[RTC] Init failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async joinRoom(roomId, userId, token) {
|
||||
if (!this.engine) throw new Error('Engine not initialized');
|
||||
|
||||
await this.engine.joinRoom(
|
||||
token,
|
||||
roomId,
|
||||
{ userId },
|
||||
{
|
||||
isAutoPublish: true,
|
||||
isAutoSubscribeAudio: true,
|
||||
isAutoSubscribeVideo: false,
|
||||
}
|
||||
);
|
||||
|
||||
await this.engine.startAudioCapture();
|
||||
|
||||
// 激活字幕接收(必须在 joinRoom 之后调用)
|
||||
try {
|
||||
await this.engine.startSubtitle({});
|
||||
console.log('[RTC] Subtitle enabled');
|
||||
} catch (e) {
|
||||
console.warn('[RTC] startSubtitle failed:', e.message || e);
|
||||
}
|
||||
|
||||
this.joined = true;
|
||||
console.log(`[RTC] Joined room ${roomId} as ${userId}`);
|
||||
}
|
||||
|
||||
async leaveRoom() {
|
||||
if (!this.engine || !this.joined) return;
|
||||
try {
|
||||
await this.engine.stopAudioCapture();
|
||||
await this.engine.leaveRoom();
|
||||
this.joined = false;
|
||||
console.log('[RTC] Left room');
|
||||
} catch (e) {
|
||||
console.warn('[RTC] Leave room error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async setMuted(muted) {
|
||||
if (!this.engine) return;
|
||||
if (muted) {
|
||||
await this.engine.stopAudioCapture();
|
||||
} else {
|
||||
await this.engine.startAudioCapture();
|
||||
}
|
||||
}
|
||||
|
||||
_handleRoomMessage(uid, parsed) {
|
||||
console.log('[RTC][FC] Parsed message type:', parsed.type || parsed.event || 'unknown', 'from:', uid);
|
||||
|
||||
// 尝试多种可能的 tool call 消息格式
|
||||
let toolCalls = null;
|
||||
|
||||
// 格式1: { type: "function_call", data: { tool_calls: [...] } }
|
||||
if (parsed.type === 'function_call' && parsed.data?.tool_calls) {
|
||||
toolCalls = parsed.data.tool_calls;
|
||||
}
|
||||
// 格式2: { event: "function_call", tool_calls: [...] }
|
||||
else if (parsed.event === 'function_call' && parsed.tool_calls) {
|
||||
toolCalls = parsed.tool_calls;
|
||||
}
|
||||
// 格式3: { type: "conversation", data: { event: "function_call", ... } }
|
||||
else if (parsed.type === 'conversation' && parsed.data?.event === 'function_call') {
|
||||
toolCalls = parsed.data.tool_calls || [parsed.data];
|
||||
}
|
||||
// 格式4: 直接是 tool_calls 数组
|
||||
else if (parsed.tool_calls) {
|
||||
toolCalls = parsed.tool_calls;
|
||||
}
|
||||
// 格式5: 单个 function_call 对象
|
||||
else if (parsed.function?.name || parsed.function_name) {
|
||||
toolCalls = [parsed];
|
||||
}
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
console.log('[RTC][FC] ✅ Tool calls detected:', toolCalls.length);
|
||||
toolCalls.forEach((tc) => {
|
||||
const callId = tc.id || tc.tool_call_id || `tc_${Date.now()}`;
|
||||
const funcName = tc.function?.name || tc.function_name || 'unknown';
|
||||
const args = tc.function?.arguments || tc.arguments || '{}';
|
||||
console.log(`[RTC][FC] Tool call: ${funcName}(${args}), id=${callId}`);
|
||||
this.callbacks.onToolCall?.({ tool_call_id: callId, function_name: funcName, arguments: args });
|
||||
});
|
||||
} else {
|
||||
console.log('[RTC][FC] Message is not a tool call, full payload:', JSON.stringify(parsed).substring(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (event in this.callbacks) {
|
||||
this.callbacks[event] = callback;
|
||||
}
|
||||
}
|
||||
|
||||
off(event) {
|
||||
if (event in this.callbacks) {
|
||||
this.callbacks[event] = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.engine) {
|
||||
try {
|
||||
if (this.joined) {
|
||||
this.engine.stopAudioCapture().catch(() => {});
|
||||
this.engine.leaveRoom().catch(() => {});
|
||||
}
|
||||
this.engine.destroyEngine?.();
|
||||
} catch (e) {
|
||||
console.warn('[RTC] Destroy error:', e);
|
||||
}
|
||||
this.engine = null;
|
||||
this.joined = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rtcService = new RTCService();
|
||||
export default rtcService;
|
||||
82
test2/client/src/services/voiceApi.js
Normal file
82
test2/client/src/services/voiceApi.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/voice',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
export async function getVoiceConfig() {
|
||||
const { data } = await api.get('/config');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function prepareVoiceChat(params) {
|
||||
const { data } = await api.post('/prepare', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startVoiceChat(params) {
|
||||
const { data } = await api.post('/start', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stopVoiceChat(sessionId) {
|
||||
const { data } = await api.post('/stop', { sessionId });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendSubtitle(params) {
|
||||
const { data } = await api.post('/subtitle', params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSubtitles(sessionId) {
|
||||
const { data } = await api.get(`/subtitles/${sessionId}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getActiveSessions() {
|
||||
const { data } = await api.get('/sessions');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function forwardRoomMessage({ roomId, uid, text }) {
|
||||
const { data } = await api.post('/room_message', { roomId, uid, text });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function executeToolCall({ sessionId, toolCallId, functionName, arguments: args }) {
|
||||
const { data } = await api.post('/tool-callback', {
|
||||
sessionId,
|
||||
tool_call_id: toolCallId,
|
||||
function_name: functionName,
|
||||
arguments: args,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function executeFcCallback({ roomId, taskId, type, message }) {
|
||||
const { data } = await api.post('/fc_callback', {
|
||||
RoomID: roomId,
|
||||
TaskID: taskId,
|
||||
Type: type,
|
||||
Message: message,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// ========== 会话历史 API ==========
|
||||
const sessionApi = axios.create({
|
||||
baseURL: '/api/session',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
export async function getSessionHistory(sessionId, limit = 20) {
|
||||
const { data } = await sessionApi.get(`/${sessionId}/history`, { params: { limit, format: 'llm' } });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function switchSessionMode(sessionId, targetMode) {
|
||||
const { data } = await sessionApi.post(`/${sessionId}/switch`, { targetMode });
|
||||
return data.data;
|
||||
}
|
||||
20
test2/client/vite.config.js
Normal file
20
test2/client/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3012',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user