mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 14:18:58 +03:00
Chat improvements (#22823)
* Add score fusion helpers for find_similar_objects chat tool * Add candidate query builder for find_similar_objects chat tool * register find_similar_objects chat tool definition * implement _execute_find_similar_objects chat tool dispatcher * Dispatch find_similar_objects in chat tool executor * Teach chat system prompt when to use find_similar_objects * Add i18n strings for find_similar_objects chat tool * Add frontend extractor for find_similar_objects tool response * Render anchor badge and similarity scores in chat results * formatting * filter similarity results in python, not sqlite-vec * extract pure chat helpers to chat_util module * Teach chat system prompt about attached_event marker * Add parseAttachedEvent and prependAttachment helpers * Add i18n strings for chat event attachments * Add ChatAttachmentChip component * Make chat thumbnails attach to composer on click * Render attachment chip in user chat bubbles * Add ChatQuickReplies pill row component * Add ChatPaperclipButton with event picker popover * Wire event attachments into chat composer and messages * add ability to stop streaming * tweak cursor to appear at the end of the same line of the streaming response * use abort signal * add tooltip * display label and camera on attachment chip
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { LuX, LuExternalLink } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
|
||||
type ChatAttachmentChipProps = {
|
||||
eventId: string;
|
||||
mode: "composer" | "bubble";
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Small horizontal chip rendering an event as an "attachment": a thumbnail,
|
||||
* a friendly label like "Person on driveway", an optional remove X (composer
|
||||
* mode), and an external-link icon that opens the event in Explore.
|
||||
*/
|
||||
export function ChatAttachmentChip({
|
||||
eventId,
|
||||
mode,
|
||||
onRemove,
|
||||
}: ChatAttachmentChipProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
|
||||
const { data: eventData } = useSWR<{ label: string; camera: string }[]>(
|
||||
`event_ids?ids=${eventId}`,
|
||||
);
|
||||
const evt = eventData?.[0];
|
||||
const cameraName = useCameraFriendlyName(evt?.camera);
|
||||
const displayLabel = evt
|
||||
? t("attachment_chip_label", {
|
||||
label: getTranslatedLabel(evt.label),
|
||||
camera: cameraName,
|
||||
})
|
||||
: eventId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-background/80 p-1.5 pr-2",
|
||||
mode === "bubble" && "border-primary-foreground/30 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="relative size-10 shrink-0 overflow-hidden rounded-md">
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={`${apiHost}api/events/${eventId}/thumbnail.webp`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = "hidden";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{evt ? (
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xs",
|
||||
mode === "bubble"
|
||||
? "text-primary-foreground/90"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
) : (
|
||||
<ActivityIndicator className="size-4" />
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/explore?event_id=${eventId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground",
|
||||
mode === "bubble" &&
|
||||
"text-primary-foreground/70 hover:text-primary-foreground",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("open_in_explore")}
|
||||
>
|
||||
<LuExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("open_in_explore")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{mode === "composer" && onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
aria-label={t("attachment_chip_remove")}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,97 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatEvent = { id: string; score?: number };
|
||||
|
||||
type ChatEventThumbnailsRowProps = {
|
||||
events: { id: string }[];
|
||||
events: ChatEvent[];
|
||||
anchor?: { id: string } | null;
|
||||
onAttach?: (eventId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal scroll row of event thumbnail images for chat (e.g. after search_objects).
|
||||
* Renders nothing when events is empty.
|
||||
* Horizontal scroll row of event thumbnail images for chat.
|
||||
* Optionally renders an anchor thumbnail with a "reference" badge above the
|
||||
* results, and per-event similarity scores when provided.
|
||||
* Clicking a thumbnail calls onAttach; a small external-link overlay opens
|
||||
* the event in Explore.
|
||||
* Renders nothing when there is nothing to show.
|
||||
*/
|
||||
export function ChatEventThumbnailsRow({
|
||||
events,
|
||||
anchor = null,
|
||||
onAttach,
|
||||
}: ChatEventThumbnailsRowProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
if (events.length === 0 && !anchor) return null;
|
||||
|
||||
const renderThumb = (event: ChatEvent, isAnchor = false) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg",
|
||||
isAnchor && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="block size-full"
|
||||
onClick={() => onAttach?.(event.id)}
|
||||
aria-label={t("attach_event_aria", { eventId: event.id })}
|
||||
>
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={`${apiHost}api/events/${event.id}/thumbnail.webp`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/explore?event_id=${event.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded bg-black/60 text-white hover:bg-black/80"
|
||||
aria-label={t("open_in_explore")}
|
||||
>
|
||||
<LuExternalLink className="size-3" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("open_in_explore")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAnchor && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
|
||||
{t("anchor")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1 self-start">
|
||||
<div className="scrollbar-container min-w-0 overflow-x-auto">
|
||||
<div className="flex w-max gap-2">
|
||||
{events.map((event) => (
|
||||
<a
|
||||
key={event.id}
|
||||
href={`/explore?event_id=${event.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg"
|
||||
>
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={`${apiHost}api/events/${event.id}/thumbnail.webp`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-2 self-start">
|
||||
{anchor && (
|
||||
<div className="scrollbar-container min-w-0 overflow-x-auto">
|
||||
<div className="flex w-max gap-2">{renderThumb(anchor, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div className="scrollbar-container min-w-0 overflow-x-auto">
|
||||
<div className="flex w-max gap-2">
|
||||
{events.map((event) => renderThumb(event))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { parseAttachedEvent } from "@/utils/chatUtil";
|
||||
|
||||
type MessageBubbleProps = {
|
||||
role: "user" | "assistant";
|
||||
@@ -126,6 +128,10 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
const { eventId: attachedEventId, body: displayContent } = isUser
|
||||
? parseAttachedEvent(content)
|
||||
: { eventId: null, body: content };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -140,9 +146,20 @@ export function MessageBubble({
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
content
|
||||
<div className="flex flex-col gap-2">
|
||||
{attachedEventId && (
|
||||
<ChatAttachmentChip eventId={attachedEventId} mode="bubble" />
|
||||
)}
|
||||
<div className="whitespace-pre-wrap">{displayContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"[&>*:last-child]:inline",
|
||||
!isComplete &&
|
||||
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -168,10 +185,7 @@ export function MessageBubble({
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{!isComplete && (
|
||||
<span className="ml-1 inline-block h-4 w-0.5 animate-pulse bg-foreground align-middle" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPaperclip } from "react-icons/lu";
|
||||
import { useApiHost } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const EVENT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
type ChatPaperclipButtonProps = {
|
||||
recentEventIds: string[];
|
||||
onAttach: (eventId: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paperclip button with a popover for picking an event to attach.
|
||||
* Shows a grid of recent thumbnails (from the latest assistant message) and a
|
||||
* "paste event ID" fallback input.
|
||||
*/
|
||||
export function ChatPaperclipButton({
|
||||
recentEventIds,
|
||||
onAttach,
|
||||
disabled = false,
|
||||
}: ChatPaperclipButtonProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pasteId, setPasteId] = useState("");
|
||||
|
||||
const handlePickThumbnail = (eventId: string) => {
|
||||
onAttach(eventId);
|
||||
setOpen(false);
|
||||
setPasteId("");
|
||||
};
|
||||
|
||||
const handlePasteSubmit = () => {
|
||||
const trimmed = pasteId.trim();
|
||||
if (!trimmed || !EVENT_ID_RE.test(trimmed)) return;
|
||||
onAttach(trimmed);
|
||||
setOpen(false);
|
||||
setPasteId("");
|
||||
};
|
||||
|
||||
const handlePasteKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handlePasteSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={disabled}
|
||||
aria-label={t("attachment_picker_placeholder")}
|
||||
>
|
||||
<LuPaperclip className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="flex flex-col gap-3">
|
||||
{recentEventIds.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{recentEventIds.slice(0, 8).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handlePickThumbnail(id)}
|
||||
className="relative aspect-square overflow-hidden rounded-md ring-offset-background hover:ring-2 hover:ring-primary"
|
||||
aria-label={t("attach_event_aria", { eventId: id })}
|
||||
>
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={`${apiHost}api/events/${id}/thumbnail.webp`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={t("attachment_picker_paste_label")}
|
||||
value={pasteId}
|
||||
onChange={(e) => setPasteId(e.target.value)}
|
||||
onKeyDown={handlePasteKeyDown}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="select"
|
||||
className="h-8"
|
||||
disabled={!pasteId.trim() || !EVENT_ID_RE.test(pasteId.trim())}
|
||||
onClick={handlePasteSubmit}
|
||||
>
|
||||
{t("attachment_picker_attach")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type QuickReply = { labelKey: string; textKey: string };
|
||||
|
||||
const REPLIES: QuickReply[] = [
|
||||
{
|
||||
labelKey: "quick_reply_find_similar",
|
||||
textKey: "quick_reply_find_similar_text",
|
||||
},
|
||||
{
|
||||
labelKey: "quick_reply_tell_me_more",
|
||||
textKey: "quick_reply_tell_me_more_text",
|
||||
},
|
||||
{ labelKey: "quick_reply_when_else", textKey: "quick_reply_when_else_text" },
|
||||
];
|
||||
|
||||
type ChatQuickRepliesProps = {
|
||||
onSend: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Row of pill buttons shown in the composer while an attachment is pending.
|
||||
* Clicking a pill immediately calls onSend with the canned text.
|
||||
*/
|
||||
export function ChatQuickReplies({
|
||||
onSend,
|
||||
disabled = false,
|
||||
}: ChatQuickRepliesProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{REPLIES.map((reply) => (
|
||||
<Button
|
||||
key={reply.labelKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-3 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => onSend(t(reply.textKey))}
|
||||
>
|
||||
{t(reply.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user