perf(stage-web): improve chat responsiveness & loading order (#754)

This commit is contained in:
Mr-Quin
2025-11-23 15:12:47 +08:00
committed by GitHub
parent 7e299e1788
commit b60e0d59c5
7 changed files with 310 additions and 237 deletions
@@ -1,251 +1,50 @@
<script setup lang="ts">
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, FieldSelect, useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDeferredMount } from '@proj-airi/ui'
import { ref } from 'vue'
import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
import ChatArea from '../Widgets/ChatArea.vue'
import ChatContainer from '../Widgets/ChatContainer.vue'
import ChatHistory from '../Widgets/ChatHistory.vue'
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
const messageInput = ref('')
const hearingTooltipOpen = ref(false)
const isComposing = ref(false)
const { isReady } = useDeferredMount()
const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { askPermission } = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
const { t } = useI18n()
const { isDark, toggleDark } = useTheme()
// Legacy whisper pipeline removed; audio pipeline handled at page level
async function handleSend() {
if (!messageInput.value.trim() || isComposing.value) {
return
}
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
watch(hearingTooltipOpen, async (value) => {
if (value) {
await askPermission()
}
})
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
})
onAfterMessageComposed(async () => {
messageInput.value = ''
})
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
let analyzerSource: MediaStreamAudioSourceNode | undefined
function teardownAnalyzer() {
try {
analyzerSource?.disconnect()
}
catch {}
analyzerSource = undefined
stopAnalyzer()
}
async function setupAnalyzer() {
teardownAnalyzer()
if (!hearingTooltipOpen.value || !enabled.value || !stream.value)
return
if (audioContext.state === 'suspended')
await audioContext.resume()
const analyser = startAnalyzer(audioContext)
if (!analyser)
return
analyzerSource = audioContext.createMediaStreamSource(stream.value)
analyzerSource.connect(analyser)
}
watch([hearingTooltipOpen, enabled, stream], () => {
setupAnalyzer()
}, { immediate: true })
onUnmounted(() => {
teardownAnalyzer()
})
const isLoading = ref(true)
</script>
<template>
<div flex="col" items-center pt-4>
<div h-full max-h="[85vh]" w-full py="4">
<div
flex="~ col"
border="solid 4 primary-200/20 dark:primary-400/20"
h-full w-full rounded-xl
bg="primary-50/50 dark:primary-950/70" backdrop-blur-md
>
<ChatHistory h-full flex-1 w="full" max-h="<md:[60%]" />
<div h="<md:full" flex gap-2>
<div
:class="[
'relative',
'w-full',
'bg-primary-200/20 dark:bg-primary-400/20',
]"
>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
text="primary-500 hover:primary-600 dark:primary-300/50 dark:hover:primary-500 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300/50 placeholder:dark:hover:primary-500"
bg="transparent"
min-h="[100px]" max-h="[300px]" w-full
rounded-t-xl p-4 font-medium
outline-none transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="{
'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic,
}"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<div>
<TooltipProvider :delay-duration="0" :skip-delay-duration="0">
<TooltipRoot v-model:open="hearingTooltipOpen">
<TooltipTrigger as-child>
<button
class="max-h-[10lh] min-h-[1lh]"
text="lg neutral-500 dark:neutral-400"
flex items-center justify-center rounded-md p-2 outline-none
transition="colors duration-200, transform duration-100" active:scale-95
:title="t('settings.hearing.title')"
>
<Transition name="fade" mode="out-in">
<IndicatorMicVolume v-if="enabled" />
<div v-else class="i-ph:microphone-slash" />
</Transition>
</button>
</TooltipTrigger>
<Transition name="fade">
<TooltipContent
side="top"
:side-offset="8"
:class="[
'w-72 max-w-[18rem] rounded-xl border border-neutral-200/60 bg-neutral-50/90 p-4',
'shadow-lg backdrop-blur-md dark:border-neutral-800/30 dark:bg-neutral-900/80',
'flex flex-col gap-3',
]"
>
<div class="flex flex-col items-center justify-center">
<div class="relative h-28 w-28 select-none">
<div
class="absolute left-1/2 top-1/2 h-20 w-20 rounded-full transition-all duration-150 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1 + normalizedVolume * 0.35})`, opacity: String(0.25 + normalizedVolume * 0.25) }"
:class="enabled ? 'bg-primary-500/15 dark:bg-primary-600/20' : 'bg-neutral-300/20 dark:bg-neutral-700/20'"
/>
<div
class="absolute left-1/2 top-1/2 h-24 w-24 rounded-full transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + normalizedVolume * 0.55})`, opacity: String(0.15 + normalizedVolume * 0.2) }"
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
/>
<div
class="absolute left-1/2 top-1/2 h-28 w-28 rounded-full transition-all duration-300 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + normalizedVolume * 0.8})`, opacity: String(0.08 + normalizedVolume * 0.15) }"
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
/>
<button
class="absolute left-1/2 top-1/2 grid h-16 w-16 place-items-center rounded-full shadow-md outline-none transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
:class="enabled
? 'bg-primary-500 text-white hover:bg-primary-600 active:scale-95'
: 'bg-neutral-200 text-neutral-600 hover:bg-neutral-300 active:scale-95 dark:bg-neutral-700 dark:text-neutral-200'"
@click="enabled = !enabled"
>
<div :class="enabled ? 'i-ph:microphone' : 'i-ph:microphone-slash'" class="h-6 w-6" />
</button>
</div>
<p class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{{ enabled ? 'Microphone enabled' : 'Microphone disabled' }}
</p>
</div>
<FieldSelect
v-model="selectedAudioInput"
label="Input device"
description="Select the microphone you want to use."
:options="audioInputs.map(device => ({ label: device.label || 'Unknown Device', value: device.deviceId }))"
layout="vertical"
placeholder="Select microphone"
/>
</TooltipContent>
</Transition>
</TooltipRoot>
</TooltipProvider>
</div>
</div>
<ChatContainer>
<div
v-if="isLoading"
absolute left-0 top-0 h-1 w-full overflow-hidden rounded-t-xl
class="bg-primary-500/20"
>
<div h-full w="1/3" origin-left bg-primary-500 class="animate-scan" />
</div>
</div>
<div w="full" max-h="<md:[60%]" py="<sm:2" flex="~ col" rounded="lg" relative h-full flex-1 overflow-hidden py-4>
<ChatHistory v-if="isReady" h-full @vue:mounted="isLoading = false" />
</div>
<ChatArea />
</ChatContainer>
</div>
<div absolute bottom--8 right-0 flex gap-2>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="cleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="toggleDark()"
>
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-bold />
<div v-else i-solar:sun-2-bold />
</Transition>
</button>
</div>
<ChatActionButtons />
</div>
</template>
<style scoped>
@keyframes scan {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.animate-scan {
animation: scan 2s infinite linear;
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useTheme } from '@proj-airi/ui'
const { cleanupMessages } = useChatStore()
const { isDark, toggleDark } = useTheme()
</script>
<template>
<div absolute bottom--8 right-0 flex gap-2>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
hover:text="red-500 dark:red-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="cleanupMessages"
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
@click="() => toggleDark()"
>
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-bold />
<div v-else i-solar:sun-2-bold />
</Transition>
</button>
</div>
</template>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IndicatorMicVolume from './IndicatorMicVolume.vue'
const messageInput = ref('')
const hearingTooltipOpen = ref(false)
const isComposing = ref(false)
const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { askPermission } = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
const { send, onAfterMessageComposed, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
const { t } = useI18n()
async function handleSend() {
if (!messageInput.value.trim() || isComposing.value) {
return
}
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
watch(hearingTooltipOpen, async (value) => {
if (value) {
await askPermission()
}
})
watch([activeProvider, activeModel], async () => {
if (activeProvider.value && activeModel.value) {
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
}
})
onAfterMessageComposed(async () => {
messageInput.value = ''
})
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
let analyzerSource: MediaStreamAudioSourceNode | undefined
function teardownAnalyzer() {
try {
analyzerSource?.disconnect()
}
catch {}
analyzerSource = undefined
stopAnalyzer()
}
async function setupAnalyzer() {
teardownAnalyzer()
if (!hearingTooltipOpen.value || !enabled.value || !stream.value)
return
if (audioContext.state === 'suspended')
await audioContext.resume()
const analyser = startAnalyzer(audioContext)
if (!analyser)
return
analyzerSource = audioContext.createMediaStreamSource(stream.value)
analyzerSource.connect(analyser)
}
watch([hearingTooltipOpen, enabled, stream], () => {
setupAnalyzer()
}, { immediate: true })
onUnmounted(() => {
teardownAnalyzer()
})
</script>
<template>
<div h="<md:full" flex gap-2>
<div
:class="[
'relative',
'w-full',
'bg-primary-200/20 dark:bg-primary-400/20',
]"
>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
text="primary-500 hover:primary-600 dark:primary-300/50 dark:hover:primary-500 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300/50 placeholder:dark:hover:primary-500"
bg="transparent"
min-h="[100px]" max-h="[300px]" w-full
rounded-t-xl p-4 font-medium
outline-none transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="{
'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic,
}"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<div>
<TooltipProvider :delay-duration="0" :skip-delay-duration="0">
<TooltipRoot v-model:open="hearingTooltipOpen">
<TooltipTrigger as-child>
<button
class="max-h-[10lh] min-h-[1lh]"
text="lg neutral-500 dark:neutral-400"
flex items-center justify-center rounded-md p-2 outline-none
transition="colors duration-200, transform duration-100" active:scale-95
:title="t('settings.hearing.title')"
>
<Transition name="fade" mode="out-in">
<IndicatorMicVolume v-if="enabled" />
<div v-else class="i-ph:microphone-slash" />
</Transition>
</button>
</TooltipTrigger>
<Transition name="fade">
<TooltipContent
side="top"
:side-offset="8"
:class="[
'w-72 max-w-[18rem] rounded-xl border border-neutral-200/60 bg-neutral-50/90 p-4',
'shadow-lg backdrop-blur-md dark:border-neutral-800/30 dark:bg-neutral-900/80',
'flex flex-col gap-3',
]"
>
<div class="flex flex-col items-center justify-center">
<div class="relative h-28 w-28 select-none">
<div
class="absolute left-1/2 top-1/2 h-20 w-20 rounded-full transition-all duration-150 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1 + normalizedVolume * 0.35})`, opacity: String(0.25 + normalizedVolume * 0.25) }"
:class="enabled ? 'bg-primary-500/15 dark:bg-primary-600/20' : 'bg-neutral-300/20 dark:bg-neutral-700/20'"
/>
<div
class="absolute left-1/2 top-1/2 h-24 w-24 rounded-full transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + normalizedVolume * 0.55})`, opacity: String(0.15 + normalizedVolume * 0.2) }"
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
/>
<div
class="absolute left-1/2 top-1/2 h-28 w-28 rounded-full transition-all duration-300 -translate-x-1/2 -translate-y-1/2"
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + normalizedVolume * 0.8})`, opacity: String(0.08 + normalizedVolume * 0.15) }"
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
/>
<button
class="absolute left-1/2 top-1/2 grid h-16 w-16 place-items-center rounded-full shadow-md outline-none transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
:class="enabled
? 'bg-primary-500 text-white hover:bg-primary-600 active:scale-95'
: 'bg-neutral-200 text-neutral-600 hover:bg-neutral-300 active:scale-95 dark:bg-neutral-700 dark:text-neutral-200'"
@click="enabled = !enabled"
>
<div :class="enabled ? 'i-ph:microphone' : 'i-ph:microphone-slash'" class="h-6 w-6" />
</button>
</div>
<p class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{{ enabled ? 'Microphone enabled' : 'Microphone disabled' }}
</p>
</div>
<FieldSelect
v-model="selectedAudioInput"
label="Input device"
description="Select the microphone you want to use."
:options="audioInputs.map(device => ({ label: device.label || 'Unknown Device', value: device.deviceId }))"
layout="vertical"
placeholder="Select microphone"
/>
</TooltipContent>
</Transition>
</TooltipRoot>
</TooltipProvider>
</div>
</div>
</div>
</template>
@@ -0,0 +1,10 @@
<template>
<div
flex="~ col"
border="solid 4 primary-200/20 dark:primary-400/20"
h-full w-full rounded-xl
bg="primary-50/50 dark:primary-950/70" backdrop-blur-md
>
<slot />
</div>
</template>
@@ -39,7 +39,7 @@ watch(sending, () => {
</script>
<template>
<div py="<sm:2" flex="~ col" rounded="lg" relative overflow-hidden py-4>
<div overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate px="<sm:2" flex="~ col" h-full w-full overflow-scroll px-4>
<div flex-1 /> <!-- spacer -->
@@ -0,0 +1,20 @@
import { onMounted, ref } from 'vue'
export function useDeferredMount() {
const isReady = ref(false)
onMounted(() => {
// NOTICE: yield the main thread to the browser to paint.
// Other approaches work too, e.g. double RAF like here, or a setTimeout 0, or Suspense with async component, etc.
// Virtualization somewhat helps, however, virtual list may break Ctrl-F and Ctrl-A.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isReady.value = true
})
})
})
return {
isReady,
}
}
+1
View File
@@ -2,4 +2,5 @@ import './fallback.css'
export * from './components/Animations'
export * from './components/Form'
export * from './composables/use-deferred-mount'
export * from './composables/use-theme'