From bcafcac7c1f57bc4029fd0bd4ea6d47a56049d23 Mon Sep 17 00:00:00 2001 From: Zach Leavitt <72157473+zsleavitt@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:31:08 -0500 Subject: [PATCH] feat(stage-*): browser provided speech recognition as provider (#933) --- .../src/renderer/pages/index.vue | 96 ++-- apps/stage-web/src/pages/index.vue | 27 +- packages/i18n/src/locales/en/settings.yaml | 3 + .../src/components/Widgets/ChatArea.vue | 279 +++++++++- .../src/pages/settings/modules/hearing.vue | 351 +++++++++++- .../transcription/browser-web-speech-api.vue | 504 ++++++++++++++++++ .../src/components/menu/radio-card-simple.vue | 4 +- .../src/composables/use-modules-list.ts | 4 +- packages/stage-ui/src/stores/audio.ts | 3 +- .../stage-ui/src/stores/modules/hearing.ts | 251 ++++++++- packages/stage-ui/src/stores/providers.ts | 119 ++++- .../stores/providers/web-speech-api/index.ts | 470 ++++++++++++++++ pnpm-lock.yaml | 239 +-------- 13 files changed, 2043 insertions(+), 307 deletions(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/transcription/browser-web-speech-api.vue create mode 100644 packages/stage-ui/src/stores/providers/web-speech-api/index.ts diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index cef24b5c5..513393cce 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -125,6 +125,7 @@ watch([isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTrans const settingsAudioDeviceStore = useSettingsAudioDevice() const { stream, enabled } = storeToRefs(settingsAudioDeviceStore) +const { askPermission } = settingsAudioDeviceStore const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream) const hearingPipeline = useHearingSpeechInputPipeline() const { @@ -163,33 +164,8 @@ type CaptionChannelEvent const { post: postCaption } = useBroadcastChannel({ name: 'airi-caption-overlay' }) async function handleSpeechStart() { - if (shouldUseStreamInput.value && stream.value) { - await transcribeForMediaStream(stream.value, { - onSentenceEnd: (delta) => { - const finalText = delta - if (!finalText || !finalText.trim()) { - return - } - - postCaption({ type: 'caption-speaker', text: finalText }) - - void (async () => { - try { - const provider = await providersStore.getProviderInstance(activeChatProvider.value) - if (!provider || !activeChatModel.value) - return - - await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider }) - } - catch (err) { - console.error('Failed to send chat from voice:', err) - } - })() - }, - onSpeechEnd: (text) => { - postCaption({ type: 'caption-speaker', text }) - }, - }) + if (shouldUseStreamInput.value) { + console.info('Speech detected - transcription session should already be active') return } @@ -207,9 +183,67 @@ async function handleSpeechEnd() { async function startAudioInteraction() { try { - await initVAD() - if (stream.value) - await startVAD(stream.value) + console.info('[Main Page] Starting audio interaction...') + + initVAD().then(() => { + if (stream.value) + return startVAD(stream.value) + }).catch((err) => { + console.warn('[Main Page] VAD initialization failed (non-critical for Web Speech API):', err) + }) + + if (shouldUseStreamInput.value) { + console.info('[Main Page] Starting streaming transcription...', { + supportsStreamInput: supportsStreamInput.value, + hasStream: !!stream.value, + }) + + if (!stream.value) { + console.warn('[Main Page] Stream not available despite shouldUseStreamInput being true') + return + } + + await transcribeForMediaStream(stream.value, { + onSentenceEnd: (delta) => { + console.info('[Main Page] Received transcription delta:', delta) + const finalText = delta + if (!finalText || !finalText.trim()) { + return + } + + postCaption({ type: 'caption-speaker', text: finalText }) + + void (async () => { + try { + const provider = await providersStore.getProviderInstance(activeChatProvider.value) + if (!provider || !activeChatModel.value) { + console.warn('[Main Page] No provider or model available, skipping chat send') + return + } + + console.info('[Main Page] Sending transcription to chat:', finalText) + await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider }) + } + catch (err) { + console.error('[Main Page] Failed to send chat from voice:', err) + } + })() + }, + onSpeechEnd: (text) => { + console.info('[Main Page] Speech ended, final text:', text) + postCaption({ type: 'caption-speaker', text }) + }, + }) + + console.info('[Main Page] Streaming transcription started successfully') + } + else { + console.warn('[Main Page] Not starting streaming transcription:', { + shouldUseStreamInput: shouldUseStreamInput.value, + hasStream: !!stream.value, + supportsStreamInput: supportsStreamInput.value, + }) + } // Hook once stopOnStopRecord = onStopRecord(async (recording) => { @@ -251,7 +285,9 @@ function stopAudioInteraction() { } watch(enabled, async (val) => { + console.info('[Main Page] Audio enabled changed:', val, 'stream available:', !!stream.value) if (val) { + await askPermission() await startAudioInteraction() } else { diff --git a/apps/stage-web/src/pages/index.vue b/apps/stage-web/src/pages/index.vue index df94b37fe..034a1a728 100644 --- a/apps/stage-web/src/pages/index.vue +++ b/apps/stage-web/src/pages/index.vue @@ -47,7 +47,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice() const { stream, enabled } = storeToRefs(settingsAudioDeviceStore) const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream) const hearingPipeline = useHearingSpeechInputPipeline() -const { transcribeForRecording, transcribeForMediaStream } = hearingPipeline +const { transcribeForRecording } = hearingPipeline const { supportsStreamInput } = storeToRefs(hearingPipeline) const providersStore = useProvidersStore() const consciousnessStore = useConsciousnessStore() @@ -99,28 +99,9 @@ async function startAudioInteraction() { } async function handleSpeechStart() { - if (shouldUseStreamInput.value && stream.value) { - await transcribeForMediaStream(stream.value, { - onSentenceEnd: (delta) => { - const finalText = delta - if (!finalText || !finalText.trim()) { - return - } - - void (async () => { - try { - const provider = await providersStore.getProviderInstance(activeChatProvider.value) - if (!provider || !activeChatModel.value) - return - - await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider }) - } - catch (err) { - console.error('Failed to send chat from voice:', err) - } - })() - }, - }) + // For streaming providers, ChatArea component handles transcription manually + // The main page should not start automatic transcription to avoid duplicate sessions + if (shouldUseStreamInput.value) { return } diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 2f4f800f8..d8498af11 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -708,6 +708,9 @@ pages: aliyun-nls: description: Aliyun NLS title: Aliyun NLS + browser-web-speech-api: + description: Browser-native STT (requires Chrome/Edge/Safari) + title: Web Speech API transcriptions: playground: title: Transcription Playground diff --git a/packages/stage-layouts/src/components/Widgets/ChatArea.vue b/packages/stage-layouts/src/components/Widgets/ChatArea.vue index d20bde1d7..c17128cc5 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatArea.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatArea.vue @@ -1,17 +1,20 @@ @@ -125,7 +384,7 @@ onUnmounted(() => { text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200" bg="transparent" min-h="[100px]" max-h="[300px]" w-full - rounded-t-xl p-4 font-medium + rounded-t-xl p-4 font-medium pb="[60px]" 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, @@ -135,20 +394,22 @@ onUnmounted(() => { @compositionend="isComposing = false" /> -
+ +
+ diff --git a/packages/stage-pages/src/pages/settings/modules/hearing.vue b/packages/stage-pages/src/pages/settings/modules/hearing.vue index 1e99d7383..af66d5fb7 100644 --- a/packages/stage-pages/src/pages/settings/modules/hearing.vue +++ b/packages/stage-pages/src/pages/settings/modules/hearing.vue @@ -9,8 +9,9 @@ import { useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { Button, FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui' +import { until } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' const { t } = useI18n() @@ -25,6 +26,8 @@ const { supportsModelListing, transcriptionModelSearchQuery, activeCustomModelName, + autoSendEnabled, + autoSendDelay, } = storeToRefs(hearingStore) const providersStore = useProvidersStore() const { configuredTranscriptionProvidersMetadata } = storeToRefs(providersStore) @@ -60,6 +63,15 @@ const audioURLs = computed(() => { }) }) +// Speech-to-Text test state +const isTestingSTT = ref(false) +const testTranscriptionText = ref('') +const testTranscriptionError = ref('') +const isTranscribing = ref(false) +const testStreamingText = ref('') +const testStatusMessage = ref('') +const testStreamWasStarted = ref(false) // Track if we started the stream for testing + const useVADThreshold = ref(0.6) // 0.1 - 0.9 const useVADModel = ref(true) // Toggle between VAD and volume-based detection const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value) @@ -218,6 +230,10 @@ onStopRecord(async (recording) => { if (shouldUseStreamInput.value) return + // Skip onStopRecord handler during STT test - the watch handler handles transcription for tests + if (isTestingSTT.value) + return + if (recording && recording.size > 0) audios.value.push(recording) @@ -227,13 +243,203 @@ onStopRecord(async (recording) => { transcriptions.value.push(res) }) -watch(selectedAudioInput, async () => isMonitoring.value && await setupAudioMonitoring()) +// Speech-to-Text test functions +async function startSTTTest() { + if (!activeTranscriptionProvider.value) { + testTranscriptionError.value = 'Please select a transcription provider first' + return + } -onMounted(async () => { - await hearingStore.loadModelsForProvider(activeTranscriptionProvider.value) + if (!selectedAudioInput.value) { + testTranscriptionError.value = 'Please select an audio input device first' + return + } + + testTranscriptionError.value = '' + testTranscriptionText.value = '' + testStreamingText.value = '' + testStatusMessage.value = '' + isTestingSTT.value = true + isTranscribing.value = true + + try { + // Ensure audio stream is available + if (!stream.value) { + testStatusMessage.value = 'Starting audio stream...' + testStreamWasStarted.value = true + await startStream() + + // Wait for the stream to become available with a 3-second timeout. + try { + await until(stream).toBeTruthy({ timeout: 3000, throwOnTimeout: true }) + } + catch { + handleStreamStartError() + return + } + + // Type guard: until guarantees stream.value is truthy, but TypeScript doesn't know this + if (!stream.value) { + handleStreamStartError() + return + } + } + else { + testStreamWasStarted.value = false // Stream was already running + } + + // Check if provider supports streaming input + if (shouldUseStreamInput.value && stream.value) { + testStatusMessage.value = 'Starting streaming transcription...' + console.info('Starting STT test with streaming input for provider:', activeTranscriptionProvider.value) + + await transcribeForMediaStream(stream.value, { + onSentenceEnd: (delta) => { + if (delta && delta.trim()) { + testStreamingText.value += `${delta} ` + testStatusMessage.value = 'Transcribing... (streaming)' + isTranscribing.value = true + console.info('STT test received sentence:', delta) + } + }, + onSpeechEnd: (text) => { + if (text) { + testTranscriptionText.value = text + testStreamingText.value = '' + testStatusMessage.value = 'Transcription complete!' + isTranscribing.value = false + console.info('STT test completed with text:', text) + } + else { + testStatusMessage.value = 'Waiting for speech...' + isTranscribing.value = false + } + }, + }) + + testStatusMessage.value = 'Listening for speech... (streaming mode active)' + isTranscribing.value = false // Not actively transcribing yet, just listening + } + else { + // Fallback to recording-based transcription + testStatusMessage.value = 'Recording audio for transcription... (3 seconds)' + console.info('Starting STT test with recording-based transcription for provider:', activeTranscriptionProvider.value) + + startRecord() + + // Wait a bit for recording to start, then stop it after a delay + setTimeout(async () => { + stopRecord() + testStatusMessage.value = 'Processing transcription...' + }, 3000) // Record for 3 seconds + } + } + catch (err) { + testTranscriptionError.value = err instanceof Error ? err.message : String(err) + testStatusMessage.value = `Error: ${testTranscriptionError.value}` + isTranscribing.value = false + isTestingSTT.value = false + console.error('STT test error:', err) + } +} + +async function stopSTTTest() { + isTestingSTT.value = false + isTranscribing.value = false + testStatusMessage.value = 'Stopped' + + try { + // Stop streaming transcription if active + if (shouldUseStreamInput.value) { + await stopStreamingTranscription(false, activeTranscriptionProvider.value) + } + else { + stopRecord() + } + } + catch (err) { + console.error('Error stopping STT test:', err) + } + + // Finalize transcription if we have streaming text + if (testStreamingText.value.trim() && !testTranscriptionText.value) { + testTranscriptionText.value = testStreamingText.value.trim() + } + + // Stop the stream if we started it for testing (and monitoring is not active) + if (testStreamWasStarted.value && !isMonitoring.value) { + try { + stopStream() + testStreamWasStarted.value = false + } + catch (err) { + console.error('Error stopping test stream:', err) + } + } +} + +// Watch for recording completion during STT test +watch(() => audios.value.length, async (newLength, oldLength) => { + if (isTestingSTT.value && !shouldUseStreamInput.value && newLength > oldLength) { + // Recording was completed, now transcribe it + const latestRecording = audios.value[audios.value.length - 1] + if (latestRecording) { + testStatusMessage.value = 'Transcribing recording...' + isTranscribing.value = true + + try { + const result = await transcribeForRecording(latestRecording) + if (result) { + testTranscriptionText.value = result + testStatusMessage.value = 'Transcription complete!' + console.info('STT test transcription result:', result) + } + else { + testTranscriptionError.value = 'No transcription result received' + testStatusMessage.value = 'Transcription failed' + } + } + catch (err) { + testTranscriptionError.value = err instanceof Error ? err.message : String(err) + testStatusMessage.value = `Error: ${testTranscriptionError.value}` + console.error('STT test transcription error:', err) + } + finally { + isTranscribing.value = false + isTestingSTT.value = false + } + } + } }) +watch(selectedAudioInput, async () => isMonitoring.value && await setupAudioMonitoring()) + +function handleStreamStartError() { + testTranscriptionError.value = 'Failed to start audio stream. Please check microphone permissions.' + testStatusMessage.value = 'Error: Failed to start audio stream' + isTranscribing.value = false + isTestingSTT.value = false + testStreamWasStarted.value = false +} + +watch(activeTranscriptionProvider, async (provider) => { + if (!provider) + return + + await hearingStore.loadModelsForProvider(provider) + + // Auto-select first model for Web Speech API if no model is selected + if (provider === 'browser-web-speech-api' && !activeTranscriptionModel.value) { + const models = providerModels.value + if (models.length > 0) { + activeTranscriptionModel.value = models[0].id + console.info('Auto-selected Web Speech API model:', models[0].id) + } + } +}, { immediate: true }) + onUnmounted(() => { + stopSTTTest() stopAudioMonitoring() disposeVAD() @@ -389,10 +595,42 @@ onUnmounted(() => {
+ + +
+
+

+ Auto-send Settings +

+
+ Configure automatic sending of transcribed text to chat +
+
+ +
+ + + +
+
+

@@ -524,6 +762,111 @@ onUnmounted(() => {

+ + +
+

+ Speech-to-Text Test +

+
+ Test your transcription provider with the selected audio device. This will help verify that STT is working correctly. +
+ +
+
+
+ Please select a transcription provider above to test +
+
+ +
+
+
+ Please select an audio input device to test +
+
+ +
+
+ +
+ + + +
+
+
+
+ {{ testStatusMessage }} +
+
+ +
+
+
+ Streaming mode: Transcription will appear in real-time as you speak +
+
+ +
+
+ +
+
+
+ Current transcription (streaming): +
+
+ {{ testStreamingText }} +
+
+
+
+ Final transcription: +
+
+ {{ testTranscriptionText }} +
+
+
+
+ No transcription yet. Click "Start Speech-to-Text Test" and speak into your microphone. +
+
+ +
+
Provider: {{ configuredTranscriptionProvidersMetadata.find(p => p.id === activeTranscriptionProvider)?.localizedName || activeTranscriptionProvider }}
+
+ Model: {{ activeTranscriptionModel }} +
+
Mode: {{ shouldUseStreamInput ? 'Streaming (real-time)' : 'Recording (file-based)' }}
+
+
+
+
diff --git a/packages/stage-pages/src/pages/settings/providers/transcription/browser-web-speech-api.vue b/packages/stage-pages/src/pages/settings/providers/transcription/browser-web-speech-api.vue new file mode 100644 index 000000000..6fa3e2545 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/transcription/browser-web-speech-api.vue @@ -0,0 +1,504 @@ + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-ui/src/components/menu/radio-card-simple.vue b/packages/stage-ui/src/components/menu/radio-card-simple.vue index 0dff6bffe..b57c9876e 100644 --- a/packages/stage-ui/src/components/menu/radio-card-simple.vue +++ b/packages/stage-ui/src/components/menu/radio-card-simple.vue @@ -66,13 +66,15 @@ const modelValue = defineModel({ required: true }) {{ description }} diff --git a/packages/stage-ui/src/composables/use-modules-list.ts b/packages/stage-ui/src/composables/use-modules-list.ts index 9df48baca..b8649dbf0 100644 --- a/packages/stage-ui/src/composables/use-modules-list.ts +++ b/packages/stage-ui/src/composables/use-modules-list.ts @@ -8,6 +8,7 @@ import { useConsciousnessStore } from '../stores/modules/consciousness' import { useDiscordStore } from '../stores/modules/discord' import { useFactorioStore } from '../stores/modules/gaming-factorio' import { useMinecraftStore } from '../stores/modules/gaming-minecraft' +import { useHearingStore } from '../stores/modules/hearing' import { useSpeechStore } from '../stores/modules/speech' import { useTwitterStore } from '../stores/modules/twitter' @@ -29,6 +30,7 @@ export function useModulesList() { // Initialize stores const consciousnessStore = useConsciousnessStore() const speechStore = useSpeechStore() + const hearingStore = useHearingStore() const discordStore = useDiscordStore() const twitterStore = useTwitterStore() const minecraftStore = useMinecraftStore() @@ -60,7 +62,7 @@ export function useModulesList() { description: t('settings.pages.modules.hearing.description'), icon: 'i-solar:microphone-3-bold-duotone', to: '/settings/modules/hearing', - configured: false, + configured: hearingStore.configured, category: 'essential', }, { diff --git a/packages/stage-ui/src/stores/audio.ts b/packages/stage-ui/src/stores/audio.ts index 1eaf31e5c..61d3b3e79 100644 --- a/packages/stage-ui/src/stores/audio.ts +++ b/packages/stage-ui/src/stores/audio.ts @@ -88,7 +88,7 @@ export function useAudioDevice(requestPermission: boolean = false) { }) function askPermission() { - devices.ensurePermissions() + return devices.ensurePermissions() .then(() => nextTick()) .then(() => { if (audioInputs.value.length > 0 && !selectedAudioInput.value) { @@ -97,6 +97,7 @@ export function useAudioDevice(requestPermission: boolean = false) { }) .catch((error) => { console.error('Error ensuring permissions:', error) + throw error // Re-throw so callers can handle the error }) } diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 95b0a092a..3a8f8fa7f 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -12,6 +12,7 @@ import vadWorkletUrl from '../../workers/vad/process.worklet?worker&url' import { createResettableLocalStorage, createResettableRef } from '../../utils/resettable' import { useProvidersStore } from '../providers' import { streamAliyunTranscription } from '../providers/aliyun/stream-transcription' +import { streamWebSpeechAPITranscription } from '../providers/web-speech-api' export interface StreamTranscriptionFileInputOptions extends Omit { file: Blob @@ -40,6 +41,7 @@ interface HearingTranscriptionInvokeOptions { const STREAM_TRANSCRIPTION_EXECUTORS: Record = { 'aliyun-nls-transcription': streamAliyunTranscription, + // Web Speech API is handled specially in transcribeForMediaStream since it works directly with MediaStream } export const useHearingStore = defineStore('hearing-store', () => { @@ -51,6 +53,8 @@ export const useHearingStore = defineStore('hearing-store', () => { const [activeTranscriptionModel, resetActiveTranscriptionModel] = createResettableLocalStorage('settings/hearing/active-model', '') const [activeCustomModelName, resetActiveCustomModelName] = createResettableLocalStorage('settings/hearing/active-custom-model', '') const [transcriptionModelSearchQuery, resetTranscriptionModelSearchQuery] = createResettableRef('') + const [autoSendEnabled, resetAutoSendEnabled] = createResettableLocalStorage('settings/hearing/auto-send-enabled', false) + const [autoSendDelay, resetAutoSendDelay] = createResettableLocalStorage('settings/hearing/auto-send-delay', 2000) // Default 2 seconds // Computed properties const availableProvidersMetadata = computed(() => allAudioTranscriptionProvidersMetadata.value) @@ -87,7 +91,16 @@ export const useHearingStore = defineStore('hearing-store', () => { } const configured = computed(() => { - return !!activeTranscriptionProvider.value && !!activeTranscriptionModel.value + if (!activeTranscriptionProvider.value) + return false + + // Web Speech API doesn't strictly need a model selected (it has a default) + // but we still check to maintain consistency + if (activeTranscriptionProvider.value === 'browser-web-speech-api') { + return true // Web Speech API is ready if provider is selected and available + } + + return !!activeTranscriptionModel.value }) function resetState() { @@ -95,6 +108,8 @@ export const useHearingStore = defineStore('hearing-store', () => { resetActiveTranscriptionModel() resetActiveCustomModelName() resetTranscriptionModelSearchQuery() + resetAutoSendEnabled() + resetAutoSendDelay() } async function transcription( @@ -113,6 +128,7 @@ export const useHearingStore = defineStore('hearing-store', () => { const streamExecutor = STREAM_TRANSCRIPTION_EXECUTORS[providerId] if (features.supportsStreamOutput && streamExecutor) { + // TODO: integrate VAD-driven silence detection to stop and restart realtime sessions based on silence thresholds. const request = provider.transcription(model, options?.providerOptions) if (features.supportsStreamInput && normalizedInput.inputAudioStream) { @@ -120,7 +136,6 @@ export const useHearingStore = defineStore('hearing-store', () => { ...request, inputAudioStream: normalizedInput.inputAudioStream, } as Parameters[0]) - // TODO: integrate VAD-driven silence detection to stop and restart realtime sessions based on silence thresholds. return { mode: 'stream', ...streamResult, @@ -132,7 +147,6 @@ export const useHearingStore = defineStore('hearing-store', () => { ...request, file: normalizedInput.file, } as Parameters[0]) - // TODO: integrate VAD-driven silence detection to stop and restart realtime sessions based on silence thresholds. return { mode: 'stream', ...streamResult, @@ -144,7 +158,6 @@ export const useHearingStore = defineStore('hearing-store', () => { ...request, file: normalizedInput.file, } as Parameters[0]) - // TODO: integrate VAD-driven silence detection to stop and restart realtime sessions based on silence thresholds. return { mode: 'stream', ...streamResult, @@ -178,6 +191,8 @@ export const useHearingStore = defineStore('hearing-store', () => { availableProvidersMetadata, activeCustomModelName, transcriptionModelSearchQuery, + autoSendEnabled, + autoSendDelay, supportsModelListing, providerModels, @@ -199,18 +214,32 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech const { activeTranscriptionProvider, activeTranscriptionModel } = storeToRefs(hearingStore) const providersStore = useProvidersStore() const streamingSession = shallowRef<{ - audioContext: AudioContext - workletNode: AudioWorkletNode - mediaStreamSource: MediaStreamAudioSourceNode + audioContext: AudioContext | Record + workletNode: AudioWorkletNode | Record + mediaStreamSource: MediaStreamAudioSourceNode | Record audioStreamController?: ReadableStreamDefaultController abortController: AbortController - result?: HearingTranscriptionResult + result?: HearingTranscriptionResult & { recognition?: any } idleTimer?: ReturnType providerId?: string + callbacks?: { + onSentenceEnd?: (delta: string) => void + onSpeechEnd?: (text: string) => void + } }>() const supportsStreamInput = computed(() => { - return providersStore.getTranscriptionFeatures(activeTranscriptionProvider.value).supportsStreamInput + const providerId = activeTranscriptionProvider.value + if (!providerId) + return false + + // Web Speech API always supports stream input when available + if (providerId === 'browser-web-speech-api') { + return typeof window !== 'undefined' + && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) + } + + return providersStore.getTranscriptionFeatures(providerId).supportsStreamInput }) const DEFAULT_SAMPLE_RATE = 16000 @@ -277,6 +306,48 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech if (!session) return + // Special handling for Web Speech API + if (session.providerId === 'browser-web-speech-api') { + try { + const reason = new DOMException(abort ? 'Aborted' : 'Stopped', 'AbortError') + if (!session.abortController.signal.aborted) { + session.abortController.abort(reason) + } + + // Stop Web Speech API recognition if it exists + const result = session.result as any + if (result?.recognition) { + try { + result.recognition.stop() + } + catch (err) { + console.warn('Error stopping Web Speech API recognition:', err) + } + } + } + catch (err) { + console.error('Error stopping Web Speech API session:', err) + } + + if (session.idleTimer) + clearTimeout(session.idleTimer) + + streamingSession.value = undefined + + if (session.result?.mode === 'stream') { + try { + const text = await session.result.text + return text + } + catch (err) { + error.value = err instanceof Error ? err.message : String(err) + console.error('Error getting transcription result:', error.value) + } + } + + return + } + try { const reason = new DOMException(abort ? 'Aborted' : 'Stopped', 'AbortError') // Ensure provider transports (e.g., Aliyun NLS) are signaled to stop over websocket. @@ -333,11 +404,171 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech onSentenceEnd?: (delta: string) => void onSpeechEnd?: (text: string) => void }) { - if (!supportsStreamInput.value) + console.info('[Hearing Pipeline] transcribeForMediaStream called', { + supportsStreamInput: supportsStreamInput.value, + hasStream: !!stream, + providerId: activeTranscriptionProvider.value, + hasCallbacks: !!(options?.onSentenceEnd || options?.onSpeechEnd), + }) + + if (!supportsStreamInput.value) { + console.warn('[Hearing Pipeline] Stream input not supported') return + } try { const providerId = activeTranscriptionProvider.value + if (!providerId) { + error.value = 'No transcription provider selected' + console.error('[Hearing Pipeline] No transcription provider selected') + return + } + + console.info('[Hearing Pipeline] Using provider:', providerId) + + // Special handling for Web Speech API - it works directly with MediaStream + if (providerId === 'browser-web-speech-api') { + // Check if Web Speech API is available + const isAvailable = typeof window !== 'undefined' + && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) + + if (!isAvailable) { + error.value = 'Web Speech API is not available in this browser' + console.error('Web Speech API is not available') + return + } + + // Check if session already exists and reuse it + const existingSession = streamingSession.value + if (existingSession && existingSession.providerId === 'browser-web-speech-api') { + // For Web Speech API, if callbacks are provided and different, we need to restart + // because recognition instance callbacks are set once and can't be changed + // However, if no new callbacks are provided, we can just reuse the session + const hasNewCallbacks = !!(options?.onSentenceEnd || options?.onSpeechEnd) + + if (hasNewCallbacks) { + // We need to restart to use new callbacks, but only if they're actually different + // Since we can't compare functions, we'll just always restart if new callbacks are provided + // This ensures callbacks are always up-to-date + console.info('Web Speech API: New callbacks provided, restarting session to use them') + await stopStreamingTranscription(false, existingSession.providerId) + // Continue to create new session below + // Note: stopStreamingTranscription already clears streamingSession.value and waits for async cleanup + } + else { + // No new callbacks - just bump idle timer and reuse existing session + const idleTimeout = options?.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT + if (existingSession.idleTimer) { + clearTimeout(existingSession.idleTimer) + existingSession.idleTimer = setTimeout(async () => { + await stopStreamingTranscription(false, existingSession.providerId) + }, idleTimeout) + } + + console.info('Web Speech API session already active, reusing existing session (no callback changes)') + return + } + } + + // Auto-select default model if not selected + if (!activeTranscriptionModel.value) { + // Try to get models for the provider and select the first one + const models = await providersStore.getModelsForProvider(providerId) + if (models.length > 0) { + activeTranscriptionModel.value = models[0].id + console.info('Auto-selected Web Speech API model:', models[0].id) + } + else { + // Fallback to default model ID + activeTranscriptionModel.value = 'web-speech-api' + console.info('Auto-selected Web Speech API default model') + } + } + + const abortController = new AbortController() + + // Get provider config for language settings + const providerConfig = providersStore.getProviderConfig(providerId) || {} + const language = (options?.providerOptions?.language as string) + || (providerConfig.language as string) + || 'en-US' + + // Web Speech API in continuous mode should run indefinitely - no idle timeout + // Only stop when explicitly requested (e.g., microphone disabled) + const idleTimeout = options?.idleTimeoutMs ?? 0 // 0 = disabled + let idleTimer: ReturnType | undefined + const bumpIdle = () => { + if (idleTimeout > 0) { + if (idleTimer) + clearTimeout(idleTimer) + idleTimer = setTimeout(async () => { + await stopStreamingTranscription(false, providerId) + }, idleTimeout) + } + } + + const result = streamWebSpeechAPITranscription(stream, { + language, + continuous: (options?.providerOptions?.continuous as boolean) ?? (providerConfig.continuous as boolean) ?? true, + interimResults: (options?.providerOptions?.interimResults as boolean) ?? (providerConfig.interimResults as boolean) ?? true, + maxAlternatives: (options?.providerOptions?.maxAlternatives as number) ?? (providerConfig.maxAlternatives as number) ?? 1, + abortSignal: abortController.signal, + onSentenceEnd: (delta) => { + bumpIdle() // Bump idle timer on activity (only if enabled) + // Call the options callback + options?.onSentenceEnd?.(delta) + }, + onSpeechEnd: (text) => { + // Call the options callback + options?.onSpeechEnd?.(text) + }, + }) + + // Store session info for cleanup + const recognitionInstance = (result as any).recognition + streamingSession.value = { + audioContext: {} as AudioContext, // Not used for Web Speech API + workletNode: {} as AudioWorkletNode, // Not used for Web Speech API + mediaStreamSource: {} as MediaStreamAudioSourceNode, // Not used for Web Speech API + audioStreamController: undefined, + abortController, + result: { ...result, mode: 'stream' as const, recognition: recognitionInstance }, + idleTimer, + providerId, + callbacks: { + onSentenceEnd: options?.onSentenceEnd, + onSpeechEnd: options?.onSpeechEnd, + }, + } as any // Type assertion needed because recognition is extra + + // Initial idle timer (only if enabled) + bumpIdle() + + // Stream out text deltas + if (result.textStream) { + void (async () => { + try { + const reader = result.textStream.getReader() + + while (true) { + const { done } = await reader.read() + if (done) + break + // onSentenceEnd is already called from the recognition.onresult handler + // Note: onSpeechEnd is called from web-speech-api/index.ts recognition.onend handler + // (line 332 for non-continuous mode, line 271 for errors) + // We don't call it here to avoid duplicate calls + } + } + catch (err) { + console.error('Error reading text stream:', err) + } + })() + } + + return + } + const provider = await providersStore.getProviderInstance>(providerId) if (!provider) { throw new Error('Failed to initialize speech provider') diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 3eaadf87f..c0dfdc2c5 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -63,6 +63,7 @@ import { useI18n } from 'vue-i18n' import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription' import { models as elevenLabsModels } from './providers/elevenlabs/list-models' import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder' +import { createWebSpeechAPIProvider } from './providers/web-speech-api' const ALIYUN_NLS_REGIONS = [ 'cn-shanghai', @@ -969,6 +970,82 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, + 'browser-web-speech-api': { + id: 'browser-web-speech-api', + category: 'transcription', + tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'], + nameKey: 'settings.pages.providers.provider.browser-web-speech-api.title', + name: 'Web Speech API (Browser)', + descriptionKey: 'settings.pages.providers.provider.browser-web-speech-api.description', + description: 'Browser-native speech recognition. No API keys.', + icon: 'i-solar:microphone-bold-duotone', + defaultOptions: () => ({ + language: 'en-US', + continuous: true, + interimResults: true, + maxAlternatives: 1, + }), + transcriptionFeatures: { + supportsGenerate: false, + supportsStreamOutput: true, + supportsStreamInput: true, + }, + isAvailableBy: async () => { + // Web Speech API is only available in browser contexts, NOT in Electron + // Even though Electron uses Chromium, Web Speech API requires Google's embedded API keys + // which are not available in Electron, causing it to fail at runtime + if (typeof window === 'undefined') + return false + + // Explicitly exclude Electron - Web Speech API doesn't work there + if (isStageTamagotchi()) + return false + + // Check if API is available in browser + return 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window + }, + createProvider: async (_config) => { + // Web Speech API doesn't need config, but we accept it for consistency + return createWebSpeechAPIProvider() + }, + capabilities: { + listModels: async () => { + return [ + { + id: 'web-speech-api', + name: 'Web Speech API', + provider: 'browser-web-speech-api', + description: 'Browser-native speech recognition (no API keys required)', + contextLength: 0, + deprecated: false, + }, + ] + }, + }, + validators: { + validateProviderConfig: () => { + // Web Speech API requires no configuration, just browser support + // Always return valid if browser supports it, so it auto-configures + const isAvailable = typeof window !== 'undefined' + && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) + + if (!isAvailable) { + return { + errors: [new Error('Web Speech API is not available. It requires a browser context with SpeechRecognition support (Chrome, Edge, Safari).')], + reason: 'Web Speech API is not available in this environment.', + valid: false, + } + } + + // Auto-configure if available (no credentials needed) + return { + errors: [], + reason: '', + valid: true, + } + }, + }, + }, 'anthropic': buildOpenAICompatibleProvider({ id: 'anthropic', name: 'Anthropic', @@ -1972,8 +2049,19 @@ export const useProvidersStore = defineStore('providers', () => { // Configuration validation functions async function validateProvider(providerId: string): Promise { + const metadata = providerMetadata[providerId] + if (!metadata) + return false + + // Web Speech API doesn't require credentials - use empty config if not present + if (providerId === 'browser-web-speech-api') { + if (!providerCredentials.value[providerId]) { + providerCredentials.value[providerId] = getDefaultProviderConfig(providerId) + } + } + const config = providerCredentials.value[providerId] - if (!config) + if (!config && providerId !== 'browser-web-speech-api') return false const configString = JSON.stringify(config || {}) @@ -1982,19 +2070,19 @@ export const useProvidersStore = defineStore('providers', () => { if (runtimeState?.validatedCredentialHash === configString && typeof runtimeState.isConfigured === 'boolean') return runtimeState.isConfigured - const metadata = providerMetadata[providerId] - if (!metadata) - return false - // Always cache the current config string to prevent re-validating the same config if (providerRuntimeState.value[providerId]) { providerRuntimeState.value[providerId].validatedCredentialHash = configString } - const validationResult = await metadata.validators.validateProviderConfig(config) + const validationResult = await metadata.validators.validateProviderConfig(config || {}) if (providerRuntimeState.value[providerId]) { providerRuntimeState.value[providerId].isConfigured = validationResult.valid + // Auto-mark Web Speech API as added if valid and available + if (providerId === 'browser-web-speech-api' && validationResult.valid) { + markProviderAdded(providerId) + } } return validationResult.valid @@ -2037,7 +2125,8 @@ export const useProvidersStore = defineStore('providers', () => { .map(async ([providerId]) => { try { if (providerRuntimeState.value[providerId]) { - providerRuntimeState.value[providerId].isConfigured = await validateProvider(providerId) + const isValid = await validateProvider(providerId) + providerRuntimeState.value[providerId].isConfigured = isValid } } catch { @@ -2253,16 +2342,22 @@ export const useProvidersStore = defineStore('providers', () => { if (cached) return cached - const config = providerCredentials.value[providerId] - if (!config) - throw new Error(`Provider credentials for ${providerId} not found`) - const metadata = providerMetadata[providerId] if (!metadata) throw new Error(`Provider metadata for ${providerId} not found`) + // Web Speech API doesn't require credentials - use empty config + let config = providerCredentials.value[providerId] + if (!config && providerId === 'browser-web-speech-api') { + config = getDefaultProviderConfig(providerId) + providerCredentials.value[providerId] = config + } + + if (!config && providerId !== 'browser-web-speech-api') + throw new Error(`Provider credentials for ${providerId} not found`) + try { - const instance = await metadata.createProvider(config) as R + const instance = await metadata.createProvider(config || {}) as R providerInstanceCache.value[providerId] = instance return instance } diff --git a/packages/stage-ui/src/stores/providers/web-speech-api/index.ts b/packages/stage-ui/src/stores/providers/web-speech-api/index.ts new file mode 100644 index 000000000..59973e46f --- /dev/null +++ b/packages/stage-ui/src/stores/providers/web-speech-api/index.ts @@ -0,0 +1,470 @@ +import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils' +import type { StreamTranscriptionDelta, StreamTranscriptionResult } from '@xsai/stream-transcription' + +// NOTICE: Copied/adapted from @xsai/stream-transcription delayed promise helper. +// Ref: @xsai/stream-transcription@0.4.0-beta.8 (dist/index.js DelayedPromise usage). +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + let _isResolved = false + let _isRejected = false + const promise = new Promise((res, rej) => { + resolve = (value) => { + _isResolved = true + res(value) + } + reject = (reason) => { + _isRejected = true + rej(reason) + } + }) + + return { + promise, + resolve, + reject, + get isResolved() { return _isResolved }, + get isRejected() { return _isRejected }, + set isResolved(value: boolean) { _isResolved = value }, + set isRejected(value: boolean) { _isRejected = value }, + } +} + +export interface WebSpeechAPIExtraOptions { + language?: string + continuous?: boolean + interimResults?: boolean + maxAlternatives?: number + abortSignal?: AbortSignal +} + +/** + * Web Speech API Speech Recognition provider + * + * This is a free, browser-native STT solution that requires no API keys. + * Available in Chrome, Edge, Safari, and other Chromium-based browsers. + * + * Limitations: + * - Only works in browser contexts (Electron renderer, web browsers) + * - Requires user permission for microphone access + * - Language support depends on browser implementation + * - Not available in Node.js or Tauri main process + */ +export function createWebSpeechAPIProvider(): TranscriptionProviderWithExtraOptions { + // Check if Web Speech API is available + const isAvailable = typeof window !== 'undefined' + && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) + + if (!isAvailable) { + throw new Error('Web Speech API is not available in this environment. It requires a browser context with SpeechRecognition support (Chrome, Edge, Safari).') + } + + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition + + return { + transcription: (model: string, extraOptions?: WebSpeechAPIExtraOptions) => { + return { + baseURL: 'about:blank', // Web Speech API doesn't use HTTP endpoints + model: model || 'web-speech-api', + fetch: async (_request: RequestInfo | URL, _init?: RequestInit) => { + // Web Speech API does not support file-based transcription - it only supports live streaming + // Check if a file is provided in the request body and reject it + if (_init?.body) { + // If body is FormData, it likely contains a file + // If body is a Blob/File, it's definitely a file + const body = _init.body + if (body instanceof FormData || body instanceof Blob || body instanceof File) { + const error = new Error('Web Speech API does not support file-based transcription. It only supports live streaming from a MediaStream. Please use the streaming transcription API or select a different provider that supports file-based transcription.') + throw error + } + } + + const deferredText = createDeferred() + let fullText = '' + let textStreamCtrl: ReadableStreamDefaultController | undefined + + const textStream = new ReadableStream({ + start(controller) { + textStreamCtrl = controller + }, + }) + + const recognition = new SpeechRecognition() + recognition.lang = extraOptions?.language || 'en-US' + recognition.continuous = extraOptions?.continuous ?? true + recognition.interimResults = extraOptions?.interimResults ?? true + recognition.maxAlternatives = extraOptions?.maxAlternatives ?? 1 + + recognition.onresult = (event: any) => { + let finalTranscript = '' + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript + if (event.results[i].isFinal) { + finalTranscript += transcript + } + } + + // Emit final results as deltas + if (finalTranscript) { + fullText += finalTranscript + textStreamCtrl?.enqueue(finalTranscript) + } + + // Optionally emit interim results (commented out to avoid spam) + // if (interimTranscript) { + // textStreamCtrl?.enqueue(interimTranscript) + // } + } + + recognition.onerror = (event: any) => { + const error = new Error(`Speech recognition error: ${event.error}`) + textStreamCtrl?.error(error) + deferredText.reject(error) + deferredText.isRejected = true + } + + recognition.onend = () => { + textStreamCtrl?.close() + if (!deferredText.isResolved && !deferredText.isRejected) { + deferredText.resolve(fullText) + deferredText.isResolved = true + } + } + + // Handle abort signal + if (extraOptions?.abortSignal) { + extraOptions.abortSignal.addEventListener('abort', () => { + recognition.stop() + const error = new DOMException('Aborted', 'AbortError') + textStreamCtrl?.error(error) + deferredText.reject(error) + deferredText.isRejected = true + }) + } + + // Start recognition + recognition.start() + + return textStream as unknown as Response + }, + } + }, + } +} + +/** + * Stream transcription using Web Speech API with MediaStream + * This is designed to work with the existing hearing pipeline + */ +export function streamWebSpeechAPITranscription( + _mediaStream: MediaStream, + options?: WebSpeechAPIExtraOptions & { + onSentenceEnd?: (delta: string) => void + onSpeechEnd?: (text: string) => void + }, +): StreamTranscriptionResult & { recognition?: any } { + const deferredText = createDeferred() + let fullText = '' + let textStreamCtrl: ReadableStreamDefaultController | undefined + let fullStreamCtrl: ReadableStreamDefaultController | undefined + let recognitionInstance: any = null + + const fullStream = new ReadableStream({ + start(controller) { + fullStreamCtrl = controller + }, + }) + + const textStream = new ReadableStream({ + start(controller) { + textStreamCtrl = controller + }, + cancel: () => { + // Clean up recognition when stream is cancelled + if (recognitionInstance) { + try { + recognitionInstance.stop() + } + catch {} + } + }, + }) + + const isAvailable = typeof window !== 'undefined' + && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) + + if (!isAvailable) { + const error = new Error('Web Speech API is not available in this environment.') + deferredText.reject(error) + deferredText.isRejected = true + textStreamCtrl?.error(error) + fullStreamCtrl?.error(error) + return { + fullStream, + text: deferredText.promise, + textStream, + } + } + + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition + const recognition = new SpeechRecognition() + recognitionInstance = recognition + + recognition.lang = options?.language || 'en-US' + recognition.continuous = options?.continuous ?? true + recognition.interimResults = options?.interimResults ?? true // Default to true for real-time feedback + recognition.maxAlternatives = options?.maxAlternatives ?? 1 + + console.info('Web Speech API configured:', { + lang: recognition.lang, + continuous: recognition.continuous, + interimResults: recognition.interimResults, + }) + + recognition.onresult = (event: any) => { + let finalTranscript = '' + let interimTranscript = '' + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const transcript = result[0]?.transcript || '' + + if (result.isFinal) { + finalTranscript = `${finalTranscript}${transcript} ` // Add space between final results + } + else if (recognition.interimResults) { + // Collect interim results but don't emit them as final yet + interimTranscript += transcript + } + } + + // Emit final results when we have them + if (finalTranscript.trim()) { + const trimmedTranscript = finalTranscript.trim() + fullText = `${fullText}${trimmedTranscript} ` + const delta: StreamTranscriptionDelta = { + type: 'transcript.text.delta', + delta: trimmedTranscript, + } + fullStreamCtrl?.enqueue(delta) + textStreamCtrl?.enqueue(trimmedTranscript) + options?.onSentenceEnd?.(trimmedTranscript) + console.info('Web Speech API transcribed (final):', trimmedTranscript) + } + + // Log interim results for debugging (don't emit as final) + if (interimTranscript && recognition.interimResults) { + console.info('Web Speech API transcribed (interim):', interimTranscript) + } + } + + recognition.onerror = (event: any) => { + const errorType = event.error || 'unknown' + console.warn('Web Speech API error:', errorType) + + if (errorType === 'no-speech') { + return + } + + if (errorType === 'audio-capture') { + console.warn('Web Speech API: Microphone access issue. Please check microphone permissions.') + return + } + + if (errorType === 'network' || errorType === 'aborted') { + return + } + const error = new Error(`Speech recognition error: ${errorType}`) + fullStreamCtrl?.error(error) + textStreamCtrl?.error(error) + deferredText.reject(error) + deferredText.isRejected = true + options?.onSpeechEnd?.(fullText) + } + + recognition.onend = () => { + console.info('Web Speech API recognition ended. Continuous mode:', options?.continuous !== false, 'Aborted:', options?.abortSignal?.aborted) + + // If continuous mode and not aborted, restart recognition + if (options?.continuous !== false && !options?.abortSignal?.aborted) { + // Use the current recognitionInstance to ensure we're using the correct instance + const currentRecognition = recognitionInstance || recognition + + // Small delay before restarting to avoid rapid restart loops + setTimeout(() => { + try { + currentRecognition.start() + console.info('Web Speech API recognition restarted (continuous mode)') + } + catch (err) { + console.warn('Web Speech API failed to restart, creating new instance:', err) + // If restart fails, create a new instance + try { + createAndStartNewRecognitionInstance(recognition) + console.info('Web Speech API created new instance and started') + } + catch (newErr) { + console.error('Web Speech API failed to create new instance:', newErr) + const error = new Error(`Failed to restart recognition: ${newErr instanceof Error ? newErr.message : String(newErr)}`) + fullStreamCtrl?.error(error) + textStreamCtrl?.error(error) + deferredText.reject(error) + deferredText.isRejected = true + } + } + }, 100) + } + else { + // Don't try to enqueue/close if the stream has already been aborted/errored + if (options?.abortSignal?.aborted || deferredText.isRejected) { + return + } + + const doneDelta: StreamTranscriptionDelta = { + type: 'transcript.text.done', + delta: '', + } + fullStreamCtrl?.enqueue(doneDelta) + fullStreamCtrl?.close() + textStreamCtrl?.close() + if (!deferredText.isResolved && !deferredText.isRejected) { + deferredText.resolve(fullText) + deferredText.isResolved = true + } + options?.onSpeechEnd?.(fullText) + } + } + + // Handle abort signal + if (options?.abortSignal) { + options.abortSignal.addEventListener('abort', () => { + try { + recognition.stop() + } + catch {} + const error = new DOMException('Aborted', 'AbortError') + fullStreamCtrl?.error(error) + textStreamCtrl?.error(error) + deferredText.reject(error) + deferredText.isRejected = true + }) + } + + function createAndStartNewRecognitionInstance(sourceRecognition: any): any { + const newRecognition = new SpeechRecognition() + newRecognition.lang = sourceRecognition.lang + newRecognition.continuous = sourceRecognition.continuous + newRecognition.interimResults = sourceRecognition.interimResults + newRecognition.maxAlternatives = sourceRecognition.maxAlternatives + newRecognition.onresult = sourceRecognition.onresult + newRecognition.onerror = sourceRecognition.onerror + newRecognition.onend = sourceRecognition.onend + recognitionInstance = newRecognition + newRecognition.start() + return newRecognition + } + + function startRecognition() { + try { + recognition.start() + console.info('Web Speech API recognition started successfully') + return true + } + catch (error: any) { + // Common errors: + // - "already started": Recognition is already running + // - "not-allowed": Microphone permission denied + // - "service-not-allowed": Service not available + const errorMessage = error?.message || String(error) + console.warn('Web Speech API recognition start failed:', errorMessage, error) + + if (errorMessage.includes('already') || errorMessage.includes('started')) { + // Recognition is already running, this is OK + console.info('Web Speech API recognition already running') + return true + } + + if (errorMessage.includes('not-allowed') || errorMessage.includes('permission')) { + // Permission denied - user needs to grant microphone access + const err = new Error('Microphone permission denied. Please grant microphone access and try again.') + console.error('Web Speech API: Microphone permission denied') + fullStreamCtrl?.error(err) + textStreamCtrl?.error(err) + deferredText.reject(err) + deferredText.isRejected = true + return false + } + + // For other errors, try creating a new instance + console.warn('Creating new recognition instance due to error') + try { + createAndStartNewRecognitionInstance(recognition) + console.info('Web Speech API recognition restarted successfully with new instance') + return true + } + catch (restartError: any) { + const err = new Error(`Failed to start Web Speech API recognition: ${restartError?.message || String(restartError)}`) + fullStreamCtrl?.error(err) + textStreamCtrl?.error(err) + deferredText.reject(err) + deferredText.isRejected = true + console.error('Web Speech API recognition failed to start after retry:', restartError) + return false + } + } + } + + // Add event listeners for debugging before starting + recognition.onstart = () => { + console.info('Web Speech API recognition started (onstart event)') + } + + recognition.onaudiostart = () => { + console.info('Web Speech API audio capture started') + } + + recognition.onsoundstart = () => { + console.info('Web Speech API sound detected') + } + + recognition.onspeechstart = () => { + console.info('Web Speech API speech detected') + } + + recognition.onspeechend = () => { + console.info('Web Speech API speech ended') + } + + recognition.onsoundend = () => { + console.info('Web Speech API sound ended') + } + + recognition.onaudioend = () => { + console.info('Web Speech API audio capture ended') + } + + recognition.onnomatch = () => { + console.info('Web Speech API: No speech match') + } + + const started = startRecognition() + if (!started) { + // If immediate start failed, it might be a permission issue + // Web Speech API will prompt for permission automatically, so we just log + console.warn('Web Speech API recognition did not start immediately. This might be due to:') + console.warn('1. Microphone permission not granted - browser should prompt automatically') + console.warn('2. Recognition already running - this is normal if called multiple times') + console.warn('3. Browser requires user gesture - ensure microphone was enabled by user action') + + // Don't retry immediately - wait for permission or user action + // The recognition instance is already created, so it can be started later if needed + } + + return { + fullStream, + text: deferredText.promise, + textStream, + recognition: recognitionInstance, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 099e110bf..5f01ef346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1154,7 +1154,7 @@ importers: version: link:../../packages/ui-transitions '@proj-airi/unplugin-fetch': specifier: ^0.2.1 - version: 0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@proj-airi/unplugin-live2d-sdk': specifier: ^0.1.6 version: 0.1.6(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1184,7 +1184,7 @@ importers: version: 66.5.11 '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + version: 6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) '@vue-macros/volar': specifier: ^3.1.1 version: 3.1.1(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) @@ -1211,37 +1211,37 @@ importers: version: 26.4.0(electron-builder-squirrel-windows@26.4.0) electron-vite: specifier: ^5.0.0 - version: 5.0.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) less: specifier: ^4.5.1 version: 4.5.1 unocss-preset-scrollbar: specifier: ^3.2.0 - version: 3.2.0(unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.2.0(unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) unplugin-info: specifier: ^1.2.4 - version: 1.2.4(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@4.54.0) + version: 1.2.4(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@4.54.0) unplugin-vue-router: specifier: ^0.19.0 version: 0.19.2(@vue/compiler-sfc@3.5.26)(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) unplugin-yaml: specifier: ^3.0.7 - version: 3.0.7(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0) + version: 3.0.7(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0) vite: specifier: catalog:rolldown-vite - version: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-bundle-visualizer: specifier: ^1.2.1 version: 1.2.1(rolldown@1.0.0-beta.53)(rollup@4.54.0) vite-plugin-vue-devtools: specifier: ^8.0.5 - version: 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + version: 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) vite-plugin-vue-layouts: specifier: ^0.11.0 - version: 0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + version: 0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) vue-macros: specifier: ^3.1.1 - version: 3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) + version: 3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) vue-tsc: specifier: ^3.1.8 version: 3.2.1(typescript@5.9.3) @@ -21174,11 +21174,6 @@ snapshots: '@proj-airi/unocss-preset-chromatic@1.0.2': {} - '@proj-airi/unplugin-fetch@0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - ofetch: 1.5.1 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@proj-airi/unplugin-fetch@0.2.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: ofetch: 1.5.1 @@ -22122,13 +22117,13 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@unocss/astro@66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@unocss/astro@66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@unocss/core': 66.5.11 '@unocss/reset': 66.5.11 - '@unocss/vite': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@unocss/vite': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) optionalDependencies: - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@unocss/astro@66.5.11(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -22310,7 +22305,7 @@ snapshots: dependencies: '@unocss/core': 66.5.11 - '@unocss/vite@66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@unocss/vite@66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@jridgewell/remapping': 2.3.5 '@unocss/config': 66.5.11 @@ -22321,7 +22316,7 @@ snapshots: pathe: 2.0.3 tinyglobby: 0.2.15 unplugin-utils: 0.3.1 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@unocss/vite@66.5.11(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -22446,12 +22441,6 @@ snapshots: dependencies: '@vibrant/types': 4.0.0 - '@vitejs/plugin-vue@6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-beta.53 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 @@ -22719,15 +22708,6 @@ snapshots: transitivePeerDependencies: - vue - '@vue-macros/devtools@3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)': - dependencies: - sirv: 3.0.2 - vue: 3.5.25(typescript@5.9.3) - optionalDependencies: - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - typescript - '@vue-macros/devtools@3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3)': dependencies: sirv: 3.0.2 @@ -22998,18 +22978,6 @@ snapshots: dependencies: '@vue/devtools-kit': 8.0.5 - '@vue/devtools-core@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': - dependencies: - '@vue/devtools-kit': 8.0.5 - '@vue/devtools-shared': 8.0.5 - mitt: 3.0.1 - nanoid: 5.1.6 - pathe: 2.0.3 - vite-hot-client: 2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - vue: 3.5.25(typescript@5.9.3) - transitivePeerDependencies: - - vite - '@vue/devtools-core@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.0.5 @@ -24850,7 +24818,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + electron-vite@5.0.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5) @@ -24858,7 +24826,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -29570,25 +29538,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@oxc-project/runtime': 0.101.0 - fdir: 6.5.0(picomatch@4.0.3) - lightningcss: 1.30.2 - picomatch: 4.0.3 - postcss: 8.5.6 - rolldown: 1.0.0-beta.53 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.4 - esbuild: 0.25.12 - fsevents: 2.3.3 - jiti: 2.6.1 - less: 4.5.1 - terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.2 - rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.101.0 @@ -30854,19 +30803,19 @@ snapshots: universalify@2.0.1: {} - unocss-preset-scrollbar@3.2.0(unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))): + unocss-preset-scrollbar@3.2.0(unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))): dependencies: '@unocss/preset-mini': 65.5.0 - unocss: 66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + unocss: 66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) unocss-preset-scrollbar@3.2.0(unocss@66.5.11(postcss@8.5.6)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))): dependencies: '@unocss/preset-mini': 65.5.0 unocss: 66.5.11(postcss@8.5.6)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + unocss@66.5.11(postcss@8.5.6)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@unocss/astro': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@unocss/astro': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@unocss/cli': 66.5.11 '@unocss/core': 66.5.11 '@unocss/postcss': 66.5.11(postcss@8.5.6) @@ -30884,9 +30833,9 @@ snapshots: '@unocss/transformer-compile-class': 66.5.11 '@unocss/transformer-directives': 66.5.11 '@unocss/transformer-variant-group': 66.5.11 - '@unocss/vite': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@unocss/vite': 66.5.11(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) optionalDependencies: - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - postcss - supports-color @@ -30947,14 +30896,6 @@ snapshots: unpipe@1.0.0: {} - unplugin-combine@2.1.3(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(unplugin@2.3.11): - optionalDependencies: - esbuild: 0.25.12 - rolldown: 1.0.0-beta.53 - rollup: 4.54.0 - unplugin: 2.3.11 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - unplugin-combine@2.1.3(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(unplugin@2.3.11): optionalDependencies: esbuild: 0.27.2 @@ -30971,19 +30912,6 @@ snapshots: unplugin: 2.3.11 vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - unplugin-info@1.2.4(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@4.54.0): - dependencies: - ci-info: 4.3.1 - git-url-parse: 16.1.0 - simple-git: 3.30.0 - unplugin: 2.3.11 - optionalDependencies: - esbuild: 0.25.12 - rollup: 4.54.0 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color - unplugin-info@1.2.4(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rollup@2.79.2): dependencies: ci-info: 4.3.1 @@ -31132,17 +31060,6 @@ snapshots: rollup: 4.54.0 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - unplugin-yaml@3.0.7(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0): - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.54.0) - unplugin: 2.3.10 - yaml: 2.8.1 - optionalDependencies: - esbuild: 0.25.12 - rolldown: 1.0.0-beta.53 - rollup: 4.54.0 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - unplugin-yaml@3.0.7(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2): dependencies: '@rollup/pluginutils': 5.3.0(rollup@2.79.2) @@ -31364,12 +31281,6 @@ snapshots: - rollup - supports-color - vite-dev-rpc@1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - birpc: 2.9.0 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-hot-client: 2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - vite-dev-rpc@1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: birpc: 2.9.0 @@ -31382,10 +31293,6 @@ snapshots: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-hot-client: 2.1.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - vite-hot-client@2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-hot-client@2.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -31433,21 +31340,6 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - ansis: 4.2.0 - debug: 4.4.3 - error-stack-parser-es: 1.0.5 - ohash: 2.0.11 - open: 10.2.0 - perfect-debounce: 2.0.0 - sirv: 3.0.2 - unplugin-utils: 0.3.1 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - transitivePeerDependencies: - - supports-color - vite-plugin-inspect@11.3.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: ansis: 4.2.0 @@ -31498,20 +31390,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)): - dependencies: - '@vue/devtools-core': 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) - '@vue/devtools-kit': 8.0.5 - '@vue/devtools-shared': 8.0.5 - sirv: 3.0.2 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - transitivePeerDependencies: - - '@nuxt/kit' - - supports-color - - vue - vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.0.5(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) @@ -31526,21 +31404,6 @@ snapshots: - supports-color - vue - vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) - '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) - '@vue/compiler-dom': 3.5.26 - kolorist: 1.8.0 - magic-string: 0.30.21 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color - vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/core': 7.28.5 @@ -31556,16 +31419,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-layouts@0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)): - dependencies: - debug: 4.4.3 - fast-glob: 3.3.3 - vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) - vue-router: 4.6.4(vue@3.5.25(typescript@5.9.3)) - transitivePeerDependencies: - - supports-color - vite-plugin-vue-layouts@0.11.0(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)): dependencies: debug: 4.4.3 @@ -31767,52 +31620,6 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.25(typescript@5.9.3) - vue-macros@3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)): - dependencies: - '@vue-macros/better-define': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/boolean-prop': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/chain-call': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/common': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/config': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-emit': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-models': 3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-prop': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-props': 3.1.1(@vue-macros/reactivity-transform@3.1.1(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-props-refs': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-render': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-slots': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/define-stylex': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/devtools': 3.1.1(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(typescript@5.9.3) - '@vue-macros/export-expose': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/export-props': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/export-render': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/hoist-static': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/jsx-directive': 3.1.1(typescript@5.9.3) - '@vue-macros/named-template': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/reactivity-transform': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/script-lang': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/setup-block': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/setup-component': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/setup-sfc': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/short-bind': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/short-emits': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/short-vmodel': 3.1.1(vue@3.5.25(typescript@5.9.3)) - '@vue-macros/volar': 3.1.1(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) - unplugin: 2.3.11 - unplugin-combine: 2.1.3(esbuild@0.25.12)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@4.54.0)(unplugin@2.3.11) - unplugin-vue-define-options: 3.1.1(vue@3.5.25(typescript@5.9.3)) - vue: 3.5.25(typescript@5.9.3) - transitivePeerDependencies: - - '@rspack/core' - - '@vueuse/core' - - esbuild - - rolldown - - rollup - - typescript - - vite - - vue-tsc - - webpack - vue-macros@3.1.1(@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3)))(esbuild@0.27.2)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(typescript@5.9.3)(vue-tsc@3.2.1(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)): dependencies: '@vue-macros/better-define': 3.1.1(vue@3.5.25(typescript@5.9.3))