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:
Josh Hawkins
2026-04-09 14:31:37 -06:00
committed by GitHub
parent 556d5d8c9d
commit 98c2fe00c1
12 changed files with 1318 additions and 109 deletions
@@ -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>
);
}
+20 -6
View File
@@ -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>
);
}