forked from admin/family_budget
Replace the synchronous PDF import with Server-Sent Events streaming between the backend (LLM) and the browser. The user can now close the import modal and continue working while the conversion runs — a fixed progress bar in the Layout shows real-time stage and percentage.
85 lines
2.0 KiB
TypeScript
85 lines
2.0 KiB
TypeScript
import type { ImportStatementResponse } from '@family-budget/shared';
|
|
import { api } from './client';
|
|
|
|
export async function importStatement(
|
|
file: File,
|
|
): Promise<ImportStatementResponse> {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
return api.postFormData<ImportStatementResponse>(
|
|
'/api/import/statement',
|
|
formData,
|
|
);
|
|
}
|
|
|
|
export interface SseProgressEvent {
|
|
stage: 'pdf' | 'llm' | 'import';
|
|
progress: number;
|
|
message: string;
|
|
}
|
|
|
|
export interface SseDoneEvent {
|
|
stage: 'done';
|
|
progress: 100;
|
|
result: ImportStatementResponse;
|
|
}
|
|
|
|
export interface SseErrorEvent {
|
|
stage: 'error';
|
|
message: string;
|
|
}
|
|
|
|
export type SseEvent = SseProgressEvent | SseDoneEvent | SseErrorEvent;
|
|
|
|
export async function importStatementStream(
|
|
file: File,
|
|
onEvent: (event: SseEvent) => void,
|
|
): Promise<void> {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const res = await fetch('/api/import/statement', {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let msg = 'Ошибка импорта';
|
|
try {
|
|
const body = await res.json();
|
|
if (body.message) msg = body.message;
|
|
} catch { /* use default */ }
|
|
onEvent({ stage: 'error', message: msg });
|
|
return;
|
|
}
|
|
|
|
const reader = res.body?.getReader();
|
|
if (!reader) {
|
|
onEvent({ stage: 'error', message: 'Streaming не поддерживается' });
|
|
return;
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() ?? '';
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('data: ')) continue;
|
|
try {
|
|
const parsed = JSON.parse(trimmed.slice(6)) as SseEvent;
|
|
onEvent(parsed);
|
|
} catch { /* skip malformed lines */ }
|
|
}
|
|
}
|
|
}
|