init: 昔涟项目骨架

This commit is contained in:
2026-05-15 20:10:35 +08:00
commit 6bde87f807
84 changed files with 3635 additions and 0 deletions
View File
View File
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,80 @@
import { useEffect, useCallback } from 'react';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useChatStore } from '@/store/chatStore';
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);
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>
);
}
@@ -0,0 +1,38 @@
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
interface MessageBubbleProps {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
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>
);
}
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>
</div>
);
}
View File
View File
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, useCallback } from 'react';
import ReconnectingWebSocket from 'reconnecting-websocket';
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;
}
interface VoiceSegment {
index: number;
text: string;
audio_url: string;
duration_ms: number;
}
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((token: string) => {
const ws = new ReconnectingWebSocket(
`ws://localhost:8080/ws/chat?token=${token}&session_id=${sessionId}`
);
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;
}
};
wsRef.current = ws;
}, [sessionId]);
// 发送消息
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);
return () => {
messageHandlersRef.current.delete(type);
};
}, []);
return { connect, sendMessage, onMessage };
}
View File
View File
View File
+52
View File
@@ -0,0 +1,52 @@
import { create } from 'zustand';
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 {
messages: Message[];
isTyping: boolean;
currentMode: 'text' | 'voice_msg' | 'voice_assistant';
// Actions
addMessage: (msg: Message) => void;
updateLastAssistantMessage: (content: string) => void;
setTyping: (typing: boolean) => void;
setMode: (mode: 'text' | 'voice_msg' | 'voice_assistant') => void;
clearMessages: () => void;
}
export const useChatStore = create<ChatState>((set) => ({
messages: [],
isTyping: false,
currentMode: 'text',
addMessage: (msg) =>
set((state) => ({
messages: [...state.messages, msg],
})),
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 };
}),
setTyping: (typing) => set({ isTyping: typing }),
setMode: (mode) => set({ currentMode: mode }),
clearMessages: () => set({ messages: [] }),
}));
View File
View File
View File
View File
View File
View File