"use client"; /** * T-172…T-175 (A-010) — the AI knowledge-assistant dialog. * * One thin view over the five assistant capabilities in `assistantApi` * (plain JSON one-shots — no SSE, no conversation panel; the server keeps * each call in a HIDDEN `assistant:*` conversation that never lists in the * panel). Mounted at the workspace shell (like the generate dialog) so the * command palette and the block context menu can open any mode. * * Mode inputs/outputs: * - extract-actions → notes textarea → real tasks (a success toast lists * them; EDIT-gated, 403 for GUEST) * - generate-faqs → the current page (context.pageId) → FAQ draft page * (navigates to it on success) * - find-duplicates → free-text query (optional) → link list of groups * - find-stale → days selector (90 default) → link list * - suggest-metadata → the current page (context.pageId) → Apply/Dismiss * (Apply = apply-metadata, the server re-checks that * each field exactly matches the stored suggestion) * * a11y mirrors the `DbAiDialog` chrome (role="dialog" aria-modal, focus trap, * Escape closes + aborts, focus restored, aria-live announces the state). * * Pinned testids (do not rename): * assistant-dialog the dialog (role=dialog) * assistant-input the mode input (textarea/select/button) * assistant-run the action button * assistant-error-banner the error banner (429/402/422/…) * assistant-result the result region (aria-live) * assistant-apply the suggest-metadata Apply button * assistant-dismiss the suggest-metadata Dismiss button */ import { useCallback, useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { AlertTriangle, Check, Loader2, RotateCcw, Sparkles, X } from "lucide-react"; import { useUiStore, toast, type AssistantMode } from "@/stores/ui-store"; import { useWorkspace } from "@/hooks/use-workspace"; import { isApiError, ApiError } from "@/lib/api"; import { assistantApi, type AssistantSuggestResult } from "@/lib/ai"; import { useQueryClient } from "@tanstack/react-query"; import { DbAiDialog } from "./db-ai-dialog"; const MODE_TITLES: Record = { "extract-actions": "Extract action items", "generate-faqs": "Generate FAQs", "find-duplicates": "Find duplicate pages", "find-stale": "Find stale pages", "suggest-metadata": "Suggest page metadata" }; /** The error-banner text per failure class (the server's message is kept — * the banner is the single error surface, like the generate dialog). */ function errorText(err: unknown): { message: string; retryable: boolean } { if (isApiError(err)) { if (err.status === 429) return { message: "Too many AI requests — slow down for a minute.", retryable: true }; if (err.code === "AI_BUDGET_EXCEEDED" || err.status === 402) return { message: "Monthly AI token budget reached. Ask an admin.", retryable: false }; if (err.status === 422) return { message: "The AI produced an invalid result — nothing was written. Try again.", retryable: true }; if (err.status === 403) return { message: "You do not have permission for this action.", retryable: false }; return { message: err.message || "Request failed.", retryable: true }; } return { message: "Something went wrong.", retryable: true }; } type ResultBody = | { kind: "tasks"; lines: string[] } | { kind: "links"; groups: Array<{ label: string; links: Array<{ id: string; title: string; hint?: string }> }> } | { kind: "suggestion"; data: AssistantSuggestResult }; export function AssistantDialog() { const open = useUiStore((s) => s.assistantOpen); const context = useUiStore((s) => s.assistantContext); const setOpen = useUiStore((s) => s.setAssistant); const slug = useParams<{ workspace: string }>()?.workspace ?? "default"; const { data: workspace } = useWorkspace(slug); const router = useRouter(); const queryClient = useQueryClient(); const mode: AssistantMode | null = context?.mode ?? null; const pageId = context?.pageId; const [text, setText] = useState(""); const [days, setDays] = useState(90); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [retryable, setRetryable] = useState(false); const [result, setResult] = useState(null); const abortRef = useRef(null); const close = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setOpen(false, null); setRunning(false); setError(null); setResult(null); setText(""); setDays(90); }, [setOpen]); const run = useCallback(async () => { if (!workspace?.id || !mode || running) return; setError(null); setResult(null); setRunning(true); const controller = new AbortController(); abortRef.current = controller; const wsId = workspace.id; try { if (mode === "extract-actions") { const res = await assistantApi.extractActions(wsId, text.trim(), controller.signal); if (res.tasks.length === 0) { setResult({ kind: "tasks", lines: ["No action items found in the text."] }); } else { setResult({ kind: "tasks", lines: res.tasks.map((t) => `• ${t.title}${t.dueDate ? ` (due ${t.dueDate})` : ""}`) }); toast("success", `Created ${res.tasks.length} task${res.tasks.length === 1 ? "" : "s"}`, "From the AI assistant"); } } else if (mode === "generate-faqs") { if (!pageId) throw new ApiError("VALIDATION_ERROR", 400, "No page selected."); const res = await assistantApi.generateFaqs(wsId, [pageId], controller.signal); toast("success", `FAQ page created (${res.faqCount} question${res.faqCount === 1 ? "" : "s"})`, res.page.title); close(); router.push(`/w/${slug}/page/${res.page.id}`); return; } else if (mode === "find-duplicates") { const res = await assistantApi.findDuplicates(wsId, { query: text.trim() || undefined }, controller.signal); setResult({ kind: "links", groups: res.groups .filter((g) => g.pages.length >= 2) .map((g) => ({ label: g.reason ? `${g.reason} (${Math.round(g.similarity * 100)}% similar)` : "Possible duplicates", links: g.pages.map((p) => ({ id: p.pageId, title: p.title, hint: p.updatedAt.slice(0, 10) })) })) }); if (res.groups.length === 0) setResult({ kind: "links", groups: [] }); } else if (mode === "find-stale") { const res = await assistantApi.findStale(wsId, { days }, controller.signal); setResult({ kind: "links", groups: res.pages.length ? [ { label: `Not updated in ${days}+ days`, links: res.pages.map((p) => ({ id: p.pageId, title: p.title, hint: p.reason })) } ] : [] }); } else { // suggest-metadata if (!pageId) throw new ApiError("VALIDATION_ERROR", 400, "No page selected."); const res = await assistantApi.suggestMetadata(wsId, pageId, controller.signal); setResult({ kind: "suggestion", data: res }); } } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; const { message, retryable: canRetry } = errorText(e); setError(message); setRetryable(canRetry); } finally { setRunning(false); abortRef.current = null; } }, [workspace, mode, running, text, days, pageId, slug, router, close]); const applySuggestion = useCallback( async (suggestionId: string, fields: { title?: string; description?: string }) => { if (!workspace?.id || running) return; setRunning(true); setError(null); try { await assistantApi.applyMetadata(workspace.id, suggestionId, fields); // Refresh the page data the suggestion changed (title/description). await queryClient.invalidateQueries({ queryKey: ["pages", pageId] }); queryClient.invalidateQueries({ queryKey: ["workspaces", workspace.id, "page-tree"] }); toast("success", "Metadata applied", "Title and/or description updated"); close(); } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; setError(errorText(e).message); setRetryable(false); } finally { setRunning(false); } }, [workspace, running, pageId, queryClient, close] ); const canRun = !running && (mode === "extract-actions" ? text.trim().length > 0 : mode === "find-stale" || mode === "find-duplicates" ? true : !!pageId); const live = running ? `Running ${mode ? MODE_TITLES[mode] : ""}…` : error ? `Error: ${error}` : result ? "Result ready." : "Describe or select to run."; const placeholder = mode === "extract-actions" ? "Paste notes, a transcript, or a chat thread…\n\n- Ship the whiteboard export\n- Review the Q3 budget" : mode === "find-duplicates" ? "Optional: narrow the search (e.g. \"pricing\")" : mode === "find-stale" ? "Pages not updated in the selected number of days will be listed." : mode === "generate-faqs" ? "FAQs will be generated from this page's content into a new draft page." : "A suggested title/description will be shown for review before anything is applied."; return ( {result && result.kind === "suggestion" ? ( <> ) : ( <> {error && retryable ? ( ) : null} )} } > {/* The mode input (textarea / select / notice). */} {mode === "extract-actions" || mode === "find-duplicates" ? (