1"use client";
2
3
35import { useCallback, useEffect, useRef, useState } from "react";
36import { useParams, useRouter } from "next/navigation";
37import { AlertTriangle, Check, Loader2, RotateCcw, Sparkles, X } from "lucide-react";
38import { useUiStore, toast, type AssistantMode } from "@/stores/ui-store";
39import { useWorkspace } from "@/hooks/use-workspace";
40import { isApiError, ApiError } from "@/lib/api";
41import { assistantApi, type AssistantSuggestResult } from "@/lib/ai";
42import { useQueryClient } from "@tanstack/react-query";
43import { DbAiDialog } from "./db-ai-dialog";
44
45const MODE_TITLES: Record<AssistantMode, string> = {
46 "extract-actions": "Extract action items",
47 "generate-faqs": "Generate FAQs",
48 "find-duplicates": "Find duplicate pages",
49 "find-stale": "Find stale pages",
50 "suggest-metadata": "Suggest page metadata"
51};
52
53
55function errorText(err: unknown): { message: string; retryable: boolean } {
56 if (isApiError(err)) {
57 if (err.status === 429) return { message: "Too many AI requests — slow down for a minute.", retryable: true };
58 if (err.code === "AI_BUDGET_EXCEEDED" || err.status === 402)
59 return { message: "Monthly AI token budget reached. Ask an admin.", retryable: false };
60 if (err.status === 422)
61 return { message: "The AI produced an invalid result — nothing was written. Try again.", retryable: true };
62 if (err.status === 403) return { message: "You do not have permission for this action.", retryable: false };
63 return { message: err.message || "Request failed.", retryable: true };
64 }
65 return { message: "Something went wrong.", retryable: true };
66}
67
68type ResultBody =
69 | { kind: "tasks"; lines: string[] }
70 | { kind: "links"; groups: Array<{ label: string; links: Array<{ id: string; title: string; hint?: string }> }> }
71 | { kind: "suggestion"; data: AssistantSuggestResult };
72
73export function AssistantDialog() {
74 const open = useUiStore((s) => s.assistantOpen);
75 const context = useUiStore((s) => s.assistantContext);
76 const setOpen = useUiStore((s) => s.setAssistant);
77 const slug = useParams<{ workspace: string }>()?.workspace ?? "default";
78 const { data: workspace } = useWorkspace(slug);
79 const router = useRouter();
80 const queryClient = useQueryClient();
81
82 const mode: AssistantMode | null = context?.mode ?? null;
83 const pageId = context?.pageId;
84
85 const [text, setText] = useState("");
86 const [days, setDays] = useState(90);
87 const [running, setRunning] = useState(false);
88 const [error, setError] = useState<string | null>(null);
89 const [retryable, setRetryable] = useState(false);
90 const [result, setResult] = useState<ResultBody | null>(null);
91 const abortRef = useRef<AbortController | null>(null);
92
93 const close = useCallback(() => {
94 abortRef.current?.abort();
95 abortRef.current = null;
96 setOpen(false, null);
97 setRunning(false);
98 setError(null);
99 setResult(null);
100 setText("");
101 setDays(90);
102 }, [setOpen]);
103
104 const run = useCallback(async () => {
105 if (!workspace?.id || !mode || running) return;
106 setError(null);
107 setResult(null);
108 setRunning(true);
109 const controller = new AbortController();
110 abortRef.current = controller;
111 const wsId = workspace.id;
112
113 try {
114 if (mode === "extract-actions") {
115 const res = await assistantApi.extractActions(wsId, text.trim(), controller.signal);
116 if (res.tasks.length === 0) {
117 setResult({ kind: "tasks", lines: ["No action items found in the text."] });
118 } else {
119 setResult({
120 kind: "tasks",
121 lines: res.tasks.map((t) => `• ${t.title}${t.dueDate ? ` (due ${t.dueDate})` : ""}`)
122 });
123 toast("success", `Created ${res.tasks.length} task${res.tasks.length === 1 ? "" : "s"}`, "From the AI assistant");
124 }
125 } else if (mode === "generate-faqs") {
126 if (!pageId) throw new ApiError("VALIDATION_ERROR", 400, "No page selected.");
127 const res = await assistantApi.generateFaqs(wsId, [pageId], controller.signal);
128 toast("success", `FAQ page created (${res.faqCount} question${res.faqCount === 1 ? "" : "s"})`, res.page.title);
129 close();
130 router.push(`/w/${slug}/page/${res.page.id}`);
131 return;
132 } else if (mode === "find-duplicates") {
133 const res = await assistantApi.findDuplicates(wsId, { query: text.trim() || undefined }, controller.signal);
134 setResult({
135 kind: "links",
136 groups: res.groups
137 .filter((g) => g.pages.length >= 2)
138 .map((g) => ({
139 label: g.reason ? `${g.reason} (${Math.round(g.similarity * 100)}% similar)` : "Possible duplicates",
140 links: g.pages.map((p) => ({ id: p.pageId, title: p.title, hint: p.updatedAt.slice(0, 10) }))
141 }))
142 });
143 if (res.groups.length === 0) setResult({ kind: "links", groups: [] });
144 } else if (mode === "find-stale") {
145 const res = await assistantApi.findStale(wsId, { days }, controller.signal);
146 setResult({
147 kind: "links",
148 groups: res.pages.length
149 ? [
150 {
151 label: `Not updated in ${days}+ days`,
152 links: res.pages.map((p) => ({ id: p.pageId, title: p.title, hint: p.reason }))
153 }
154 ]
155 : []
156 });
157 } else {
158
159 if (!pageId) throw new ApiError("VALIDATION_ERROR", 400, "No page selected.");
160 const res = await assistantApi.suggestMetadata(wsId, pageId, controller.signal);
161 setResult({ kind: "suggestion", data: res });
162 }
163 } catch (e) {
164 if (e instanceof DOMException && e.name === "AbortError") return;
165 const { message, retryable: canRetry } = errorText(e);
166 setError(message);
167 setRetryable(canRetry);
168 } finally {
169 setRunning(false);
170 abortRef.current = null;
171 }
172 }, [workspace, mode, running, text, days, pageId, slug, router, close]);
173
174 const applySuggestion = useCallback(
175 async (suggestionId: string, fields: { title?: string; description?: string }) => {
176 if (!workspace?.id || running) return;
177 setRunning(true);
178 setError(null);
179 try {
180 await assistantApi.applyMetadata(workspace.id, suggestionId, fields);
181
182 await queryClient.invalidateQueries({ queryKey: ["pages", pageId] });
183 queryClient.invalidateQueries({ queryKey: ["workspaces", workspace.id, "page-tree"] });
184 toast("success", "Metadata applied", "Title and/or description updated");
185 close();
186 } catch (e) {
187 if (e instanceof DOMException && e.name === "AbortError") return;
188 setError(errorText(e).message);
189 setRetryable(false);
190 } finally {
191 setRunning(false);
192 }
193 },
194 [workspace, running, pageId, queryClient, close]
195 );
196
197 const canRun =
198 !running &&
199 (mode === "extract-actions" ? text.trim().length > 0 : mode === "find-stale" || mode === "find-duplicates" ? true : !!pageId);
200
201 const live = running
202 ? `Running ${mode ? MODE_TITLES[mode] : ""}…`
203 : error
204 ? `Error: ${error}`
205 : result
206 ? "Result ready."
207 : "Describe or select to run.";
208
209 const placeholder =
210 mode === "extract-actions"
211 ? "Paste notes, a transcript, or a chat thread…\n\n- Ship the whiteboard export\n- Review the Q3 budget"
212 : mode === "find-duplicates"
213 ? "Optional: narrow the search (e.g. \"pricing\")"
214 : mode === "find-stale"
215 ? "Pages not updated in the selected number of days will be listed."
216 : mode === "generate-faqs"
217 ? "FAQs will be generated from this page's content into a new draft page."
218 : "A suggested title/description will be shown for review before anything is applied.";
219
220 return (
221 <DbAiDialog
222 open={open && mode !== null}
223 title={mode ? MODE_TITLES[mode] : "AI assistant"}
224 testId="assistant-dialog"
225 onClose={close}
226 loadingText="Working…"
227 live={live}
228 footer={
229 <div className="flex w-full items-center justify-end gap-2">
230 {result && result.kind === "suggestion" ? (
231 <>
232 <button
233 type="button"
234 data-testid="assistant-dismiss"
235 onClick={close}
236 className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"
237 >
238 Dismiss
239 </button>
240 <button
241 type="button"
242 data-testid="assistant-apply"
243 disabled={running}
244 onClick={() =>
245 void applySuggestion(result.data.suggestionId, {
246 ...(result.data.suggested.title !== undefined ? { title: result.data.suggested.title } : {}),
247 ...(result.data.suggested.description !== undefined ? { description: result.data.suggested.description } : {})
248 })
249 }
250 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90 disabled:opacity-50"
251 >
252 <Check className="size-4" aria-hidden />
253 Apply
254 </button>
255 </>
256 ) : (
257 <>
258 <button
259 type="button"
260 onClick={close}
261 className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"
262 >
263 Close
264 </button>
265 {error && retryable ? (
266 <button
267 type="button"
268 onClick={() => void run()}
269 className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"
270 >
271 <RotateCcw className="size-4" aria-hidden />
272 Retry
273 </button>
274 ) : null}
275 <button
276 type="button"
277 data-testid="assistant-run"
278 disabled={!canRun}
279 onClick={() => void run()}
280 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90 disabled:opacity-50"
281 >
282 {running ? <Loader2 className="size-4 animate-spin" aria-hidden /> : <Sparkles className="size-4" aria-hidden />}
283 Run
284 </button>
285 </>
286 )}
287 </div>
288 }
289 >
290 {}
291 {mode === "extract-actions" || mode === "find-duplicates" ? (
292 <textarea
293 data-testid="assistant-input"
294 value={text}
295 onChange={(e) => setText(e.target.value)}
296 rows={mode === "extract-actions" ? 8 : 3}
297 placeholder={placeholder}
298 className="w-full rounded-md border border-border bg-background p-2.5 text-sm outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
299 aria-label={placeholder}
300 />
301 ) : null}
302
303 {mode === "find-stale" ? (
304 <div className="flex items-center gap-2" data-testid="assistant-input">
305 <label htmlFor="assistant-stale-days" className="text-sm text-muted-foreground">
306 Not updated in
307 </label>
308 <select
309 id="assistant-stale-days"
310 value={days}
311 onChange={(e) => setDays(Number(e.target.value))}
312 className="h-8 rounded-md border border-border bg-background px-2 text-sm"
313 >
314 <option value={30}>30 days</option>
315 <option value={90}>90 days</option>
316 <option value={180}>180 days</option>
317 <option value={365}>365 days</option>
318 </select>
319 </div>
320 ) : null}
321
322 {mode === "generate-faqs" || mode === "suggest-metadata" ? (
323 <p className="text-sm text-muted-foreground" data-testid="assistant-input">
324 {placeholder}
325 </p>
326 ) : null}
327
328 {}
329 {error ? (
330 <div
331 data-testid="assistant-error-banner"
332 role="alert"
333 className="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-2.5 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300"
334 >
335 <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
336 <span>{error}</span>
337 </div>
338 ) : null}
339
340 {}
341 {result ? (
342 <div data-testid="assistant-result" className="text-sm">
343 {result.kind === "tasks" ? (
344 <ul className="space-y-1">
345 {result.lines.map((l) => (
346 <li key={l} className="flex items-start gap-2">
347 <Check className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
348 <span>{l}</span>
349 </li>
350 ))}
351 </ul>
352 ) : null}
353
354 {result.kind === "links" ? (
355 result.groups.length === 0 ? (
356 <p className="rounded-md border border-border bg-background/60 p-3 text-muted-foreground">
357 Nothing found — the workspace looks clean.
358 </p>
359 ) : (
360 <div className="space-y-3">
361 {result.groups.map((g) => (
362 <div key={g.label}>
363 <p className="mb-1 text-xs font-medium text-muted-foreground">{g.label}</p>
364 <ul className="space-y-0.5">
365 {g.links.map((l) => (
366 <li key={l.id}>
367 <button
368 type="button"
369 onClick={() => {
370 close();
371 router.push(`/w/${slug}/page/${l.id}`);
372 }}
373 className="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-accent"
374 >
375 <span className="min-w-0 flex-1 truncate">{l.title || "Untitled"}</span>
376 {l.hint ? <span className="shrink-0 text-xs text-muted-foreground">{l.hint}</span> : null}
377 </button>
378 </li>
379 ))}
380 </ul>
381 </div>
382 ))}
383 </div>
384 )
385 ) : null}
386
387 {result.kind === "suggestion" ? (
388 <div className="space-y-2 rounded-md border border-border bg-background/60 p-3">
389 {result.data.suggested.title !== undefined ? (
390 <div>
391 <p className="text-xs font-medium text-muted-foreground">Title</p>
392 <p className="text-foreground">{result.data.suggested.title}</p>
393 </div>
394 ) : null}
395 {result.data.suggested.description !== undefined ? (
396 <div>
397 <p className="text-xs font-medium text-muted-foreground">Description</p>
398 <p className="text-foreground">{result.data.suggested.description}</p>
399 </div>
400 ) : null}
401 <p className="text-xs text-muted-foreground">Apply writes these via the normal page update (you keep review control).</p>
402 </div>
403 ) : null}
404 </div>
405 ) : null}
406 </DbAiDialog>
407 );
408}
409