385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|