Update code

This commit is contained in:
User
2026-03-12 12:47:56 +08:00
parent 92e7fc5bda
commit 9dab61345c
9383 changed files with 1463454 additions and 1 deletions

13
test2/client/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

26
test2/client/package.json Normal file
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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
View 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>
);

View 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;
}

View 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;

View 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;
}

View 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,
},
},
},
});