diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index 5178eadd..8cfe8389 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -782,6 +782,10 @@ const props = defineProps<{ followUpLoading?: boolean; }>(); +const emit = defineEmits<{ + (event: 'render-complete-change', ready: boolean): void; +}>(); + const embedAuthProps = computed(() => ({ embeddedMode: props.embeddedMode, embedChannelId: props.embedChannelId, @@ -1257,6 +1261,7 @@ const answerFullyRendered = computed( () => isConversationDone.value && typedAnswer.value.length >= activeAnswerMarkdown.value.length, ); watch(answerFullyRendered, (ready) => { + emit('render-complete-change', ready); if (!ready) return; // Clear before this reactive update renders, so a source that returned 404 // mid-stream gets one real final-attempt node instead of remaining @@ -1265,7 +1270,7 @@ watch(answerFullyRendered, (ready) => { nextTick(async () => { await hydrateProtectedFileImages(rootElement.value); }); -}); +}, { immediate: true }); // Whether any currently visible step is actively pending (a running tool, or a // blocking approval/OAuth prompt). A pending step shimmers on its own, so we diff --git a/frontend/src/views/chat/components/answerToolbarCompletion.test.mjs b/frontend/src/views/chat/components/answerToolbarCompletion.test.mjs index 7168fdb5..b901e54e 100644 --- a/frontend/src/views/chat/components/answerToolbarCompletion.test.mjs +++ b/frontend/src/views/chat/components/answerToolbarCompletion.test.mjs @@ -35,3 +35,16 @@ test('follow-up loading is shown compactly inside both answer toolbars', () => { assert.match(sharedStyles, /background-clip: text/) assert.match(sharedStyles, /follow-up-toolbar-loading-leave-to/) }) + +test('follow-up suggestions wait until the answer is fully rendered', () => { + assert.match( + chatView, + /@render-complete-change="\(ready\) => handleAnswerRenderComplete\(session, ready\)"/, + ) + assert.match( + chatView, + / + :user-query="userQuery" :rag-mode="true" :follow-up-loading="followUpLoading" + @render-complete-change="emit('render-complete-change', $event)" /> @@ -117,7 +119,7 @@ const mentionTagIcon = (item) => { return 'file'; }; -const emit = defineEmits(['scroll-bottom']) +const emit = defineEmits(['scroll-bottom', 'render-complete-change']) const { t } = useI18n() const uiStore = useUIStore(); let parentMd = ref() @@ -205,6 +207,14 @@ const answerFullyRendered = computed(() => Boolean(props.session?.is_completed) && typedAnswer.value.length >= answerText.value.length ); +watch( + answerFullyRendered, + (ready) => { + if (!props.session?.isAgentMode) emit('render-complete-change', ready); + }, + { immediate: true }, +); + // 单次渲染整个 Markdown 内容(替代 token-by-token,修复 KaTeX 公式在 streaming 时闪烁消失的问题) const renderedHTML = computed(() => { const text = typedAnswer.value; diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index d0fedf9c..c1ac2845 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -85,9 +85,10 @@ + :follow-up-loading="Boolean(session.suggestionLoading && !session.suggestionSet?.questions?.length)" + @render-complete-change="(ready) => handleAnswerRenderComplete(session, ready)"> - { const resolveAssistantMessageId = (message) => message?.id || message?.assistant_message_id; +const handleAnswerRenderComplete = (message, ready) => { + message.answerFullyRendered = Boolean(ready); +}; + const loadFollowUpSuggestions = async (message, ensure = false, regenerate = false) => { const messageId = resolveAssistantMessageId(message); const targetSessionId = session_id.value;