feat(ai): enhance chat interface with animated text and buffer improvements

- Introduced an animatedPlugin for enhanced text rendering in AIMarkdownMessage, adding a fade-in effect for words.
- Updated StreamingMessageBuffer to include a maximum buffer size to prevent out-of-memory issues.
- Improved the layout of ChatInput in ChatInterface for better spacing.
- Adjusted the PoweredByFooter link color for better visibility.
- Refactored parseMarkdown to support rehype plugins, allowing for more flexible markdown processing.

These changes enhance the user experience in the AI chat interface by improving text animations and performance during message rendering.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-08-05 01:31:47 +08:00
parent 510f0b94e1
commit 5b2669e1b5
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
6 changed files with 67 additions and 9 deletions

View File

@ -9,7 +9,7 @@ export const PoweredByFooter: Component = ({ className }) => (
<Logo className="size-5" />{" "}
<a
href={pkg.homepage}
className="text-accent cursor-pointer font-bold no-underline"
className="cursor-pointer font-bold text-orange-500 no-underline"
target="_blank"
rel="noreferrer"
>

View File

@ -186,7 +186,7 @@ export const ChatInterface = () => {
)}
{hasMessages && (
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-4xl p-6">
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-4xl px-6 pb-6">
<ChatInput onSend={handleSendMessage} />
</div>
)}

View File

@ -7,6 +7,7 @@ import {
memo,
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
@ -17,9 +18,12 @@ import { MermaidDiagram } from "~/components/ui/diagrams"
import { MarkdownLink } from "~/components/ui/markdown/renderers/MarkdownLink"
import { usePeekModal } from "~/hooks/biz/usePeekModal"
import { animatedPlugin } from "./animatedPlugin"
// Buffer configuration interface
interface BufferConfig {
minBufferSize: number
maxBufferSize: number // Maximum buffer size to prevent OOM
maxBufferTime: number
semanticTimeout: number
emergencyTimeout: number
@ -118,6 +122,9 @@ class StreamingMessageBuffer {
// Emergency timeout - always flush
if (timeSinceLastFlush > this.config.emergencyTimeout) return true
// Buffer size limit - prevent OOM
if (bufferedText.length > this.config.maxBufferSize) return true
// Minimum buffer size not met
if (bufferedText.length < this.config.minBufferSize) return false
@ -211,6 +218,7 @@ class StreamingMessageBuffer {
// Buffer configurations
const BUFFER_CONFIG = {
minBufferSize: 10,
maxBufferSize: 10000, // Prevent OOM with 10KB limit
maxBufferTime: 100,
semanticTimeout: 300,
emergencyTimeout: 500,
@ -218,7 +226,7 @@ const BUFFER_CONFIG = {
// Hook to use streaming text buffer
const useStreamingTextBuffer = (text: string, isProcessing: boolean) => {
const bufferRef = useRef<StreamingMessageBuffer>(null)
const bufferRef = useRef<StreamingMessageBuffer | null>(null)
// Initialize buffer if needed
if (!bufferRef.current) {
@ -234,6 +242,14 @@ const useStreamingTextBuffer = (text: string, isProcessing: boolean) => {
bufferRef.current.getSnapshot,
)
// Cleanup on unmount to prevent memory leaks
useEffect(() => {
return () => {
bufferRef.current?.destroy()
bufferRef.current = null
}
}, [])
return displayedText
}
@ -262,6 +278,7 @@ const useThrottledMarkdownParsing = (text: string, isProcessing: boolean) => {
// Parse and cache the result
const result = parseMarkdown(content, {
rehypePlugins: [animatedPlugin],
components: {
pre: ({ children }) => {
const props = isValidElement(children) && "props" in children && children.props

View File

@ -0,0 +1,36 @@
import type { Element, ElementContent, Root } from "hast"
import type { BuildVisitor } from "unist-util-visit"
import { visit } from "unist-util-visit"
export const animatedPlugin = () => {
return (tree: Root) => {
visit(tree, "element", ((node: Element) => {
if (
["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "strong"].includes(node.tagName) &&
node.children
) {
const newChildren: Array<ElementContent> = []
for (const child of node.children) {
if (child.type === "text") {
const segmenter = new Intl.Segmenter("zh", { granularity: "word" })
const segments = segmenter.segment(child.value)
const words = [...segments].map((segment) => segment.segment).filter(Boolean)
words.forEach((word: string) => {
newChildren.push({
children: [{ type: "text", value: word }],
properties: {
className: tw`fade-in duration-1000 ease-in-out animate-in`,
},
tagName: "span",
type: "element",
})
})
} else {
newChildren.push(child)
}
}
node.children = newChildren
}
}) as BuildVisitor<Root, "element">)
}
}

View File

@ -6,7 +6,7 @@
* automatic light/dark mode adaptation.
*/
export const defaultLexicalTheme = {
paragraph: "mb-1",
paragraph: "mb-1 last:mb-0",
text: {
bold: "font-semibold",
italic: "italic",

View File

@ -14,16 +14,17 @@ import remarkGfm from "remark-gfm"
import remarkGithubAlerts from "remark-gh-alerts"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import type { Processor } from "unified"
import type { PluggableList, Processor } from "unified"
import { unified } from "unified"
export interface RemarkOptions {
components: Partial<Components>
applyMiddleware?: <T extends Processor<any, any, any, any, any>>(pipeline: T) => T
rehypePlugins?: PluggableList
}
export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {
const { components, applyMiddleware } = options || {}
const { components, applyMiddleware, rehypePlugins } = options || {}
let pipeline: Processor<any, any, any, any, any> = unified()
.use(remarkDirective)
@ -67,9 +68,13 @@ export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>)
pipeline = applyMiddleware(pipeline)
}
pipeline = pipeline
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })
pipeline = pipeline.use(remarkRehype, { allowDangerousHtml: true })
if (rehypePlugins) {
pipeline = pipeline.use(rehypePlugins)
}
pipeline = pipeline.use(rehypeStringify, { allowDangerousHtml: true })
const tree = pipeline.parse(content)