|
1 | 1 | <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' |
15 | 4 |
|
| 5 | +import ChatActionButtons from '../Widgets/ChatActionButtons.vue' |
| 6 | +import ChatArea from '../Widgets/ChatArea.vue' |
| 7 | +import ChatContainer from '../Widgets/ChatContainer.vue' |
16 | 8 | 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 | | -}) |
76 | 9 |
|
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() |
80 | 11 |
|
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) |
110 | 13 | </script> |
111 | 14 |
|
112 | 15 | <template> |
113 | 16 | <div flex="col" items-center pt-4> |
114 | 17 | <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" /> |
219 | 28 | </div> |
220 | | - </div> |
| 29 | + <ChatArea /> |
| 30 | + </ChatContainer> |
221 | 31 | </div> |
222 | 32 |
|
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 /> |
250 | 34 | </div> |
251 | 35 | </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> |
0 commit comments