CI / checks (push) Has been cancelled
Cap get_api_docs, break duplicate tool rounds, force a reply after successful writes, and log each tool call so limit hits are diagnosable.
469 lines
16 KiB
TypeScript
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 "add milk to the shopping list", 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>
|
|
);
|
|
}
|