feat(web/chat): stream assistant responses

This commit is contained in:
Alix-007 2026-03-27 00:47:24 +08:00
parent 463a647a33
commit 8260f743cd
2 changed files with 70 additions and 2 deletions

View file

@ -1,5 +1,5 @@
import { IconCheck, IconCopy } from "@tabler/icons-react" import { IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react" import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize" import rehypeSanitize from "rehype-sanitize"
@ -8,19 +8,86 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat" import { formatMessageTime } from "@/hooks/use-pico-chat"
const STREAM_FRAME_MS = 18
const STREAM_STEPS = 60
interface AssistantMessageProps { interface AssistantMessageProps {
messageId?: string
content: string content: string
timestamp?: string | number timestamp?: string | number
} }
export function AssistantMessage({ export function AssistantMessage({
messageId = "",
content, content,
timestamp = "", timestamp = "",
}: AssistantMessageProps) { }: AssistantMessageProps) {
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false)
const [displayedContent, setDisplayedContent] = useState(
messageId.startsWith("hist-") ? content : "",
)
const timerRef = useRef<number | null>(null)
const displayedRef = useRef(displayedContent)
const formattedTimestamp = const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : "" 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 = () => { const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => { navigator.clipboard.writeText(content).then(() => {
setIsCopied(true) setIsCopied(true)
@ -48,7 +115,7 @@ export function AssistantMessage({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, rehypeSanitize]}
> >
{content} {displayedContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
<Button <Button

View file

@ -150,6 +150,7 @@ export function ChatPage() {
<div key={msg.id} className="flex w-full"> <div key={msg.id} className="flex w-full">
{msg.role === "assistant" ? ( {msg.role === "assistant" ? (
<AssistantMessage <AssistantMessage
messageId={msg.id}
content={msg.content} content={msg.content}
timestamp={msg.timestamp} timestamp={msg.timestamp}
/> />