dev 分支暂存
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cyrene-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||
import { ChatInput } from '@/components/chat/ChatInput';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
|
||||
export default function App() {
|
||||
const { isLoggedIn, login, register, loading: authLoading } = useAuth();
|
||||
const { send } = useChat();
|
||||
|
||||
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAuth = async () => {
|
||||
setError('');
|
||||
const fn = authMode === 'login' ? login : register;
|
||||
const result = await fn(username, password);
|
||||
if (!result.success) {
|
||||
setError(result.error || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 登录页面
|
||||
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">
|
||||
一位永远在你身边的AI伙伴 ♪
|
||||
</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-lg bg-gray-100 dark:bg-gray-800 p-1">
|
||||
<button
|
||||
onClick={() => setAuthMode('login')}
|
||||
className={`flex-1 py-2 text-sm rounded-md transition-colors ${
|
||||
authMode === 'login'
|
||||
? 'bg-white dark:bg-gray-700 text-pink-500 font-medium shadow-sm'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAuthMode('register')}
|
||||
className={`flex-1 py-2 text-sm rounded-md transition-colors ${
|
||||
authMode === 'register'
|
||||
? 'bg-white dark:bg-gray-700 text-pink-500 font-medium shadow-sm'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
|
||||
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' && handleAuth()}
|
||||
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>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAuth}
|
||||
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 ? '请稍候...' : authMode === 'login' ? '进入昔涟的世界 ♪' : '注册并开始'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 聊天界面
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex flex-col h-full">
|
||||
<ChatContainer />
|
||||
<ChatInput onSend={send} />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// 认证API(重新导出client中的认证函数)
|
||||
export {
|
||||
login,
|
||||
register,
|
||||
refreshToken,
|
||||
setToken,
|
||||
getToken,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
} from './client';
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// HTTP 客户端封装
|
||||
|
||||
import type { AuthResponse } from '@/types/session';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
/** 请求选项 */
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
/** API 响应格式 */
|
||||
interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送API请求
|
||||
*/
|
||||
async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
|
||||
const { method = 'GET', body, auth = true } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as T, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 存储认证令牌 */
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
/** 获取认证令牌 */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/** 清除认证令牌 */
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
|
||||
/** 检查是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// ========== 认证API ==========
|
||||
|
||||
export async function login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function refreshToken(): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/refresh', { method: 'POST' });
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ========== 会话API ==========
|
||||
|
||||
export async function createSession(title?: string) {
|
||||
return request('/sessions', { method: 'POST', body: { title } });
|
||||
}
|
||||
|
||||
export async function listSessions() {
|
||||
return request('/sessions');
|
||||
}
|
||||
|
||||
export async function getSession(id: string) {
|
||||
return request(`/sessions/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string) {
|
||||
return request(`/sessions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ========== 记忆API ==========
|
||||
|
||||
export async function searchMemory(query: string) {
|
||||
return request(`/memory/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export async function listMemories() {
|
||||
return request('/memory');
|
||||
}
|
||||
|
||||
export async function addMemory(content: string, category?: string, priority?: number) {
|
||||
return request('/memory', { method: 'POST', body: { content, category, priority } });
|
||||
}
|
||||
|
||||
export { request, type ApiResponse };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// 记忆API
|
||||
export {
|
||||
searchMemory,
|
||||
listMemories,
|
||||
addMemory,
|
||||
} from './client';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 会话API
|
||||
export {
|
||||
createSession,
|
||||
listSessions,
|
||||
getSession,
|
||||
deleteSession,
|
||||
} from './client';
|
||||
|
||||
@@ -1,80 +1,8 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
|
||||
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||
const { messages, isTyping, addMessage, setTyping } = useChatStore();
|
||||
const { connect, sendMessage, onMessage } = useWebSocket(sessionId);
|
||||
export function ChatContainer() {
|
||||
const { messages, isTyping } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
// 连接WebSocket (使用JWT token)
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) connect(token);
|
||||
|
||||
// 监听回复
|
||||
onMessage('onResponse', (msg) => {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
role: 'assistant',
|
||||
content: msg.text || '',
|
||||
audioUrl: msg.full_audio_url,
|
||||
segments: msg.segments?.map(s => ({
|
||||
index: s.index,
|
||||
text: s.text,
|
||||
audioUrl: s.audio_url,
|
||||
})),
|
||||
timestamp: msg.timestamp,
|
||||
isStreaming: false,
|
||||
});
|
||||
setTyping(false);
|
||||
});
|
||||
|
||||
onMessage('onError', (msg) => {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
role: 'assistant',
|
||||
content: '啊……不好意思,人家刚才走神了。能再说一遍吗?♪',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setTyping(false);
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const handleSend = useCallback((content: string, mode: string) => {
|
||||
// 添加用户消息
|
||||
addMessage({
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setTyping(true);
|
||||
sendMessage(content, mode);
|
||||
}, [sendMessage, addMessage, setTyping]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]">
|
||||
{/* 顶部栏 */}
|
||||
<header className="flex items-center justify-between px-6 py-3 border-b border-pink-100 dark:border-pink-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-pink-600">昔涟</h1>
|
||||
<MoodIndicator />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">🌸 永远在你身边</span>
|
||||
</header>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<MessageList messages={messages} isTyping={isTyping} />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<ChatInput onSend={handleSend} disabled={isTyping} />
|
||||
</div>
|
||||
);
|
||||
return <MessageList messages={messages} isTyping={isTyping} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { ChatMode } from '@/types/chat';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, mode: ChatMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [mode, setMode] = useState<ChatMode>('text');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
|
||||
onSend(trimmed, mode);
|
||||
setContent('');
|
||||
|
||||
// 重置文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [content, mode, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-t border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'text'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="文字模式"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('voice_msg')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'voice_msg'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="语音消息"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="和昔涟说点什么吧..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !content.trim()}
|
||||
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode !== 'text' && (
|
||||
<p className="text-xs text-gray-400 text-center mt-2">
|
||||
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,35 +4,51 @@ interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp }: MessageBubbleProps) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end px-4 py-2">
|
||||
<div className="max-w-[70%] bg-pink-400 text-white rounded-2xl rounded-br-md px-4 py-2 shadow-sm">
|
||||
<p className="text-sm leading-relaxed">{content}</p>
|
||||
<span className="text-xs text-pink-100 mt-1 block">
|
||||
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex px-4 py-2 gap-3">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<div className="max-w-[70%]">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-2 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-1 block ml-1">
|
||||
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{/* 头像 */}
|
||||
{!isUser && (
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
)}
|
||||
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
className={`
|
||||
max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm
|
||||
${
|
||||
isUser
|
||||
? 'bg-pink-400 text-white rounded-br-md'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900'
|
||||
}
|
||||
${isStreaming ? 'animate-pulse' : ''}
|
||||
`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{content}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 用户头像占位 */}
|
||||
{isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
|
||||
<span className="text-white text-sm">开拓者</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { Message } from '@/types/chat';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
|
||||
<div className="text-6xl mb-4">🌸</div>
|
||||
<p className="text-lg font-medium text-pink-300 mb-2">
|
||||
昔涟在这里等你哦 ♪
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
无论是开心的事还是烦恼,都可以和人家说~
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
/>
|
||||
))}
|
||||
{isTyping && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex px-4 py-2 gap-3">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||
<div className="flex gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-300 animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-400 animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-pink-500 animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]">
|
||||
{/* 侧边栏 */}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-20 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}
|
||||
>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-1 text-gray-400 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-pink-600 dark:text-pink-400">
|
||||
昔涟
|
||||
</h1>
|
||||
<MoodIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 hidden sm:block">🌸 永远在你身边</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 transition-colors px-2 py-1"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useEffect } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onClose }: SidebarProps) {
|
||||
const { sessions, currentSessionId, loadSessions, createSession, deleteSession, setCurrentSession } = useSession();
|
||||
const storeSessions = useSessionStore((s) => s.sessions);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const displaySessions = sessions.length > 0 ? sessions : storeSessions;
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await createSession();
|
||||
if (session && onClose) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="h-full bg-white/90 dark:bg-gray-900/90 border-r border-pink-100 dark:border-pink-900 flex flex-col">
|
||||
{/* 侧边栏头部 */}
|
||||
<div className="p-4 border-b border-pink-100 dark:border-pink-900">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-pink-400 hover:bg-pink-500 text-white rounded-xl text-sm font-medium transition-colors"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>新对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{displaySessions.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 text-center py-8">
|
||||
还没有对话哦,开始和昔涟聊天吧 ♪
|
||||
</p>
|
||||
) : (
|
||||
displaySessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
setCurrentSession(session.id);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className={`
|
||||
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
|
||||
${
|
||||
currentSessionId === session.id || session.id === useSessionStore.getState().currentSessionId
|
||||
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{session.title || '新的对话'}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{session.message_count || 0} 条消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="p-4 border-t border-pink-100 dark:border-pink-900">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<CyreneAvatar size="sm" />
|
||||
<div>
|
||||
<p className="font-medium text-pink-400">昔涟 AI</p>
|
||||
<p>v0.1.0 MVP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { usePersonaStore } from '@/store/personaStore';
|
||||
import type { CyreneForm } from '@/types/persona';
|
||||
|
||||
interface CyreneAvatarProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FORM_AVATAR: Record<CyreneForm, string> = {
|
||||
mimi: '🌸',
|
||||
default: '🌺',
|
||||
de_moi_ge: '🌌',
|
||||
};
|
||||
|
||||
const SIZE_CLASS = {
|
||||
sm: 'w-8 h-8 text-lg',
|
||||
md: 'w-12 h-12 text-2xl',
|
||||
lg: 'w-20 h-20 text-4xl',
|
||||
};
|
||||
|
||||
export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) {
|
||||
const { currentForm } = usePersonaStore();
|
||||
const emoji = FORM_AVATAR[currentForm] || '🌸';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md ${className}`}
|
||||
title={`昔涟 · ${currentForm === 'mimi' ? '迷迷' : currentForm === 'de_moi_ge' ? '德谬歌' : '小昔涟'}`}
|
||||
>
|
||||
<span role="img" aria-label="昔涟">
|
||||
{emoji}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePersonaStore, getMoodEmoji } from '@/store/personaStore';
|
||||
import type { Mood } from '@/types/persona';
|
||||
|
||||
const MOOD_LABEL: Record<Mood, string> = {
|
||||
happy: '心情愉快',
|
||||
thoughtful: '正在思考',
|
||||
worried: '有点担心你',
|
||||
playful: '想逗你玩',
|
||||
nostalgic: '有些怀旧',
|
||||
};
|
||||
|
||||
export function MoodIndicator() {
|
||||
const { mood } = usePersonaStore();
|
||||
const emoji = getMoodEmoji(mood);
|
||||
const label = MOOD_LABEL[mood] || mood;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<span>{emoji}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
|
||||
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
userId: string | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
isLoggedIn: isAuthenticated(),
|
||||
userId: localStorage.getItem('user_id'),
|
||||
token: getToken(),
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const resp = await apiLogin(username, password);
|
||||
if (resp.error) {
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
setState({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return { success: false, error: err instanceof Error ? err.message : '登录失败' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const resp = await apiRegister(username, password);
|
||||
if (resp.error) {
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
setState({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return { success: false, error: err instanceof Error ? err.message : '注册失败' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setState({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import type { ChatMode } from '@/types/chat';
|
||||
|
||||
export function useChat() {
|
||||
const {
|
||||
messages,
|
||||
isTyping,
|
||||
addMessage,
|
||||
setTyping,
|
||||
clearMessages,
|
||||
} = useChatStore();
|
||||
|
||||
const { sendMessage, isConnected } = useWebSocket();
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, mode: ChatMode = 'text') => {
|
||||
const userMsgId = `user_${Date.now()}`;
|
||||
|
||||
// 添加用户消息
|
||||
addMessage({
|
||||
id: userMsgId,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 设置输入中状态
|
||||
setTyping(true);
|
||||
|
||||
// 发送WebSocket消息
|
||||
sendMessage({
|
||||
type: 'message',
|
||||
content,
|
||||
mode,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
[addMessage, setTyping, sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isTyping,
|
||||
isConnected,
|
||||
send,
|
||||
clearMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { listSessions as apiListSessions, createSession as apiCreateSession, deleteSession as apiDeleteSession } from '@/api/client';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
const [state, setState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const resp = await apiListSessions();
|
||||
if (resp.data) {
|
||||
const data = resp.data as { sessions: Session[] };
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: data.sessions || [],
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createSession = useCallback(async (title?: string) => {
|
||||
const resp = await apiCreateSession(title);
|
||||
if (resp.data) {
|
||||
const session = resp.data as Session;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: [session, ...prev.sessions],
|
||||
currentSessionId: session.id,
|
||||
}));
|
||||
return session;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
await apiDeleteSession(id);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: prev.sessions.filter(s => s.id !== id),
|
||||
currentSessionId: prev.currentSessionId === id ? null : prev.currentSessionId,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setCurrentSession = useCallback((id: string) => {
|
||||
setState(prev => ({ ...prev, currentSessionId: id }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
loadSessions,
|
||||
createSession,
|
||||
deleteSession,
|
||||
setCurrentSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,79 +1,93 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { getToken } from '@/api/client';
|
||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
|
||||
interface WSMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update';
|
||||
message_id: string;
|
||||
text?: string;
|
||||
segments?: VoiceSegment[];
|
||||
full_audio_url?: string;
|
||||
response_mode?: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
const WS_BASE_URL =
|
||||
import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws/chat';
|
||||
|
||||
interface VoiceSegment {
|
||||
index: number;
|
||||
text: string;
|
||||
audio_url: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
export function useWebSocket() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const { addMessage, setTyping } = useChatStore();
|
||||
|
||||
export function useWebSocket(sessionId: string | null) {
|
||||
const wsRef = useRef<ReconnectingWebSocket | null>(null);
|
||||
const messageHandlersRef = useRef<Map<string, (msg: WSMessage) => void>>(new Map());
|
||||
const segmentQueueRef = useRef<VoiceSegment[]>([]);
|
||||
const connect = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const connect = useCallback((token: string) => {
|
||||
const ws = new ReconnectingWebSocket(
|
||||
`ws://localhost:8080/ws/chat?token=${token}&session_id=${sessionId}`
|
||||
);
|
||||
const url = `${WS_BASE_URL}?token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
console.log('[WS] 已连接');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
console.log('[WS] 已断开,3秒后重连...');
|
||||
// 自动重连
|
||||
setTimeout(() => connect(), 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[WS] 连接错误:', err);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WSMessage = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'response':
|
||||
// 完整回复到达
|
||||
messageHandlersRef.current.get('onResponse')?.(msg);
|
||||
break;
|
||||
|
||||
case 'segment':
|
||||
// 断句片段到达 (语音助手模式)
|
||||
if (msg.segments) {
|
||||
segmentQueueRef.current.push(...msg.segments);
|
||||
messageHandlersRef.current.get('onSegment')?.(msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('服务端错误:', msg.error);
|
||||
messageHandlersRef.current.get('onError')?.(msg);
|
||||
break;
|
||||
try {
|
||||
const msg: WSServerMessage = JSON.parse(event.data);
|
||||
handleServerMessage(msg);
|
||||
} catch (err) {
|
||||
console.error('[WS] 消息解析失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [sessionId]);
|
||||
}, [addMessage, setTyping]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((content: string, mode: string = 'text') => {
|
||||
wsRef.current?.send(JSON.stringify({
|
||||
type: 'message',
|
||||
session_id: sessionId,
|
||||
mode,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}, [sessionId]);
|
||||
|
||||
// 注册消息处理器
|
||||
const onMessage = useCallback((type: string, handler: (msg: WSMessage) => void) => {
|
||||
messageHandlersRef.current.set(type, handler);
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
messageHandlersRef.current.delete(type);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendMessage = useCallback((msg: WSClientMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { connect, sendMessage, onMessage };
|
||||
return { isConnected, sendMessage };
|
||||
}
|
||||
|
||||
function handleServerMessage(msg: WSServerMessage) {
|
||||
const { addMessage, setTyping } = useChatStore.getState();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'response':
|
||||
if (msg.text) {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
role: 'assistant',
|
||||
content: msg.text,
|
||||
timestamp: msg.timestamp,
|
||||
});
|
||||
}
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[WS] 服务端错误:', msg.error);
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// 忽略心跳响应
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===== 昔涟主题全局样式 ===== */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Noto Sans SC',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #FFFAF5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(244, 114, 182, 0.3);
|
||||
color: #9d174d;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(244, 114, 182, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(244, 114, 182, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 动画 ===== */
|
||||
@layer utilities {
|
||||
.animate-bounce {
|
||||
animation: bounce 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(244, 114, 182, 0.2);
|
||||
color: #fbcfe8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,52 +1,28 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Message } from '@/types/chat';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
audioUrl?: string;
|
||||
segments?: { index: number; text: string; audioUrl?: string }[];
|
||||
timestamp: number;
|
||||
isStreaming?: boolean; // 是否还在流式输出中
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
interface ChatStore {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
currentMode: 'text' | 'voice_msg' | 'voice_assistant';
|
||||
|
||||
// Actions
|
||||
addMessage: (msg: Message) => void;
|
||||
updateLastAssistantMessage: (content: string) => void;
|
||||
addMessage: (message: Message) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setTyping: (typing: boolean) => void;
|
||||
setMode: (mode: 'text' | 'voice_msg' | 'voice_assistant') => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
currentMode: 'text',
|
||||
|
||||
addMessage: (msg) =>
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [...state.messages, msg],
|
||||
messages: [...state.messages, message],
|
||||
})),
|
||||
|
||||
updateLastAssistantMessage: (content) =>
|
||||
set((state) => {
|
||||
const messages = [...state.messages];
|
||||
const lastIdx = messages.length - 1;
|
||||
if (lastIdx >= 0 && messages[lastIdx].role === 'assistant') {
|
||||
messages[lastIdx] = {
|
||||
...messages[lastIdx],
|
||||
content: messages[lastIdx].content + content,
|
||||
};
|
||||
}
|
||||
return { messages };
|
||||
}),
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
setTyping: (typing) => set({ isTyping: typing }),
|
||||
setMode: (mode) => set({ currentMode: mode }),
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
|
||||
clearMessages: () => set({ messages: [], isTyping: false }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CyreneForm, Mood } from '@/types/persona';
|
||||
import { DEFAULT_PERSONA_STATE, MOOD_EMOJI } from '@/types/persona';
|
||||
|
||||
interface PersonaStore {
|
||||
currentForm: CyreneForm;
|
||||
mood: Mood;
|
||||
affectionScore: number;
|
||||
affectionLevel: number;
|
||||
|
||||
setForm: (form: CyreneForm) => void;
|
||||
setMood: (mood: Mood) => void;
|
||||
addAffectionScore: (score: number) => void;
|
||||
resetPersona: () => void;
|
||||
}
|
||||
|
||||
export const usePersonaStore = create<PersonaStore>((set) => ({
|
||||
currentForm: DEFAULT_PERSONA_STATE.currentForm,
|
||||
mood: DEFAULT_PERSONA_STATE.mood,
|
||||
affectionScore: DEFAULT_PERSONA_STATE.affectionScore,
|
||||
affectionLevel: DEFAULT_PERSONA_STATE.affectionLevel,
|
||||
|
||||
setForm: (form) => set({ currentForm: form }),
|
||||
setMood: (mood) => set({ mood }),
|
||||
|
||||
addAffectionScore: (score) =>
|
||||
set((state) => {
|
||||
const newScore = state.affectionScore + score;
|
||||
// 根据分数计算等级
|
||||
let newLevel = 1;
|
||||
for (const level of DEFAULT_PERSONA_STATE.affectionLevels) {
|
||||
if (newScore >= level.threshold) {
|
||||
newLevel = level.level;
|
||||
}
|
||||
}
|
||||
return {
|
||||
affectionScore: newScore,
|
||||
affectionLevel: newLevel,
|
||||
};
|
||||
}),
|
||||
|
||||
resetPersona: () =>
|
||||
set({
|
||||
currentForm: DEFAULT_PERSONA_STATE.currentForm,
|
||||
mood: DEFAULT_PERSONA_STATE.mood,
|
||||
affectionScore: DEFAULT_PERSONA_STATE.affectionScore,
|
||||
affectionLevel: DEFAULT_PERSONA_STATE.affectionLevel,
|
||||
}),
|
||||
}));
|
||||
|
||||
/** 获取心情对应的 Emoji */
|
||||
export function getMoodEmoji(mood: Mood): string {
|
||||
return MOOD_EMOJI[mood] || '🌸';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
interface SessionStore {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
loading: boolean;
|
||||
|
||||
setSessions: (sessions: Session[]) => void;
|
||||
addSession: (session: Session) => void;
|
||||
removeSession: (id: string) => void;
|
||||
setCurrentSessionId: (id: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionStore>((set) => ({
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
loading: false,
|
||||
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
addSession: (session) =>
|
||||
set((state) => ({ sessions: [session, ...state.sessions] })),
|
||||
removeSession: (id) =>
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== id),
|
||||
currentSessionId: state.currentSessionId === id ? null : state.currentSessionId,
|
||||
})),
|
||||
setCurrentSessionId: (id) => set({ currentSessionId: id }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 聊天相关类型定义
|
||||
|
||||
/** 消息角色 */
|
||||
export type MessageRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
/** 对话模式 */
|
||||
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
|
||||
|
||||
/** 语音片段 */
|
||||
export interface VoiceSegment {
|
||||
index: number;
|
||||
text: string;
|
||||
audioUrl?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/** 单条消息 */
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
audioUrl?: string;
|
||||
segments?: VoiceSegment[];
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/** WebSocket 客户端消息 */
|
||||
export interface WSClientMessage {
|
||||
type: 'message' | 'voice_input' | 'ping';
|
||||
session_id?: string;
|
||||
mode?: ChatMode;
|
||||
content?: string;
|
||||
audio_data?: string; // base64
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong';
|
||||
message_id: string;
|
||||
text?: string;
|
||||
segments?: VoiceSegment[];
|
||||
full_audio_url?: string;
|
||||
response_mode?: ChatMode;
|
||||
tool_calls?: ToolCall[];
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 工具调用 */
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** 聊天状态 */
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
currentMode: ChatMode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 昔涟人格相关类型定义
|
||||
|
||||
/** 昔涟形态 */
|
||||
export type CyreneForm = 'mimi' | 'default' | 'de_moi_ge';
|
||||
|
||||
/** 心情状态 */
|
||||
export type Mood = 'happy' | 'thoughtful' | 'worried' | 'playful' | 'nostalgic';
|
||||
|
||||
/** 好感度等级 */
|
||||
export interface AffectionLevel {
|
||||
level: number; // 1-5
|
||||
name: string; // 初识/熟悉/亲近/信赖/羁绊
|
||||
threshold: number; // 所需好感度分数
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 形态配置 */
|
||||
export interface FormConfig {
|
||||
id: CyreneForm;
|
||||
name: string;
|
||||
description: string;
|
||||
traits: string[];
|
||||
}
|
||||
|
||||
/** 人格状态 */
|
||||
export interface PersonaState {
|
||||
currentForm: CyreneForm;
|
||||
mood: Mood;
|
||||
affectionScore: number;
|
||||
affectionLevel: number;
|
||||
forms: FormConfig[];
|
||||
affectionLevels: AffectionLevel[];
|
||||
}
|
||||
|
||||
/** 初始化人格状态 */
|
||||
export const DEFAULT_PERSONA_STATE: PersonaState = {
|
||||
currentForm: 'default',
|
||||
mood: 'happy',
|
||||
affectionScore: 0,
|
||||
affectionLevel: 1,
|
||||
forms: [
|
||||
{ id: 'mimi', name: '迷迷', description: '精简模式', traits: ['简洁', '高效', '俏皮'] },
|
||||
{ id: 'default', name: '小昔涟', description: '日常模式', traits: ['温柔', '关心', '活泼'] },
|
||||
{ id: 'de_moi_ge', name: '德谬歌', description: '完整模式', traits: ['深沉', '智慧', '神秘'] },
|
||||
],
|
||||
affectionLevels: [
|
||||
{ level: 1, name: '初识', threshold: 0, description: '温柔但略带距离感' },
|
||||
{ level: 2, name: '熟悉', threshold: 50, description: '更多俏皮互动' },
|
||||
{ level: 3, name: '亲近', threshold: 150, description: '主动分享小故事' },
|
||||
{ level: 4, name: '信赖', threshold: 350, description: '展现更多真实情感' },
|
||||
{ level: 5, name: '羁绊', threshold: 700, description: '最深层的连接' },
|
||||
],
|
||||
};
|
||||
|
||||
/** 心情表情映射 */
|
||||
export const MOOD_EMOJI: Record<Mood, string> = {
|
||||
happy: '😊',
|
||||
thoughtful: '🤔',
|
||||
worried: '😟',
|
||||
playful: '😋',
|
||||
nostalgic: '🌌',
|
||||
};
|
||||
|
||||
/** 心情颜色映射 */
|
||||
export const MOOD_COLOR: Record<Mood, string> = {
|
||||
happy: '#FFB7C5',
|
||||
thoughtful: '#B7C5FF',
|
||||
worried: '#FFD700',
|
||||
playful: '#FF69B4',
|
||||
nostalgic: '#BBA0E3',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 会话相关类型定义
|
||||
|
||||
/** 会话 */
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
persona: string;
|
||||
mode: string;
|
||||
message_count: number;
|
||||
is_active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 创建会话参数 */
|
||||
export interface CreateSessionParams {
|
||||
title?: string;
|
||||
persona?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
/** 会话列表响应 */
|
||||
export interface SessionListResponse {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
/** 认证相关 */
|
||||
export interface AuthResponse {
|
||||
user_id: string;
|
||||
token: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user