feat: 新增聊天页面和AI服务,支持文本、语音交互、流式响应和会话管理。

This commit is contained in:
2026-01-30 13:05:50 +08:00
parent 596bbe19e8
commit 9adf293489
2 changed files with 147 additions and 69 deletions

View File

@@ -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 && (

View File

@@ -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,
};