Files
TREK/client/src/store/backgroundTasksStore.ts
T
RemEx b1c05bca83 feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
2026-09-26 14:38:01 +02:00

109 lines
5.7 KiB
TypeScript

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { BookingImportPreviewItem, BookingImportMode, ReceiptRead } from '@trek/shared'
/**
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
* The upload modal closes the moment a parse starts and adds a task here; the
* server pushes import:progress / import:done / import:error over the user's
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
* renders the list. The trip page turns a finished task into the review flow.
*
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
* endpoint, so a reload mid-parse must NOT drop the widget — we persist the running
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
* transient review flags (so a reload never auto-reopens the review flow).
*/
export type BackgroundTaskKind = 'transports' | 'bookings' | 'costs'
export interface BackgroundImportTask {
id: string // server job id
tripId: string
label: string // file name(s) being parsed
status: 'running' | 'done' | 'error'
done: number
total: number
items?: BookingImportPreviewItem[]
/** A receipt scanned from Costs (`kind: 'costs'`): what the model read, or null when it read nothing. */
receipt?: ReceiptRead | null
warnings?: string[]
error?: string
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
consumed?: boolean // review has been handed to the trip page
/** 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
/**
* Which tab the import was started from. It decides which form an item opens
* when its type is guessed (#2076), and unlike the other transient flags it IS
* persisted: the parse deliberately outlives navigation and reload, and the
* review is triggered by the global widget on any page, so a component-local
* value is gone by the time it is needed. `costs` is a receipt scan, reviewed
* in the expense editor instead of the booking forms.
*/
kind?: BackgroundTaskKind
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[]; mode?: BookingImportMode; kind?: BackgroundTaskKind }) => void
setProgress: (id: string, tripId: string, done: number, total: number) => void
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[], receipt?: ReceiptRead | null) => void
setError: (id: string, tripId: string, error: string) => void
requestReview: (id: string) => void
markConsumed: (id: string) => void
dismiss: (id: string) => void
}
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
persist(
(set) => {
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
set((state) => {
const idx = state.tasks.findIndex((t) => t.id === id)
if (idx === -1) {
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
return { tasks: [...state.tasks, { ...base, ...patch }] }
}
const tasks = state.tasks.slice()
tasks[idx] = { ...tasks[idx], ...patch }
return { tasks }
})
return {
tasks: [],
addTask: ({ id, tripId, label, total, files, mode, kind }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files, mode, kind }),
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
setDone: (id, tripId, items, warnings, receipt) => upsert(id, tripId, { status: 'done', items, warnings, receipt, done: items?.length ?? 0 }),
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
}
},
{
name: 'trek.bg-import-tasks',
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
// status. The widget re-fetches each job's real status (and parsed items) on mount,
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
// that also guarantees a reload never re-opens the review flow on its own.
partialize: (state) => ({
tasks: state.tasks
.filter((t) => !t.consumed && t.status !== 'error')
// `kind` rides along: it is not a review flag but the context the review
// needs, and without it a reload silently falls back to 'bookings'.
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total, kind: t.kind })),
}),
},
),
)
/** Whether a finished task has something to review: a read receipt, or at least one booking. */
export function taskFoundSomething(task: BackgroundImportTask): boolean {
return task.kind === 'costs' ? Boolean(task.receipt) : (task.items?.length ?? 0) > 0
}