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.
This commit is contained in:
Brian Fertig 2026-08-12 15:48:05 -06:00
parent aa6a2e1cfd
commit 088da63de2
6 changed files with 598 additions and 9 deletions

View File

@ -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<GroupAiMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg" data-color-mode={colorMode}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Ask AI {group.title}
</DialogTitle>
</DialogHeader>
{pendingNotes ? (
<>
<div className="flex-1 space-y-2 overflow-y-auto">
{hasExistingNotes && (
<p className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
This will replace the group&apos;s existing notes.
</p>
)}
<div className="max-h-[50vh] overflow-y-auto rounded-md border p-4">
<MarkdownPreview source={pendingNotes} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleDiscard} disabled={saving}>
Keep chatting
</Button>
<Button onClick={handleAccept} disabled={saving}>
{saving ? "Saving…" : hasExistingNotes ? "Replace notes" : "Save notes"}
</Button>
</DialogFooter>
</>
) : (
<>
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
Tell me about this group what it&apos;s for, or what should be tracked here.
</p>
) : (
messages.map((message, index) => (
<div
key={index}
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.content}
</div>
))
)}
{sending && (
<div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
Thinking
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="space-y-2">
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={listening ? "Listening…" : "Type or use the microphone…"}
rows={2}
className="flex-1 resize-none"
disabled={sending}
/>
<div className="flex flex-col gap-2">
{micSupported && (
<Button
type="button"
variant={listening ? "destructive" : "outline"}
size="icon"
onClick={listening ? stop : start}
disabled={sending}
aria-label={listening ? "Stop recording" : "Record voice input"}
>
{listening ? <Square className="size-4" /> : <Mic className="size-4" />}
</Button>
)}
<Button
type="button"
size="icon"
onClick={handleSend}
disabled={sending || !input.trim()}
aria-label="Send"
>
<Send className="size-4" />
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={handleGenerateNow}
disabled={sending || messages.length === 0}
>
Generate now, using what I&apos;ve said so far
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -4,7 +4,15 @@ import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import { Archive, GripVertical, MoreVertical, Pencil, StickyNote, Trash2 } from "lucide-react";
import {
Archive,
GripVertical,
MoreVertical,
Pencil,
Sparkles,
StickyNote,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@ -20,6 +28,7 @@ import {
import { getComplementaryColor, getGroupColor } from "@/lib/colors";
import { useBoard } from "@/components/board/board-context";
import { NotesDialog } from "@/components/board/notes-dialog";
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
@ -32,6 +41,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const { resolvedTheme } = useTheme();
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const {
@ -123,6 +133,10 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAiOpen(true)}>
<Sparkles className="size-4" />
Ask AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
<Trash2 className="size-4" />
@ -208,6 +222,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}

View File

@ -0,0 +1,168 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
// The Web Speech API isn't part of TypeScript's built-in DOM types, so
// this declares just enough of the shape this hook actually touches
// (both the standard and Chrome/Safari's webkit-prefixed constructor
// implement the same interface).
interface SpeechRecognitionResultLike {
isFinal: boolean;
[index: number]: { transcript: string };
}
interface SpeechRecognitionEventLike extends Event {
resultIndex: number;
results: ArrayLike<SpeechRecognitionResultLike>;
}
interface SpeechRecognitionErrorEventLike extends Event {
error: string;
}
interface SpeechRecognitionLike extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
}
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
function getSpeechRecognitionConstructor(): SpeechRecognitionConstructor | null {
if (typeof window === "undefined") return null;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
/**
* Thin wrapper around the browser's native SpeechRecognition API.
* `supported` reflects whether it exists at all -- notably absent in
* Firefox, inconsistent on some mobile browsers -- so callers can hide
* the mic affordance entirely rather than show a button that just fails.
*
* Recognizes continuously while listening; each finalized phrase is
* reported via `onFinalResult` as it resolves (not batched up until
* `stop()` -- the API has no such mode), so by the time the caller stops
* it, everything spoken has already arrived.
*/
export function useSpeechRecognition({
onFinalResult,
onError,
}: {
onFinalResult: (transcript: string) => void;
onError?: (message: string) => void;
}) {
const [listening, setListening] = useState(false);
const [supported, setSupported] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
// True only while the user has deliberately asked to stop -- lets
// `onend` (below) tell "the user clicked stop" apart from "the browser
// ended the session on its own", which it can do well before that (a
// few seconds of silence is enough on some browsers, even with
// `continuous = true`). Without that distinction, a spontaneous end
// right after starting looked exactly like a single click both
// starting *and* immediately stopping recording.
const userStoppedRef = useRef(false);
// Kept in a ref so `start` doesn't need to change identity (and doesn't
// need to be recreated) just because the caller's callback did. Synced
// in an effect rather than during render -- refs aren't meant to be
// written outside of effects/event handlers.
const onFinalResultRef = useRef(onFinalResult);
useEffect(() => {
onFinalResultRef.current = onFinalResult;
}, [onFinalResult]);
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
// `start` restarts itself (see onend below) on a spontaneous end, but a
// useCallback can't cleanly reference its own not-yet-assigned const
// from inside its own body -- routed through a ref instead, assigned
// once start's identity is known.
const startRef = useRef<() => void>(() => {});
useEffect(() => {
setSupported(getSpeechRecognitionConstructor() !== null);
}, []);
const start = useCallback(() => {
const Ctor = getSpeechRecognitionConstructor();
if (!Ctor) return;
userStoppedRef.current = false;
// Scoped to this one recognition instance/attempt -- a restart (see
// onend below) gets a fresh recognition object and a fresh flag, not
// whatever an earlier attempt left behind.
let erroredMessage: string | null = null;
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = false;
recognition.lang = typeof navigator !== "undefined" ? navigator.language : "en-US";
recognition.onresult = (event) => {
let finalText = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) finalText += result[0].transcript;
}
if (finalText.trim()) onFinalResultRef.current(finalText.trim());
};
recognition.onerror = (event) => {
const errorType = event.error;
erroredMessage =
errorType === "not-allowed" || errorType === "service-not-allowed"
? "Microphone access was blocked."
: errorType === "audio-capture"
? "No microphone was found."
: errorType === "no-speech"
? null // silence isn't an error worth surfacing -- just let it end/restart
: "Voice input stopped unexpectedly.";
};
recognition.onend = () => {
if (userStoppedRef.current) {
setListening(false);
return;
}
if (erroredMessage) {
setListening(false);
onErrorRef.current?.(erroredMessage);
return;
}
// Ended on its own while the user still wants to be listening --
// pick the mic back up transparently rather than flipping the
// button back to "Mic", which is what made this look like a
// double-click in the first place.
startRef.current();
};
recognitionRef.current = recognition;
recognition.start();
setListening(true);
}, []);
useEffect(() => {
startRef.current = start;
}, [start]);
const stop = useCallback(() => {
userStoppedRef.current = true;
recognitionRef.current?.stop();
setListening(false);
}, []);
// Stop listening if the component unmounts (dialog closed, etc.) --
// otherwise the browser keeps the mic hot and events fire into a
// detached callback.
useEffect(() => {
return () => {
recognitionRef.current?.stop();
};
}, []);
return { supported, listening, start, stop };
}

View File

@ -3,16 +3,10 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth-helpers";
import { getAiCredentials, saveAiSettings } from "@/lib/ai-settings";
import { getAiCredentials, joinApiUrl, saveAiSettings } from "@/lib/ai-settings";
export type TestAiConnectionResult = { error?: string; models?: string[] };
/** Builds the provider's `/models` endpoint from an admin-entered base URL. */
function buildModelsUrl(apiUrl: string): URL {
const withTrailingSlash = apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
return new URL("models", withTrailingSlash);
}
function extractModelIds(body: unknown): string[] {
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : undefined;
if (!Array.isArray(data)) return [];
@ -38,7 +32,7 @@ export async function testAiConnection(
let modelsUrl: URL;
try {
modelsUrl = buildModelsUrl(trimmedUrl);
modelsUrl = joinApiUrl(trimmedUrl, "models");
} catch {
return { error: "That doesn't look like a valid URL." };
}

163
lib/actions/group-ai.ts Normal file
View File

@ -0,0 +1,163 @@
"use server";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { categoryAccessFilter } from "@/lib/access";
import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
export interface GroupAiMessage {
role: "user" | "assistant";
content: string;
}
export type GroupAiResult =
| { action: "ask"; question: string }
| { action: "finalize"; notes: string }
| { error: string };
// Keeps the model's job narrow and its output parseable: always exactly
// one of "ask" (need more from the user) or "finalize" (here's the
// notes), never free-form prose mixed in around it. `{{GROUP_TITLE}}` is
// substituted per call.
const SYSTEM_PROMPT = `You are helping a user write the notes/description for a group called "{{GROUP_TITLE}}" in a to-do list app. A "group" is a card that holds a list of related to-dos; its notes are a short markdown description of what the group is about and any context worth remembering.
Have a brief conversation to understand what this group is about. Ask at most one short, specific follow-up question at a time if you genuinely need more detail to write something useful. Once you have enough -- or the user asks you to wrap up -- respond with the final notes.
You must respond with ONLY a single JSON object and nothing else -- no preamble, no code block, no text before or after it. It must match exactly one of these two shapes:
Still gathering information:
{"action": "ask", "question": "<one short, specific question>"}
Ready to finalize:
{"action": "finalize", "notes_markdown": "<the notes, in markdown, 1-4 short paragraphs and/or a short list. Do not repeat the group's title as a heading. Do not add commentary, disclaimers, or phrases like \\"Here are your notes\\" -- output only the notes content itself.>"}
Respond with the raw JSON object only.`;
function extractMessageContent(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const choices = (body as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0]) return null;
const message = (choices[0] as { message?: unknown }).message;
if (!message || typeof message !== "object") return null;
const content = (message as { content?: unknown }).content;
return typeof content === "string" ? content : null;
}
/** Model output sometimes arrives fenced in a ```json code block despite
* instructions not to -- strip that before parsing rather than failing. */
function stripCodeFence(text: string): string {
const trimmed = text.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return fenced ? fenced[1] : trimmed;
}
function parseModelOutput(content: string): GroupAiResult | null {
let json: unknown;
try {
json = JSON.parse(stripCodeFence(content));
} catch {
return null;
}
if (!json || typeof json !== "object") return null;
const action = (json as { action?: unknown }).action;
if (action === "ask") {
const question = (json as { question?: unknown }).question;
return typeof question === "string" && question.trim()
? { action: "ask", question: question.trim() }
: null;
}
if (action === "finalize") {
const notes = (json as { notes_markdown?: unknown }).notes_markdown;
return typeof notes === "string" && notes.trim()
? { action: "finalize", notes: notes.trim() }
: null;
}
return null;
}
/**
* Sends the popover's conversation so far to the configured AI provider
* and returns either a follow-up question or the final notes. Reuses the
* same admin-configured endpoint/key as the "AI generation" admin
* settings (see lib/ai-settings.ts) -- there's nothing group- or
* user-specific to configure beyond that.
*/
export async function converseAboutGroup(
groupId: string,
groupTitle: string,
messages: GroupAiMessage[],
forceFinalize: boolean
): Promise<GroupAiResult> {
const userId = await requireUserId();
const group = await prisma.group.findFirst({
where: { id: groupId, category: categoryAccessFilter(userId) },
select: { id: true },
});
if (!group) return { error: "Group not found." };
const [{ apiUrl, apiKey }, settings] = await Promise.all([
getAiCredentials(),
getAiSettingsView(),
]);
if (!apiUrl || !settings.model) {
return { error: "AI generation isn't configured yet. Ask an administrator to set it up." };
}
let chatUrl: URL;
try {
chatUrl = joinApiUrl(apiUrl, "chat/completions");
} catch {
return { error: "The configured AI API URL is invalid." };
}
let systemPrompt = SYSTEM_PROMPT.replace("{{GROUP_TITLE}}", groupTitle);
if (forceFinalize) {
systemPrompt +=
'\n\nThe user has asked you to wrap up now with whatever information you have, even if it feels incomplete. Do not ask another question -- respond with {"action": "finalize", ...} using your best effort.';
}
let response: Response;
try {
response = await fetch(chatUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
temperature: 0.4,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { error: `Couldn't reach the AI provider -- ${message}` };
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return { error: "The AI provider rejected the configured API key." };
}
return { error: `The AI provider responded with ${response.status} ${response.statusText}.` };
}
let body: unknown;
try {
body = await response.json();
} catch {
return { error: "The AI provider didn't return valid JSON." };
}
const content = extractMessageContent(body);
if (!content) return { error: "The AI provider's response was empty." };
const parsed = parseModelOutput(content);
// Not every backend reliably follows the "JSON only" instruction --
// rather than erroring out, treat unparseable output as a follow-up
// question so the conversation degrades gracefully instead of dying.
return parsed ?? { action: "ask", question: content.trim() };
}

View File

@ -19,6 +19,20 @@ function maskKey(key: string): string {
return `••••••••${key.slice(-4)}`;
}
/**
* Builds an OpenAI-compatible endpoint URL from an admin-entered base URL
* (e.g. "https://host/api/v1") plus a path (e.g. "models",
* "chat/completions"). Shared by every call site that talks to the
* configured provider, so the trailing-slash handling only lives once --
* `new URL(path, base)` silently drops the base's last path segment
* (turning ".../v1" into ".../models" instead of ".../v1/models") unless
* the base ends in "/".
*/
export function joinApiUrl(apiUrl: string, path: string): URL {
const withTrailingSlash = apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`;
return new URL(path, withTrailingSlash);
}
/** The client-safe view of the AI settings -- never includes the real key. */
export async function getAiSettingsView(): Promise<AiSettingsView> {
const settings = await prisma.aiSettings.findUnique({ where: { id: SETTINGS_ID } });