From 7d66d063aac67f06112b97c6b24f7586d38aff2f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 11 Jun 2026 16:44:55 -0600 Subject: [PATCH] Cleanup chat page rendering --- web/src/pages/Chat.tsx | 264 ++++++++++++++++++++++++----------------- 1 file changed, 156 insertions(+), 108 deletions(-) diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 7103a189d1..ac708fd05e 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -13,6 +13,7 @@ import { ChatComposer } from "@/components/chat/ChatComposer"; import ChatSettings from "@/components/chat/ChatSettings"; import type { ChatMessage, + ChatStats, GenAIModelsResponse, ShowStatsMode, } from "@/types/chat"; @@ -22,12 +23,28 @@ import { getFindSimilarObjectsFromToolCalls, prependAttachment, streamChatCompletion, + toolCallsForMessage, + toolResponsesById, } from "@/utils/chatUtil"; +type StreamingTurn = { + content: string; + reasoning: string; + turn: ChatMessage[]; + stats?: ChatStats; +}; + +const hasText = (content: unknown): content is string => + typeof content === "string" && content.trim().length > 0; + +const toWire = (messages: ChatMessage[]): ChatMessage[] => + messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest); + export default function ChatPage() { const { t } = useTranslation(["views/chat"]); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [attachedEventId, setAttachedEventId] = useState(null); @@ -72,28 +89,19 @@ export default function ChatPage() { if (isNearBottom) { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } - }, [messages, autoScroll]); + }, [messages, streaming, autoScroll]); const submitConversation = useCallback( async (messagesToSend: ChatMessage[]) => { if (isLoading) return; const last = messagesToSend[messagesToSend.length - 1]; - if (!last || last.role !== "user" || !last.content.trim()) return; + if (!last || last.role !== "user" || !hasText(last.content)) return; setError(null); - const assistantPlaceholder: ChatMessage = { - role: "assistant", - content: "", - toolCalls: undefined, - }; - setMessages([...messagesToSend, assistantPlaceholder]); + setMessages(messagesToSend); + setStreaming({ content: "", reasoning: "", turn: [] }); setIsLoading(true); - const apiMessages = messagesToSend.map((m) => ({ - role: m.role, - content: m.content, - })); - const baseURL = axios.defaults.baseURL ?? ""; const url = `${baseURL}chat/completion`; const headers: Record = { @@ -104,16 +112,49 @@ export default function ChatPage() { const controller = new AbortController(); abortRef.current = controller; + let turn: ChatMessage[] = []; + let stats: ChatStats | undefined; + let reasoning = ""; + let hadError = false; + await streamChatCompletion( url, headers, - apiMessages, + toWire(messagesToSend), { - updateMessages: (updater) => setMessages(updater), - onError: (message) => setError(message), + onContentDelta: (delta) => + setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)), + onReasoningDelta: (delta) => { + reasoning += delta; + setStreaming((s) => + s ? { ...s, reasoning: s.reasoning + delta } : s, + ); + }, + onTurnMessages: (turnMessages) => { + turn = turnMessages; + setStreaming((s) => (s ? { ...s, turn: turnMessages } : s)); + }, + onStats: (s) => { + stats = s; + setStreaming((cur) => (cur ? { ...cur, stats: s } : cur)); + }, + onError: (message) => { + hadError = true; + setError(message); + }, onDone: () => { abortRef.current = null; setIsLoading(false); + setStreaming(null); + const lastMsg = turn[turn.length - 1]; + if (!hadError && lastMsg?.role === "assistant") { + const committed = turn.map((m, i) => + i === turn.length - 1 + ? { ...m, reasoning: reasoning || undefined, stats } + : m, + ); + setMessages((prev) => [...prev, ...committed]); + } }, defaultErrorMessage: t("error"), }, @@ -125,12 +166,14 @@ export default function ChatPage() { ); const recentEventIds = useMemo(() => { + const responses = toolResponsesById(messages); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.role !== "assistant" || !msg.toolCalls) continue; - const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls); + if (msg.role !== "assistant" || !msg.tool_calls?.length) continue; + const calls = toolCallsForMessage(msg, responses); + const similar = getFindSimilarObjectsFromToolCalls(calls); if (similar) return similar.results.map((e) => e.id); - const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls); + const events = getEventIdsFromSearchObjectsToolCalls(calls); if (events.length > 0) return events.map((e) => e.id); } return []; @@ -154,12 +197,14 @@ export default function ChatPage() { abortRef.current?.abort(); abortRef.current = null; setIsLoading(false); + setStreaming(null); }, []); const startNewChat = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setIsLoading(false); + setStreaming(null); setMessages([]); setInput(""); setAttachedEventId(null); @@ -181,7 +226,81 @@ export default function ChatPage() { setAttachedEventId(null); }, []); - const hasStarted = messages.length > 0; + const hasStarted = messages.length > 0 || streaming != null; + + // The conversation plus any in-flight turn, rendered as one flat list. + const renderList = streaming ? [...messages, ...streaming.turn] : messages; + const responses = toolResponsesById(renderList); + const streamingTail = streaming?.turn[streaming.turn.length - 1]; + const finalShown = + streamingTail?.role === "assistant" && hasText(streamingTail.content); + + const renderMessage = (msg: ChatMessage, i: number) => { + if (msg.role === "tool") return null; + + if (msg.role === "user") { + if (!hasText(msg.content)) return null; + return ( +
+ +
+ ); + } + + const calls = toolCallsForMessage(msg, responses); + const contentText = hasText(msg.content) ? msg.content : ""; + const similar = getFindSimilarObjectsFromToolCalls(calls); + const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls); + + return ( +
+ {calls.length > 0 && } + {hasText(msg.reasoning) && ( + + )} + {contentText && ( + + )} + {similar ? ( + + ) : ( + + )} +
+ ); + }; + + const processingDots = ( +
+ + + +
+ ); return (
@@ -212,102 +331,31 @@ export default function ChatPage() {
{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 hasReasoning = !!msg.reasoning?.trim(); - const showProcessing = - isLastAssistant && - isLoading && - !hasContent && - !hasReasoning; - - // Hide empty placeholder only when there are no tool calls - // and no reasoning streaming yet - if ( - isLastAssistant && - isLoading && - !hasContent && - !hasToolCalls && - !hasReasoning - ) - return ( -
- - - -
- ); - - return ( -
- {msg.role === "assistant" && hasToolCalls && ( - - )} - {msg.role === "assistant" && hasReasoning && ( + {renderList.map((msg, i) => renderMessage(msg, i))} + {streaming && + !finalShown && + (streaming.content || streaming.reasoning ? ( +
+ {hasText(streaming.reasoning) && ( )} - {showProcessing ? ( -
- - - -
- ) : msg.role === "assistant" && - !hasContent && - hasReasoning && - !isComplete ? null : ( + {streaming.content && ( )} - {msg.role === "assistant" && - isComplete && - (() => { - const similar = getFindSimilarObjectsFromToolCalls( - msg.toolCalls, - ); - if (similar) { - return ( - - ); - } - const events = getEventIdsFromSearchObjectsToolCalls( - msg.toolCalls, - ); - return ( - - ); - })()}
- ); - })} + ) : ( + processingDots + ))} {error && (