feat: 新增聊天页面和AI服务,支持文本、语音交互、流式响应和会话管理。
This commit is contained in:
@@ -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