This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/frontend/web/src/App.tsx
T
AskaEth 6ef9e082a6 feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST)
- Gateway: VoiceStreamManager代理WS流式STT到voice-service
- Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码
- 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端)
- 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并
- 文档: 完善gateway-api.md和voice-service.md语音API文档
- 工具: scripts/voice/ 语音转换脚本集

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 11:50:40 +08:00

373 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react';
import { AppLayout } from '@/components/layout/AppLayout';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatInput } from '@/components/chat/ChatInput';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useAuth } from '@/hooks/useAuth';
import { useChat } from '@/hooks/useChat';
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
import { useChatStore } from '@/store/chatStore';
import { usePageStore } from '@/store/pageStore';
import { fetchMessages } from '@/api/sessions';
import { registerServiceWorker } from '@/hooks/usePWA';
import { ModelsAdminPage } from '@/components/admin/ModelsAdminPage';
import { AdminDashboard } from '@/components/admin/AdminDashboard';
import { ProfilePage } from '@/components/profile/ProfilePage';
/** URL Hash 工具 */
const SESSION_HASH_PREFIX = 'session=';
function getSessionIdFromHash(): string | null {
const hash = window.location.hash.slice(1); // 去掉 #
if (hash.startsWith(SESSION_HASH_PREFIX)) {
return hash.slice(SESSION_HASH_PREFIX.length);
}
return null;
}
function setHashSessionId(sessionId: string | null) {
if (sessionId) {
window.location.hash = SESSION_HASH_PREFIX + sessionId;
} else {
// 清除 hash
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
export default function App() {
const { isLoggedIn, login, register, loading: authLoading, userId } = useAuth();
const { send, sendVoiceStreamMessage } = useChat();
const { loadSessionsFromServer, ensureMainSession, setCurrentSessionId, setMessages, loadMessagesFromServer, sessions, currentSessionId } = useSessionStore();
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [nickname, setNickname] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const initializedRef = useRef(false);
// ========== PWA Service Worker ==========
// 注册 Service Worker(在 main.tsx 移除内联注册后,由 App 组件负责)
useEffect(() => {
registerServiceWorker();
}, []);
// ========== URL Hash 路由 ==========
/** 根据 hash 恢复或选择初始会话 */
const initSession = useCallback(async () => {
if (!userId || initializedRef.current) return;
initializedRef.current = true;
try {
const admin = isAdminUser(userId);
// 1. 从服务端加载会话列表
await loadSessionsFromServer(userId);
const currentSessions = useSessionStore.getState().sessions;
// 2. 检查 URL hash
const hashId = getSessionIdFromHash();
if (hashId) {
// 尝试加载 hash 指定的会话
const found = currentSessions.find((s) => s.id === hashId);
if (found) {
setCurrentSessionId(found.id);
setHashSessionId(found.id);
await loadMessagesFromServer(found.id);
return;
}
// 会话可能已被删除,尝试从 API 获取消息(404 时 catch
try {
const resp = await fetchMessages(hashId);
if (resp.messages && resp.messages.length > 0) {
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
setCurrentSessionId(hashId);
setHashSessionId(hashId);
const msgs = resp.messages.map((m: any, i: number) => ({
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
role: m.role,
content: m.content,
timestamp: typeof m.created_at === 'number' ? m.created_at : Date.now(),
isStreaming: false,
}));
setMessages(msgs);
useChatStore.getState().setMessages(msgs);
return;
}
} catch {
// 会话不存在,回退
}
// 回退:清除 hash,加载最新/主对话
setHashSessionId(null);
}
// 3. 无 hash 或 hash 无效:加载最新会话
if (admin) {
// 管理员:确保主对话存在
const mainSession = await ensureMainSession(userId);
if (mainSession) {
setCurrentSessionId(mainSession.id);
setHashSessionId(mainSession.id);
await loadMessagesFromServer(mainSession.id);
return;
}
}
// 普通用户:选择最新会话
if (currentSessions.length > 0) {
const latest = currentSessions[0]; // 已按 updated_at DESC 排序
setCurrentSessionId(latest.id);
setHashSessionId(latest.id);
await loadMessagesFromServer(latest.id);
}
} catch (error) {
console.error('[App] initSession failed:', error);
// 重置状态避免死锁,允许后续重试
initializedRef.current = false;
}
}, [userId, loadSessionsFromServer, ensureMainSession, setCurrentSessionId, setMessages, loadMessagesFromServer]);
// 登录后初始化
useEffect(() => {
if (isLoggedIn && userId) {
initSession();
}
}, [isLoggedIn, userId, initSession]);
// 监听 hashchange 事件 (浏览器前进/后退)
useEffect(() => {
if (!isLoggedIn) return;
const handleHashChange = async () => {
const hashId = getSessionIdFromHash();
const currentId = useSessionStore.getState().currentSessionId;
if (hashId && hashId !== currentId) {
// hash 变化,切换会话
setCurrentSessionId(hashId);
await loadMessagesFromServer(hashId);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [isLoggedIn, setCurrentSessionId, loadMessagesFromServer]);
// 当前会话变化时更新 URL hash(仅在登录后、非 hashchange 驱动时)
useEffect(() => {
if (!isLoggedIn || !currentSessionId) return;
const hashId = getSessionIdFromHash();
if (hashId !== currentSessionId) {
setHashSessionId(currentSessionId);
}
}, [isLoggedIn, currentSessionId]);
// ========== 认证相关 ==========
const handleLogin = async () => {
setError('');
const result = await login(username, password);
if (!result.success) {
setError(result.error || '登录失败');
}
};
const handleRegister = async () => {
setError('');
setSuccessMsg('');
if (!email) {
setError('请输入邮箱');
return;
}
if (!nickname) {
setError('请输入昵称');
return;
}
const result = await register(username, password, email, nickname, verifyCode || '000000');
if (!result.success) {
setError(result.error || '注册失败');
} else {
setSuccessMsg('注册成功!正在进入...');
}
};
const switchMode = (mode: 'login' | 'register') => {
setAuthMode(mode);
setError('');
setSuccessMsg('');
};
// 登录/注册页面
if (!isLoggedIn) {
return (
<div className="min-h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="text-6xl mb-4">🌸</div>
<h1 className="text-2xl font-bold text-pink-500 mb-2"></h1>
<p className="text-sm text-gray-400">
</p>
</div>
{/* 登录/注册表单 */}
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-6 space-y-4 border border-pink-100 dark:border-pink-900">
{/* 模式切换 */}
<div className="flex rounded-xl bg-pink-50 dark:bg-pink-900/20 p-1">
<button
onClick={() => switchMode('login')}
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
authMode === 'login'
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
: 'text-gray-400 hover:text-pink-400'
}`}
>
</button>
<button
onClick={() => switchMode('register')}
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
authMode === 'register'
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
: 'text-gray-400 hover:text-pink-400'
}`}
>
</button>
</div>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
{authMode === 'register' && (
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
{authMode === 'register' && (
<input
type="text"
placeholder="昵称 (昔涟会这样称呼你)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
{authMode === 'register' && (
<input
type="text"
placeholder="验证码 (开发环境输入 000000)"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
maxLength={6}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
{error && (
<p className="text-xs text-red-400 text-center">{error}</p>
)}
{successMsg && (
<p className="text-xs text-green-400 text-center">{successMsg}</p>
)}
{authMode === 'login' ? (
<button
onClick={handleLogin}
disabled={authLoading || !username || !password}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
</button>
) : (
<button
onClick={handleRegister}
disabled={authLoading || !username || !password || !email || !nickname}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '注册并进入 ♪'}
</button>
)}
<p className="text-xs text-gray-400 text-center">
{authMode === 'register' ? '开发阶段 · 验证码统一使用 000000' : '欢迎回来 ♪'}
</p>
</div>
</div>
</div>
);
}
// 聊天界面
return (
<ErrorBoundary>
<AppLayout>
<PageRouter onSend={send} onSendVoiceStream={sendVoiceStreamMessage} />
</AppLayout>
</ErrorBoundary>
);
}
type SendFn = (content: string, mode?: import('@/types/chat').ChatMode, attachments?: import('@/types/chat').MessageAttachment[]) => void;
type SendVoiceStreamFn = (msg: import('@/types/chat').WSClientMessage) => void;
function PageRouter({ onSend, onSendVoiceStream }: { onSend: SendFn; onSendVoiceStream: SendVoiceStreamFn }) {
const currentPage = usePageStore((s) => s.currentPage);
const isAdmin = isAdminUser(localStorage.getItem('user_id') || '');
switch (currentPage) {
case 'admin-models':
if (!isAdmin) return <ChatPage onSend={onSend} onSendVoiceStream={onSendVoiceStream} />;
return <ModelsAdminPage />;
case 'admin-dashboard':
if (!isAdmin) return <ChatPage onSend={onSend} onSendVoiceStream={onSendVoiceStream} />;
return <AdminDashboard />;
case 'profile':
return <ProfilePage />;
case 'chat':
default:
return <ChatPage onSend={onSend} onSendVoiceStream={onSendVoiceStream} />;
}
}
function ChatPage({ onSend, onSendVoiceStream }: { onSend: SendFn; onSendVoiceStream: SendVoiceStreamFn }) {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ChatContainer />
</div>
<div className="flex-shrink-0">
<ChatInput onSend={onSend} onSendVoiceStream={onSendVoiceStream} />
</div>
</div>
);
}