diff --git a/src/api/viewService.ts b/src/api/viewService.ts index cb56ad2..19665a4 100644 --- a/src/api/viewService.ts +++ b/src/api/viewService.ts @@ -13,12 +13,9 @@ function headers(): Record { return h; } -async function request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`${baseUrl()}${path}`, { - method, - headers: headers(), - body: body ? JSON.stringify(body) : undefined, - }); +// Shared response handling — 401 bounces to login, error bodies are unwrapped +// from { error }. Used by both the JSON and multipart request paths. +async function parseResponse(res: Response): Promise { if (res.status === 401) { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem("p2p_user"); @@ -34,6 +31,37 @@ async function request(method: string, path: string, body?: unknown): Promise return res.json() as Promise; } +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${baseUrl()}${path}`, { + method, + headers: headers(), + body: body ? JSON.stringify(body) : undefined, + }); + return parseResponse(res); +} + +// Multipart POST for the field-scoped core-service endpoints (/upload, +// /ocr-extract). Content-Type is deliberately omitted so the browser sets the +// multipart boundary itself. +async function postFile(path: string, file: File, ctx: FieldFileContext): Promise { + const form = new FormData(); + form.append("file", file); + form.append("workflow_uuid", ctx.workflowUuid); + form.append("activity_id", ctx.activityId); + form.append("field_id", ctx.fieldId); + if (ctx.columnId) form.append("column_id", ctx.columnId); + if (ctx.versionUuid) form.append("version_uuid", ctx.versionUuid); + if (ctx.instanceId) form.append("instance_id", String(ctx.instanceId)); + + const token = localStorage.getItem(TOKEN_KEY); + const res = await fetch(`${baseUrl()}${path}`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + return parseResponse(res); +} + // --------------------------------------------------------------------------- // Header // --------------------------------------------------------------------------- @@ -248,6 +276,33 @@ export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise< // Form Screens // --------------------------------------------------------------------------- +// One key the vision model must return, as configured in studio's OCR modal. +export interface OcrExtractionField { + key: string; + label: string; + data_type?: string; + description?: string; +} + +// extraction_key → the form field that receives the extracted value. +export interface OcrFieldMapping { + extraction_key: string; + target_field: string; +} + +// Studio's ocr_config block. `prompt` / `extraction_fields` are resolved +// server-side by /ocr-extract and are present here only for picker UX; the +// client genuinely needs `field_mappings` to know where to write the results. +export interface OcrConfig { + prompt?: string; + template?: string; + allowed_types?: string[] | string; + max_size_mb?: number | string; + placeholder?: string; + extraction_fields?: OcrExtractionField[]; + field_mappings?: OcrFieldMapping[]; +} + export interface FormScreenField { id: string; uid: string; @@ -259,6 +314,15 @@ export interface FormScreenField { properties?: { options?: Array<{ label: string; value: string }>; country_code?: string; + placeholder?: string; + multiple?: boolean; + ocr_config?: OcrConfig; + // Some studio configs put the OCR keys flat on properties instead of + // nested under ocr_config — mirrored from FFOcrField's resolveOcrConfig. + allowed_types?: string[] | string; + max_size_mb?: number | string; + extraction_fields?: OcrExtractionField[]; + field_mappings?: OcrFieldMapping[]; }; } @@ -270,6 +334,10 @@ export interface FormScreenResponse { fields: FormScreenField[]; grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>; layout: any[]; + // Identifiers the field-scoped core endpoints (/upload, /ocr-extract) need + // so they can resolve the field's config + RBAC server-side. + workflow_uuid?: string; + version_uuid?: string; } export function getFormScreen( @@ -287,6 +355,53 @@ export function getFormScreen( }); } +// --------------------------------------------------------------------------- +// Field files & OCR (core-service) +// --------------------------------------------------------------------------- + +// Identifiers sent alongside every field-scoped file upload. The backend uses +// these to resolve the field's deployed allowed_types / max_size_mb / ocr_config +// and to run activity RBAC — anything the client sends about that config is +// ignored, so this trio is mandatory. +export interface FieldFileContext { + workflowUuid: string; + activityId: string; + fieldId: string; + columnId?: string; + versionUuid?: string; + instanceId?: string; +} + +// What /upload returns and what gets stored in instance data. `uuid` is the +// stable app-scoped handle; blob_path is kept for the legacy serve fallback. +export interface FileMeta { + uuid: string; + blob_path: string; + original_name: string; + mime_type: string; + size_bytes: number; +} + +export interface OcrExtractResponse { + // Keyed by the studio-configured extraction_fields[].key. A key whose value + // wasn't found in the document comes back as null. + extracted: Record; + raw?: string; + // Set when the model's reply wasn't parseable JSON — `raw` still holds the text. + parse_error?: string; +} + +// Stores a form-field file and returns the metadata to submit in its place. +export function uploadFieldFile(file: File, ctx: FieldFileContext): Promise { + return postFile(`/app/${APP_ID}/upload`, file, ctx); +} + +// Runs vision OCR over an uploaded document. The prompt and extraction schema +// live in the deployed ocr_config, so only the file + identifiers go over the wire. +export function extractOcr(file: File, ctx: FieldFileContext): Promise { + return postFile(`/app/${APP_ID}/ocr-extract`, file, ctx); +} + // --------------------------------------------------------------------------- // Workflow Actions // --------------------------------------------------------------------------- diff --git a/src/components/FormModal.tsx b/src/components/FormModal.tsx index cccd07d..3ed7ba2 100644 --- a/src/components/FormModal.tsx +++ b/src/components/FormModal.tsx @@ -1,12 +1,24 @@ import { useEffect, useState, useRef } from "react"; import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react"; -import { getFormScreen, startWorkflow, performActivity } from "../api/viewService"; +import { getFormScreen, startWorkflow, performActivity, uploadFieldFile } from "../api/viewService"; +import type { FormScreenField, FieldFileContext } from "../api/viewService"; import { WORKFLOW_ID } from "../config"; import { Skeleton } from "./Skeleton"; +import OcrUploadField, { resolveOcrConfig } from "./OcrUploadField"; -interface FormField { id: string; uid: string; name: string; type: string; data_type: string; mandatory: boolean; value?: any; properties?: { options?: Array<{ label: string; value: string }>; country_code?: string }; } +type FormField = FormScreenField; interface GridItem { i: string; x: number; y: number; w: number; h: number; } +// Field data types whose value is a file, not a scalar. They skip the +// string-based validation and number coercion paths, and their File is uploaded +// via /upload before submit so instance data references the blob by metadata. +const MEDIA_TYPES = new Set(["ocr", "file", "image"]); + +const isEmpty = (v: any) => + v == null || + (typeof v === "string" && v.trim() === "") || + (Array.isArray(v) && v.length === 0); + interface Props { activityId: string; instanceId?: string; @@ -19,7 +31,13 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu const [fields, setFields] = useState([]); const [gridConfig, setGridConfig] = useState([]); const [formTitle, setFormTitle] = useState(title || ""); - const [formData, setFormData] = useState>({}); + // Values are strings for scalar fields and a File for media fields. + const [formData, setFormData] = useState>({}); + // Identifiers /upload + /ocr-extract need to resolve a field's deployed + // config and RBAC server-side. The form screen echoes them; WORKFLOW_ID is + // the fallback for older view-service builds that don't. + const [wfUuid, setWfUuid] = useState(WORKFLOW_ID); + const [versionUuid, setVersionUuid] = useState(undefined); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -38,8 +56,14 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu setFields(f); setGridConfig(res.grid_config ?? []); setFormTitle(res.activity_name || title || "Form"); - const pre: Record = {}; - f.forEach((field) => { if (field.value != null) pre[field.id] = String(field.value); }); + if (res.workflow_uuid) setWfUuid(res.workflow_uuid); + setVersionUuid(res.version_uuid || undefined); + const pre: Record = {}; + f.forEach((field) => { + // Media prefills are already-uploaded metadata arrays, not strings. + if (field.value == null) return; + pre[field.id] = MEDIA_TYPES.has(field.data_type) ? field.value : String(field.value); + }); setFormData(pre); }) .catch((err: any) => setError(err.message)) @@ -51,11 +75,44 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu setTimeout(callback, 200); }; + // Base context for the field-scoped file endpoints; each call adds its fieldId. + const fileCtx: Omit = { + workflowUuid: wfUuid, + activityId, + versionUuid, + instanceId, + }; + + // Write an OCR result into the fields ocr_config.field_mappings points at. + // Returns the number of fields that actually received a value, which the OCR + // field reports back to the user ("Filled 9 fields…"). + const applyExtraction = (ocrField: FormField, extracted: Record): number => { + const mappings = resolveOcrConfig(ocrField).field_mappings ?? []; + const next: Record = {}; + for (const m of mappings) { + if (!m?.extraction_key || !m?.target_field) continue; + const raw = extracted[m.extraction_key]; + if (raw == null || raw === "") continue; // key absent, or the model found nothing + const target = fields.find((f) => f.id === m.target_field); + const coerced = coerceExtracted(raw, target?.data_type); + if (coerced === "") continue; + next[m.target_field] = coerced; + } + const filled = Object.keys(next); + if (filled.length > 0) { + setFormData((p) => ({ ...p, ...next })); + // Mark them touched so a value the model got wrong still shows validation. + setTouched((t) => { const s = new Set(t); filled.forEach((k) => s.add(k)); return s; }); + setError(null); + } + return filled.length; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTouched(new Set(fields.map((f) => f.id))); - const missing = fields.filter((f) => f.mandatory && !formData[f.id]?.trim()); + const missing = fields.filter((f) => f.mandatory && isEmpty(formData[f.id])); if (missing.length > 0) { setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`); return; @@ -64,7 +121,22 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu setSubmitting(true); setError(null); try { const data: Record = {}; - for (const f of fields) { const v = formData[f.id] ?? ""; data[f.id] = f.data_type === "number" ? (v ? Number(v) : 0) : v; } + for (const f of fields) { + const v = formData[f.id]; + if (MEDIA_TYPES.has(f.data_type)) { + // Freshly picked Files are uploaded now and submitted as metadata; + // an array is already-uploaded metadata from a prefill. Instance data + // holds an array either way, matching the platform's file fields. + if (v instanceof File) { + data[f.id] = [await uploadFieldFile(v, { ...fileCtx, fieldId: f.id })]; + } else if (Array.isArray(v)) { + data[f.id] = v; + } + continue; + } + const s = v ?? ""; + data[f.id] = f.data_type === "number" ? (s !== "" ? Number(s) : 0) : String(s); + } instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data); setSuccess(true); setTimeout(() => animateClose(onSuccess), 800); @@ -81,7 +153,7 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu const rows: FormField[][] = []; let cy = -1; for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); } - const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim(); + const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && isEmpty(formData[f.id]); return (
animateClose(onClose)}> @@ -141,11 +213,26 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu {f.name} {f.mandatory && *} - {renderInput(f, formData[f.id] ?? "", (v) => { - setFormData((p) => ({ ...p, [f.id]: v })); - setTouched((t) => new Set(t).add(f.id)); - if (error) setError(null); - }, isMissing(f))} + {MEDIA_TYPES.has(f.data_type) ? ( + { + setFormData((p) => ({ ...p, [f.id]: file })); + setTouched((t) => new Set(t).add(f.id)); + if (error) setError(null); + }} + onExtracted={(extracted) => applyExtraction(f, extracted)} + hasError={isMissing(f)} + /> + ) : ( + renderInput(f, formData[f.id] ?? "", (v) => { + setFormData((p) => ({ ...p, [f.id]: v })); + setTouched((t) => new Set(t).add(f.id)); + if (error) setError(null); + }, isMissing(f)) + )} {isMissing(f) && (

Required @@ -201,6 +288,32 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu ); } +// --------------------------------------------------------------------------- +// OCR value coercion +// --------------------------------------------------------------------------- + +// The vision model returns JSON scalars, but every input here is string-valued. +// The OCR prompt asks for plain numbers and YYYY-MM-DD dates; this is the +// belt-and-braces pass for when it doesn't comply — a currency symbol or +// thousands separator on a number, or a date in some other notation. Returns "" +// when the value can't be used, which the caller treats as "not filled". +function coerceExtracted(raw: unknown, dataType?: string): string { + if (typeof raw === "boolean") return raw ? "true" : "false"; + const s = String(raw).trim(); + if (s === "") return ""; + if (dataType === "number") { + const n = Number(s.replace(/[^0-9.\-]/g, "")); + return Number.isFinite(n) ? String(n) : ""; + } + if (dataType === "date") { + const iso = s.match(/^\d{4}-\d{2}-\d{2}/); + if (iso) return iso[0]; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); + } + return s; +} + // --------------------------------------------------------------------------- // Input renderer // --------------------------------------------------------------------------- diff --git a/src/components/OcrUploadField.tsx b/src/components/OcrUploadField.tsx new file mode 100644 index 0000000..e3af168 --- /dev/null +++ b/src/components/OcrUploadField.tsx @@ -0,0 +1,241 @@ +// OcrUploadField — document dropzone for the file-valued form field types. +// +// Handles `ocr`, `file` and `image`. The Extract action only appears for `ocr` +// fields that actually carry an extraction schema and field mappings; for a +// plain file/image field this is just a picker. +// +// The user drops a PDF (or image), we POST it to the core-service's +// /ocr-extract, and hand the extracted key→value map back to the parent, which +// walks ocr_config.field_mappings and writes each value into its target form +// field. The file itself stays in the parent's form state as a raw File and is +// uploaded via /upload at submit time. +// +// The prompt and extraction schema are NOT sent from here — the backend +// resolves them from the deployed ocr_config using the identifier trio +// (workflow_uuid / activity_id / field_id) plus activity RBAC. What we read +// out of the local config is only picker UX (allowed_types / max_size_mb / +// placeholder); mirrors renderer-desktop's FFOcrField. + +import { useRef, useState } from "react"; +import { FileText, UploadCloud, X, ScanText, CheckCircle2, AlertCircle } from "lucide-react"; +import { extractOcr } from "../api/viewService"; +import type { FieldFileContext, FormScreenField, OcrConfig } from "../api/viewService"; + +// Studio writes the OCR keys either nested under properties.ocr_config or flat +// on properties. The nested shape wins when it actually carries a schema. +export function resolveOcrConfig(field: FormScreenField): OcrConfig { + const nested = field.properties?.ocr_config; + if (nested && Array.isArray(nested.extraction_fields)) return nested; + return (field.properties ?? {}) as OcrConfig; +} + +// allowed_types arrives as a CSV string or an array, holding any of ".pdf", +// "pdf", "application/pdf" or "image/*". Normalise to what wants. +function toAcceptAttr(raw: OcrConfig["allowed_types"]): string { + const entries = Array.isArray(raw) + ? raw + : typeof raw === "string" + ? raw.split(",") + : []; + const cleaned = entries + .map((e) => e.trim().toLowerCase()) + .filter(Boolean) + .map((e) => (e.includes("/") || e.startsWith(".") ? e : `.${e}`)); + return cleaned.join(","); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +type ExtractState = + | { status: "idle" } + | { status: "loading" } + | { status: "done"; applied: number } + | { status: "error"; message: string }; + +interface Props { + field: FormScreenField; + /** Identifiers the backend needs to resolve this field's ocr_config + RBAC. */ + ctx: Omit; + file: File | null; + onFileChange: (file: File | null) => void; + /** Receives the raw extraction map; the parent applies field_mappings. */ + onExtracted: (extracted: Record) => number; + disabled?: boolean; + hasError?: boolean; +} + +export default function OcrUploadField({ + field, ctx, file, onFileChange, onExtracted, disabled, hasError, +}: Props) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [state, setState] = useState({ status: "idle" }); + + const config = resolveOcrConfig(field); + const accept = toAcceptAttr(config.allowed_types); + const maxSizeMb = Number(config.max_size_mb) > 0 ? Number(config.max_size_mb) : null; + const mappings = Array.isArray(config.field_mappings) ? config.field_mappings : []; + // Without extraction_fields the backend has nothing to ask for, and without + // mappings there is nowhere to put the answer — either way, no Extract button. + const canExtract = + (config.extraction_fields?.length ?? 0) > 0 && mappings.length > 0 && !!ctx.workflowUuid; + + const acceptHint = accept + ? accept.replace(/\./g, "").replace(/,/g, ", ").toUpperCase() + : "PDF or image"; + + const pick = (next: File | null) => { + setState({ status: "idle" }); + if (next && maxSizeMb && next.size > maxSizeMb * 1024 * 1024) { + onFileChange(null); + setState({ status: "error", message: `File exceeds the ${maxSizeMb} MB limit` }); + return; + } + onFileChange(next); + }; + + const runExtract = async () => { + if (!file || !canExtract) return; + setState({ status: "loading" }); + try { + const res = await extractOcr(file, { ...ctx, fieldId: field.id }); + const applied = onExtracted(res?.extracted ?? {}); + if (applied === 0) { + setState({ + status: "error", + message: res?.parse_error + ? `Could not read the document: ${res.parse_error}` + : "Nothing could be read from this document — please fill the fields manually.", + }); + return; + } + setState({ status: "done", applied }); + } catch (err: any) { + setState({ status: "error", message: err?.message || "Extraction failed" }); + } + }; + + const clear = () => { + pick(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( +

+ pick(e.target.files?.[0] ?? null)} + /> + + {!file ? ( + /* ── Empty dropzone ─────────────────────────────────────────────── */ + + ) : ( + /* ── Picked file + extract action ────────────────────────────────── */ +
+
+
+ +
+
+
+ {file.name} +
+
+ {formatBytes(file.size)} +
+
+ + {canExtract && ( + + )} + + +
+ + {state.status === "done" && ( +
+ + Filled {state.applied} field{state.applied === 1 ? "" : "s"} from the document — review before submitting. +
+ )} + {state.status === "error" && ( +
+ + {state.message} +
+ )} +
+ )} + + {state.status === "error" && !file && ( +
+ + {state.message} +
+ )} +
+ ); +}