From 1a3ac6e3cffda93579a9a4b66b73d4aa67109045 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 14 May 2026 09:36:42 -0600 Subject: [PATCH] Add settings to handle token stats and other options --- .../chat/ChatEventThumbnailsRow.tsx | 18 +- web/src/components/chat/ChatSettings.tsx | 108 +++++++ web/src/pages/Chat.tsx | 283 +++++++++++------- web/src/types/chat.ts | 10 + web/src/utils/chatUtil.ts | 35 ++- 5 files changed, 328 insertions(+), 126 deletions(-) create mode 100644 web/src/components/chat/ChatSettings.tsx diff --git a/web/src/components/chat/ChatEventThumbnailsRow.tsx b/web/src/components/chat/ChatEventThumbnailsRow.tsx index a12153e894..94eca3f2d6 100644 --- a/web/src/components/chat/ChatEventThumbnailsRow.tsx +++ b/web/src/components/chat/ChatEventThumbnailsRow.tsx @@ -6,7 +6,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; type ChatEvent = { id: string; score?: number }; @@ -37,10 +36,7 @@ export function ChatEventThumbnailsRow({ const renderThumb = (event: ChatEvent, isAnchor = false) => (
); diff --git a/web/src/components/chat/ChatSettings.tsx b/web/src/components/chat/ChatSettings.tsx new file mode 100644 index 0000000000..78fe70203a --- /dev/null +++ b/web/src/components/chat/ChatSettings.tsx @@ -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 = ( + + ); + + const content = ( +
+
+
+
{t("settings.show_stats.title")}
+
+ {t("settings.show_stats.desc")} +
+
+ setShowStats(v as ShowStatsMode)} + > +
+ + +
+
+ + +
+
+
+ +
+
+ +
+ {t("settings.auto_scroll.desc")} +
+
+ +
+
+ ); + + return ( + + ); +} diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 474aa6d213..ee881d127b 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -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(null); const [attachedEventId, setAttachedEventId] = useState(null); + const [showStats, setShowStats] = usePersistence( + "chat-show-stats", + "while_generating", + ); + const [autoScroll, setAutoScroll] = usePersistence( + "chat-auto-scroll", + true, + ); const scrollRef = useRef(null); const abortRef = useRef(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 ( -
-
- {messages.length === 0 ? ( - { - setInput(""); - submitConversation([{ role: "user", content: message }]); - }} - /> - ) : ( - <> -
- {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; +
+
+ {hasStarted && ( + + )} + +
+
+
+
+ {hasStarted ? ( +
+ {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 ( -
- - - -
- ); - - return ( -
- {msg.role === "assistant" && hasToolCalls && ( - - )} - {showProcessing ? ( -
- - - + // Hide empty placeholder only when there are no tool calls yet + if ( + isLastAssistant && + isLoading && + !hasContent && + !hasToolCalls + ) + return ( +
+ + +
- ) : ( - - )} - {msg.role === "assistant" && - isComplete && - (() => { - const similar = getFindSimilarObjectsFromToolCalls( - msg.toolCalls, - ); - if (similar) { + ); + + return ( +
+ {msg.role === "assistant" && hasToolCalls && ( + + )} + {showProcessing ? ( +
+ + + +
+ ) : ( + + )} + {msg.role === "assistant" && + isComplete && + (() => { + const similar = getFindSimilarObjectsFromToolCalls( + msg.toolCalls, + ); + if (similar) { + return ( + + ); + } + const events = getEventIdsFromSearchObjectsToolCalls( + msg.toolCalls, + ); return ( ); - } - const events = getEventIdsFromSearchObjectsToolCalls( - msg.toolCalls, - ); - return ( - - ); - })()} -
- ); - })} - {error && ( -

- - {error} -

- )} -
- - )} - {messages.length > 0 && ( - - )} + })()} +
+ ); + })} + {error && ( +

+ + {error} +

+ )} +
+ ) : ( + { + setInput(""); + submitConversation([{ role: "user", content: message }]); + }} + /> + )} +
+
+ {hasStarted && ( +
+
+ +
+
+ )}
); } @@ -298,7 +349,7 @@ function ChatEntry({ }; return ( -
+
{attachedEventId && (
void; /** Called when the stream finishes (success or error). */ 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. */ defaultErrorMessage?: string; }; +type StatsChunk = { + type: "stats"; + prompt_tokens?: number; + completion_tokens?: number; + completion_duration_ms?: number; + tokens_per_second?: number; +}; + type StreamChunk = | { type: "error"; error: string } | { 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 @@ -31,6 +44,7 @@ export async function streamChatCompletion( updateMessages, onError, onDone, + onStats, defaultErrorMessage = "Something went wrong. Please try again.", } = callbacks; @@ -95,6 +109,23 @@ export async function streamChatCompletion( }); 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"; };