dev 分支暂存

This commit is contained in:
2026-05-16 08:26:56 +08:00
parent 58c8caa570
commit eb4129176c
71 changed files with 8474 additions and 214 deletions
+109
View File
@@ -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>
);
}
+10
View File
@@ -0,0 +1,10 @@
// 认证API(重新导出client中的认证函数)
export {
login,
register,
refreshToken,
setToken,
getToken,
clearToken,
isAuthenticated,
} from './client';
+154
View File
@@ -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 };
+6
View File
@@ -0,0 +1,6 @@
// 记忆API
export {
searchMemory,
listMemories,
addMemory,
} from './client';
+7
View File
@@ -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>
);
}
+75
View File
@@ -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,
};
}
+50
View File
@@ -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,
};
}
+71
View File
@@ -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,
};
}
+77 -63
View File
@@ -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;
}
}
+82
View File
@@ -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;
}
}
+10
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>
);
+10 -34
View File
@@ -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 }),
}));
+54
View File
@@ -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] || '🌸';
}
+31
View File
@@ -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 }),
}));
+63
View File
@@ -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;
}
+71
View File
@@ -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',
};
+43
View File
@@ -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;
}