"use client"; /** * TASK-0124 — "Extract records from text" dialog. * * Flow: the user pastes text or a markdown table into the textarea. As they * type (debounced) the dialog calls the server-side dry-run * (`.../extract/preview`) and renders the live column→property mapping * (matched columns + the unmatched `ignored` columns + the row count). "Apply" * streams the real extraction (SSE — `sendDbExtract`); the `done` frame * carries the created `recordIds` + `ignoredColumns`. * * The mapping is computed SERVER-side (the client never names properties by * position); the preview writes nothing and never touches the LLM. * * Pinned testids (tests/e2e/specs/ai-database.spec.ts — do not rename): * db-ai-extract the dialog * db-ai-extract-text the textarea * db-ai-extract-hint the "paste a markdown table" hint * db-ai-extract-map the live mapping preview * db-ai-extract-ignored the unmatched-column chips * db-ai-extract-apply the Apply button * db-ai-extract-done the success summary (record count) * db-ai-error-banner / db-ai-loading (shared chrome) */ import { useCallback, useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CheckCheck, Loader2, Sparkles, Table2 } from "lucide-react"; import { useDbAiStream } from "@/hooks/use-db-ai-stream"; import { dbAiApi, type DbExtractPreview } from "@/lib/ai-database"; import type { DatabaseProperty } from "@/hooks/use-database"; import { DbAiDialog, DbAiErrorBanner, DbAiLoading } from "@/components/ai/db-ai-dialog"; export interface AiExtractDialogProps { open: boolean; workspaceId: string; databaseId: string; /** The database's properties (to show the matched property name/type). */ properties: DatabaseProperty[]; onClose: () => void; } const DEBOUNCE_MS = 400; const MAX_TEXT_CHARS = 50_000; export function AiExtractDialog({ open, workspaceId, databaseId, properties, onClose }: AiExtractDialogProps) { const qc = useQueryClient(); const [text, setText] = useState(""); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const { phase, result, error, runExtract } = useDbAiStream({ workspaceId }); const lastText = useRef(""); const seq = useRef(0); // Reset on open. useEffect(() => { if (open) { setText(""); setPreview(null); setPreviewError(null); } }, [open]); // Live mapping preview (debounced; the dry-run writes nothing, no LLM). useEffect(() => { if (!open) return; const trimmed = text.trim(); if (trimmed.length === 0) { setPreview(null); setPreviewError(null); return; } const my = ++seq.current; const t = setTimeout(async () => { try { const p = await dbAiApi.previewExtract(workspaceId, { databaseId, text: trimmed }); if (seq.current === my) { setPreview(p); setPreviewError(null); } } catch { if (seq.current === my) { setPreview(null); setPreviewError("Could not preview the mapping."); } } }, DEBOUNCE_MS); return () => clearTimeout(t); }, [open, text, workspaceId, databaseId]); const propertyById = useRef(new Map()); propertyById.current = new Map(properties.map((p) => [p.id, p])); const oversize = text.length > MAX_TEXT_CHARS; const canApply = text.trim().length > 0 && !oversize && phase !== "streaming"; const apply = useCallback(() => { lastText.current = text; void runExtract(databaseId, text); }, [text, databaseId, runExtract]); const retry = useCallback(() => { void runExtract(databaseId, lastText.current); }, [databaseId, runExtract]); const close = useCallback(() => { onClose(); }, [onClose]); const done = phase === "done" && result?.done; const recordCount = done ? (result!.done as { recordIds?: string[] }).recordIds?.length ?? 0 : 0; const ignored = done ? (result!.done as { ignoredColumns?: string[] }).ignoredColumns ?? [] : []; const invalidateDb = useCallback(() => { void qc.invalidateQueries({ queryKey: ["database", databaseId] }); }, [qc, databaseId]); useEffect(() => { if (phase === "done" && result?.done) invalidateDb(); }, [phase, result, invalidateDb]); const budget = error?.budget; const live = phase === "streaming" ? "AI is extracting records" : done ? `Extracted ${recordCount} records` : error ? `Error: ${error.message}` : preview && preview.isTable ? `Preview: ${preview.mapped.length} columns mapped, ${preview.rowCount} rows, ${preview.ignored.length} unmatched` : ""; return ( {oversize ? `Too long (${text.length}/${MAX_TEXT_CHARS})` : `${text.length}/${MAX_TEXT_CHARS} chars`} } >

Paste a markdown table (or plain text). Columns are matched to this database's properties by name; unmatched columns are listed and skipped.