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

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