Files
famapp/src/modules/agent/components/assistant-panel.tsx
T
ginnoir 72d123b9a0
CI / checks (push) Has been cancelled
fix(agent): stop tool thrash after writes and log rounds
Cap get_api_docs, break duplicate tool rounds, force a reply after
successful writes, and log each tool call so limit hits are diagnosable.
2026-07-08 22:51:31 -05:00

469 lines
16 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { ChevronDown, ImagePlus, Loader2, Mic, RefreshCw, Send, Square } from "lucide-react";
import { setAssistantModel } from "@/app/settings/assistant-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { consumeAgentChatStream } from "../assistant-chat-stream";
import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
toClientChatMessage,
type AssistantChatMessage,
} from "../assistant-chat-storage";
import { useVoiceInput } from "./use-voice-input";
type Props = {
configured: boolean;
userId: string;
assistantName: string;
assistantModel: string | null;
};
type PendingImage = {
url: string;
};
type LlmModelOption = {
id: string;
label: string;
};
type ModelsResponse = {
models: LlmModelOption[];
selectedModel: string;
fallbackModel: string;
degraded: boolean;
};
async function uploadAssistantImage(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/uploads?scope=assistant", { method: "POST", body: formData });
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error ?? "Image upload failed");
}
const payload = (await response.json()) as { url: string };
return payload.url;
}
export function AssistantPanel({ configured, userId, assistantName, assistantModel }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [pendingImage, setPendingImage] = useState<PendingImage | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const [modelOptions, setModelOptions] = useState<LlmModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState(assistantModel ?? "");
const [fallbackModel, setFallbackModel] = useState("");
const [modelsDegraded, setModelsDegraded] = useState(false);
const [modelsLoading, setModelsLoading] = useState(true);
const [savingModel, startTransition] = useTransition();
const listRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const { state: voiceState, toggleRecording } = useVoiceInput({
disabled: isPending || uploadingImage,
onTranscript: (text) => {
setInput((current) => (current.trim() ? `${current.trim()} ${text}` : text));
setError(null);
},
onError: (message) => setError(message),
});
useEffect(() => {
saveAssistantChat(userId, messages);
}, [messages, userId]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const loadModels = useCallback(async (options?: { refresh?: boolean; signal?: AbortSignal }) => {
if (options?.signal?.aborted) return;
setModelsLoading(true);
try {
const params = new URLSearchParams();
if (options?.refresh) params.set("refresh", "1");
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await fetch(`/api/agent/models${query}`, {
cache: "no-store",
signal: options?.signal,
});
if (!response.ok) throw new Error("Model discovery unavailable");
const payload = (await response.json()) as ModelsResponse;
if (options?.signal?.aborted) return;
setModelOptions(payload.models);
setSelectedModel(payload.selectedModel);
setFallbackModel(payload.fallbackModel);
setModelsDegraded(payload.degraded);
setError(null);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setModelsDegraded(true);
setError("Model discovery unavailable");
} finally {
if (!options?.signal?.aborted) setModelsLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
queueMicrotask(() => {
void loadModels({ signal: controller.signal });
});
return () => {
controller.abort();
};
}, [loadModels]);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function clearChat() {
abortRef.current?.abort();
setMessages([]);
setInput("");
setPendingImage(null);
setError(null);
setActivityLabel(null);
setIsPending(false);
clearAssistantChat(userId);
}
async function handleImageSelect(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || isPending || uploadingImage) return;
setUploadingImage(true);
setError(null);
try {
const url = await uploadAssistantImage(file);
setPendingImage({ url });
} catch (err) {
setError(err instanceof Error ? err.message : "Image upload failed");
} finally {
setUploadingImage(false);
}
}
function changeModel(nextModel: string | null) {
if (!nextModel) return;
setSelectedModel(nextModel);
setError(null);
startTransition(async () => {
try {
await setAssistantModel(nextModel === fallbackModel ? null : nextModel);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save assistant model");
}
});
}
async function sendMessage() {
const text = input.trim();
const hasImage = pendingImage !== null;
if ((!text && !hasImage) || isPending || voiceState !== "idle") return;
const content = text || "Help me with this image.";
const userMessage: AssistantChatMessage = {
role: "user",
content,
...(pendingImage ? { imageUrl: pendingImage.url } : {}),
};
const nextMessages: AssistantChatMessage[] = [...messages, userMessage];
setInput("");
setPendingImage(null);
setError(null);
setMessages(nextMessages);
setIsPending(true);
setActivityLabel(hasImage ? "Reading your photo…" : "Understanding your request…");
scrollToBottom();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: nextMessages.map(toClientChatMessage),
model: selectedModel || undefined,
stream: true,
}),
signal: controller.signal,
});
const result = await consumeAgentChatStream(response, (event) => {
if (event.type === "thinking" || event.type === "tool" || event.type === "responding") {
setActivityLabel(event.label);
scrollToBottom();
}
});
if (!result.message.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [
...current,
{
role: "assistant",
content: result.message.content,
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
},
]);
scrollToBottom();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsPending(false);
setActivityLabel(null);
abortRef.current = null;
}
}
const showEmptyState = messages.length === 0 && !isPending;
const inputDisabled = isPending || voiceState === "transcribing" || uploadingImage;
const canSend =
!isPending &&
!savingModel &&
voiceState === "idle" &&
!uploadingImage &&
(input.trim().length > 0 || pendingImage !== null);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="muted text-[12px] leading-relaxed">
{configured
? "Type, talk, or send a photo — I can update lists, calendar, notes, and more."
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
</p>
{modelsDegraded ? (
<p className="muted mt-1 text-[11px]">Model discovery unavailable; using fallback.</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{modelOptions.length > 0 ? (
<div className="relative max-w-36">
<select
aria-label="Assistant model"
value={selectedModel}
onChange={(event) => changeModel(event.target.value)}
disabled={modelsLoading || savingModel || isPending}
className="h-9 w-full max-w-36 appearance-none truncate rounded-[min(var(--radius-md),10px)] border border-input bg-[var(--card)] py-0 pr-8 pl-3 text-[13px] leading-9 text-[var(--ink)] outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{modelOptions.map((model) => (
<option key={model.id} value={model.id}>
{model.label}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute top-1/2 right-2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
</div>
) : null}
<Button
type="button"
size="sm"
variant="outline"
aria-label="Refresh assistant models"
disabled={modelsLoading || savingModel || isPending}
onClick={() => void loadModels({ refresh: true })}
>
{modelsLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
</div>
<div
ref={listRef}
className="min-h-0 flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-3"
style={{ borderColor: "var(--hair)" }}
>
{showEmptyState ? (
<p className="muted text-[12px]">
Try &quot;add milk to the shopping list&quot;, tap the mic, or attach a photo of an
appointment card.
</p>
) : (
<div className="grid gap-2">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-2.5 py-2 text-[12.5px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-6 bg-[var(--card)]"
: "mr-6 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : assistantName}
</div>
{message.imageUrl ? (
// User-uploaded assistant attachment preview
<img
src={message.imageUrl}
alt=""
className="mb-2 max-h-40 w-full rounded-md object-contain"
/>
) : null}
{message.content}
{message.role === "assistant" &&
message.toolCalls &&
message.toolCalls.length > 0 ? (
<p className="mt-1 text-[10px] text-muted-foreground">
{message.toolCalls.map((call) => `${call.name}${call.status}`).join(" · ")}
</p>
) : null}
</div>
))}
{isPending ? (
<div
className="mr-6 rounded-lg border-[0.5px] bg-[var(--card)] px-2.5 py-2"
style={{ borderColor: "var(--hair)" }}
aria-live="polite"
aria-busy="true"
>
<div className="eyebrow mb-1 text-[10px]">{assistantName}</div>
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span>
</div>
</div>
) : null}
</div>
)}
</div>
{pendingImage ? (
<div className="flex items-center gap-2 rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-2">
<img
src={pendingImage.url}
alt=""
className="max-h-16 max-w-[40%] rounded-md object-contain"
/>
<div className="min-w-0 flex-1 text-[11px] text-muted-foreground">Photo attached</div>
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setPendingImage(null)}
disabled={inputDisabled}
>
Remove
</button>
</div>
) : null}
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => void handleImageSelect(event)}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={inputDisabled}
aria-label="Attach photo"
onClick={() => imageInputRef.current?.click()}
>
{uploadingImage ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ImagePlus className="size-4" />
)}
</Button>
<Button
type="button"
size="sm"
variant={voiceState === "recording" ? "destructive" : "outline"}
disabled={inputDisabled}
aria-label={voiceState === "recording" ? "Stop recording" : "Record voice message"}
aria-pressed={voiceState === "recording"}
onClick={() => void toggleRecording()}
>
{voiceState === "transcribing" ? (
<Loader2 className="size-4 animate-spin" />
) : voiceState === "recording" ? (
<Square className="size-4" />
) : (
<Mic className="size-4" />
)}
</Button>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
voiceState === "recording"
? "Listening… tap mic to stop"
: voiceState === "transcribing"
? "Transcribing…"
: `Ask ${assistantName}…`
}
disabled={inputDisabled}
aria-label={`Message for ${assistantName}`}
className="h-9 min-w-0 flex-1"
/>
<Button type="submit" size="sm" disabled={!canSend} aria-label="Send">
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</form>
</div>
);
}