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 }),