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)