From 5b2669e1b5a10a1e01e8ff6fef485dcdc25d601b Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 5 Aug 2025 01:31:47 +0800 Subject: [PATCH] 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 --- .../src/components/common/PoweredByFooter.tsx | 2 +- .../components/layouts/ChatInterface.tsx | 2 +- .../components/message/AIMarkdownMessage.tsx | 19 +++++++++- .../components/message/animatedPlugin.ts | 36 +++++++++++++++++++ .../src/ui/lexical-rich-editor/theme.ts | 2 +- .../components/src/utils/parse-markdown.tsx | 15 +++++--- 6 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animatedPlugin.ts diff --git a/apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx b/apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx index f7d1367d6..0ed2a0faf 100644 --- a/apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx +++ b/apps/desktop/layer/renderer/src/components/common/PoweredByFooter.tsx @@ -9,7 +9,7 @@ export const PoweredByFooter: Component = ({ className }) => ( {" "} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index 5209422bb..485c8baa6 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -186,7 +186,7 @@ export const ChatInterface = () => { )} {hasMessages && ( -
+
)} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx index 99ba7eaa6..6182b9ed4 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx @@ -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(null) + const bufferRef = useRef(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 diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animatedPlugin.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animatedPlugin.ts new file mode 100644 index 000000000..21a2d4013 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/animatedPlugin.ts @@ -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 = [] + 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) + } +} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/theme.ts b/packages/internal/components/src/ui/lexical-rich-editor/theme.ts index e77b0c75b..bfdbcb678 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/theme.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/theme.ts @@ -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", diff --git a/packages/internal/components/src/utils/parse-markdown.tsx b/packages/internal/components/src/utils/parse-markdown.tsx index 2c0fdff93..03553de68 100644 --- a/packages/internal/components/src/utils/parse-markdown.tsx +++ b/packages/internal/components/src/utils/parse-markdown.tsx @@ -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 applyMiddleware?: >(pipeline: T) => T + rehypePlugins?: PluggableList } export const parseMarkdown = (content: string, options?: Partial) => { - const { components, applyMiddleware } = options || {} + const { components, applyMiddleware, rehypePlugins } = options || {} let pipeline: Processor = unified() .use(remarkDirective) @@ -67,9 +68,13 @@ export const parseMarkdown = (content: string, options?: Partial) 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)