perf(stage-web): improve chat responsiveness & loading order (#754)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user