mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
perf(ai): optimize long conversation rendering
This commit is contained in:
1 parent
c44180ca11
commit
d53a99359b
17 files changed
+782
-57
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"chat": {
|
||||
"scrollToBottom": "下へ移動",
|
||||
"history": {
|
||||
"loadEarlier": "以前の会話を読み込む"
|
||||
},
|
||||
"input": {
|
||||
"placeholderWithSlash": "質問を入力、または / でスキル、{'@'} でメンバーを選択...",
|
||||
"placeholderWithActiveSkill": "/{name} を選択済みです。具体的な質問を入力してください...",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"chat": {
|
||||
"scrollToBottom": "返回底部",
|
||||
"history": {
|
||||
"loadEarlier": "加载更早对话"
|
||||
},
|
||||
"input": {
|
||||
"placeholderWithSlash": "输入你的问题,或输入 / 选择技能、输入 {'@'} 选择成员...",
|
||||
"placeholderWithActiveSkill": "已选择 /{name},请输入具体问题...",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"chat": {
|
||||
"scrollToBottom": "回到底部",
|
||||
"history": {
|
||||
"loadEarlier": "載入更早對話"
|
||||
},
|
||||
"input": {
|
||||
"placeholderWithSlash": "輸入你的問題,或輸入 / 選擇技能、輸入 {'@'} 選擇成員...",
|
||||
"placeholderWithActiveSkill": "已選擇 /{name},請輸入具體問題...",
|
||||
|
||||
+109
-47
@@ -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)
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user