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:
parent
510f0b94e1
commit
5b2669e1b5
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue