From ad2d52e3caf0aa8d3cd32cec2498e5ac19309929 Mon Sep 17 00:00:00 2001
From: ooooooo <139197180+TongEc@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:29:45 +0800
Subject: [PATCH] fix(editor): preserve container link references while
streaming
---
.../src/components/editor/AiAssistant.vue | 40 ++-
.../lib/__tests__/ai/aiMessageRender.spec.ts | 241 +++++++++++++++-
.../__tests__/ai/aiMessageRenderSplit.spec.ts | 113 ++++++++
apps/desktop/src/lib/ai/aiMessageRender.ts | 258 ++++++++++++++++--
4 files changed, 614 insertions(+), 38 deletions(-)
create mode 100644 apps/desktop/src/lib/__tests__/ai/aiMessageRenderSplit.spec.ts
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 }}
-
+
@@ -2000,14 +2024,16 @@ async function openExternalUrl(url: string) {
{{ seg.lang }}
+
+
-