feat: listen mode + volume knob in chat; briefing discuss auto-send; fix LLM proactive note search

- ChatView: listen mode toggle (auto-reads new responses via TTS), volume popup
  with range slider persisted per-device in localStorage via GainNode
- useVoiceAudio: shared module-level _volume ref with localStorage persistence,
  GainNode for volume control, exported setVoiceVolume()
- tts.py: pre-warm all Kokoro voices at pipeline load to eliminate HuggingFace
  HEAD requests at synthesis time (reduces TTS latency)
- BriefingView: discuss article button now auto-sends instead of just filling input;
  prompt capped to 15 sentences; send() accepts optional overrideText
- llm.py: instruct LLM not to proactively search notes or comment on note absence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:52:11 -04:00
parent ab397e78f3
commit baeb0b14e5
5 changed files with 169 additions and 22 deletions
+130 -1
View File
@@ -3,9 +3,10 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet, transcribeAudio } from "@/api/client";
import { apiGet, transcribeAudio, synthesiseSpeech } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
@@ -26,10 +27,46 @@ const sidebarOpen = ref(false);
// Voice PTT — use store so status is globally reactive
const transcribingVoice = ref(false);
const recorder = useVoiceRecorder();
const audio = useVoiceAudio();
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady);
const listenMode = ref(false);
const synthesising = ref(false);
const showVolumeSlider = ref(false);
async function speakLastAssistantMessage() {
const messages = store.currentConversation?.messages ?? [];
const last = [...messages].reverse().find((m) => m.role === "assistant");
if (!last?.content) return;
const plain = last.content
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]+`/g, (m: string) => m.slice(1, -1))
.replace(/#{1,6}\s+/g, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/\n{2,}/g, " ")
.trim();
if (!plain) return;
synthesising.value = true;
try {
const blob = await synthesiseSpeech(plain);
await audio.play(blob);
} catch { /* TTS failure non-critical */ }
finally { synthesising.value = false; }
}
watch(() => store.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200));
await speakLastAssistantMessage();
}
});
async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return;
audio.stop();
await recorder.startRecording();
}
@@ -873,6 +910,55 @@ onUnmounted(() => {
</div>
</div>
<!-- Listen mode + volume (TTS) -->
<template v-if="voiceTtsEnabled">
<div class="volume-wrapper">
<button
class="btn-attach btn-listen"
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode"
>
<svg v-if="!synthesising && !audio.playing.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<button
class="btn-attach btn-volume"
@click="showVolumeSlider = !showVolumeSlider"
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
aria-label="Volume"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<path v-if="audio.volume.value === 0" d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3z"/>
<path v-else d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<div v-if="showVolumeSlider" class="volume-popup">
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="volume-range"
aria-label="Volume"
/>
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
</div>
<button
v-if="synthesising || audio.playing.value"
class="btn-attach"
@click="audio.stop(); synthesising = false"
title="Stop playback"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
</button>
</template>
<!-- Mic PTT button -->
<button
v-if="voiceEnabled && recorder.isSupported"
@@ -1692,6 +1778,49 @@ details[open] .thinking-summary::before {
50% { opacity: 1; }
}
/* Listen mode + volume controls */
.volume-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 2px;
}
.btn-listen--active {
color: var(--color-primary) !important;
opacity: 1 !important;
}
.btn-listen--busy {
color: #a78bfa !important;
opacity: 1 !important;
}
.volume-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--color-surface, #1e1e2e);
border: 1px solid var(--color-border, rgba(255,255,255,0.08));
border-radius: 10px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
white-space: nowrap;
z-index: 50;
}
.volume-range {
width: 96px;
accent-color: var(--color-primary, #6366f1);
cursor: pointer;
}
.volume-pct {
font-size: 0.72rem;
color: var(--color-muted, rgba(255,255,255,0.4));
min-width: 28px;
text-align: right;
}
.btn-send {
width: 34px;
min-width: 34px;