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