mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 06:08: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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user