6ef9e082a6
- 前端: 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>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|