1"use client";
2
3
26import { useCallback, useEffect, useRef, useState } from "react";
27import { useQueryClient } from "@tanstack/react-query";
28import { CheckCheck, Loader2, Sparkles, Table2 } from "lucide-react";
29import { useDbAiStream } from "@/hooks/use-db-ai-stream";
30import { dbAiApi, type DbExtractPreview } from "@/lib/ai-database";
31import type { DatabaseProperty } from "@/hooks/use-database";
32import { DbAiDialog, DbAiErrorBanner, DbAiLoading } from "@/components/ai/db-ai-dialog";
33
34export interface AiExtractDialogProps {
35 open: boolean;
36 workspaceId: string;
37 databaseId: string;
38
39 properties: DatabaseProperty[];
40 onClose: () => void;
41}
42
43const DEBOUNCE_MS = 400;
44const MAX_TEXT_CHARS = 50_000;
45
46export function AiExtractDialog({ open, workspaceId, databaseId, properties, onClose }: AiExtractDialogProps) {
47 const qc = useQueryClient();
48 const [text, setText] = useState("");
49 const [preview, setPreview] = useState<DbExtractPreview | null>(null);
50 const [previewError, setPreviewError] = useState<string | null>(null);
51 const { phase, result, error, runExtract } = useDbAiStream({ workspaceId });
52 const lastText = useRef("");
53 const seq = useRef(0);
54
55
56 useEffect(() => {
57 if (open) {
58 setText("");
59 setPreview(null);
60 setPreviewError(null);
61 }
62 }, [open]);
63
64
65 useEffect(() => {
66 if (!open) return;
67 const trimmed = text.trim();
68 if (trimmed.length === 0) {
69 setPreview(null);
70 setPreviewError(null);
71 return;
72 }
73 const my = ++seq.current;
74 const t = setTimeout(async () => {
75 try {
76 const p = await dbAiApi.previewExtract(workspaceId, { databaseId, text: trimmed });
77 if (seq.current === my) {
78 setPreview(p);
79 setPreviewError(null);
80 }
81 } catch {
82 if (seq.current === my) {
83 setPreview(null);
84 setPreviewError("Could not preview the mapping.");
85 }
86 }
87 }, DEBOUNCE_MS);
88 return () => clearTimeout(t);
89 }, [open, text, workspaceId, databaseId]);
90
91 const propertyById = useRef(new Map<string, DatabaseProperty>());
92 propertyById.current = new Map(properties.map((p) => [p.id, p]));
93
94 const oversize = text.length > MAX_TEXT_CHARS;
95 const canApply = text.trim().length > 0 && !oversize && phase !== "streaming";
96
97 const apply = useCallback(() => {
98 lastText.current = text;
99 void runExtract(databaseId, text);
100 }, [text, databaseId, runExtract]);
101
102 const retry = useCallback(() => {
103 void runExtract(databaseId, lastText.current);
104 }, [databaseId, runExtract]);
105
106 const close = useCallback(() => {
107 onClose();
108 }, [onClose]);
109
110 const done = phase === "done" && result?.done;
111 const recordCount = done ? (result!.done as { recordIds?: string[] }).recordIds?.length ?? 0 : 0;
112 const ignored = done ? (result!.done as { ignoredColumns?: string[] }).ignoredColumns ?? [] : [];
113
114 const invalidateDb = useCallback(() => {
115 void qc.invalidateQueries({ queryKey: ["database", databaseId] });
116 }, [qc, databaseId]);
117
118 useEffect(() => {
119 if (phase === "done" && result?.done) invalidateDb();
120 }, [phase, result, invalidateDb]);
121
122 const budget = error?.budget;
123 const live =
124 phase === "streaming"
125 ? "AI is extracting records"
126 : done
127 ? `Extracted ${recordCount} records`
128 : error
129 ? `Error: ${error.message}`
130 : preview && preview.isTable
131 ? `Preview: ${preview.mapped.length} columns mapped, ${preview.rowCount} rows, ${preview.ignored.length} unmatched`
132 : "";
133
134 return (
135 <DbAiDialog
136 open={open}
137 title="Extract records from text"
138 testId="db-ai-extract"
139 onClose={close}
140 live={live}
141 footer={
142 <>
143 <span className="min-w-0 truncate text-[11px] text-muted-foreground">
144 {oversize ? `Too long (${text.length}/${MAX_TEXT_CHARS})` : `${text.length}/${MAX_TEXT_CHARS} chars`}
145 </span>
146 <button
147 type="button"
148 data-testid="db-ai-extract-apply"
149 onClick={apply}
150 disabled={!canApply}
151 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
152 >
153 {phase === "streaming" ? (
154 <Loader2 className="size-3.5 animate-spin" aria-hidden />
155 ) : (
156 <Sparkles className="size-3.5" aria-hidden />
157 )}
158 Apply
159 </button>
160 </>
161 }
162 >
163 <div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
164 <Table2 className="mt-0.5 size-3.5 shrink-0" aria-hidden />
165 <p data-testid="db-ai-extract-hint">
166 Paste a <strong>markdown table</strong> (or plain text). Columns are matched to this database's
167 properties by name; unmatched columns are listed and skipped.
168 </p>
169 </div>
170
171 <textarea
172 data-testid="db-ai-extract-text"
173 value={text}
174 onChange={(e) => setText(e.target.value)}
175 rows={6}
176 maxLength={MAX_TEXT_CHARS + 100}
177 placeholder={"| Name | Score |\n| --- | --- |\n| Alice | 42 |"}
178 aria-label="Text or markdown table to extract"
179 className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-primary"
180 />
181
182 {previewError ? <p className="text-[11px] text-muted-foreground">{previewError}</p> : null}
183
184 {preview && !previewError ? (
185 <div data-testid="db-ai-extract-map" className="rounded-md border border-border">
186 <div className="flex items-center justify-between border-b border-border px-3 py-1.5 text-[11px] text-muted-foreground">
187 <span className="font-medium">Mapping preview</span>
188 <span>
189 {preview.isTable ? `${preview.rowCount} row${preview.rowCount === 1 ? "" : "s"}` : "no table detected"}
190 </span>
191 </div>
192 {preview.isTable ? (
193 <ul className="divide-y divide-border">
194 {preview.columns.map((col) => {
195 const match = preview.mapped.find((m) => m.column === col);
196 const prop = match ? propertyById.current.get(match.propertyId) : undefined;
197 return (
198 <li key={col} className="flex items-center justify-between gap-2 px-3 py-1 text-xs">
199 <span className="min-w-0 truncate">{col}</span>
200 {match ? (
201 <span className="shrink-0 rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-800 dark:bg-green-950/60 dark:text-green-300">
202 → {prop?.name ?? match.propertyId}
203 </span>
204 ) : (
205 <span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800 dark:bg-amber-950/60 dark:text-amber-300">
206 skipped
207 </span>
208 )}
209 </li>
210 );
211 })}
212 </ul>
213 ) : (
214 <p className="px-3 py-2 text-[11px] text-muted-foreground">
215 No markdown table found — the AI will parse the text into records.
216 </p>
217 )}
218 {preview.ignored.length > 0 ? (
219 <div data-testid="db-ai-extract-ignored" className="flex flex-wrap items-center gap-1 border-t border-border px-3 py-1.5">
220 <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
221 Skipped:
222 </span>
223 {preview.ignored.map((c) => (
224 <span key={c} className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800 dark:bg-amber-950/60 dark:text-amber-300">
225 {c}
226 </span>
227 ))}
228 </div>
229 ) : null}
230 </div>
231 ) : null}
232
233 {phase === "streaming" ? <DbAiLoading text="Extracting records…" /> : null}
234
235 {phase === "error" && error ? (
236 <DbAiErrorBanner code={error.code} message={error.message} budget={budget} retryable={error.retryable} onRetry={retry} />
237 ) : null}
238
239 {done ? (
240 <div
241 data-testid="db-ai-extract-done"
242 role="status"
243 className="flex items-center gap-2 rounded-md border border-green-300 bg-green-50 px-3 py-2 text-xs text-green-900 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200"
244 >
245 <CheckCheck className="size-3.5 shrink-0" aria-hidden />
246 <span className="min-w-0 flex-1">
247 Added {recordCount} record{recordCount === 1 ? "" : "s"}.
248 {ignored.length > 0 ? ` Skipped ${ignored.length} unmatched column${ignored.length === 1 ? "" : "s"}.` : ""}
249 </span>
250 </div>
251 ) : null}
252 </DbAiDialog>
253 );
254}
255