mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-23 23:14:46 +03:00
* Initial tool approval implementation * Cleanups and fixes * Improve robustness of loading case
544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { LuCircleAlert, LuMessageSquarePlus } from "react-icons/lu";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
import axios from "axios";
|
|
import useSWR from "swr";
|
|
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
|
|
import { MessageBubble } from "@/components/chat/ChatMessage";
|
|
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
|
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
|
import { ToolApprovalCard } from "@/components/chat/ToolApprovalCard";
|
|
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
|
import { ChatComposer } from "@/components/chat/ChatComposer";
|
|
import ChatSettings from "@/components/chat/ChatSettings";
|
|
import type {
|
|
ChatMessage,
|
|
ChatStats,
|
|
GenAIModelsResponse,
|
|
PendingToolCall,
|
|
ShowStatsMode,
|
|
ToolDecision,
|
|
} from "@/types/chat";
|
|
import { usePersistence } from "@/hooks/use-persistence";
|
|
import {
|
|
getEventIdsFromToolCalls,
|
|
getFindSimilarObjectsFromToolCalls,
|
|
prependAttachment,
|
|
streamChatCompletion,
|
|
toolCallsForMessage,
|
|
toolResponsesById,
|
|
} from "@/utils/chatUtil";
|
|
|
|
type StreamingTurn = {
|
|
content: string;
|
|
reasoning: string;
|
|
chain: ChatMessage[];
|
|
stats?: ChatStats;
|
|
};
|
|
|
|
const hasText = (content: unknown): content is string =>
|
|
typeof content === "string" && content.trim().length > 0;
|
|
|
|
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
|
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
|
|
|
// Stable default so usePersistence does not reload on every render.
|
|
const NO_TOOLS: string[] = [];
|
|
|
|
type ResumeOptions = {
|
|
toolDecisions: Record<string, ToolDecision>;
|
|
};
|
|
|
|
export default function ChatPage() {
|
|
const { t } = useTranslation(["views/chat"]);
|
|
const [input, setInput] = useState("");
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [streaming, setStreaming] = useState<StreamingTurn | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
|
// Write tool calls the backend paused on, plus the user's decisions so far.
|
|
const [pendingApprovals, setPendingApprovals] = useState<
|
|
PendingToolCall[] | null
|
|
>(null);
|
|
const [approvalDecisions, setApprovalDecisions] = useState<
|
|
Record<string, ToolDecision>
|
|
>({});
|
|
// Tools the user chose to always allow. Kept only in this browser; the
|
|
// backend never sees the list, the client just answers for them.
|
|
const [alwaysAllowTools, setAlwaysAllowTools] = usePersistence<string[]>(
|
|
"chat-always-allow-tools",
|
|
NO_TOOLS,
|
|
);
|
|
const alwaysAllowRef = useRef<string[]>(NO_TOOLS);
|
|
alwaysAllowRef.current = alwaysAllowTools ?? NO_TOOLS;
|
|
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
|
"chat-show-stats",
|
|
"while_generating",
|
|
);
|
|
const [autoScroll, setAutoScroll] = usePersistence<boolean>(
|
|
"chat-auto-scroll",
|
|
true,
|
|
);
|
|
const [thinkingEnabled, setThinkingEnabled] = usePersistence<boolean>(
|
|
"chat-thinking-enabled",
|
|
false,
|
|
);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const loadingRef = useRef(false);
|
|
|
|
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
const supportsThinking = useMemo(() => {
|
|
if (!genaiInfo) return false;
|
|
for (const entry of Object.values(genaiInfo)) {
|
|
if (entry.roles?.includes("chat") && entry.supports_toggleable_thinking) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}, [genaiInfo]);
|
|
|
|
useEffect(() => {
|
|
document.title = t("documentTitle");
|
|
}, [t]);
|
|
|
|
// Auto-scroll to bottom when messages change, but only if near bottom
|
|
useEffect(() => {
|
|
if (!autoScroll) return;
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
|
if (isNearBottom) {
|
|
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
}
|
|
}, [messages, streaming, autoScroll]);
|
|
|
|
const submitConversation = useCallback(
|
|
async function submit(
|
|
messagesToSend: ChatMessage[],
|
|
resume?: ResumeOptions,
|
|
) {
|
|
if (loadingRef.current) return;
|
|
const last = messagesToSend[messagesToSend.length - 1];
|
|
if (!last) return;
|
|
// A normal turn ends with the user's message; a resume after an
|
|
// approval pause ends with the assistant's pending tool calls.
|
|
if (resume) {
|
|
if (last.role !== "assistant" || !last.tool_calls?.length) return;
|
|
} else if (last.role !== "user" || !hasText(last.content)) {
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setPendingApprovals(null);
|
|
setApprovalDecisions({});
|
|
setMessages(messagesToSend);
|
|
setStreaming({ content: "", reasoning: "", chain: [] });
|
|
loadingRef.current = true;
|
|
setIsLoading(true);
|
|
|
|
const baseURL = axios.defaults.baseURL ?? "";
|
|
const url = `${baseURL}chat/completion`;
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(axios.defaults.headers.common as Record<string, string>),
|
|
};
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
let chain: ChatMessage[] = [];
|
|
let stats: ChatStats | undefined;
|
|
let reasoning = "";
|
|
let hadError = false;
|
|
let approvals: PendingToolCall[] | null = null;
|
|
|
|
await streamChatCompletion(
|
|
url,
|
|
headers,
|
|
toWire(messagesToSend),
|
|
{
|
|
onContentDelta: (delta) =>
|
|
setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)),
|
|
onReasoningDelta: (delta) => {
|
|
reasoning += delta;
|
|
setStreaming((s) =>
|
|
s ? { ...s, reasoning: s.reasoning + delta } : s,
|
|
);
|
|
},
|
|
onChain: (fullChain) => {
|
|
chain = fullChain;
|
|
setStreaming((s) => (s ? { ...s, chain: fullChain } : s));
|
|
},
|
|
onStats: (s) => {
|
|
stats = s;
|
|
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
|
},
|
|
onApprovalRequired: (toolCalls) => {
|
|
approvals = toolCalls;
|
|
},
|
|
onError: (message) => {
|
|
hadError = true;
|
|
setError(message);
|
|
},
|
|
onDone: () => {
|
|
abortRef.current = null;
|
|
loadingRef.current = false;
|
|
setIsLoading(false);
|
|
setStreaming(null);
|
|
const lastMsg = chain[chain.length - 1];
|
|
if (!hadError && lastMsg?.role === "assistant") {
|
|
const committed = chain.map((m, i) =>
|
|
i === chain.length - 1
|
|
? { ...m, reasoning: reasoning || undefined, stats }
|
|
: m,
|
|
);
|
|
setMessages(committed);
|
|
if (approvals?.length) {
|
|
// Calls to always-allowed tools are answered here without
|
|
// prompting; anything else waits for the user.
|
|
const allowed = alwaysAllowRef.current;
|
|
const auto: Record<string, ToolDecision> = {};
|
|
for (const tc of approvals) {
|
|
if (allowed.includes(tc.name)) auto[tc.id] = "approve";
|
|
}
|
|
if (Object.keys(auto).length === approvals.length) {
|
|
submit(committed, { toolDecisions: auto });
|
|
} else {
|
|
setApprovalDecisions(auto);
|
|
setPendingApprovals(approvals);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
defaultErrorMessage: t("error"),
|
|
},
|
|
controller.signal,
|
|
{
|
|
...(supportsThinking ? { enableThinking: !!thinkingEnabled } : {}),
|
|
toolDecisions: resume?.toolDecisions,
|
|
},
|
|
);
|
|
},
|
|
[supportsThinking, t, thinkingEnabled],
|
|
);
|
|
|
|
// Resume the paused turn once every pending call has a decision.
|
|
const applyDecisions = useCallback(
|
|
(next: Record<string, ToolDecision>) => {
|
|
setApprovalDecisions(next);
|
|
if (!pendingApprovals) return;
|
|
if (!pendingApprovals.every((tc) => next[tc.id] !== undefined)) return;
|
|
submitConversation(messages, { toolDecisions: next });
|
|
},
|
|
[messages, pendingApprovals, submitConversation],
|
|
);
|
|
|
|
const handleApprove = useCallback(
|
|
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "approve" }),
|
|
[applyDecisions, approvalDecisions],
|
|
);
|
|
|
|
const handleReject = useCallback(
|
|
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "reject" }),
|
|
[applyDecisions, approvalDecisions],
|
|
);
|
|
|
|
const handleAlwaysAllow = useCallback(
|
|
(id: string, name: string) => {
|
|
const current = alwaysAllowTools ?? NO_TOOLS;
|
|
const allowed = current.includes(name) ? current : [...current, name];
|
|
setAlwaysAllowTools(allowed);
|
|
const next = { ...approvalDecisions, [id]: "approve" as const };
|
|
for (const tc of pendingApprovals ?? []) {
|
|
if (tc.name === name) next[tc.id] = "approve";
|
|
}
|
|
applyDecisions(next);
|
|
},
|
|
[
|
|
alwaysAllowTools,
|
|
applyDecisions,
|
|
approvalDecisions,
|
|
pendingApprovals,
|
|
setAlwaysAllowTools,
|
|
],
|
|
);
|
|
|
|
const clearAlwaysAllowTools = useCallback(
|
|
() => setAlwaysAllowTools(NO_TOOLS),
|
|
[setAlwaysAllowTools],
|
|
);
|
|
|
|
const recentEventIds = useMemo(() => {
|
|
const responses = toolResponsesById(messages);
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i];
|
|
if (msg.role !== "assistant" || !msg.tool_calls?.length) continue;
|
|
const calls = toolCallsForMessage(msg, responses);
|
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
|
if (similar) return similar.results.map((e) => e.id);
|
|
const events = getEventIdsFromToolCalls(calls);
|
|
if (events.length > 0) return events.map((e) => e.id);
|
|
}
|
|
return [];
|
|
}, [messages]);
|
|
|
|
const sendMessage = useCallback(
|
|
(textOverride?: string) => {
|
|
const text = (textOverride ?? input).trim();
|
|
if (!text || isLoading) return;
|
|
const wireText = attachedEventId
|
|
? prependAttachment(text, attachedEventId)
|
|
: text;
|
|
setInput("");
|
|
setAttachedEventId(null);
|
|
submitConversation([...messages, { role: "user", content: wireText }]);
|
|
},
|
|
[attachedEventId, input, isLoading, messages, submitConversation],
|
|
);
|
|
|
|
const stopGeneration = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
abortRef.current = null;
|
|
loadingRef.current = false;
|
|
setIsLoading(false);
|
|
setStreaming(null);
|
|
setPendingApprovals(null);
|
|
setApprovalDecisions({});
|
|
}, []);
|
|
|
|
const startNewChat = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
abortRef.current = null;
|
|
loadingRef.current = false;
|
|
setIsLoading(false);
|
|
setStreaming(null);
|
|
setMessages([]);
|
|
setInput("");
|
|
setAttachedEventId(null);
|
|
setError(null);
|
|
setPendingApprovals(null);
|
|
setApprovalDecisions({});
|
|
}, []);
|
|
|
|
const handleEditSubmit = useCallback(
|
|
(messageIndex: number, newContent: string) => {
|
|
const newList: ChatMessage[] = [
|
|
...messages.slice(0, messageIndex),
|
|
{ role: "user", content: newContent },
|
|
];
|
|
submitConversation(newList);
|
|
},
|
|
[messages, submitConversation],
|
|
);
|
|
|
|
const handleClearAttachment = useCallback(() => {
|
|
setAttachedEventId(null);
|
|
}, []);
|
|
|
|
const hasStarted = messages.length > 0 || streaming != null;
|
|
|
|
// While streaming, the backend's in-flight chain is the source of truth;
|
|
// otherwise the committed conversation is.
|
|
const renderList =
|
|
streaming && streaming.chain.length ? streaming.chain : messages;
|
|
const responses = toolResponsesById(renderList);
|
|
const renderTail = renderList[renderList.length - 1];
|
|
const finalShown =
|
|
renderTail?.role === "assistant" && hasText(renderTail.content);
|
|
|
|
const renderMessage = (msg: ChatMessage, i: number) => {
|
|
if (msg.role === "system" || msg.role === "tool") return null;
|
|
|
|
if (msg.role === "user") {
|
|
if (!hasText(msg.content)) return null;
|
|
return (
|
|
<div key={i} className="flex flex-col gap-2">
|
|
<MessageBubble
|
|
role="user"
|
|
content={msg.content}
|
|
messageIndex={i}
|
|
onEditSubmit={handleEditSubmit}
|
|
isComplete
|
|
showStats={showStats}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const calls = toolCallsForMessage(msg, responses);
|
|
const contentText = hasText(msg.content) ? msg.content : "";
|
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
|
const events = similar ? [] : getEventIdsFromToolCalls(calls);
|
|
|
|
return (
|
|
<div key={i} className="flex flex-col gap-2">
|
|
{calls.length > 0 && <ToolCallsGroup toolCalls={calls} />}
|
|
{hasText(msg.reasoning) && (
|
|
<ReasoningBubble
|
|
reasoning={msg.reasoning}
|
|
answerStarted={!!contentText}
|
|
/>
|
|
)}
|
|
{contentText && (
|
|
<MessageBubble
|
|
role="assistant"
|
|
content={contentText}
|
|
messageIndex={i}
|
|
isComplete
|
|
stats={msg.stats}
|
|
showStats={showStats}
|
|
/>
|
|
)}
|
|
{similar ? (
|
|
<ChatEventThumbnailsRow
|
|
events={similar.results}
|
|
anchor={similar.anchor}
|
|
onAttach={setAttachedEventId}
|
|
/>
|
|
) : (
|
|
<ChatEventThumbnailsRow
|
|
events={events}
|
|
onAttach={setAttachedEventId}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const processingDots = (
|
|
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
|
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
|
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
|
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<div className="flex shrink-0 items-center justify-end gap-2 px-2 pb-3 pt-2 md:px-4 md:pt-4">
|
|
{hasStarted && (
|
|
<Button
|
|
className="flex items-center md:gap-2"
|
|
aria-label={t("new_chat")}
|
|
size="sm"
|
|
onClick={startNewChat}
|
|
>
|
|
<LuMessageSquarePlus className="text-secondary-foreground" />
|
|
<span className="hidden md:inline">{t("new_chat")}</span>
|
|
</Button>
|
|
)}
|
|
<ChatSettings
|
|
showStats={showStats ?? "while_generating"}
|
|
setShowStats={setShowStats}
|
|
autoScroll={autoScroll ?? true}
|
|
setAutoScroll={setAutoScroll}
|
|
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
|
|
clearAlwaysAllowTools={clearAlwaysAllowTools}
|
|
/>
|
|
</div>
|
|
<div
|
|
ref={scrollRef}
|
|
className="scrollbar-container flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
>
|
|
<div className="flex flex-1 justify-center px-2 md:px-4">
|
|
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
|
{hasStarted ? (
|
|
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
|
{renderList.map((msg, i) => renderMessage(msg, i))}
|
|
{pendingApprovals && !streaming && (
|
|
<div className="flex flex-col gap-2">
|
|
{pendingApprovals.map((tc) => (
|
|
<ToolApprovalCard
|
|
key={tc.id}
|
|
toolCall={tc}
|
|
decision={approvalDecisions[tc.id]}
|
|
onApprove={handleApprove}
|
|
onAlwaysAllow={handleAlwaysAllow}
|
|
onReject={handleReject}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{streaming &&
|
|
!finalShown &&
|
|
(streaming.content || streaming.reasoning ? (
|
|
<div className="flex flex-col gap-2">
|
|
{hasText(streaming.reasoning) && (
|
|
<ReasoningBubble
|
|
reasoning={streaming.reasoning}
|
|
answerStarted={!!streaming.content}
|
|
/>
|
|
)}
|
|
{streaming.content && (
|
|
<MessageBubble
|
|
role="assistant"
|
|
content={streaming.content}
|
|
messageIndex={-1}
|
|
isComplete={false}
|
|
stats={streaming.stats}
|
|
showStats={showStats}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
processingDots
|
|
))}
|
|
{error && (
|
|
<p
|
|
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
|
role="alert"
|
|
>
|
|
<LuCircleAlert className="size-3.5 shrink-0" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<ChatStartingState
|
|
onSendMessage={(message) => {
|
|
setInput("");
|
|
submitConversation([{ role: "user", content: message }]);
|
|
}}
|
|
supportsThinking={supportsThinking}
|
|
thinkingEnabled={!!thinkingEnabled}
|
|
setThinkingEnabled={setThinkingEnabled}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{hasStarted && (
|
|
<div className="flex shrink-0 justify-center p-2 md:px-4 md:pb-4">
|
|
<div className="flex w-full xl:w-[50%] 3xl:w-[35%]">
|
|
<ChatComposer
|
|
input={input}
|
|
setInput={setInput}
|
|
sendMessage={sendMessage}
|
|
isLoading={isLoading}
|
|
disabled={pendingApprovals != null}
|
|
placeholder={
|
|
pendingApprovals != null
|
|
? t("approval.placeholder")
|
|
: t("placeholder")
|
|
}
|
|
attachedEventId={attachedEventId}
|
|
onClearAttachment={handleClearAttachment}
|
|
onAttach={setAttachedEventId}
|
|
onStop={stopGeneration}
|
|
recentEventIds={recentEventIds}
|
|
supportsThinking={supportsThinking}
|
|
thinkingEnabled={!!thinkingEnabled}
|
|
setThinkingEnabled={setThinkingEnabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|