Organize/components/board/group-ai-dialog.tsx

235 lines
7.9 KiB
TypeScript

"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>
);
}