diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 584aa2163..6df18596e 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -234,7 +234,9 @@ const MESSAGE_SCROLL_BUTTON_HIDE_THRESHOLD_PX = 48; let messageScrollViewport: HTMLElement | null = null; let messageTouchStartY: number | null = null; let lastMessageScrollTop = 0; +const STREAM_RENDER_INTERVAL_MS = 33; let assistantDeltaFrame: number | null = null; +let lastAssistantFlushAt = 0; let pendingAssistantDelta = ""; let pendingAssistantReasoning = ""; let pendingAssistantIndex = -1; @@ -682,6 +684,7 @@ function changeDatabase(value: string) { function flushAssistantDeltas() { assistantDeltaFrame = null; + lastAssistantFlushAt = performance.now(); const msg = messages.value[pendingAssistantIndex]; if (!msg) return; if (pendingAssistantReasoning) { @@ -697,12 +700,22 @@ function flushAssistantDeltas() { scrollToBottom(); } +function runAssistantDeltaFrame() { + // Markdown is rendered live, so keep the refresh rate under the frame rate: + // a repaint every STREAM_RENDER_INTERVAL_MS still reads as continuous typing. + if (performance.now() - lastAssistantFlushAt < STREAM_RENDER_INTERVAL_MS) { + assistantDeltaFrame = requestAnimationFrame(runAssistantDeltaFrame); + return; + } + flushAssistantDeltas(); +} + function scheduleAssistantDeltaFlush(assistantIdx: number) { pendingAssistantIndex = assistantIdx; if (assistantDeltaFrame !== null) return; - // Providers can emit many tiny chunks. Render once per animation frame so + // Providers can emit many tiny chunks. Batch them on an animation frame so // Markdown parsing, highlighting, and layout do not run for every token. - assistantDeltaFrame = requestAnimationFrame(flushAssistantDeltas); + assistantDeltaFrame = requestAnimationFrame(runAssistantDeltaFrame); } function appendAssistantDelta(assistantIdx: number, delta: string) { @@ -1645,6 +1658,7 @@ function clearMessages() { conversationId.value = ""; historyIndex.value = -1; draftBeforeHistory.value = ""; + messageRenderer.value.clear(); } async function persistConversation() { @@ -1675,6 +1689,8 @@ async function setConversationListOpen(open: boolean) { function selectConversation(conv: AiConversation) { conversationId.value = conv.id; + // Drop the previous conversation's rendered Markdown instead of keeping it until the LRU evicts it. + messageRenderer.value.clear(); messages.value = conv.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, @@ -1816,6 +1832,15 @@ const messageRenderer = computed(() => { }); }); +/** + * Renders Markdown live while the answer streams in. The renderer reuses the + * already-finished segments, so a frame only re-parses the growing tail. + */ +function renderMessageSegments(msg: ChatMessage) { + const streaming = isGenerating.value && msg === messages.value[messages.value.length - 1]; + return messageRenderer.value.render(msg.content, { streaming }); +} + function onMarkdownClick(event: MouseEvent) { handleAiMarkdownLinkClick(event, openExternalUrl); } @@ -1990,8 +2015,7 @@ async function openExternalUrl(url: string) { -
{{ msg.content }}
-