Chat improvements (#23195)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* Support token streaming stats

* Propogate streaming token stats to chat calls

* Show token stats for each image

* Add settings to handle token stats and other options

* i18n

* Use select

* Improve mobile layout and spacing
This commit is contained in:
Nicolas Mowen
2026-05-14 12:05:38 -05:00
committed by GitHub
parent 78fc472026
commit d9c1ea908d
14 changed files with 542 additions and 128 deletions
+167 -116
View File
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
import { LuCircleAlert } from "react-icons/lu";
import { LuCircleAlert, LuMessageSquarePlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import axios from "axios";
@@ -12,7 +12,9 @@ import { ChatStartingState } from "@/components/chat/ChatStartingState";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
import type { ChatMessage } from "@/types/chat";
import ChatSettings from "@/components/chat/ChatSettings";
import type { ChatMessage, ShowStatsMode } from "@/types/chat";
import { usePersistence } from "@/hooks/use-persistence";
import {
getEventIdsFromSearchObjectsToolCalls,
getFindSimilarObjectsFromToolCalls,
@@ -27,6 +29,14 @@ export default function ChatPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
"chat-show-stats",
"while_generating",
);
const [autoScroll, setAutoScroll] = usePersistence<boolean>(
"chat-auto-scroll",
true,
);
const scrollRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
@@ -36,13 +46,14 @@ export default function ChatPage() {
// 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]);
}, [messages, autoScroll]);
const submitConversation = useCallback(
async (messagesToSend: ChatMessage[]) => {
@@ -125,6 +136,16 @@ export default function ChatPage() {
setIsLoading(false);
}, []);
const startNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsLoading(false);
setMessages([]);
setInput("");
setAttachedEventId(null);
setError(null);
}, []);
const handleEditSubmit = useCallback(
(messageIndex: number, newContent: string) => {
const newList: ChatMessage[] = [
@@ -140,127 +161,157 @@ export default function ChatPage() {
setAttachedEventId(null);
}, []);
const hasStarted = messages.length > 0;
return (
<div className="flex size-full justify-center p-2 md:p-4">
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
{messages.length === 0 ? (
<ChatStartingState
onSendMessage={(message) => {
setInput("");
submitConversation([{ role: "user", content: message }]);
}}
/>
) : (
<>
<div
ref={scrollRef}
className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-3 overflow-y-auto"
>
{messages.map((msg, i) => {
const isLastAssistant =
i === messages.length - 1 && msg.role === "assistant";
const isComplete =
msg.role === "user" || !isLoading || !isLastAssistant;
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
const hasContent = !!msg.content?.trim();
const showProcessing =
isLastAssistant && isLoading && !hasContent;
<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}
/>
</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">
{messages.map((msg, i) => {
const isLastAssistant =
i === messages.length - 1 && msg.role === "assistant";
const isComplete =
msg.role === "user" || !isLoading || !isLastAssistant;
const hasToolCalls =
msg.toolCalls && msg.toolCalls.length > 0;
const hasContent = !!msg.content?.trim();
const showProcessing =
isLastAssistant && isLoading && !hasContent;
// Hide empty placeholder only when there are no tool calls yet
if (
isLastAssistant &&
isLoading &&
!hasContent &&
!hasToolCalls
)
return (
<div
key={i}
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 key={i} className="flex flex-col gap-2">
{msg.role === "assistant" && hasToolCalls && (
<ToolCallsGroup toolCalls={msg.toolCalls!} />
)}
{showProcessing ? (
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
// Hide empty placeholder only when there are no tool calls yet
if (
isLastAssistant &&
isLoading &&
!hasContent &&
!hasToolCalls
)
return (
<div
key={i}
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>
) : (
<MessageBubble
role={msg.role}
content={msg.content}
messageIndex={i}
onEditSubmit={
msg.role === "user" ? handleEditSubmit : undefined
}
isComplete={isComplete}
/>
)}
{msg.role === "assistant" &&
isComplete &&
(() => {
const similar = getFindSimilarObjectsFromToolCalls(
msg.toolCalls,
);
if (similar) {
);
return (
<div key={i} className="flex flex-col gap-2">
{msg.role === "assistant" && hasToolCalls && (
<ToolCallsGroup toolCalls={msg.toolCalls!} />
)}
{showProcessing ? (
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
) : (
<MessageBubble
role={msg.role}
content={msg.content}
messageIndex={i}
onEditSubmit={
msg.role === "user" ? handleEditSubmit : undefined
}
isComplete={isComplete}
stats={msg.stats}
showStats={showStats}
/>
)}
{msg.role === "assistant" &&
isComplete &&
(() => {
const similar = getFindSimilarObjectsFromToolCalls(
msg.toolCalls,
);
if (similar) {
return (
<ChatEventThumbnailsRow
events={similar.results}
anchor={similar.anchor}
onAttach={setAttachedEventId}
/>
);
}
const events = getEventIdsFromSearchObjectsToolCalls(
msg.toolCalls,
);
return (
<ChatEventThumbnailsRow
events={similar.results}
anchor={similar.anchor}
events={events}
onAttach={setAttachedEventId}
/>
);
}
const events = getEventIdsFromSearchObjectsToolCalls(
msg.toolCalls,
);
return (
<ChatEventThumbnailsRow
events={events}
onAttach={setAttachedEventId}
/>
);
})()}
</div>
);
})}
{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>
</>
)}
{messages.length > 0 && (
<ChatEntry
input={input}
setInput={setInput}
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
attachedEventId={attachedEventId}
onClearAttachment={handleClearAttachment}
onAttach={setAttachedEventId}
onStop={stopGeneration}
recentEventIds={recentEventIds}
/>
)}
})()}
</div>
);
})}
{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 }]);
}}
/>
)}
</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%]">
<ChatEntry
input={input}
setInput={setInput}
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
attachedEventId={attachedEventId}
onClearAttachment={handleClearAttachment}
onAttach={setAttachedEventId}
onStop={stopGeneration}
recentEventIds={recentEventIds}
/>
</div>
</div>
)}
</div>
);
}
@@ -298,7 +349,7 @@ function ChatEntry({
};
return (
<div className="mt-2 flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
{attachedEventId && (
<div className="flex items-center">
<ChatAttachmentChip