diff --git a/components/board/group-ai-dialog.tsx b/components/board/group-ai-dialog.tsx new file mode 100644 index 0000000..e73559a --- /dev/null +++ b/components/board/group-ai-dialog.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState } from "react"; +import { useTheme } from "next-themes"; +import { toast } from "sonner"; +import { Mic, Send, Sparkles, Square } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { MarkdownPreview } from "@/components/markdown/markdown-widgets"; +import { useBoard } from "@/components/board/board-context"; +import { useSpeechRecognition } from "@/hooks/use-speech-recognition"; +import { converseAboutGroup, type GroupAiMessage } from "@/lib/actions/group-ai"; +import type { GroupDTO } from "@/types/board"; + +export function GroupAiDialog({ + group, + open, + onOpenChange, +}: { + group: GroupDTO; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { saveNote } = useBoard(); + const { resolvedTheme } = useTheme(); + const colorMode = resolvedTheme === "dark" ? "dark" : "light"; + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Set once the model finalizes -- shown as a review step the user has + // to explicitly accept before it touches the group's real notes. + const [pendingNotes, setPendingNotes] = useState(null); + + const { supported: micSupported, listening, start, stop } = useSpeechRecognition({ + onFinalResult: (transcript) => + setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)), + onError: setError, + }); + + // Starts fresh every time it's opened -- a half-finished conversation + // from last time would be confusing to resume out of context. + function handleOpenChange(next: boolean) { + if (!next) { + stop(); + setMessages([]); + setInput(""); + setError(null); + setPendingNotes(null); + } + onOpenChange(next); + } + + async function callAi(nextMessages: GroupAiMessage[], forceFinalize: boolean) { + setError(null); + setSending(true); + const result = await converseAboutGroup(group.id, group.title, nextMessages, forceFinalize); + setSending(false); + + if ("error" in result) { + setError(result.error); + return; + } + if (result.action === "ask") { + setMessages([...nextMessages, { role: "assistant", content: result.question }]); + } else { + setMessages(nextMessages); + setPendingNotes(result.notes); + } + } + + async function handleSend() { + const text = input.trim(); + if (!text || sending) return; + if (listening) stop(); + const next: GroupAiMessage[] = [...messages, { role: "user", content: text }]; + setMessages(next); + setInput(""); + await callAi(next, false); + } + + async function handleGenerateNow() { + if (sending || messages.length === 0) return; + if (listening) stop(); + await callAi(messages, true); + } + + async function handleAccept() { + if (!pendingNotes) return; + setSaving(true); + const ok = await saveNote(group.id, group.categoryId, pendingNotes); + setSaving(false); + if (ok) { + toast.success("Notes updated."); + handleOpenChange(false); + } + } + + function handleDiscard() { + setPendingNotes(null); + } + + const hasExistingNotes = group.noteContent.trim().length > 0; + + return ( + + + + + + Ask AI — {group.title} + + + + {pendingNotes ? ( + <> +
+ {hasExistingNotes && ( +

+ This will replace the group's existing notes. +

+ )} +
+ +
+
+ + + + + + ) : ( + <> +
+ {messages.length === 0 ? ( +

+ Tell me about this group — what it's for, or what should be tracked here. +

+ ) : ( + messages.map((message, index) => ( +
+ {message.content} +
+ )) + )} + {sending && ( +
+ Thinking… +
+ )} + {error &&

{error}

} +
+ +
+
+