Skip to content

Commit b60e0d5

Browse files
authored
perf(stage-web): improve chat responsiveness & loading order (#754)
1 parent 7e299e1 commit b60e0d5

File tree

7 files changed

+310
-237
lines changed

7 files changed

+310
-237
lines changed
Lines changed: 35 additions & 236 deletions
Original file line numberDiff line numberDiff line change
@@ -1,251 +1,50 @@
11
<script setup lang="ts">
2-
import type { ChatProvider } from '@xsai-ext/shared-providers'
3-
4-
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
5-
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
6-
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
7-
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
8-
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
9-
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
10-
import { BasicTextarea, FieldSelect, useTheme } from '@proj-airi/ui'
11-
import { storeToRefs } from 'pinia'
12-
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
13-
import { computed, onUnmounted, ref, watch } from 'vue'
14-
import { useI18n } from 'vue-i18n'
2+
import { useDeferredMount } from '@proj-airi/ui'
3+
import { ref } from 'vue'
154
5+
import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
6+
import ChatArea from '../Widgets/ChatArea.vue'
7+
import ChatContainer from '../Widgets/ChatContainer.vue'
168
import ChatHistory from '../Widgets/ChatHistory.vue'
17-
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
18-
19-
const messageInput = ref('')
20-
const hearingTooltipOpen = ref(false)
21-
const isComposing = ref(false)
22-
23-
const providersStore = useProvidersStore()
24-
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
25-
const { themeColorsHueDynamic } = storeToRefs(useSettings())
26-
27-
const { askPermission } = useSettingsAudioDevice()
28-
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
29-
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
30-
const { messages } = storeToRefs(useChatStore())
31-
const { audioContext } = useAudioContext()
32-
const { t } = useI18n()
33-
34-
const { isDark, toggleDark } = useTheme()
35-
36-
// Legacy whisper pipeline removed; audio pipeline handled at page level
37-
38-
async function handleSend() {
39-
if (!messageInput.value.trim() || isComposing.value) {
40-
return
41-
}
42-
43-
try {
44-
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
45-
46-
await send(messageInput.value, {
47-
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
48-
model: activeModel.value,
49-
providerConfig,
50-
})
51-
}
52-
catch (error) {
53-
messages.value.pop()
54-
messages.value.push({
55-
role: 'error',
56-
content: (error as Error).message,
57-
})
58-
}
59-
}
60-
61-
watch(hearingTooltipOpen, async (value) => {
62-
if (value) {
63-
await askPermission()
64-
}
65-
})
66-
67-
watch([activeProvider, activeModel], async () => {
68-
if (activeProvider.value && activeModel.value) {
69-
await discoverToolsCompatibility(activeModel.value, await providersStore.getProviderInstance<ChatProvider>(activeProvider.value), [])
70-
}
71-
})
72-
73-
onAfterMessageComposed(async () => {
74-
messageInput.value = ''
75-
})
769
77-
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
78-
const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
79-
let analyzerSource: MediaStreamAudioSourceNode | undefined
10+
const { isReady } = useDeferredMount()
8011
81-
function teardownAnalyzer() {
82-
try {
83-
analyzerSource?.disconnect()
84-
}
85-
catch {}
86-
analyzerSource = undefined
87-
stopAnalyzer()
88-
}
89-
90-
async function setupAnalyzer() {
91-
teardownAnalyzer()
92-
if (!hearingTooltipOpen.value || !enabled.value || !stream.value)
93-
return
94-
if (audioContext.state === 'suspended')
95-
await audioContext.resume()
96-
const analyser = startAnalyzer(audioContext)
97-
if (!analyser)
98-
return
99-
analyzerSource = audioContext.createMediaStreamSource(stream.value)
100-
analyzerSource.connect(analyser)
101-
}
102-
103-
watch([hearingTooltipOpen, enabled, stream], () => {
104-
setupAnalyzer()
105-
}, { immediate: true })
106-
107-
onUnmounted(() => {
108-
teardownAnalyzer()
109-
})
12+
const isLoading = ref(true)
11013
</script>
11114

11215
<template>
11316
<div flex="col" items-center pt-4>
11417
<div h-full max-h="[85vh]" w-full py="4">
115-
<div
116-
flex="~ col"
117-
border="solid 4 primary-200/20 dark:primary-400/20"
118-
h-full w-full rounded-xl
119-
bg="primary-50/50 dark:primary-950/70" backdrop-blur-md
120-
>
121-
<ChatHistory h-full flex-1 w="full" max-h="<md:[60%]" />
122-
<div h="<md:full" flex gap-2>
123-
<div
124-
:class="[
125-
'relative',
126-
'w-full',
127-
'bg-primary-200/20 dark:bg-primary-400/20',
128-
]"
129-
>
130-
<BasicTextarea
131-
v-model="messageInput"
132-
:placeholder="t('stage.message')"
133-
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"
134-
bg="transparent"
135-
min-h="[100px]" max-h="[300px]" w-full
136-
rounded-t-xl p-4 font-medium
137-
outline-none transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
138-
:class="{
139-
'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic,
140-
}"
141-
@submit="handleSend"
142-
@compositionstart="isComposing = true"
143-
@compositionend="isComposing = false"
144-
/>
145-
146-
<div>
147-
<TooltipProvider :delay-duration="0" :skip-delay-duration="0">
148-
<TooltipRoot v-model:open="hearingTooltipOpen">
149-
<TooltipTrigger as-child>
150-
<button
151-
class="max-h-[10lh] min-h-[1lh]"
152-
text="lg neutral-500 dark:neutral-400"
153-
flex items-center justify-center rounded-md p-2 outline-none
154-
transition="colors duration-200, transform duration-100" active:scale-95
155-
:title="t('settings.hearing.title')"
156-
>
157-
<Transition name="fade" mode="out-in">
158-
<IndicatorMicVolume v-if="enabled" />
159-
<div v-else class="i-ph:microphone-slash" />
160-
</Transition>
161-
</button>
162-
</TooltipTrigger>
163-
<Transition name="fade">
164-
<TooltipContent
165-
side="top"
166-
:side-offset="8"
167-
:class="[
168-
'w-72 max-w-[18rem] rounded-xl border border-neutral-200/60 bg-neutral-50/90 p-4',
169-
'shadow-lg backdrop-blur-md dark:border-neutral-800/30 dark:bg-neutral-900/80',
170-
'flex flex-col gap-3',
171-
]"
172-
>
173-
<div class="flex flex-col items-center justify-center">
174-
<div class="relative h-28 w-28 select-none">
175-
<div
176-
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"
177-
:style="{ transform: `translate(-50%, -50%) scale(${1 + normalizedVolume * 0.35})`, opacity: String(0.25 + normalizedVolume * 0.25) }"
178-
:class="enabled ? 'bg-primary-500/15 dark:bg-primary-600/20' : 'bg-neutral-300/20 dark:bg-neutral-700/20'"
179-
/>
180-
<div
181-
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"
182-
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + normalizedVolume * 0.55})`, opacity: String(0.15 + normalizedVolume * 0.2) }"
183-
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
184-
/>
185-
<div
186-
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"
187-
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + normalizedVolume * 0.8})`, opacity: String(0.08 + normalizedVolume * 0.15) }"
188-
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
189-
/>
190-
<button
191-
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"
192-
:class="enabled
193-
? 'bg-primary-500 text-white hover:bg-primary-600 active:scale-95'
194-
: 'bg-neutral-200 text-neutral-600 hover:bg-neutral-300 active:scale-95 dark:bg-neutral-700 dark:text-neutral-200'"
195-
@click="enabled = !enabled"
196-
>
197-
<div :class="enabled ? 'i-ph:microphone' : 'i-ph:microphone-slash'" class="h-6 w-6" />
198-
</button>
199-
</div>
200-
<p class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
201-
{{ enabled ? 'Microphone enabled' : 'Microphone disabled' }}
202-
</p>
203-
</div>
204-
205-
<FieldSelect
206-
v-model="selectedAudioInput"
207-
label="Input device"
208-
description="Select the microphone you want to use."
209-
:options="audioInputs.map(device => ({ label: device.label || 'Unknown Device', value: device.deviceId }))"
210-
layout="vertical"
211-
placeholder="Select microphone"
212-
/>
213-
</TooltipContent>
214-
</Transition>
215-
</TooltipRoot>
216-
</TooltipProvider>
217-
</div>
218-
</div>
18+
<ChatContainer>
19+
<div
20+
v-if="isLoading"
21+
absolute left-0 top-0 h-1 w-full overflow-hidden rounded-t-xl
22+
class="bg-primary-500/20"
23+
>
24+
<div h-full w="1/3" origin-left bg-primary-500 class="animate-scan" />
25+
</div>
26+
<div w="full" max-h="<md:[60%]" py="<sm:2" flex="~ col" rounded="lg" relative h-full flex-1 overflow-hidden py-4>
27+
<ChatHistory v-if="isReady" h-full @vue:mounted="isLoading = false" />
21928
</div>
220-
</div>
29+
<ChatArea />
30+
</ChatContainer>
22131
</div>
22232

223-
<div absolute bottom--8 right-0 flex gap-2>
224-
<button
225-
class="max-h-[10lh] min-h-[1lh]"
226-
bg="neutral-100 dark:neutral-800"
227-
text="lg neutral-500 dark:neutral-400"
228-
hover:text="red-500 dark:red-400"
229-
flex items-center justify-center rounded-md p-2 outline-none
230-
transition-colors transition-transform active:scale-95
231-
@click="cleanupMessages"
232-
>
233-
<div class="i-solar:trash-bin-2-bold-duotone" />
234-
</button>
235-
236-
<button
237-
class="max-h-[10lh] min-h-[1lh]"
238-
bg="neutral-100 dark:neutral-800"
239-
text="lg neutral-500 dark:neutral-400"
240-
flex items-center justify-center rounded-md p-2 outline-none
241-
transition-colors transition-transform active:scale-95
242-
@click="toggleDark()"
243-
>
244-
<Transition name="fade" mode="out-in">
245-
<div v-if="isDark" i-solar:moon-bold />
246-
<div v-else i-solar:sun-2-bold />
247-
</Transition>
248-
</button>
249-
</div>
33+
<ChatActionButtons />
25034
</div>
25135
</template>
36+
37+
<style scoped>
38+
@keyframes scan {
39+
0% {
40+
transform: translateX(-100%);
41+
}
42+
100% {
43+
transform: translateX(400%);
44+
}
45+
}
46+
47+
.animate-scan {
48+
animation: scan 2s infinite linear;
49+
}
50+
</style>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
3+
import { useTheme } from '@proj-airi/ui'
4+
5+
const { cleanupMessages } = useChatStore()
6+
const { isDark, toggleDark } = useTheme()
7+
</script>
8+
9+
<template>
10+
<div absolute bottom--8 right-0 flex gap-2>
11+
<button
12+
class="max-h-[10lh] min-h-[1lh]"
13+
bg="neutral-100 dark:neutral-800"
14+
text="lg neutral-500 dark:neutral-400"
15+
hover:text="red-500 dark:red-400"
16+
flex items-center justify-center rounded-md p-2 outline-none
17+
transition-colors transition-transform active:scale-95
18+
@click="cleanupMessages"
19+
>
20+
<div class="i-solar:trash-bin-2-bold-duotone" />
21+
</button>
22+
23+
<button
24+
class="max-h-[10lh] min-h-[1lh]"
25+
bg="neutral-100 dark:neutral-800"
26+
text="lg neutral-500 dark:neutral-400"
27+
flex items-center justify-center rounded-md p-2 outline-none
28+
transition-colors transition-transform active:scale-95
29+
@click="() => toggleDark()"
30+
>
31+
<Transition name="fade" mode="out-in">
32+
<div v-if="isDark" i-solar:moon-bold />
33+
<div v-else i-solar:sun-2-bold />
34+
</Transition>
35+
</button>
36+
</div>
37+
</template>

0 commit comments

Comments
 (0)