init: 昔涟项目骨架
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
Reference in New Issue
Block a user