diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue
index 45c2f89e..b698c80a 100644
--- a/src/components/AIChat/ChatExplorer.vue
+++ b/src/components/AIChat/ChatExplorer.vue
@@ -23,6 +23,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill'
import { useChatScroll } from './composables/useChatScroll'
+import { useProgressiveChatHistory } from './composables/useProgressiveChatHistory'
import { useChatModals } from './composables/useChatModals'
import { groupMessagesToQAPairs } from './utils/chatMessages'
import type { MentionedMemberContext } from '@/composables/useAIChat'
@@ -181,6 +182,8 @@ const assistantBackupName = computed(() => {
// QA 对
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
+const progressiveHistory = useProgressiveChatHistory(qaPairs, currentAIChatId, chatScroll.messagesContainer)
+const { visiblePairs, hasOlderPairs, loadOlderPairs } = progressiveHistory
// 检查 LLM 配置
async function checkLLMConfig() {
@@ -515,8 +518,19 @@ watch(
+
+
+
+
-
+
{
}
// 渲染 Markdown 文本
+const MAX_MARKDOWN_CACHE_ENTRIES = 32
+const markdownCache = new Map()
+
function renderMarkdown(text: string): string {
if (!text) return ''
- return md.render(text)
+
+ const cached = markdownCache.get(text)
+ if (cached !== undefined) {
+ markdownCache.delete(text)
+ markdownCache.set(text, cached)
+ return cached
+ }
+
+ const rendered = md.render(text)
+ markdownCache.set(text, rendered)
+ if (markdownCache.size > MAX_MARKDOWN_CACHE_ENTRIES) {
+ const oldest = markdownCache.keys().next().value
+ if (oldest !== undefined) markdownCache.delete(oldest)
+ }
+ return rendered
}
// 思考标签名称映射
diff --git a/src/components/AIChat/composables/useChatScroll.test.ts b/src/components/AIChat/composables/useChatScroll.test.ts
new file mode 100644
index 00000000..0dd6aa12
--- /dev/null
+++ b/src/components/AIChat/composables/useChatScroll.test.ts
@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createCoalescedScrollScheduler } from './useChatScroll'
+
+test('coalesces repeated scroll requests into one pending task', (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const calls: boolean[] = []
+ const scheduler = createCoalescedScrollScheduler((force) => calls.push(force))
+
+ scheduler.schedule()
+ scheduler.schedule()
+ scheduler.schedule()
+ t.mock.timers.tick(100)
+
+ assert.deepEqual(calls, [false])
+})
+
+test('preserves a force request when upgrading a pending scroll', (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const calls: boolean[] = []
+ const scheduler = createCoalescedScrollScheduler((force) => calls.push(force))
+
+ scheduler.schedule()
+ scheduler.schedule(true)
+ t.mock.timers.tick(100)
+
+ assert.deepEqual(calls, [true])
+})
+
+test('cancels the pending scroll task', (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const calls: boolean[] = []
+ const scheduler = createCoalescedScrollScheduler((force) => calls.push(force))
+
+ scheduler.schedule(true)
+ scheduler.cancel()
+ t.mock.timers.tick(100)
+
+ assert.deepEqual(calls, [])
+})
diff --git a/src/components/AIChat/composables/useChatScroll.ts b/src/components/AIChat/composables/useChatScroll.ts
index 0bb509b0..a952e1b2 100644
--- a/src/components/AIChat/composables/useChatScroll.ts
+++ b/src/components/AIChat/composables/useChatScroll.ts
@@ -10,21 +10,57 @@ import type { Ref } from 'vue'
import type { ChatMessage } from '@/stores/aiChat'
const RESTICK_THRESHOLD = 30
+const SCROLL_DELAY_MS = 100
+
+export function createCoalescedScrollScheduler(
+ callback: (force: boolean) => void,
+ delayMs = SCROLL_DELAY_MS
+): {
+ schedule: (force?: boolean) => void
+ cancel: () => void
+} {
+ let pendingTimer: ReturnType | null = null
+ let pendingForce = false
+
+ function schedule(force = false) {
+ pendingForce ||= force
+ if (pendingTimer !== null) return
+
+ pendingTimer = setTimeout(() => {
+ pendingTimer = null
+ const forceCurrentRun = pendingForce
+ pendingForce = false
+ callback(forceCurrentRun)
+ }, delayMs)
+ }
+
+ function cancel() {
+ if (pendingTimer !== null) {
+ clearTimeout(pendingTimer)
+ pendingTimer = null
+ }
+ pendingForce = false
+ }
+
+ return { schedule, cancel }
+}
export function useChatScroll(messages: Ref, isAIThinking: Ref) {
const messagesContainer = ref(null)
const isStickToBottom = ref(true)
const showScrollToBottom = ref(false)
+ const scrollScheduler = createCoalescedScrollScheduler((force) => {
+ if (!messagesContainer.value) return
+ if (force || isStickToBottom.value) {
+ messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
+ isStickToBottom.value = true
+ showScrollToBottom.value = false
+ }
+ })
+
function scrollToBottom(force = false) {
- setTimeout(() => {
- if (!messagesContainer.value) return
- if (force || isStickToBottom.value) {
- messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
- isStickToBottom.value = true
- showScrollToBottom.value = false
- }
- }, 100)
+ scrollScheduler.schedule(force)
}
function handleWheel(event: WheelEvent) {
@@ -67,6 +103,7 @@ export function useChatScroll(messages: Ref, isAIThinking: Ref {
+ scrollScheduler.cancel()
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition)
messagesContainer.value.removeEventListener('wheel', handleWheel)
diff --git a/src/components/AIChat/composables/useProgressiveChatHistory.test.ts b/src/components/AIChat/composables/useProgressiveChatHistory.test.ts
new file mode 100644
index 00000000..828fc831
--- /dev/null
+++ b/src/components/AIChat/composables/useProgressiveChatHistory.test.ts
@@ -0,0 +1,72 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { effectScope, nextTick, ref } from 'vue'
+import { useProgressiveChatHistory } from './useProgressiveChatHistory'
+import type { QAPair } from '../utils/chatMessages'
+
+function createPairs(count: number): QAPair[] {
+ return Array.from({ length: count }, (_, index) => ({
+ id: `pair-${index}`,
+ user: null,
+ assistant: null,
+ standalone: null,
+ }))
+}
+
+test('starts with the latest 20 pairs and reveals older history in batches', async () => {
+ const scope = effectScope()
+ const pairs = ref(createPairs(45))
+ const conversationId = ref('conversation-a')
+ const container = ref(null)
+ const history = scope.run(() => useProgressiveChatHistory(pairs, conversationId, container))!
+
+ assert.deepEqual(
+ history.visiblePairs.value.map((pair) => pair.id),
+ createPairs(45)
+ .slice(-20)
+ .map((pair) => pair.id)
+ )
+ await history.loadOlderPairs()
+ assert.equal(history.visiblePairs.value.length, 40)
+ await history.loadOlderPairs()
+ assert.equal(history.visiblePairs.value.length, 45)
+ assert.equal(history.hasOlderPairs.value, false)
+
+ scope.stop()
+})
+
+test('preserves the visible anchor when older pairs are inserted above', async () => {
+ const scope = effectScope()
+ const pairs = ref(createPairs(45))
+ const conversationId = ref('conversation-a')
+ const container = ref(null)
+ const history = scope.run(() => useProgressiveChatHistory(pairs, conversationId, container))!
+ const element = {
+ scrollTop: 120,
+ get scrollHeight() {
+ return history.visiblePairs.value.length * 100
+ },
+ } as HTMLElement
+ container.value = element
+
+ await history.loadOlderPairs()
+
+ assert.equal(element.scrollTop, 2_120)
+ scope.stop()
+})
+
+test('resets the window when switching conversations', async () => {
+ const scope = effectScope()
+ const pairs = ref(createPairs(10))
+ const conversationId = ref('conversation-a')
+ const container = ref(null)
+ const history = scope.run(() => useProgressiveChatHistory(pairs, conversationId, container))!
+
+ assert.equal(history.visiblePairs.value.length, 10)
+ conversationId.value = 'conversation-b'
+ pairs.value = createPairs(45)
+ await nextTick()
+
+ assert.equal(history.visiblePairs.value.length, 20)
+ scope.stop()
+})
diff --git a/src/components/AIChat/composables/useProgressiveChatHistory.ts b/src/components/AIChat/composables/useProgressiveChatHistory.ts
new file mode 100644
index 00000000..321bf9c2
--- /dev/null
+++ b/src/components/AIChat/composables/useProgressiveChatHistory.ts
@@ -0,0 +1,49 @@
+import { computed, nextTick, ref, watch, type Ref } from 'vue'
+import type { QAPair } from '../utils/chatMessages'
+
+export const CHAT_HISTORY_BATCH_SIZE = 20
+
+export function useProgressiveChatHistory(
+ pairs: Ref,
+ conversationId: Ref,
+ scrollContainer: Ref
+) {
+ const visibleCount = ref(CHAT_HISTORY_BATCH_SIZE)
+ const visiblePairs = computed(() => pairs.value.slice(-visibleCount.value))
+ const hasOlderPairs = computed(() => visibleCount.value < pairs.value.length)
+
+ watch(
+ [conversationId, () => pairs.value.length] as const,
+ ([currentConversationId, length], [previousConversationId, previousLength]) => {
+ if (currentConversationId !== previousConversationId) {
+ visibleCount.value = CHAT_HISTORY_BATCH_SIZE
+ return
+ }
+ if (length < previousLength) {
+ visibleCount.value = Math.max(CHAT_HISTORY_BATCH_SIZE, Math.min(visibleCount.value, length))
+ } else if (previousLength > 0 && visibleCount.value >= previousLength) {
+ visibleCount.value = length
+ }
+ }
+ )
+
+ async function loadOlderPairs(): Promise {
+ if (!hasOlderPairs.value) return
+
+ const container = scrollContainer.value
+ const previousScrollHeight = container?.scrollHeight ?? 0
+ const previousScrollTop = container?.scrollTop ?? 0
+ visibleCount.value = Math.min(pairs.value.length, visibleCount.value + CHAT_HISTORY_BATCH_SIZE)
+ await nextTick()
+
+ if (container && scrollContainer.value === container) {
+ container.scrollTop = previousScrollTop + (container.scrollHeight - previousScrollHeight)
+ }
+ }
+
+ return {
+ visiblePairs,
+ hasOlderPairs,
+ loadOlderPairs,
+ }
+}
diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json
index 817c26b1..81403731 100644
--- a/src/i18n/locales/en-US/ai.json
+++ b/src/i18n/locales/en-US/ai.json
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "Back to Bottom",
+ "history": {
+ "loadEarlier": "Load earlier messages"
+ },
"input": {
"placeholderWithSlash": "Enter your question, or type / for skills and {'@'} for members...",
"placeholderWithActiveSkill": "/{name} selected. Enter your specific question...",
diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json
index f8e4d6af..4604ae1c 100644
--- a/src/i18n/locales/ja-JP/ai.json
+++ b/src/i18n/locales/ja-JP/ai.json
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "下へ移動",
+ "history": {
+ "loadEarlier": "以前の会話を読み込む"
+ },
"input": {
"placeholderWithSlash": "質問を入力、または / でスキル、{'@'} でメンバーを選択...",
"placeholderWithActiveSkill": "/{name} を選択済みです。具体的な質問を入力してください...",
diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json
index 8e818767..83219e65 100644
--- a/src/i18n/locales/zh-CN/ai.json
+++ b/src/i18n/locales/zh-CN/ai.json
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "返回底部",
+ "history": {
+ "loadEarlier": "加载更早对话"
+ },
"input": {
"placeholderWithSlash": "输入你的问题,或输入 / 选择技能、输入 {'@'} 选择成员...",
"placeholderWithActiveSkill": "已选择 /{name},请输入具体问题...",
diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json
index 88e1bc6d..0fbb695b 100644
--- a/src/i18n/locales/zh-TW/ai.json
+++ b/src/i18n/locales/zh-TW/ai.json
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "回到底部",
+ "history": {
+ "loadEarlier": "載入更早對話"
+ },
"input": {
"placeholderWithSlash": "輸入你的問題,或輸入 / 選擇技能、輸入 {'@'} 選擇成員...",
"placeholderWithActiveSkill": "已選擇 /{name},請輸入具體問題...",
diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts
index f0e7a329..c8370987 100644
--- a/src/stores/aiChat.ts
+++ b/src/stores/aiChat.ts
@@ -44,6 +44,7 @@ import {
toSerializableContentBlocks,
} from './aiChatContentBlocks'
import { createToolLifecycleTracker, type ToolStatus } from './aiToolLifecycle'
+import { createAIStreamTextBatcher } from './aiChatStreamBatcher'
export type { ToolStatus } from './aiToolLifecycle'
@@ -664,61 +665,18 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
) => void
completeTurn: (hadToolCalls: boolean) => void
settleProcessDuration: () => void
+ flushPendingText: () => void
}
function createStreamBlockHelpers(targetBuffer: AIChatBuffer, getAiMessageIndex: () => number): StreamBlockHelpers {
let currentTurnFirstContentAt: number | undefined
- const updateAIMessage = (updates: Partial) => {
+ const applyAIMessageUpdates = (updates: Partial) => {
const idx = getAiMessageIndex()
targetBuffer.messages[idx] = { ...targetBuffer.messages[idx], ...updates }
}
- const settleProcessDuration = () => {
- const idx = getAiMessageIndex()
- const message = targetBuffer.messages[idx]
- if (!message || message.processDurationMs !== undefined) return
- const durationMs = Math.max(0, Date.now() - message.timestamp)
- updateAIMessage({
- processDurationMs: durationMs,
- contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
- })
- }
-
- const appendTextToBlocks = (text: string) => {
- if (!text) return
- const idx = getAiMessageIndex()
- const blocks = targetBuffer.messages[idx].contentBlocks || []
- const lastBlock = blocks[blocks.length - 1]
- if (text.trim().length === 0 && (!lastBlock || lastBlock.type !== 'text')) return
- if (text.trim().length > 0 && currentTurnFirstContentAt === undefined) {
- currentTurnFirstContentAt = Date.now()
- }
- if (lastBlock && lastBlock.type === 'text') {
- lastBlock.text += text
- } else {
- blocks.push({ type: 'text', text })
- }
- updateAIMessage({ contentBlocks: [...blocks], content: targetBuffer.messages[idx].content + text })
- }
-
- const completeTurn = (hadToolCalls: boolean) => {
- const firstContentAt = currentTurnFirstContentAt
- currentTurnFirstContentAt = undefined
- if (hadToolCalls || firstContentAt === undefined) return
-
- const idx = getAiMessageIndex()
- const message = targetBuffer.messages[idx]
- if (!message || message.processDurationMs !== undefined) return
- const durationMs = Math.max(0, firstContentAt - message.timestamp)
- updateAIMessage({
- processDurationMs: durationMs,
- contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
- })
- }
-
- const appendThinkToBlocks = (text: string, tag?: string, durationMs?: number) => {
- if (!text && durationMs === undefined) return
+ const appendThinkImmediately = (text: string, tag?: string, durationMs?: number) => {
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
const thinkTag = tag || 'think'
@@ -741,11 +699,95 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
if (durationMs !== undefined && targetBlock && targetBlock.type === 'think') {
targetBlock.durationMs = durationMs
}
- updateAIMessage({ contentBlocks: [...blocks] })
+ applyAIMessageUpdates({ contentBlocks: [...blocks] })
+ }
+
+ const streamTextBatcher = createAIStreamTextBatcher((deltas) => {
+ const idx = getAiMessageIndex()
+ const message = targetBuffer.messages[idx]
+ const blocks = message.contentBlocks || []
+ let content = message.content
+ let changed = false
+
+ for (const delta of deltas) {
+ const lastBlock = blocks[blocks.length - 1]
+ if (delta.type === 'content') {
+ if (delta.content.trim().length === 0 && (!lastBlock || lastBlock.type !== 'text')) continue
+ if (lastBlock?.type === 'text') lastBlock.text += delta.content
+ else blocks.push({ type: 'text', text: delta.content })
+ content += delta.content
+ changed = true
+ continue
+ }
+
+ const thinkTag = delta.thinkTag || 'think'
+ if (lastBlock?.type === 'think' && lastBlock.tag === thinkTag) {
+ lastBlock.text += delta.content
+ changed = true
+ } else if (delta.content.trim().length > 0) {
+ blocks.push({ type: 'think', tag: thinkTag, text: delta.content })
+ changed = true
+ }
+ }
+
+ if (changed) applyAIMessageUpdates({ contentBlocks: [...blocks], content })
+ })
+
+ const flushPendingText = () => streamTextBatcher.flush()
+
+ const updateAIMessage = (updates: Partial) => {
+ flushPendingText()
+ applyAIMessageUpdates(updates)
+ }
+
+ const settleProcessDuration = () => {
+ flushPendingText()
+ const idx = getAiMessageIndex()
+ const message = targetBuffer.messages[idx]
+ if (!message || message.processDurationMs !== undefined) return
+ const durationMs = Math.max(0, Date.now() - message.timestamp)
+ applyAIMessageUpdates({
+ processDurationMs: durationMs,
+ contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
+ })
+ }
+
+ const appendTextToBlocks = (text: string) => {
+ if (!text) return
+ if (text.trim().length > 0 && currentTurnFirstContentAt === undefined) {
+ currentTurnFirstContentAt = Date.now()
+ }
+ streamTextBatcher.push({ type: 'content', content: text })
+ }
+
+ const completeTurn = (hadToolCalls: boolean) => {
+ flushPendingText()
+ const firstContentAt = currentTurnFirstContentAt
+ currentTurnFirstContentAt = undefined
+ if (hadToolCalls || firstContentAt === undefined) return
+
+ const idx = getAiMessageIndex()
+ const message = targetBuffer.messages[idx]
+ if (!message || message.processDurationMs !== undefined) return
+ const durationMs = Math.max(0, firstContentAt - message.timestamp)
+ applyAIMessageUpdates({
+ processDurationMs: durationMs,
+ contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
+ })
+ }
+
+ const appendThinkToBlocks = (text: string, tag?: string, durationMs?: number) => {
+ if (!text && durationMs === undefined) return
+ if (text) streamTextBatcher.push({ type: 'think', content: text, thinkTag: tag })
+ if (durationMs !== undefined) {
+ flushPendingText()
+ appendThinkImmediately('', tag, durationMs)
+ }
}
const appendChartsToBlocks = (charts: ChartPayload[]) => {
if (charts.length === 0) return
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push(...toChartContentBlocks(charts))
@@ -753,6 +795,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
const appendEvidenceToBlocks = (evidence: ChatEvidencePayload) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push(toEvidenceContentBlock(evidence))
@@ -761,24 +804,28 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const appendPlanDraftToBlocks = (delta: string) => {
if (!delta) return
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
updateAIMessage({ contentBlocks: [...appendPlanDraftDelta(blocks, delta)] })
}
const appendPlanToBlocks = (plan: PlanContentBlock) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
updateAIMessage({ contentBlocks: [...replacePlanDraftWithPlan(blocks, plan)] })
}
const removePlanDraftsFromBlocks = () => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
updateAIMessage({ contentBlocks: [...removePlanDraftBlocks(blocks)] })
}
const updatePlanBlockStatus = (status: PlanBlockStatus) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
for (let index = blocks.length - 1; index >= 0; index--) {
@@ -792,6 +839,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
const appendErrorToBlocks = (error: SerializedErrorInfo) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push({ type: 'error', error })
@@ -799,6 +847,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
const addToolBlock = (toolName: string, params?: Record, toolCallId?: string) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push({
@@ -811,6 +860,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const addRenderOnlyToolPendingBlock = (toolName: string, params?: Record, toolCallId?: string) => {
const pendingBlock = createRenderOnlyToolPendingBlock(toolName, params, toolCallId)
if (!pendingBlock) return
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push(pendingBlock)
@@ -823,6 +873,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
charts: ChartPayload[],
errorBlock: ReturnType
) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
@@ -836,6 +887,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
status: 'done' | 'error',
completion?: { toolCallId?: string; result?: string; displayResult?: string; isError?: boolean }
) => {
+ flushPendingText()
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
const isRunningTool = (block: ContentBlock): block is Extract =>
@@ -886,6 +938,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
+ flushPendingText,
}
}
@@ -1013,6 +1066,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
+ flushPendingText,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale)
@@ -1113,6 +1167,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
break
case 'tool_update':
+ flushPendingText()
if (chunk.toolName && chunk.toolProgress) {
state.currentToolStatus = toolLifecycle.update({
name: chunk.toolName,
@@ -1187,12 +1242,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
break
case 'status':
+ flushPendingText()
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
}
break
case 'compression_done':
+ flushPendingText()
if (chunk.compressionResult) {
const summaryMsg: ChatMessage = {
id: `summary-${Date.now()}`,
@@ -1257,6 +1314,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
setActiveTaskMeta(chatKey, content, agentReqId, resolvedAIChatId)
const result = await agentPromise
+ flushPendingText()
if (state.isAborted) {
clearActiveTask(chatKey, agentReqId)
return { success: false, reason: 'aborted' }
@@ -1635,6 +1693,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
+ flushPendingText,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
try {
@@ -1726,6 +1785,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
break
case 'tool_update':
+ flushPendingText()
if (chunk.toolName && chunk.toolProgress) {
state.currentToolStatus = toolLifecycle.update({
name: chunk.toolName,
@@ -1786,6 +1846,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
completeTurn(Boolean(chunk.hadToolCalls))
break
case 'status':
+ flushPendingText()
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
}
@@ -1823,6 +1884,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
setActiveTaskMeta(chatKey, content, agentReqId, state.currentAIChatId)
const result = await agentPromise
+ flushPendingText()
if (state.isAborted) {
restoreOriginal()
clearActiveTask(chatKey, agentReqId)
diff --git a/src/stores/aiChatStreamBatcher.test.ts b/src/stores/aiChatStreamBatcher.test.ts
new file mode 100644
index 00000000..0e681908
--- /dev/null
+++ b/src/stores/aiChatStreamBatcher.test.ts
@@ -0,0 +1,74 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createAIStreamTextBatcher, type AIStreamTextDelta } from './aiChatStreamBatcher'
+
+function createManualScheduler() {
+ let nextHandle = 1
+ const callbacks = new Map void>()
+ return {
+ scheduler: {
+ request(callback: () => void) {
+ const handle = nextHandle++
+ callbacks.set(handle, callback)
+ return handle
+ },
+ cancel(handle: number) {
+ callbacks.delete(handle)
+ },
+ },
+ runFrame() {
+ const pending = [...callbacks.values()]
+ callbacks.clear()
+ pending.forEach((callback) => callback())
+ },
+ pendingCount() {
+ return callbacks.size
+ },
+ }
+}
+
+test('merges adjacent text and thinking deltas within one frame', () => {
+ const frame = createManualScheduler()
+ const applied: AIStreamTextDelta[][] = []
+ const batcher = createAIStreamTextBatcher((deltas) => applied.push(deltas), frame.scheduler)
+
+ batcher.push({ type: 'content', content: 'A' })
+ batcher.push({ type: 'content', content: 'B' })
+ batcher.push({ type: 'think', thinkTag: 'analysis', content: 'C' })
+ batcher.push({ type: 'think', thinkTag: 'analysis', content: 'D' })
+ batcher.push({ type: 'content', content: 'E' })
+
+ assert.equal(frame.pendingCount(), 1)
+ frame.runFrame()
+ assert.deepEqual(applied, [
+ [
+ { type: 'content', content: 'AB' },
+ { type: 'think', thinkTag: 'analysis', content: 'CD' },
+ { type: 'content', content: 'E' },
+ ],
+ ])
+})
+
+test('flushes queued deltas synchronously before a stream boundary', () => {
+ const frame = createManualScheduler()
+ const applied: AIStreamTextDelta[][] = []
+ const batcher = createAIStreamTextBatcher((deltas) => applied.push(deltas), frame.scheduler)
+
+ batcher.push({ type: 'content', content: 'before tool' })
+ batcher.flush()
+
+ assert.equal(frame.pendingCount(), 0)
+ assert.deepEqual(applied, [[{ type: 'content', content: 'before tool' }]])
+})
+
+test('cancels the pending frame without applying stale deltas', () => {
+ const frame = createManualScheduler()
+ const applied: AIStreamTextDelta[][] = []
+ const batcher = createAIStreamTextBatcher((deltas) => applied.push(deltas), frame.scheduler)
+
+ batcher.push({ type: 'content', content: 'stale' })
+ batcher.cancel()
+ frame.runFrame()
+
+ assert.deepEqual(applied, [])
+})
diff --git a/src/stores/aiChatStreamBatcher.ts b/src/stores/aiChatStreamBatcher.ts
new file mode 100644
index 00000000..02b4f2be
--- /dev/null
+++ b/src/stores/aiChatStreamBatcher.ts
@@ -0,0 +1,74 @@
+export type AIStreamTextDelta =
+ | { type: 'content'; content: string }
+ | { type: 'think'; content: string; thinkTag?: string }
+
+interface FrameScheduler {
+ request: (callback: () => void) => number
+ cancel: (handle: number) => void
+}
+
+function createDefaultFrameScheduler(): FrameScheduler {
+ if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') {
+ return {
+ request: (callback) => requestAnimationFrame(callback),
+ cancel: (handle) => cancelAnimationFrame(handle),
+ }
+ }
+
+ return {
+ request: (callback) => setTimeout(callback, 16) as unknown as number,
+ cancel: (handle) => clearTimeout(handle),
+ }
+}
+
+export function createAIStreamTextBatcher(
+ apply: (deltas: AIStreamTextDelta[]) => void,
+ scheduler: FrameScheduler = createDefaultFrameScheduler()
+): {
+ push: (delta: AIStreamTextDelta) => void
+ flush: () => void
+ cancel: () => void
+} {
+ let pendingFrame: number | null = null
+ let pendingDeltas: AIStreamTextDelta[] = []
+
+ function flush() {
+ if (pendingFrame !== null) {
+ scheduler.cancel(pendingFrame)
+ pendingFrame = null
+ }
+ if (pendingDeltas.length === 0) return
+
+ const deltas = pendingDeltas
+ pendingDeltas = []
+ apply(deltas)
+ }
+
+ function push(delta: AIStreamTextDelta) {
+ const previous = pendingDeltas[pendingDeltas.length - 1]
+ if (previous?.type === 'content' && delta.type === 'content') {
+ previous.content += delta.content
+ } else if (previous?.type === 'think' && delta.type === 'think' && previous.thinkTag === delta.thinkTag) {
+ previous.content += delta.content
+ } else {
+ pendingDeltas.push({ ...delta })
+ }
+
+ if (pendingFrame === null) {
+ pendingFrame = scheduler.request(() => {
+ pendingFrame = null
+ flush()
+ })
+ }
+ }
+
+ function cancel() {
+ if (pendingFrame !== null) {
+ scheduler.cancel(pendingFrame)
+ pendingFrame = null
+ }
+ pendingDeltas = []
+ }
+
+ return { push, flush, cancel }
+}
diff --git a/tests/ai-chat-performance/BenchmarkApp.vue b/tests/ai-chat-performance/BenchmarkApp.vue
new file mode 100644
index 00000000..ee200e09
--- /dev/null
+++ b/tests/ai-chat-performance/BenchmarkApp.vue
@@ -0,0 +1,216 @@
+
+
+
+
+
+ {{ report }}
+
+
+
+
+
+
+
+
+
diff --git a/tests/ai-chat-performance/index.html b/tests/ai-chat-performance/index.html
new file mode 100644
index 00000000..fd73578e
--- /dev/null
+++ b/tests/ai-chat-performance/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ AI Chat Performance Benchmark
+
+
+
+
+
+
+
diff --git a/tests/ai-chat-performance/main.ts b/tests/ai-chat-performance/main.ts
new file mode 100644
index 00000000..71f25caf
--- /dev/null
+++ b/tests/ai-chat-performance/main.ts
@@ -0,0 +1,13 @@
+import '@/icons/disable-iconify-api'
+import 'virtual:nuxt-icon-bundle/register'
+
+import { createApp } from 'vue'
+import ui from '@nuxt/ui/vue-plugin'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import i18n from '@/i18n'
+import BenchmarkApp from './BenchmarkApp.vue'
+import '@/assets/styles/main.css'
+
+const router = createRouter({ history: createMemoryHistory(), routes: [] })
+
+createApp(BenchmarkApp).use(router).use(ui).use(i18n).mount('#app')
diff --git a/tests/ai-chat-performance/vite.config.mts b/tests/ai-chat-performance/vite.config.mts
new file mode 100644
index 00000000..4bcc2de2
--- /dev/null
+++ b/tests/ai-chat-performance/vite.config.mts
@@ -0,0 +1,30 @@
+import { resolve } from 'node:path'
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import ui from '@nuxt/ui/vite'
+import { chatlabIconBundle } from '../../vite.icon-bundle.config'
+
+const repositoryRoot = resolve(import.meta.dirname, '../..')
+
+export default defineConfig({
+ root: import.meta.dirname,
+ resolve: {
+ alias: {
+ '@': resolve(repositoryRoot, 'src'),
+ '~': resolve(repositoryRoot, 'src'),
+ '@openchatlab': resolve(repositoryRoot, 'packages'),
+ '@electron/shared': resolve(repositoryRoot, 'apps/desktop/shared'),
+ '@electron/preload': resolve(repositoryRoot, 'apps/desktop/preload'),
+ },
+ },
+ define: {
+ __IS_ELECTRON__: JSON.stringify(false),
+ __IS_WEB_WASM__: JSON.stringify(false),
+ __APP_VERSION__: JSON.stringify('benchmark'),
+ },
+ plugins: [vue(), ui({ dts: false }), chatlabIconBundle(repositoryRoot)],
+ server: {
+ host: '127.0.0.1',
+ port: 3190,
+ },
+})