From 088da63de2e4020e71aaf0ac465e5d44b8bb6f12 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 12 Aug 2026 15:48:05 -0600 Subject: [PATCH] feat: add "Ask AI" dialog for groups with voice input Introduce GroupAiDialog component allowing users to chat with an AI assistant to generate or refine group notes. Add useSpeechRecognition hook wrapping the Web Speech API for microphone support. Implement converseAboutGroup server action to handle the AI conversation loop, supporting iterative questioning and final note generation. Extract joinApiUrl helper in lib/ai-settings.ts for consistent endpoint URL construction. --- components/board/group-ai-dialog.tsx | 234 +++++++++++++++++++++++++++ components/board/group-card.tsx | 18 ++- hooks/use-speech-recognition.ts | 168 +++++++++++++++++++ lib/actions/ai.ts | 10 +- lib/actions/group-ai.ts | 163 +++++++++++++++++++ lib/ai-settings.ts | 14 ++ 6 files changed, 598 insertions(+), 9 deletions(-) create mode 100644 components/board/group-ai-dialog.tsx create mode 100644 hooks/use-speech-recognition.ts create mode 100644 lib/actions/group-ai.ts 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}

} +
+ +
+
+