perf(ai): optimize long conversation rendering

This commit is contained in:
digua committed 2026-08-19 18:49:20 +08:00
1 parent c44180ca11
commit d53a99359b
17 files changed
+782 -57

No files matched your search

+15 -1
View File
@@ -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(
</div>
</div>
<div v-if="hasOlderPairs" class="flex justify-center pb-2">
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-full px-3 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
@click="loadOlderPairs"
>
<UIcon name="i-heroicons-arrow-up" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.history.loadEarlier') }}</span>
</button>
</div>
<!-- QA 对渲染 -->
<template v-for="pair in qaPairs" :key="pair.id">
<template v-for="pair in visiblePairs" :key="pair.id">
<!-- 独立消息(summary 等非 user/assistant) -->
<ChatMessage
v-if="pair.standalone"
+18 -1
View File
@@ -88,9 +88,26 @@ md.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
}
// 渲染 Markdown 文本
const MAX_MARKDOWN_CACHE_ENTRIES = 32
const markdownCache = new Map<string, string>()
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
}
// 思考标签名称映射
@@ -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, [])
})
@@ -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<typeof setTimeout> | 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<ChatMessage[]>, isAIThinking: Ref<boolean>) {
const messagesContainer = ref<HTMLElement | null>(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<ChatMessage[]>, isAIThinking: Ref<bo
})
onBeforeUnmount(() => {
scrollScheduler.cancel()
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition)
messagesContainer.value.removeEventListener('wheel', handleWheel)
@@ -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<string | null>('conversation-a')
const container = ref<HTMLElement | null>(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<string | null>('conversation-a')
const container = ref<HTMLElement | null>(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<string | null>('conversation-a')
const container = ref<HTMLElement | null>(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()
})
@@ -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<QAPair[]>,
conversationId: Ref<string | null>,
scrollContainer: Ref<HTMLElement | null>
) {
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<void> {
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,
}
}
+3
View File
@@ -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...",
+3
View File
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "下へ移動",
"history": {
"loadEarlier": "以前の会話を読み込む"
},
"input": {
"placeholderWithSlash": "質問を入力、または / でスキル、{'@'} でメンバーを選択...",
"placeholderWithActiveSkill": "/{name} を選択済みです。具体的な質問を入力してください...",
+3
View File
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "返回底部",
"history": {
"loadEarlier": "加载更早对话"
},
"input": {
"placeholderWithSlash": "输入你的问题,或输入 / 选择技能、输入 {'@'} 选择成员...",
"placeholderWithActiveSkill": "已选择 /{name},请输入具体问题...",
+3
View File
@@ -5,6 +5,9 @@
},
"chat": {
"scrollToBottom": "回到底部",
"history": {
"loadEarlier": "載入更早對話"
},
"input": {
"placeholderWithSlash": "輸入你的問題,或輸入 / 選擇技能、輸入 {'@'} 選擇成員...",
"placeholderWithActiveSkill": "已選擇 /{name},請輸入具體問題...",
+109 -47
View File
@@ -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<ChatMessage>) => {
const applyAIMessageUpdates = (updates: Partial<ChatMessage>) => {
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<ChatMessage>) => {
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<string, unknown>, 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<string, unknown>, 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<typeof toRenderOnlyToolErrorBlock>
) => {
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<ContentBlock, { type: 'tool' }> =>
@@ -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)
+74
View File
@@ -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<number, () => 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, [])
})
+74
View File
@@ -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 }
}
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import ChatMessage from '@/components/AIChat/chat/ChatMessage.vue'
import { useProgressiveChatHistory } from '@/components/AIChat/composables/useProgressiveChatHistory'
import { groupMessagesToQAPairs } from '@/components/AIChat/utils/chatMessages'
import type { ChatMessage as ChatMessageData, ContentBlock } from '@/stores/aiChat'
import { createAIStreamTextBatcher } from '@/stores/aiChatStreamBatcher'
interface BenchmarkReport {
pairs: number
mountedPairs: number
blocksPerAnswer: number
answerCharacters: number
initialRenderMs: number
domNodes: number
stream?: {
chunks: number
intervalMs: number
batched: boolean
renderUpdates: number
durationMs: number
longTasks: number
longTaskDurationMs: number
}
}
declare global {
interface Window {
__AI_CHAT_BENCHMARK_STARTED_AT__: number
__AI_CHAT_BENCHMARK__?: BenchmarkReport
}
}
const params = new URLSearchParams(window.location.search)
const pairCount = readPositiveInteger('pairs', 100)
const blocksPerAnswer = readPositiveInteger('blocks', 10)
const answerCharacters = readPositiveInteger('chars', 10_000)
const streamChunks = readPositiveInteger('streamChunks', 0)
const streamIntervalMs = readPositiveInteger('streamInterval', 4)
const useStreamBatcher = params.get('batch') === '1'
const useProgressiveHistory = params.get('progressive') === '1'
const report = ref<BenchmarkReport | null>(null)
const messages = ref(createMessages(pairCount, blocksPerAnswer, answerCharacters))
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
const benchmarkConversationId = ref<string | null>('benchmark')
const benchmarkScrollContainer = ref<HTMLElement | null>(null)
const progressiveHistory = useProgressiveChatHistory(qaPairs, benchmarkConversationId, benchmarkScrollContainer)
const renderedPairs = computed(() => (useProgressiveHistory ? progressiveHistory.visiblePairs.value : qaPairs.value))
function readPositiveInteger(key: string, fallback: number): number {
const value = Number.parseInt(params.get(key) ?? '', 10)
return Number.isInteger(value) && value > 0 ? value : fallback
}
function buildText(length: number, seed: number): string {
const paragraph = `第 ${seed + 1} 段合成回答只用于性能测试,不包含任何用户数据。这里包含 **Markdown**、列表和换行。\n\n- 项目一\n- 项目二\n\n`
return paragraph.repeat(Math.ceil(length / paragraph.length)).slice(0, length)
}
function createBlocks(blockCount: number, totalCharacters: number, seed: number): ContentBlock[] {
const textPerBlock = Math.max(1, Math.floor(totalCharacters / blockCount))
return Array.from({ length: blockCount }, (_, index): ContentBlock => {
const text = buildText(textPerBlock, seed * blockCount + index)
if (index % 3 === 0) return { type: 'think', tag: 'analysis', text, durationMs: 500 }
if (index % 3 === 1) {
return {
type: 'tool',
tool: {
name: 'benchmark_tool',
displayName: 'benchmark_tool',
status: 'done',
result: text,
displayResult: text,
},
}
}
return { type: 'text', text }
})
}
function createMessages(pairs: number, blockCount: number, characters: number): ChatMessageData[] {
const result: ChatMessageData[] = []
const timestamp = Date.now()
for (let index = 0; index < pairs; index += 1) {
result.push({
id: `user-${index}`,
role: 'user',
content: `这是第 ${index + 1} 个合成问题。`,
timestamp: timestamp + index * 2,
})
result.push({
id: `assistant-${index}`,
role: 'assistant',
content: buildText(characters, index),
contentBlocks: createBlocks(blockCount, characters, index),
processDurationMs: 1_500,
timestamp: timestamp + index * 2 + 1,
})
}
return result
}
onMounted(async () => {
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
const result: BenchmarkReport = {
pairs: pairCount,
mountedPairs: renderedPairs.value.length,
blocksPerAnswer,
answerCharacters,
initialRenderMs: performance.now() - window.__AI_CHAT_BENCHMARK_STARTED_AT__,
domNodes: document.getElementsByTagName('*').length,
}
report.value = result
window.__AI_CHAT_BENCHMARK__ = result
if (streamChunks > 0) {
await runStreamingBenchmark(result)
}
})
async function runStreamingBenchmark(result: BenchmarkReport): Promise<void> {
const longTaskDurations: number[] = []
const observer =
typeof PerformanceObserver === 'undefined'
? null
: new PerformanceObserver((entries) => {
longTaskDurations.push(...entries.getEntries().map((entry) => entry.duration))
})
observer?.observe({ type: 'longtask', buffered: false })
const assistantIndex = messages.value.length - 1
const assistant = messages.value[assistantIndex]
const initialBlocks = assistant.contentBlocks ?? []
messages.value[assistantIndex] = {
...assistant,
isStreaming: true,
contentBlocks: [...initialBlocks, { type: 'text', text: '' }],
}
await nextTick()
const chunkText = '合成流式内容 **Markdown**。'
const startedAt = performance.now()
let emitted = 0
let renderUpdates = 0
const applyText = (text: string) => {
const current = messages.value[assistantIndex]
const blocks = current.contentBlocks ?? []
const lastBlock = blocks[blocks.length - 1]
if (lastBlock?.type === 'text') lastBlock.text += text
messages.value[assistantIndex] = {
...current,
content: current.content + text,
contentBlocks: [...blocks],
}
renderUpdates += 1
}
const batcher = createAIStreamTextBatcher((deltas) => {
applyText(deltas.map((delta) => delta.content).join(''))
})
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (useStreamBatcher) batcher.push({ type: 'content', content: chunkText })
else applyText(chunkText)
emitted += 1
if (emitted < streamChunks) return
window.clearInterval(timer)
batcher.flush()
resolve()
}, streamIntervalMs)
})
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
observer?.disconnect()
result.stream = {
chunks: streamChunks,
intervalMs: streamIntervalMs,
batched: useStreamBatcher,
renderUpdates,
durationMs: performance.now() - startedAt,
longTasks: longTaskDurations.length,
longTaskDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
}
report.value = { ...result }
window.__AI_CHAT_BENCHMARK__ = report.value
}
</script>
<template>
<UApp>
<main class="mx-auto max-w-4xl px-6 py-8">
<pre id="benchmark-report" class="mb-8 rounded-lg bg-gray-100 p-4 text-xs dark:bg-gray-900">{{ report }}</pre>
<template v-for="pair in renderedPairs" :key="pair.id">
<div v-if="!pair.standalone" class="space-y-6 pb-4">
<ChatMessage
v-if="pair.user"
:message-id="pair.user.id"
:role="pair.user.role"
:content="pair.user.content"
:timestamp="pair.user.timestamp"
/>
<ChatMessage
v-if="pair.assistant"
:message-id="pair.assistant.id"
:role="pair.assistant.role"
:content="pair.assistant.content"
:content-blocks="pair.assistant.contentBlocks"
:process-duration-ms="pair.assistant.processDurationMs"
:timestamp="pair.assistant.timestamp"
/>
</div>
</template>
</main>
</UApp>
</template>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat Performance Benchmark</title>
<script>
window.__AI_CHAT_BENCHMARK_STARTED_AT__ = performance.now()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
+13
View File
@@ -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')
+30
View File
@@ -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,
},
})