feat(booking-import): retry a failed import with AI parsing

This commit is contained in:
Xre0uS
2026-07-25 16:07:50 +08:00
committed by Maurice
parent eae2ade740
commit 6854436c24
3 changed files with 44 additions and 12 deletions
@@ -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')}
</button>
) : (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.previewEmpty')}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
<div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.previewEmpty')}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button
onClick={() => retryWithAi(task)}
className="bg-surface-tertiary text-content"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('reservations.import.tryAi')}
</button>
)}
</div>
)
@@ -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'))
+5 -3
View File
@@ -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<BackgroundTasksState>()(
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 }),