From 8260f743cde3c76c1e66588294a4876f1f8a6dd9 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:47:24 +0800 Subject: [PATCH] feat(web/chat): stream assistant responses --- .../src/components/chat/assistant-message.tsx | 71 ++++++++++++++++++- .../src/components/chat/chat-page.tsx | 1 + 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 05da3ceb1..ae63130c6 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,5 +1,5 @@ import { IconCheck, IconCopy } from "@tabler/icons-react" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import ReactMarkdown from "react-markdown" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" @@ -8,19 +8,86 @@ import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" +const STREAM_FRAME_MS = 18 +const STREAM_STEPS = 60 + interface AssistantMessageProps { + messageId?: string content: string timestamp?: string | number } export function AssistantMessage({ + messageId = "", content, timestamp = "", }: AssistantMessageProps) { const [isCopied, setIsCopied] = useState(false) + const [displayedContent, setDisplayedContent] = useState( + messageId.startsWith("hist-") ? content : "", + ) + const timerRef = useRef(null) + const displayedRef = useRef(displayedContent) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" + const syncDisplayedContent = (nextContent: string) => { + setDisplayedContent((previous) => + previous === nextContent ? previous : nextContent, + ) + } + + useEffect(() => { + displayedRef.current = displayedContent + }, [displayedContent]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current) + } + } + }, []) + + useEffect(() => { + const isHistoryMessage = messageId.startsWith("hist-") + if (timerRef.current !== null) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + + if (isHistoryMessage || content.trim() === "") { + queueMicrotask(() => syncDisplayedContent(content)) + return + } + + const currentDisplayed = displayedRef.current + const targetRunes = Array.from(content) + const start = content.startsWith(currentDisplayed) ? currentDisplayed : "" + const startLen = Array.from(start).length + + if (startLen >= targetRunes.length) { + queueMicrotask(() => syncDisplayedContent(content)) + return + } + + let cursor = startLen + const chunkSize = Math.max( + 1, + Math.ceil((targetRunes.length - startLen) / STREAM_STEPS), + ) + + queueMicrotask(() => syncDisplayedContent(start)) + timerRef.current = window.setInterval(() => { + cursor = Math.min(targetRunes.length, cursor + chunkSize) + setDisplayedContent(targetRunes.slice(0, cursor).join("")) + if (cursor >= targetRunes.length && timerRef.current !== null) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + }, STREAM_FRAME_MS) + }, [content, messageId]) + const handleCopy = () => { navigator.clipboard.writeText(content).then(() => { setIsCopied(true) @@ -48,7 +115,7 @@ export function AssistantMessage({ remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]} > - {content} + {displayedContent}