+
)}
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)