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