diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 977833e4e..5600fc7e7 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -1,10 +1,10 @@ import ReactDOM from 'react-dom' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { addListener, removeListener } from '../../api/websocket' -import { reservationsApi } from '../../api/client' +import { reservationsApi, healthApi } from '../../api/client' import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' /** @@ -23,6 +23,25 @@ export default function BackgroundTasksWidget() { const setError = useBackgroundTasksStore((s) => s.setError) const requestReview = useBackgroundTasksStore((s) => s.requestReview) const dismiss = useBackgroundTasksStore((s) => s.dismiss) + const addTask = useBackgroundTasksStore((s) => s.addTask) + + const [aiParsing, setAiParsing] = useState(false) + useEffect(() => { + healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) + }, []) + + // Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped. + const retryWithAi = (task: BackgroundImportTask) => { + if (!task.sourceFiles || task.sourceFiles.length === 0) return + reservationsApi + .importBookingAsync(task.tripId, task.sourceFiles, 'force-ai') + .then(({ jobId }) => { + dismiss(task.id) + addTask({ id: jobId, tripId: task.tripId, label: task.label, total: task.sourceFiles!.length, files: task.sourceFiles, mode: 'force-ai' }) + }) + // 409 when the addon is enabled but this user has no model configured. + .catch((err) => setError(task.id, task.tripId, err?.response?.data?.error ?? t('reservations.import.error'))) + } // On (re)load, reconcile tasks restored from localStorage with the server: a parse // that was still running when the page reloaded must keep its widget, so re-fetch each @@ -136,12 +155,23 @@ export default function BackgroundTasksWidget() { {t('common.import')} ) : ( -
- {t('reservations.import.previewEmpty')} - {(task.warnings?.length ?? 0) > 0 && ( -
- {task.warnings!.join('\n')} -
+
+
+ {t('reservations.import.previewEmpty')} + {(task.warnings?.length ?? 0) > 0 && ( +
+ {task.warnings!.join('\n')} +
+ )} +
+ {aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && ( + )}
) diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index 62c7932e2..b3e567b5d 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -100,7 +100,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI // Keep the uploaded files so the review can attach each source document to its booking — // in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse. await saveImportFiles(jobId, files) - addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files }) + addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files, mode }) handleClose() } catch (err: any) { setError(err?.response?.data?.error ?? t('reservations.import.error')) diff --git a/client/src/store/backgroundTasksStore.ts b/client/src/store/backgroundTasksStore.ts index 8b1289f05..5ffd2901f 100644 --- a/client/src/store/backgroundTasksStore.ts +++ b/client/src/store/backgroundTasksStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type { BookingImportPreviewItem } from '@trek/shared' +import type { BookingImportPreviewItem, BookingImportMode } from '@trek/shared' /** * Tracks booking-import parses that run in the BACKGROUND (the async endpoint). @@ -30,11 +30,13 @@ export interface BackgroundImportTask { /** The uploaded files this parse ran on — kept in memory so the review can attach the * source document to each created booking. Not persisted (a File can't survive a reload). */ sourceFiles?: File[] + /** Mode this run used — the widget only offers an AI retry if it wasn't already one. Not persisted. */ + mode?: BookingImportMode } interface BackgroundTasksState { tasks: BackgroundImportTask[] - addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[] }) => void + addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[]; mode?: BookingImportMode }) => void setProgress: (id: string, tripId: string, done: number, total: number) => void setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void setError: (id: string, tripId: string, error: string) => void @@ -61,7 +63,7 @@ export const useBackgroundTasksStore = create()( return { tasks: [], - addTask: ({ id, tripId, label, total, files }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files }), + addTask: ({ id, tripId, label, total, files, mode }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files, mode }), setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }), setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }), setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),