fix: voice status global store, per-view mic reactivity, single-voice preview
- Move voice status into settings store (voiceSttReady, voiceTtsReady), checked once at login and refreshed after admin model reload - ChatView, BriefingView, DashboardChatInput now use computed refs from the store — mic buttons appear reactively without needing a page reload - BriefingView: separate STT-only guard for mic PTT vs TTS-only guard for listen mode / speak buttons - Add ▶ Preview button to Voice & Speed section in Settings for single- voice testing without enabling blend mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
2298268
|
||||||
@@ -22,6 +22,7 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
|||||||
function startAppServices() {
|
function startAppServices() {
|
||||||
chatStore.startStatusPolling();
|
chatStore.startStatusPolling();
|
||||||
settingsStore.fetchSettings();
|
settingsStore.fetchSettings();
|
||||||
|
settingsStore.checkVoiceStatus();
|
||||||
// Sync browser timezone to the server on every login/page load.
|
// Sync browser timezone to the server on every login/page load.
|
||||||
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted } from "vue";
|
import { ref, computed, nextTick } from "vue";
|
||||||
import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client";
|
import { apiGet, transcribeAudio } from "@/api/client";
|
||||||
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -108,17 +109,11 @@ function focus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Voice PTT
|
// Voice PTT
|
||||||
const voiceEnabled = ref(false);
|
const settingsStore = useSettingsStore();
|
||||||
|
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
|
||||||
const transcribingVoice = ref(false);
|
const transcribingVoice = ref(false);
|
||||||
const recorder = useVoiceRecorder();
|
const recorder = useVoiceRecorder();
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const status = await getVoiceStatus();
|
|
||||||
voiceEnabled.value = status.enabled && status.stt;
|
|
||||||
} catch { /* voice absent */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
async function startPtt() {
|
async function startPtt() {
|
||||||
if (!voiceEnabled.value || recorder.recording.value) return;
|
if (!voiceEnabled.value || recorder.recording.value) return;
|
||||||
await recorder.startRecording();
|
await recorder.startRecording();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { apiGet, apiPut } from "@/api/client";
|
import { apiGet, apiPut, getVoiceStatus } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { AppSettings } from "@/types/settings";
|
import type { AppSettings } from "@/types/settings";
|
||||||
|
|
||||||
@@ -16,6 +16,24 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
() => settings.value.default_model || ""
|
() => settings.value.default_model || ""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Voice status — checked once on login, refreshable from Settings
|
||||||
|
const voiceEnabled = ref(false);
|
||||||
|
const voiceSttReady = ref(false);
|
||||||
|
const voiceTtsReady = ref(false);
|
||||||
|
|
||||||
|
async function checkVoiceStatus() {
|
||||||
|
try {
|
||||||
|
const s = await getVoiceStatus();
|
||||||
|
voiceEnabled.value = s.enabled;
|
||||||
|
voiceSttReady.value = s.enabled && s.stt;
|
||||||
|
voiceTtsReady.value = s.enabled && s.tts;
|
||||||
|
} catch {
|
||||||
|
voiceEnabled.value = false;
|
||||||
|
voiceSttReady.value = false;
|
||||||
|
voiceTtsReady.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSettings() {
|
async function fetchSettings() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +62,10 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
loading,
|
loading,
|
||||||
assistantName,
|
assistantName,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
|
voiceEnabled,
|
||||||
|
voiceSttReady,
|
||||||
|
voiceTtsReady,
|
||||||
|
checkVoiceStatus,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
|||||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
||||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import ChatMessage from '@/components/ChatMessage.vue'
|
import ChatMessage from '@/components/ChatMessage.vue'
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
postRssReaction,
|
postRssReaction,
|
||||||
deleteRssReaction,
|
deleteRssReaction,
|
||||||
getNewsItems,
|
getNewsItems,
|
||||||
getVoiceStatus,
|
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
synthesiseSpeech,
|
synthesiseSpeech,
|
||||||
type BriefingConversation,
|
type BriefingConversation,
|
||||||
@@ -39,6 +39,7 @@ interface WeatherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Setup wizard
|
// Setup wizard
|
||||||
const showWizard = ref(false)
|
const showWizard = ref(false)
|
||||||
@@ -230,7 +231,9 @@ function convLabel(c: BriefingConversation): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Voice ────────────────────────────────────────────────────────────────────
|
// ─── Voice ────────────────────────────────────────────────────────────────────
|
||||||
const voiceEnabled = ref(false)
|
// Mic PTT needs STT; listen/speak mode also needs TTS
|
||||||
|
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
||||||
|
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||||
const listenMode = ref(false)
|
const listenMode = ref(false)
|
||||||
const transcribing = ref(false)
|
const transcribing = ref(false)
|
||||||
const synthesising = ref(false)
|
const synthesising = ref(false)
|
||||||
@@ -238,14 +241,6 @@ const synthesising = ref(false)
|
|||||||
const recorder = useVoiceRecorder()
|
const recorder = useVoiceRecorder()
|
||||||
const audio = useVoiceAudio()
|
const audio = useVoiceAudio()
|
||||||
|
|
||||||
// Check voice availability once on mount
|
|
||||||
async function checkVoice() {
|
|
||||||
try {
|
|
||||||
const status = await getVoiceStatus()
|
|
||||||
voiceEnabled.value = status.enabled && status.stt && status.tts
|
|
||||||
} catch { /* voice feature absent */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the latest assistant message aloud
|
// Read the latest assistant message aloud
|
||||||
async function listenToLatest() {
|
async function listenToLatest() {
|
||||||
const lastAssistant = [...messages.value].reverse().find((m) => m.role === 'assistant')
|
const lastAssistant = [...messages.value].reverse().find((m) => m.role === 'assistant')
|
||||||
@@ -254,7 +249,7 @@ async function listenToLatest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function speakText(text: string) {
|
async function speakText(text: string) {
|
||||||
if (!voiceEnabled.value || synthesising.value) return
|
if (!voiceTtsEnabled.value || synthesising.value) return
|
||||||
// Strip markdown for cleaner TTS output
|
// Strip markdown for cleaner TTS output
|
||||||
const plain = text
|
const plain = text
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
@@ -304,7 +299,7 @@ async function stopPtt() {
|
|||||||
|
|
||||||
// Auto-TTS when listen mode is on and streaming ends
|
// Auto-TTS when listen mode is on and streaming ends
|
||||||
watch(() => chatStore.streaming, async (streaming) => {
|
watch(() => chatStore.streaming, async (streaming) => {
|
||||||
if (!streaming && listenMode.value && voiceEnabled.value) {
|
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||||||
// Small delay to let messages update after stream
|
// Small delay to let messages update after stream
|
||||||
await new Promise((r) => setTimeout(r, 200))
|
await new Promise((r) => setTimeout(r, 200))
|
||||||
await listenToLatest()
|
await listenToLatest()
|
||||||
@@ -348,7 +343,6 @@ useBackgroundRefresh(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSetup()
|
await checkSetup()
|
||||||
if (!showWizard.value) await loadAll()
|
if (!showWizard.value) await loadAll()
|
||||||
checkVoice()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -430,7 +424,7 @@ onMounted(async () => {
|
|||||||
<div v-if="isToday" class="briefing-input-bar">
|
<div v-if="isToday" class="briefing-input-bar">
|
||||||
<!-- Listen toggle -->
|
<!-- Listen toggle -->
|
||||||
<button
|
<button
|
||||||
v-if="voiceEnabled"
|
v-if="voiceTtsEnabled"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
||||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||||
@@ -446,7 +440,7 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
<!-- Stop TTS playback -->
|
<!-- Stop TTS playback -->
|
||||||
<button
|
<button
|
||||||
v-if="voiceEnabled && (synthesising || audio.playing.value)"
|
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
|
||||||
class="btn-icon btn-icon-danger"
|
class="btn-icon btn-icon-danger"
|
||||||
@click="audio.stop(); synthesising = false"
|
@click="audio.stop(); synthesising = false"
|
||||||
title="Stop playback"
|
title="Stop playback"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client";
|
import { apiGet, transcribeAudio } from "@/api/client";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
||||||
import ChatMessage from "@/components/ChatMessage.vue";
|
import ChatMessage from "@/components/ChatMessage.vue";
|
||||||
@@ -23,17 +23,10 @@ const sending = ref(false);
|
|||||||
const summarizing = ref(false);
|
const summarizing = ref(false);
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
// Voice PTT
|
// Voice PTT — use store so status is globally reactive
|
||||||
const voiceEnabled = ref(false);
|
|
||||||
const transcribingVoice = ref(false);
|
const transcribingVoice = ref(false);
|
||||||
const recorder = useVoiceRecorder();
|
const recorder = useVoiceRecorder();
|
||||||
|
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
|
||||||
async function checkVoice() {
|
|
||||||
try {
|
|
||||||
const status = await getVoiceStatus();
|
|
||||||
voiceEnabled.value = status.enabled && status.stt;
|
|
||||||
} catch { /* voice absent */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startPtt() {
|
async function startPtt() {
|
||||||
if (!voiceEnabled.value || recorder.recording.value) return;
|
if (!voiceEnabled.value || recorder.recording.value) return;
|
||||||
@@ -178,7 +171,7 @@ const inputPlaceholder = computed(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener("keydown", onGlobalKeydown);
|
document.addEventListener("keydown", onGlobalKeydown);
|
||||||
await Promise.all([store.fetchConversations(), loadProjects(), checkVoice()]);
|
await Promise.all([store.fetchConversations(), loadProjects()]);
|
||||||
if (convId.value) {
|
if (convId.value) {
|
||||||
if (store.currentConversation?.id !== convId.value) {
|
if (store.currentConversation?.id !== convId.value) {
|
||||||
await store.fetchConversation(convId.value);
|
await store.fetchConversation(convId.value);
|
||||||
|
|||||||
@@ -484,6 +484,8 @@ async function reloadVoiceModels() {
|
|||||||
if (status.stt && status.tts) {
|
if (status.stt && status.tts) {
|
||||||
voiceStatus.value = status;
|
voiceStatus.value = status;
|
||||||
voiceTabLoaded.value = true;
|
voiceTabLoaded.value = true;
|
||||||
|
// Propagate to global store so mic buttons appear everywhere
|
||||||
|
store.checkVoiceStatus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch { /* keep polling */ }
|
} catch { /* keep polling */ }
|
||||||
@@ -569,6 +571,28 @@ async function loadVoiceTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voicePreviewing = ref(false);
|
||||||
|
let _voicePreviewUrl = "";
|
||||||
|
|
||||||
|
async function previewVoice() {
|
||||||
|
if (voicePreviewing.value) return;
|
||||||
|
voicePreviewing.value = true;
|
||||||
|
try {
|
||||||
|
const blob = await synthesiseSpeech(
|
||||||
|
"Hello! I'm your assistant. How can I help you today?",
|
||||||
|
voiceTtsVoice.value,
|
||||||
|
voiceTtsSpeed.value,
|
||||||
|
);
|
||||||
|
if (_voicePreviewUrl) URL.revokeObjectURL(_voicePreviewUrl);
|
||||||
|
_voicePreviewUrl = URL.createObjectURL(blob);
|
||||||
|
new Audio(_voicePreviewUrl).play();
|
||||||
|
} catch {
|
||||||
|
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||||||
|
} finally {
|
||||||
|
voicePreviewing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveVoiceSettings() {
|
async function saveVoiceSettings() {
|
||||||
savingVoice.value = true;
|
savingVoice.value = true;
|
||||||
voiceSaved.value = false;
|
voiceSaved.value = false;
|
||||||
@@ -2250,6 +2274,9 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
<button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
|
||||||
|
{{ voicePreviewing ? 'Generating…' : '▶ Preview' }}
|
||||||
|
</button>
|
||||||
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
|
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
|
||||||
{{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
|
{{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user