Add settings to handle token stats and other options

This commit is contained in:
Nicolas Mowen 2026-05-14 09:36:42 -06:00
parent 8f07a81ac3
commit 1a3ac6e3cf
5 changed files with 328 additions and 126 deletions

View File

@ -6,7 +6,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type ChatEvent = { id: string; score?: number }; type ChatEvent = { id: string; score?: number };
@ -37,10 +36,7 @@ export function ChatEventThumbnailsRow({
const renderThumb = (event: ChatEvent, isAnchor = false) => ( const renderThumb = (event: ChatEvent, isAnchor = false) => (
<div <div
key={event.id} key={event.id}
className={cn( className="relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg"
"relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg",
isAnchor && "ring-2 ring-primary",
)}
> >
<button <button
type="button" type="button"
@ -71,9 +67,15 @@ export function ChatEventThumbnailsRow({
<TooltipContent>{t("open_in_explore")}</TooltipContent> <TooltipContent>{t("open_in_explore")}</TooltipContent>
</Tooltip> </Tooltip>
{isAnchor && ( {isAnchor && (
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground"> <>
{t("anchor")} <span
</span> aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-inset ring-primary"
/>
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
{t("anchor")}
</span>
</>
)} )}
</div> </div>
); );

View File

@ -0,0 +1,108 @@
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaCog } from "react-icons/fa";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { useTranslation } from "react-i18next";
import type { ShowStatsMode } from "@/types/chat";
type ChatSettingsProps = {
showStats: ShowStatsMode;
setShowStats: (mode: ShowStatsMode) => void;
autoScroll: boolean;
setAutoScroll: (enabled: boolean) => void;
};
export default function ChatSettings({
showStats,
setShowStats,
autoScroll,
setAutoScroll,
}: ChatSettingsProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
const trigger = (
<Button
className="flex items-center gap-2"
aria-label={t("settings.title")}
size="sm"
>
<FaCog className="text-secondary-foreground" />
{t("settings.title")}
</Button>
);
const content = (
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
<div className="space-y-3">
<div className="space-y-0.5">
<div className="text-md">{t("settings.show_stats.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.show_stats.desc")}
</div>
</div>
<RadioGroup
value={showStats}
onValueChange={(v) => setShowStats(v as ShowStatsMode)}
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="show-stats-while-generating"
value="while_generating"
/>
<Label
htmlFor="show-stats-while-generating"
className="cursor-pointer text-sm"
>
{t("settings.show_stats.while_generating")}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="show-stats-always" value="always" />
<Label
htmlFor="show-stats-always"
className="cursor-pointer text-sm"
>
{t("settings.show_stats.always")}
</Label>
</div>
</RadioGroup>
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
{t("settings.auto_scroll.title")}
</Label>
<div className="text-xs text-muted-foreground">
{t("settings.auto_scroll.desc")}
</div>
</div>
<Switch
id="auto-scroll"
checked={autoScroll}
onCheckedChange={setAutoScroll}
/>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={cn(
"scrollbar-container h-auto overflow-y-auto",
isDesktop ? "max-h-[80dvh] w-72" : "px-4",
)}
open={open}
onOpenChange={setOpen}
/>
);
}

View File

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FaArrowUpLong, FaStop } from "react-icons/fa6"; 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 { useTranslation } from "react-i18next";
import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import axios from "axios"; import axios from "axios";
@ -12,7 +12,9 @@ import { ChatStartingState } from "@/components/chat/ChatStartingState";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip"; import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies"; import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton"; 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 { import {
getEventIdsFromSearchObjectsToolCalls, getEventIdsFromSearchObjectsToolCalls,
getFindSimilarObjectsFromToolCalls, getFindSimilarObjectsFromToolCalls,
@ -27,6 +29,14 @@ export default function ChatPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [attachedEventId, setAttachedEventId] = 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 scrollRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(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 // Auto-scroll to bottom when messages change, but only if near bottom
useEffect(() => { useEffect(() => {
if (!autoScroll) return;
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
if (isNearBottom) { if (isNearBottom) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} }
}, [messages]); }, [messages, autoScroll]);
const submitConversation = useCallback( const submitConversation = useCallback(
async (messagesToSend: ChatMessage[]) => { async (messagesToSend: ChatMessage[]) => {
@ -125,6 +136,16 @@ export default function ChatPage() {
setIsLoading(false); setIsLoading(false);
}, []); }, []);
const startNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsLoading(false);
setMessages([]);
setInput("");
setAttachedEventId(null);
setError(null);
}, []);
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
(messageIndex: number, newContent: string) => { (messageIndex: number, newContent: string) => {
const newList: ChatMessage[] = [ const newList: ChatMessage[] = [
@ -140,127 +161,157 @@ export default function ChatPage() {
setAttachedEventId(null); setAttachedEventId(null);
}, []); }, []);
const hasStarted = messages.length > 0;
return ( return (
<div className="flex size-full justify-center p-2 md:p-4"> <div className="flex size-full flex-col">
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]"> <div className="flex shrink-0 items-center justify-end gap-2 px-2 pt-2 md:px-4 md:pt-4">
{messages.length === 0 ? ( {hasStarted && (
<ChatStartingState <Button
onSendMessage={(message) => { className="flex items-center gap-2"
setInput(""); aria-label={t("new_chat")}
submitConversation([{ role: "user", content: message }]); size="sm"
}} onClick={startNewChat}
/> >
) : ( <LuMessageSquarePlus className="text-secondary-foreground" />
<> {t("new_chat")}
<div </Button>
ref={scrollRef} )}
className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-3 overflow-y-auto" <ChatSettings
> showStats={showStats ?? "while_generating"}
{messages.map((msg, i) => { setShowStats={setShowStats}
const isLastAssistant = autoScroll={autoScroll ?? true}
i === messages.length - 1 && msg.role === "assistant"; setAutoScroll={setAutoScroll}
const isComplete = />
msg.role === "user" || !isLoading || !isLastAssistant; </div>
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; <div
const hasContent = !!msg.content?.trim(); ref={scrollRef}
const showProcessing = className="scrollbar-container flex min-h-0 flex-1 flex-col overflow-y-auto"
isLastAssistant && isLoading && !hasContent; >
<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 pt-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 // Hide empty placeholder only when there are no tool calls yet
if ( if (
isLastAssistant && isLastAssistant &&
isLoading && isLoading &&
!hasContent && !hasContent &&
!hasToolCalls !hasToolCalls
) )
return ( return (
<div <div
key={i} key={i}
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4" 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.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 [animation-delay:-0.16s]" />
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" /> <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" />
</div> </div>
) : ( );
<MessageBubble
role={msg.role} return (
content={msg.content} <div key={i} className="flex flex-col gap-2">
messageIndex={i} {msg.role === "assistant" && hasToolCalls && (
onEditSubmit={ <ToolCallsGroup toolCalls={msg.toolCalls!} />
msg.role === "user" ? handleEditSubmit : undefined )}
} {showProcessing ? (
isComplete={isComplete} <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]" />
{msg.role === "assistant" && <span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
isComplete && </div>
(() => { ) : (
const similar = getFindSimilarObjectsFromToolCalls( <MessageBubble
msg.toolCalls, role={msg.role}
); content={msg.content}
if (similar) { 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 ( return (
<ChatEventThumbnailsRow <ChatEventThumbnailsRow
events={similar.results} events={events}
anchor={similar.anchor}
onAttach={setAttachedEventId} onAttach={setAttachedEventId}
/> />
); );
} })()}
const events = getEventIdsFromSearchObjectsToolCalls( </div>
msg.toolCalls, );
); })}
return ( {error && (
<ChatEventThumbnailsRow <p
events={events} className="flex items-center gap-1.5 self-start text-sm text-destructive"
onAttach={setAttachedEventId} role="alert"
/> >
); <LuCircleAlert className="size-3.5 shrink-0" />
})()} {error}
</div> </p>
); )}
})} </div>
{error && ( ) : (
<p <ChatStartingState
className="flex items-center gap-1.5 self-start text-sm text-destructive" onSendMessage={(message) => {
role="alert" setInput("");
> submitConversation([{ role: "user", content: message }]);
<LuCircleAlert className="size-3.5 shrink-0" /> }}
{error} />
</p> )}
)} </div>
</div> </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> </div>
{hasStarted && (
<div className="flex shrink-0 justify-center px-2 pb-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> </div>
); );
} }
@ -298,7 +349,7 @@ function ChatEntry({
}; };
return ( 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 && ( {attachedEventId && (
<div className="flex items-center"> <div className="flex items-center">
<ChatAttachmentChip <ChatAttachmentChip

View File

@ -8,9 +8,19 @@ export type ChatMessage = {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
toolCalls?: ToolCall[]; toolCalls?: ToolCall[];
stats?: ChatStats;
}; };
export type StartingRequest = { export type StartingRequest = {
label: string; label: string;
prompt: string; prompt: string;
}; };
export type ChatStats = {
promptTokens?: number;
completionTokens?: number;
completionDurationMs?: number;
tokensPerSecond?: number;
};
export type ShowStatsMode = "while_generating" | "always";

View File

@ -1,4 +1,4 @@
import type { ChatMessage, ToolCall } from "@/types/chat"; import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
export type StreamChatCallbacks = { export type StreamChatCallbacks = {
/** Update the messages array (e.g. pass to setState). */ /** Update the messages array (e.g. pass to setState). */
@ -7,14 +7,27 @@ export type StreamChatCallbacks = {
onError: (message: string) => void; onError: (message: string) => void;
/** Called when the stream finishes (success or error). */ /** Called when the stream finishes (success or error). */
onDone: () => void; onDone: () => void;
/** Called when the stream emits token/timing stats. The stats are also
* attached to the last assistant message in updateMessages, so consumers
* can usually rely on the message itself rather than wiring this up. */
onStats?: (stats: ChatStats) => void;
/** Message used when fetch throws and no server error is available. */ /** Message used when fetch throws and no server error is available. */
defaultErrorMessage?: string; defaultErrorMessage?: string;
}; };
type StatsChunk = {
type: "stats";
prompt_tokens?: number;
completion_tokens?: number;
completion_duration_ms?: number;
tokens_per_second?: number;
};
type StreamChunk = type StreamChunk =
| { type: "error"; error: string } | { type: "error"; error: string }
| { type: "tool_calls"; tool_calls: ToolCall[] } | { type: "tool_calls"; tool_calls: ToolCall[] }
| { type: "content"; delta: string }; | { type: "content"; delta: string }
| StatsChunk;
/** /**
* POST to chat/completion with stream: true, parse NDJSON stream, and invoke * POST to chat/completion with stream: true, parse NDJSON stream, and invoke
@ -31,6 +44,7 @@ export async function streamChatCompletion(
updateMessages, updateMessages,
onError, onError,
onDone, onDone,
onStats,
defaultErrorMessage = "Something went wrong. Please try again.", defaultErrorMessage = "Something went wrong. Please try again.",
} = callbacks; } = callbacks;
@ -95,6 +109,23 @@ export async function streamChatCompletion(
}); });
return "continue"; return "continue";
} }
if (data.type === "stats") {
const stats: ChatStats = {
promptTokens: data.prompt_tokens,
completionTokens: data.completion_tokens,
completionDurationMs: data.completion_duration_ms,
tokensPerSecond: data.tokens_per_second,
};
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = { ...lastMsg, stats };
return next;
});
onStats?.(stats);
return "continue";
}
return "continue"; return "continue";
}; };