feat(web/chat): stream assistant responses
This commit is contained in:
parent
463a647a33
commit
8260f743cd
2 changed files with 70 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue