diff --git a/public/nginx.conf b/public/nginx.conf
index c96a4ef6..91c8acc4 100644
--- a/public/nginx.conf
+++ b/public/nginx.conf
@@ -65,13 +65,14 @@ http {
root html;
}
- location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
+ location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
- add_header Cache-Control no-cache;
+ add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
+ gzip off;
proxy_buffering off;
proxy_cache off;
diff --git a/src/components/agent/AgentAssistantEntry.vue b/src/components/agent/AgentAssistantEntry.vue
index 8a1b76b7..1c75ee53 100644
--- a/src/components/agent/AgentAssistantEntry.vue
+++ b/src/components/agent/AgentAssistantEntry.vue
@@ -47,6 +47,8 @@ const props = withDefaults(
},
)
+const ASSISTANT_PREVIEW_MAX_LENGTH = 480
+
const emit = defineEmits<{
open: []
}>()
@@ -1014,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
if (!props.active || !bubble.text) return
+ const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
+ if (existingIndex >= 0) {
+ fabBubbles.value[existingIndex] = bubble
+ setFabDocked(false)
+ nextTick(scheduleFabBubblePositionUpdate)
+ if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
+ return
+ }
+
const hadBubbles = hasFabBubbles.value
const wasDocked = fabDocked.value
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
@@ -1061,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
showBubble({
id: 'assistant-preview',
kind: 'assistant',
- text: value,
+ text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
})
}
diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue
index 0859979f..fcf75d06 100644
--- a/src/components/agent/AgentAssistantPanel.vue
+++ b/src/components/agent/AgentAssistantPanel.vue
@@ -1,11 +1,10 @@
diff --git a/src/components/agent/AgentMarkdownContent.vue b/src/components/agent/AgentMarkdownContent.vue
new file mode 100644
index 00000000..57cb9321
--- /dev/null
+++ b/src/components/agent/AgentMarkdownContent.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
diff --git a/src/components/agent/__tests__/AgentAssistantEntry.spec.ts b/src/components/agent/__tests__/AgentAssistantEntry.spec.ts
index db06680d..c06da5e0 100644
--- a/src/components/agent/__tests__/AgentAssistantEntry.spec.ts
+++ b/src/components/agent/__tests__/AgentAssistantEntry.spec.ts
@@ -68,4 +68,44 @@ describe('AgentAssistantEntry lifecycle motion', () => {
wrapper.unmount()
})
+
+ it('updates an existing assistant preview without recreating its resize observer', async () => {
+ const observe = vi.fn()
+ const disconnect = vi.fn()
+ const resizeObserverConstructor = vi.fn()
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor() {
+ resizeObserverConstructor()
+ }
+
+ observe = observe
+ disconnect = disconnect
+ },
+ )
+ const wrapper = shallowMount(AgentAssistantEntry, {
+ global: {
+ stubs: {
+ AgentPetStage: true,
+ VIcon: true,
+ },
+ },
+ props: {
+ active: true,
+ motionActive: true,
+ },
+ })
+
+ wrapper.vm.showAssistantReplyPreview('第一段')
+ await nextTick()
+ expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
+
+ wrapper.vm.showAssistantReplyPreview('第二段')
+ await nextTick()
+ expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
+ expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
+
+ wrapper.unmount()
+ })
})
diff --git a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts
index 65eaabeb..f6896540 100644
--- a/src/components/agent/__tests__/AgentAssistantPanel.spec.ts
+++ b/src/components/agent/__tests__/AgentAssistantPanel.spec.ts
@@ -27,6 +27,12 @@ interface MockServerSession {
messages: Array>
}
+const agentMarkdownContentStub = {
+ props: ['content', 'variant'],
+ template:
+ "{{ content }}
",
+}
+
// 构造符合 Agent 标准响应包装的 fetch 返回值。
function createAgentResponse(data: unknown) {
return {
@@ -111,6 +117,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
+ AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '' },
PerfectScrollbar: { template: '
' },
VIcon: true,
@@ -203,6 +210,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
+ AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '' },
PerfectScrollbar: { template: '
' },
VIcon: true,
@@ -265,6 +273,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
+ AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '' },
PerfectScrollbar: { template: '
' },
VIcon: true,
@@ -298,4 +307,47 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
+
+ it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
+ const streamEvents = [
+ { type: 'start', session_id: 'web-agent:coalesced' },
+ ...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
+ { type: 'done' },
+ ]
+ const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
+ return new Response(streamBody, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ }
+ return createAgentResponse([])
+ }),
+ )
+
+ const wrapper = shallowMount(AgentAssistantPanel, {
+ props: { modelValue: true },
+ global: {
+ stubs: {
+ AgentMarkdownContent: agentMarkdownContentStub,
+ IconBtn: { template: '' },
+ PerfectScrollbar: { template: '
' },
+ VIcon: true,
+ },
+ },
+ })
+ await wrapper.find('textarea').setValue('测试突发事件')
+ await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
+ await flushPromises()
+
+ expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
+ expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
+ Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
+ )
+
+ wrapper.unmount()
+ })
})
diff --git a/src/components/agent/__tests__/AgentAssistantWidget.spec.ts b/src/components/agent/__tests__/AgentAssistantWidget.spec.ts
index 18cfe8fa..449d7d29 100644
--- a/src/components/agent/__tests__/AgentAssistantWidget.spec.ts
+++ b/src/components/agent/__tests__/AgentAssistantWidget.spec.ts
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
-import { ref } from 'vue'
+import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
@@ -14,6 +14,7 @@ vi.mock('@/composables/useAppActivityLifecycle', () => ({
describe('AgentAssistantWidget layering', () => {
afterEach(() => {
+ vi.useRealTimers()
document.body
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
.forEach(element => element.remove())
@@ -50,4 +51,44 @@ describe('AgentAssistantWidget layering', () => {
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
})
+
+ it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
+ vi.useFakeTimers()
+ const showAssistantReplyPreview = vi.fn()
+ const entryStub = defineComponent({
+ setup(_props, { expose }) {
+ expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
+ return () => h('div', { 'data-agent-assistant-entry': '' })
+ },
+ })
+ const panelStub = defineComponent({
+ emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
+ setup() {
+ return () => h('div', { 'data-agent-assistant-panel': '' })
+ },
+ })
+ const wrapper = mount(AgentAssistantWidget, {
+ global: {
+ stubs: {
+ AgentAssistantEntry: entryStub,
+ AgentAssistantPanel: panelStub,
+ },
+ },
+ })
+ const panel = wrapper.findComponent(panelStub)
+
+ panel.vm.$emit('assistant-preview', '第一段')
+ panel.vm.$emit('assistant-preview', '第二段')
+ panel.vm.$emit('assistant-preview', '最终预览')
+ await nextTick()
+
+ expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
+ expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
+
+ await vi.advanceTimersByTimeAsync(125)
+ expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
+ expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
+
+ wrapper.unmount()
+ })
})
diff --git a/src/components/agent/__tests__/AgentMarkdownContent.spec.ts b/src/components/agent/__tests__/AgentMarkdownContent.spec.ts
new file mode 100644
index 00000000..40b7fec3
--- /dev/null
+++ b/src/components/agent/__tests__/AgentMarkdownContent.spec.ts
@@ -0,0 +1,38 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
+
+describe('AgentMarkdownContent', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('throttles streaming Markdown and renders the final content immediately', async () => {
+ const wrapper = mount(AgentMarkdownContent, {
+ props: { content: '**开始**', streaming: true },
+ })
+
+ expect(wrapper.html()).toContain('开始')
+ await wrapper.setProps({ content: '**开始继续**' })
+ expect(wrapper.html()).not.toContain('开始继续')
+
+ await vi.advanceTimersByTimeAsync(96)
+ expect(wrapper.html()).toContain('开始继续')
+
+ await wrapper.setProps({ content: '**最终结果**', streaming: false })
+ expect(wrapper.html()).toContain('最终结果')
+ })
+
+ it('escapes raw HTML from Agent output', () => {
+ const wrapper = mount(AgentMarkdownContent, {
+ props: { content: '
' },
+ })
+
+ expect(wrapper.html()).not.toContain('