Files
Novault-Frontend-web/src/pages/Chat/Chat.tsx

385 lines
17 KiB
TypeScript
Raw Normal View History

import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { FiArrowLeft, FiSend, FiMic, FiMicOff } from 'react-icons/fi';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import aiService from '../../services/aiService';
import type { AIChatResponse, ConfirmationCard } from '../../types';
import './Chat.css';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
confirmationCard?: ConfirmationCard;
timestamp: Date;
}
export default function Chat() {
const navigate = useNavigate();
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// 滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 发送消息
const handleSend = async () => {
if (!inputText.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputText.trim(),
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setInputText('');
setIsLoading(true);
try {
const response: AIChatResponse = await aiService.sendChatMessage(
userMessage.content,
sessionId || undefined
);
setSessionId(response.sessionId);
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response.message,
confirmationCard: response.confirmationCard,
timestamp: new Date(),
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Failed to send message:', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,我遇到了一些问题,请稍后再试。',
timestamp: new Date(),
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
// 确认记账
const handleConfirm = async (card: ConfirmationCard) => {
setIsLoading(true);
try {
await aiService.confirmTransaction(card);
const confirmMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: `✅ 已记录!${card.type === 'income' ? '收入' : '支出'} ${card.amount.toFixed(2)}\n分类${card.category}\n账户${card.account}`,
timestamp: new Date(),
};
setMessages(prev => {
// 移除最后一条消息的确认卡片
const updated = prev.map((msg, idx) => {
if (idx === prev.length - 1 && msg.confirmationCard) {
return { ...msg, confirmationCard: undefined };
}
return msg;
});
return [...updated, confirmMessage];
});
// 清除会话以开始新对话
aiService.clearSession();
setSessionId(null);
} catch (error) {
console.error('Failed to confirm transaction:', error);
} finally {
setIsLoading(false);
}
};
// 取消记账
const handleCancel = () => {
setMessages(prev => {
const updated = prev.map((msg, idx) => {
if (idx === prev.length - 1 && msg.confirmationCard) {
return { ...msg, confirmationCard: undefined };
}
return msg;
});
return updated;
});
const cancelMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: '好的,这笔账不记了。还有什么需要帮忙的吗?',
timestamp: new Date(),
};
setMessages(prev => [...prev, cancelMessage]);
aiService.clearSession();
setSessionId(null);
};
// 语音录制
const handleVoiceToggle = async () => {
if (isRecording) {
// 停止录音
mediaRecorderRef.current?.stop();
setIsRecording(false);
} else {
// 开始录音
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
stream.getTracks().forEach(track => track.stop());
// 处理语音
setIsLoading(true);
try {
const response = await aiService.processVoiceInput(audioBlob);
// 添加用户消息(使用转录文本)
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: '🎤 [语音消息]',
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setSessionId(response.sessionId);
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response.message,
confirmationCard: response.confirmationCard,
timestamp: new Date(),
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Voice processing failed:', error);
} finally {
setIsLoading(false);
}
};
mediaRecorder.start();
setIsRecording(true);
} catch (error) {
console.error('Failed to start recording:', error);
}
}
};
// 处理快捷建议
const handleSuggestion = (text: string) => {
setInputText(text);
inputRef.current?.focus();
};
// 处理回车发送
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="chat-page">
{/* Header */}
<header className="chat-header">
<button className="chat-header-back" onClick={() => navigate(-1)}>
<FiArrowLeft size={20} />
</button>
<div className="chat-header-avatar">🪙</div>
<div className="chat-header-info">
<div className="chat-header-name"></div>
<div className="chat-header-status"></div>
</div>
</header>
{/* Messages */}
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
<div className="chat-empty-icon">🪙</div>
<div className="chat-empty-title"></div>
<div className="chat-empty-desc">
</div>
<div className="chat-empty-suggestions">
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('想喝杯奶茶15块')}
>
15
</button>
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('中午点了外卖 35元')}
>
35
</button>
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('想买个耳机 299')}
>
299
</button>
</div>
</div>
) : (
<>
{messages.map((msg) => (
<div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar">
{msg.role === 'assistant' ? '🪙' : '👤'}
</div>
<div>
<div className="chat-message-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
{msg.confirmationCard && (
<div className="confirmation-card">
<div className="confirmation-card-header">
<span className="confirmation-card-icon">📝</span>
</div>
<div className="confirmation-card-fields">
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className={`confirmation-card-value amount ${msg.confirmationCard.type === 'income' ? 'income' : ''}`}>
{msg.confirmationCard.type === 'income' ? '+' : '-'}
¥{msg.confirmationCard.amount.toFixed(2)}
</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">
{msg.confirmationCard.type === 'income' ? '收入' : '支出'}
</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.category}</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.account}</div>
</div>
{msg.confirmationCard.note && (
<div className="confirmation-card-field full-width">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.note}</div>
</div>
)}
</div>
<div className="confirmation-card-actions">
<button
className="confirmation-card-btn cancel"
onClick={handleCancel}
disabled={isLoading}
>
</button>
<button
className="confirmation-card-btn confirm"
onClick={() => handleConfirm(msg.confirmationCard!)}
disabled={isLoading}
>
</button>
</div>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="chat-message assistant">
<div className="chat-message-avatar">🪙</div>
<div className="chat-loading">
<div className="chat-loading-dots">
<div className="chat-loading-dot"></div>
<div className="chat-loading-dot"></div>
<div className="chat-loading-dot"></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="chat-input-area">
<div className="chat-input-container">
<textarea
ref={inputRef}
className="chat-input"
placeholder="说说你想买什么..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={isLoading}
/>
<button
className={`chat-input-btn ${isRecording ? 'recording' : ''}`}
onClick={handleVoiceToggle}
disabled={isLoading}
>
{isRecording ? <FiMicOff size={20} /> : <FiMic size={20} />}
</button>
<button
className="chat-input-btn send"
onClick={handleSend}
disabled={!inputText.trim() || isLoading}
>
<FiSend size={18} />
</button>
</div>
</div>
</div>
);
}