feat: 新增聊天页面和AI服务,支持文本、语音交互、流式响应和会话管理。
This commit is contained in:
@@ -5,8 +5,7 @@ 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 { Typewriter } from '../../components/common/Typewriter/Typewriter';
|
||||
import type { ConfirmationCard } from '../../types';
|
||||
import './Chat.css';
|
||||
|
||||
interface Message {
|
||||
@@ -54,69 +53,42 @@ export default function Chat() {
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
const assistantMsgId = (Date.now() + 1).toString();
|
||||
|
||||
// Initial empty assistant message
|
||||
setMessages(prev => [...prev, {
|
||||
id: assistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
|
||||
try {
|
||||
const response: AIChatResponse = await aiService.sendChatMessage(
|
||||
const response = await aiService.streamChatMessage(
|
||||
userMessage.content,
|
||||
sessionId || undefined
|
||||
sessionId || undefined,
|
||||
(chunk) => {
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === assistantMsgId
|
||||
? { ...msg, content: msg.content + chunk }
|
||||
: msg
|
||||
));
|
||||
}
|
||||
);
|
||||
|
||||
setSessionId(response.sessionId);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
confirmationCard: response.confirmationCard,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Update the message with confirmation card if present
|
||||
if (response.confirmationCard) {
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === assistantMsgId
|
||||
? { ...msg, confirmationCard: response.confirmationCard }
|
||||
: msg
|
||||
));
|
||||
}
|
||||
|
||||
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: any) {
|
||||
console.error('Failed to confirm transaction:', error);
|
||||
// 显示后端返回的具体错误信息(如余额不足)
|
||||
const errorMsg = error.response?.data?.error || '抱歉,记账失败,请稍后再试。';
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
@@ -281,19 +253,9 @@ export default function Chat() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="chat-message-content">
|
||||
{msg.role === 'assistant' && index === messages.length - 1 ? (
|
||||
<Typewriter text={msg.content} speed={20}>
|
||||
{(text) => (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</Typewriter>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{msg.confirmationCard && (
|
||||
|
||||
@@ -95,6 +95,121 @@ export async function sendChatMessage(
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message with streaming response
|
||||
* Validates: Requirements 12.1, 12.5 (Streaming Upgrade)
|
||||
*/
|
||||
export async function streamChatMessage(
|
||||
message: string,
|
||||
existingSessionId: string | undefined,
|
||||
onChunk: (text: string) => void
|
||||
): Promise<AIChatResponse> {
|
||||
const sessionId = existingSessionId || getSessionId();
|
||||
const token = localStorage.getItem('token'); // Assuming auth token is stored here
|
||||
|
||||
// Use fetch directly for streaming support
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/ai/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to start stream: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return new Promise<AIChatResponse>(async (resolve, reject) => {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// State for SSE parsing
|
||||
let currentEvent: string | null = null;
|
||||
let finalResult: AIChatResponse | null = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') {
|
||||
// End of event block
|
||||
currentEvent = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('event:')) {
|
||||
currentEvent = trimmed.substring(6).trim();
|
||||
} else if (trimmed.startsWith('data:')) {
|
||||
const dataStr = trimmed.substring(5).trim();
|
||||
if (!dataStr) continue;
|
||||
|
||||
if (currentEvent === 'message') {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (data.text) {
|
||||
onChunk(data.text);
|
||||
}
|
||||
} catch (e) {
|
||||
// If not JSON, treat as raw text (fallback)
|
||||
onChunk(dataStr);
|
||||
}
|
||||
} else if (currentEvent === 'result') {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
// Convert snake to camel
|
||||
finalResult = toCamelCase(data) as unknown as AIChatResponse;
|
||||
if (finalResult.sessionId) {
|
||||
currentSessionId = finalResult.sessionId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse result:', e);
|
||||
}
|
||||
} else if (currentEvent === 'error') {
|
||||
try {
|
||||
const errData = JSON.parse(dataStr);
|
||||
reject(new Error(errData.error || 'Unknown stream error'));
|
||||
return;
|
||||
} catch (e) {
|
||||
reject(new Error(dataStr));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalResult) {
|
||||
resolve(finalResult);
|
||||
} else {
|
||||
// If we have no result but the stream finished safely,
|
||||
// we might want to construct a minimal success response if we got messages.
|
||||
// For now, let's treat it as a success if we got at least one message, or error otherwise.
|
||||
// Actually, the result event is CRITICAL for session ID and confirmation cards.
|
||||
reject(new Error("Stream ended without final result"));
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe audio to text
|
||||
* Validates: Requirements 12.2, 12.6
|
||||
@@ -415,4 +530,5 @@ export default {
|
||||
formatConfirmationCard,
|
||||
getFinancialAdvice,
|
||||
getDailyInsight,
|
||||
streamChatMessage,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user