mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 02:28:59 +03:00
GenAI Chat Improvements (#24173)
* Initial tool approval implementation * Cleanups and fixes * Improve robustness of loading case
This commit is contained in:
@@ -26,6 +26,9 @@ type ChatComposerProps = {
|
||||
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
/** Blocks input without showing the stop button, e.g. while a tool call
|
||||
* is waiting for the user's approval. */
|
||||
disabled?: boolean;
|
||||
|
||||
attachedEventId?: string | null;
|
||||
onClearAttachment?: () => void;
|
||||
@@ -45,6 +48,7 @@ export function ChatComposer({
|
||||
setThinkingEnabled,
|
||||
isLoading = false,
|
||||
onStop,
|
||||
disabled = false,
|
||||
attachedEventId,
|
||||
onClearAttachment,
|
||||
onAttach,
|
||||
@@ -62,6 +66,7 @@ export function ChatComposer({
|
||||
|
||||
const showPaperclip = !!onAttach;
|
||||
const showStop = isLoading && !!onStop;
|
||||
const inputBlocked = isLoading || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
@@ -77,7 +82,7 @@ export function ChatComposer({
|
||||
{attachedEventId && (
|
||||
<ChatQuickReplies
|
||||
onSend={(text) => sendMessage(text)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
@@ -85,7 +90,7 @@ export function ChatComposer({
|
||||
<ChatPaperclipButton
|
||||
recentEventIds={recentEventIds ?? []}
|
||||
onAttach={onAttach!}
|
||||
disabled={isLoading || attachedEventId != null}
|
||||
disabled={inputBlocked || attachedEventId != null}
|
||||
/>
|
||||
)}
|
||||
{supportsThinking && (
|
||||
@@ -103,7 +108,7 @@ export function ChatComposer({
|
||||
!thinkingEnabled && "text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
>
|
||||
<LuBrain className="size-4" />
|
||||
</Button>
|
||||
@@ -122,6 +127,7 @@ export function ChatComposer({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showStop ? (
|
||||
<Button
|
||||
@@ -135,7 +141,7 @@ export function ChatComposer({
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={!input.trim() || inputBlocked}
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<FaArrowUpLong className="size-4" />
|
||||
|
||||
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShowStatsMode } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ChatSettingsProps = {
|
||||
showStats: ShowStatsMode;
|
||||
setShowStats: (mode: ShowStatsMode) => void;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (enabled: boolean) => void;
|
||||
alwaysAllowTools: string[];
|
||||
clearAlwaysAllowTools: () => void;
|
||||
};
|
||||
|
||||
export default function ChatSettings({
|
||||
@@ -29,6 +32,8 @@ export default function ChatSettings({
|
||||
setShowStats,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
alwaysAllowTools,
|
||||
clearAlwaysAllowTools,
|
||||
}: ChatSettingsProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -90,6 +95,40 @@ export default function ChatSettings({
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("settings.always_allow.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.desc")}
|
||||
</div>
|
||||
</div>
|
||||
{alwaysAllowTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alwaysAllowTools.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-md bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
||||
>
|
||||
{formatToolName(name)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.none")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={alwaysAllowTools.length === 0}
|
||||
onClick={clearAlwaysAllowTools}
|
||||
>
|
||||
{t("settings.always_allow.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShieldAlert, LuCheck, LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
import type { PendingToolCall, ToolDecision } from "@/types/chat";
|
||||
|
||||
type ToolApprovalCardProps = {
|
||||
toolCall: PendingToolCall;
|
||||
decision?: ToolDecision;
|
||||
onApprove: (id: string) => void;
|
||||
onAlwaysAllow: (id: string, name: string) => void;
|
||||
onReject: (id: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt shown when the assistant wants to run a state-changing tool.
|
||||
* Renders the call's arguments and approve / always allow / reject actions;
|
||||
* once decided it collapses into a status line.
|
||||
*/
|
||||
export function ToolApprovalCard({
|
||||
toolCall,
|
||||
decision,
|
||||
onApprove,
|
||||
onAlwaysAllow,
|
||||
onReject,
|
||||
}: ToolApprovalCardProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const displayName = formatToolName(toolCall.name);
|
||||
const hasArguments = Object.keys(toolCall.arguments ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-[85%] flex-col gap-3 self-start rounded-xl border border-border bg-muted px-4 py-3"
|
||||
role="group"
|
||||
aria-label={t("approval.title", { tool: displayName })}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<LuShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("approval.title", { tool: displayName })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("approval.desc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasArguments && (
|
||||
<pre className="scrollbar-container max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-background/50 p-2 text-[10px]">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{decision ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-medium",
|
||||
decision === "approve" ? "text-success" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{decision === "approve" ? (
|
||||
<LuCheck className="size-3.5" />
|
||||
) : (
|
||||
<LuX className="size-3.5" />
|
||||
)}
|
||||
{decision === "approve"
|
||||
? t("approval.approved")
|
||||
: t("approval.rejected")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onApprove(toolCall.id)}
|
||||
>
|
||||
{t("approval.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="select"
|
||||
onClick={() => onAlwaysAllow(toolCall.id, toolCall.name)}
|
||||
>
|
||||
{t("approval.always_allow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(toolCall.id)}
|
||||
>
|
||||
{t("approval.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,19 +7,12 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { LuChevronsUpDown } from "react-icons/lu";
|
||||
import type { ToolCall } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ToolCallsGroupProps = {
|
||||
toolCalls: ToolCall[];
|
||||
};
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ToolCall[]>();
|
||||
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
|
||||
function ToolCallRow({ name, calls }: ToolCallRowProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const displayName = normalizeName(name);
|
||||
const displayName = formatToolName(name);
|
||||
const label =
|
||||
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;
|
||||
|
||||
|
||||
+149
-15
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
GenAIModelsResponse,
|
||||
PendingToolCall,
|
||||
ShowStatsMode,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
getEventIdsFromToolCalls,
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
prependAttachment,
|
||||
streamChatCompletion,
|
||||
@@ -40,6 +43,13 @@ const hasText = (content: unknown): content is string =>
|
||||
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("");
|
||||
@@ -48,6 +58,21 @@ export default function ChatPage() {
|
||||
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",
|
||||
@@ -62,6 +87,7 @@ export default function ChatPage() {
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -92,14 +118,27 @@ export default function ChatPage() {
|
||||
}, [messages, streaming, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
if (isLoading) return;
|
||||
async function submit(
|
||||
messagesToSend: ChatMessage[],
|
||||
resume?: ResumeOptions,
|
||||
) {
|
||||
if (loadingRef.current) return;
|
||||
const last = messagesToSend[messagesToSend.length - 1];
|
||||
if (!last || last.role !== "user" || !hasText(last.content)) return;
|
||||
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 ?? "";
|
||||
@@ -116,6 +155,7 @@ export default function ChatPage() {
|
||||
let stats: ChatStats | undefined;
|
||||
let reasoning = "";
|
||||
let hadError = false;
|
||||
let approvals: PendingToolCall[] | null = null;
|
||||
|
||||
await streamChatCompletion(
|
||||
url,
|
||||
@@ -138,32 +178,99 @@ export default function ChatPage() {
|
||||
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") {
|
||||
setMessages(
|
||||
chain.map((m, i) =>
|
||||
i === chain.length - 1
|
||||
? { ...m, reasoning: reasoning || undefined, stats }
|
||||
: m,
|
||||
),
|
||||
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 } : {},
|
||||
{
|
||||
...(supportsThinking ? { enableThinking: !!thinkingEnabled } : {}),
|
||||
toolDecisions: resume?.toolDecisions,
|
||||
},
|
||||
);
|
||||
},
|
||||
[isLoading, supportsThinking, t, thinkingEnabled],
|
||||
[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(() => {
|
||||
@@ -174,7 +281,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
if (similar) return similar.results.map((e) => e.id);
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = getEventIdsFromToolCalls(calls);
|
||||
if (events.length > 0) return events.map((e) => e.id);
|
||||
}
|
||||
return [];
|
||||
@@ -197,19 +304,25 @@ export default function ChatPage() {
|
||||
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(
|
||||
@@ -260,7 +373,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const contentText = hasText(msg.content) ? msg.content : "";
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromToolCalls(calls);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
@@ -324,6 +437,8 @@ export default function ChatPage() {
|
||||
setShowStats={setShowStats}
|
||||
autoScroll={autoScroll ?? true}
|
||||
setAutoScroll={setAutoScroll}
|
||||
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
|
||||
clearAlwaysAllowTools={clearAlwaysAllowTools}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -335,6 +450,20 @@ export default function ChatPage() {
|
||||
{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 ? (
|
||||
@@ -391,7 +520,12 @@ export default function ChatPage() {
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
disabled={pendingApprovals != null}
|
||||
placeholder={
|
||||
pendingApprovals != null
|
||||
? t("approval.placeholder")
|
||||
: t("placeholder")
|
||||
}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
|
||||
@@ -20,11 +20,21 @@ export type ChatMessage = {
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id?: string;
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
response?: string;
|
||||
};
|
||||
|
||||
export type ToolDecision = "approve" | "reject";
|
||||
|
||||
/** A state-changing tool call the backend paused on, awaiting the user. */
|
||||
export type PendingToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
|
||||
+56
-14
@@ -1,4 +1,10 @@
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
PendingToolCall,
|
||||
ToolCall,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Streamed delta of the assistant's final answer text. */
|
||||
@@ -11,6 +17,10 @@ export type StreamChatCallbacks = {
|
||||
onChain: (chain: ChatMessage[]) => void;
|
||||
/** Token/timing stats for the turn. */
|
||||
onStats: (stats: ChatStats) => void;
|
||||
/** The backend paused before running state-changing tools; the chain
|
||||
* emitted just before this ends with the assistant message requesting
|
||||
* them. Resend that chain with `toolDecisions` to continue. */
|
||||
onApprovalRequired?: (toolCalls: PendingToolCall[]) => void;
|
||||
/** Called when the stream sends an error or fetch fails. */
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
@@ -30,6 +40,7 @@ type StatsChunk = {
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "messages"; messages: ChatMessage[] }
|
||||
| { type: "approval_required"; tool_calls: PendingToolCall[] }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "reasoning"; delta: string }
|
||||
| StatsChunk;
|
||||
@@ -40,6 +51,8 @@ type StreamChunk =
|
||||
*/
|
||||
export type StreamChatOptions = {
|
||||
enableThinking?: boolean;
|
||||
/** Decisions for tool calls that paused for approval, keyed by call id. */
|
||||
toolDecisions?: Record<string, ToolDecision>;
|
||||
};
|
||||
|
||||
export async function streamChatCompletion(
|
||||
@@ -55,6 +68,7 @@ export async function streamChatCompletion(
|
||||
onReasoningDelta,
|
||||
onChain,
|
||||
onStats,
|
||||
onApprovalRequired,
|
||||
onError,
|
||||
onDone,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
@@ -68,6 +82,9 @@ export async function streamChatCompletion(
|
||||
if (options.enableThinking !== undefined) {
|
||||
body.enable_thinking = options.enableThinking;
|
||||
}
|
||||
if (options.toolDecisions && Object.keys(options.toolDecisions).length) {
|
||||
body.tool_decisions = options.toolDecisions;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -103,6 +120,10 @@ export async function streamChatCompletion(
|
||||
onChain(data.messages ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "approval_required") {
|
||||
onApprovalRequired?.(data.tool_calls ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "content" && data.delta !== undefined) {
|
||||
onContentDelta(data.delta);
|
||||
return "continue";
|
||||
@@ -198,6 +219,7 @@ export function toolCallsForMessage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: args,
|
||||
response: responses.get(tc.id),
|
||||
@@ -205,28 +227,48 @@ export function toolCallsForMessage(
|
||||
});
|
||||
}
|
||||
|
||||
/** Human-readable tool name: "search_objects" -> "Search Objects". */
|
||||
export function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const hasStringId = (item: unknown): item is { id: string } =>
|
||||
!!item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string";
|
||||
|
||||
/**
|
||||
* Parse search_objects tool call response(s) into event ids for thumbnails.
|
||||
* Collect event ids from tool responses that reference tracked objects:
|
||||
* search_objects returns a list of events and get_event_image a single one.
|
||||
*/
|
||||
export function getEventIdsFromSearchObjectsToolCalls(
|
||||
export function getEventIdsFromToolCalls(
|
||||
toolCalls: ToolCall[] | undefined,
|
||||
): { id: string }[] {
|
||||
if (!toolCalls?.length) return [];
|
||||
const results: { id: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (item: unknown) => {
|
||||
if (hasStringId(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
results.push({ id: item.id });
|
||||
}
|
||||
};
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
|
||||
if (!tc.response?.trim()) continue;
|
||||
if (tc.name !== "search_objects" && tc.name !== "get_event_image") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(tc.response) as unknown;
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string"
|
||||
) {
|
||||
results.push({ id: (item as { id: string }).id });
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(push);
|
||||
} else {
|
||||
push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
|
||||
Reference in New Issue
Block a user