feat: stream PDF import progress via SSE with global progress bar

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.
This commit is contained in:
vakabunga
2026-03-14 16:18:31 +03:00
parent 627706228b
commit 1d7fbea9ef
9 changed files with 680 additions and 70 deletions

View File

@@ -3,7 +3,7 @@ import multer from 'multer';
import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import';
import {
convertPdfToStatement,
convertPdfToStatementStreaming,
isPdfConversionError,
} from '../services/pdfToStatement';
@@ -28,6 +28,10 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
);
}
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
const router = Router();
router.post(
@@ -51,28 +55,73 @@ router.post(
return;
}
let body: unknown;
if (isPdfFile(file)) {
const converted = await convertPdfToStatement(file.buffer);
if (isPdfConversionError(converted)) {
res.status(converted.status).json({
error: converted.error,
message: converted.message,
});
return;
}
body = converted;
} else {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
try {
body = JSON.parse(file.buffer.toString('utf-8'));
} catch {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Некорректный JSON-файл',
const converted = await convertPdfToStatementStreaming(
file.buffer,
(stage, progress, message) => {
sseWrite(res, { stage, progress, message });
},
);
if (isPdfConversionError(converted)) {
sseWrite(res, {
stage: 'error',
message: converted.message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'import',
progress: 88,
message: 'Импорт в базу данных...',
});
const result = await importStatement(converted);
if (isValidationError(result)) {
sseWrite(res, {
stage: 'error',
message: (result as { message: string }).message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'done',
progress: 100,
result,
});
} catch (err) {
console.error('SSE import error:', err);
sseWrite(res, {
stage: 'error',
message: 'Внутренняя ошибка сервера',
});
return;
}
res.end();
return;
}
// JSON files — synchronous response as before
let body: unknown;
try {
body = JSON.parse(file.buffer.toString('utf-8'));
} catch {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Некорректный JSON-файл',
});
return;
}
const result = await importStatement(body);