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

385 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}