6cf880506d
Add 'wasm-unsafe-eval' to script-src and blob: to worker-src in Content-Security-Policy header. Required by onnxruntime-web to compile the Silero VAD ONNX model. Also surface VAD init errors as a toast instead of silent console log. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
668 lines
21 KiB
Vue
668 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
|
import { apiGet, transcribeAudio } from '@/api/client'
|
|
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
|
import { useVad } from '@/composables/useVad'
|
|
import { useStreamingTts } from '@/composables/useStreamingTts'
|
|
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
|
|
import { useListenMode } from '@/composables/useListenMode'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import { useSettingsStore } from '@/stores/settings'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import type { Note } from '@/types/note'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
/** Textarea placeholder */
|
|
placeholder?: string
|
|
/** When true, hides the note picker (briefing mode) */
|
|
briefingMode?: boolean
|
|
/** Pill shape — compact rounded style for widget */
|
|
pill?: boolean
|
|
}>(), {
|
|
placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
|
|
briefingMode: false,
|
|
pill: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
submit: [payload: { content: string; contextNoteId?: number }]
|
|
abort: []
|
|
}>()
|
|
|
|
const store = useChatStore()
|
|
const settingsStore = useSettingsStore()
|
|
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
|
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
|
|
|
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
|
|
const listenMode = useListenMode()
|
|
const audio = useVoiceAudio()
|
|
const speakerPopoverOpen = ref(false)
|
|
|
|
const tts = useStreamingTts({
|
|
streamingContent: computed(() => store.streamingContent),
|
|
streaming: computed(() => store.streaming),
|
|
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
})
|
|
|
|
function lastAssistantContent(): string {
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
|
|
}
|
|
|
|
function toggleListen() {
|
|
listenMode.value = !listenMode.value
|
|
if (listenMode.value) {
|
|
tts.speak(lastAssistantContent())
|
|
} else {
|
|
tts.stop()
|
|
}
|
|
}
|
|
|
|
// ── Core input ────────────────────────────────────────────────────────────────
|
|
const messageInput = ref('')
|
|
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
|
const wrapperEl = ref<HTMLElement | null>(null)
|
|
|
|
function autoResize() {
|
|
const el = inputEl.value
|
|
if (!el) return
|
|
el.style.height = 'auto'
|
|
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
|
}
|
|
|
|
function resetTextareaHeight() {
|
|
const el = inputEl.value
|
|
if (!el) return
|
|
el.style.height = 'auto'
|
|
}
|
|
|
|
function onInputKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
onSubmit()
|
|
}
|
|
}
|
|
|
|
function onSubmit() {
|
|
const content = messageInput.value.trim()
|
|
if (!content) return
|
|
emit('submit', { content, contextNoteId: attachedNote.value?.id })
|
|
messageInput.value = ''
|
|
attachedNote.value = null
|
|
resetTextareaHeight()
|
|
}
|
|
|
|
// ── Note picker ───────────────────────────────────────────────────────────────
|
|
const attachedNote = ref<{ id: number; title: string } | null>(null)
|
|
const showNotePicker = ref(false)
|
|
const noteSearchQuery = ref('')
|
|
const noteSearchResults = ref<{ id: number; title: string }[]>([])
|
|
const noteSearchLoading = ref(false)
|
|
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function toggleNotePicker() {
|
|
showNotePicker.value = !showNotePicker.value
|
|
if (showNotePicker.value) {
|
|
noteSearchQuery.value = ''
|
|
noteSearchResults.value = []
|
|
nextTick(() => {
|
|
(wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
|
|
})
|
|
}
|
|
}
|
|
|
|
function onNoteSearchInput() {
|
|
if (noteSearchTimer) clearTimeout(noteSearchTimer)
|
|
noteSearchTimer = setTimeout(async () => {
|
|
const q = noteSearchQuery.value.trim()
|
|
if (!q) { noteSearchResults.value = []; return }
|
|
noteSearchLoading.value = true
|
|
try {
|
|
const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
|
|
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
|
|
} catch {
|
|
noteSearchResults.value = []
|
|
} finally {
|
|
noteSearchLoading.value = false
|
|
}
|
|
}, 250)
|
|
}
|
|
|
|
function selectNote(note: { id: number; title: string }) {
|
|
attachedNote.value = note
|
|
showNotePicker.value = false
|
|
}
|
|
|
|
function removeAttachedNote() {
|
|
attachedNote.value = null
|
|
}
|
|
|
|
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
|
|
const transcribingVoice = ref(false)
|
|
const recorder = useVoiceRecorder()
|
|
// Tracks whether VAD detected speech during the current recording session.
|
|
// Used to skip the Whisper round-trip when the user manually stops without
|
|
// ever speaking — the recording is ambient noise and will transcribe to "".
|
|
const vadSawSpeech = ref(false)
|
|
|
|
const vad = useVad({
|
|
onSpeechEnd: () => {
|
|
// VAD auto-stop: speech was detected and the user has paused. Proceed
|
|
// to transcribe the MediaRecorder output.
|
|
void stopRecording(false)
|
|
},
|
|
onNoSpeech: () => {
|
|
useToastStore().show('No speech detected', 'warning')
|
|
},
|
|
onVadError: (msg) => {
|
|
useToastStore().show(`VAD failed: ${msg}`, 'error')
|
|
},
|
|
})
|
|
|
|
// Live mic halo. A solid red disc sits behind the mic button and scales
|
|
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
|
|
const micGlowStyle = computed(() => {
|
|
if (!recorder.recording.value) return { display: 'none' }
|
|
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
|
|
return {
|
|
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
|
opacity: String(0.45 + pulse * 0.4),
|
|
}
|
|
})
|
|
|
|
// vad.speaking flips true on speech-start. Watch it once per session to
|
|
// capture that speech was detected at some point, without clearing when
|
|
// speech ends. This drives the no-speech guard in stopRecording.
|
|
watch(() => vad.speaking.value, (on) => {
|
|
if (on) vadSawSpeech.value = true
|
|
})
|
|
|
|
async function toggleVoice() {
|
|
if (transcribingVoice.value) return
|
|
if (recorder.recording.value) {
|
|
await stopRecording(true)
|
|
} else {
|
|
await startRecording()
|
|
}
|
|
}
|
|
|
|
async function startRecording() {
|
|
if (!voiceEnabled.value || recorder.recording.value) return
|
|
if (!recorder.isSupported) {
|
|
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
|
|
return
|
|
}
|
|
vadSawSpeech.value = false
|
|
await recorder.startRecording()
|
|
if (recorder.error.value) {
|
|
useToastStore().show(recorder.error.value, 'error')
|
|
return
|
|
}
|
|
if (recorder.stream.value) {
|
|
await vad.start(recorder.stream.value)
|
|
}
|
|
}
|
|
|
|
async function stopRecording(manual: boolean) {
|
|
if (manual) {
|
|
await vad.stopAndCheck()
|
|
} else {
|
|
await vad.stop()
|
|
}
|
|
if (!recorder.recording.value) return
|
|
transcribingVoice.value = true
|
|
try {
|
|
const blob = await recorder.stopRecording()
|
|
// No-speech guard: user manually stopped without VAD seeing speech.
|
|
// Skip Whisper — the toast was already shown by onNoSpeech.
|
|
if (manual && !vadSawSpeech.value) {
|
|
return
|
|
}
|
|
// Pass last assistant message as context to reduce STT mishearings
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
|
|
const { transcript } = await transcribeAudio(blob, lastAssistant)
|
|
if (transcript.trim()) {
|
|
messageInput.value = transcript.trim()
|
|
await nextTick()
|
|
autoResize()
|
|
onSubmit()
|
|
}
|
|
} catch { /* transcription failed silently */ }
|
|
finally { transcribingVoice.value = false }
|
|
}
|
|
|
|
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
|
|
function onDocumentMousedown(e: MouseEvent) {
|
|
if (!speakerPopoverOpen.value) return
|
|
const target = e.target as HTMLElement | null
|
|
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
|
|
}
|
|
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
|
|
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
|
|
|
|
// ── Exposed interface ─────────────────────────────────────────────────────────
|
|
function focus() {
|
|
inputEl.value?.focus()
|
|
}
|
|
|
|
function prefill(text: string) {
|
|
messageInput.value = text
|
|
nextTick(() => {
|
|
autoResize()
|
|
inputEl.value?.focus()
|
|
})
|
|
}
|
|
|
|
defineExpose({ focus, prefill })
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
|
|
<!-- Attached note pill -->
|
|
<div v-if="attachedNote" class="attached-note">
|
|
<span class="attached-note-pill">
|
|
{{ attachedNote.title }}
|
|
<button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">×</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<!-- Note picker -->
|
|
<div class="note-picker-wrapper">
|
|
<button
|
|
class="btn-icon"
|
|
@click="toggleNotePicker"
|
|
:disabled="!store.chatReady"
|
|
title="Attach a note"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
|
</svg>
|
|
</button>
|
|
<div v-if="showNotePicker" class="note-picker-dropdown">
|
|
<input
|
|
class="note-picker-search"
|
|
v-model="noteSearchQuery"
|
|
@input="onNoteSearchInput"
|
|
placeholder="Search notes..."
|
|
/>
|
|
<div class="note-picker-results">
|
|
<div
|
|
v-for="note in noteSearchResults"
|
|
:key="note.id"
|
|
class="note-picker-item"
|
|
@click="selectNote(note)"
|
|
>{{ note.title || 'Untitled' }}</div>
|
|
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
|
|
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Textarea -->
|
|
<textarea
|
|
ref="inputEl"
|
|
v-model="messageInput"
|
|
@keydown="onInputKeydown"
|
|
@input="autoResize"
|
|
:placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
|
|
:disabled="!store.chatReady"
|
|
rows="1"
|
|
class="input-textarea"
|
|
></textarea>
|
|
|
|
<!-- Speaker / listen-mode popover -->
|
|
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
|
|
<button
|
|
class="btn-icon btn-speaker"
|
|
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
|
|
@click="speakerPopoverOpen = !speakerPopoverOpen"
|
|
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
|
aria-label="Listen and volume settings"
|
|
>
|
|
<svg v-if="!tts.speaking.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>
|
|
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
|
<button
|
|
class="speaker-row speaker-row--toggle"
|
|
:class="{ 'speaker-row--active': listenMode }"
|
|
@click="toggleListen"
|
|
>
|
|
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
|
|
<span class="speaker-switch" :class="{ on: listenMode }"></span>
|
|
</button>
|
|
<div class="speaker-row">
|
|
<span class="speaker-row-label">Volume</span>
|
|
<input
|
|
type="range" min="0" max="1" step="0.05"
|
|
:value="audio.volume.value"
|
|
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
|
|
class="speaker-volume-range"
|
|
aria-label="Volume"
|
|
/>
|
|
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
|
</div>
|
|
<button
|
|
v-if="tts.speaking.value"
|
|
class="speaker-row speaker-row--stop"
|
|
@click="tts.stop()"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
|
<span>Stop playback</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PTT mic (with live amplitude halo behind) -->
|
|
<div v-if="voiceEnabled" class="mic-wrapper">
|
|
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
|
<button
|
|
class="btn-icon btn-mic"
|
|
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
|
@click.prevent="toggleVoice"
|
|
:disabled="transcribingVoice || !store.chatReady"
|
|
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
|
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
|
>
|
|
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
|
</svg>
|
|
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Abort (streaming) or Send -->
|
|
<button
|
|
v-if="store.streaming"
|
|
class="btn-abort-inline"
|
|
@click="emit('abort')"
|
|
title="Stop generation"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="btn-send"
|
|
@click="onSubmit"
|
|
:disabled="!messageInput.trim() || !store.chatReady"
|
|
>↑</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chat-input-bar {
|
|
width: 100%;
|
|
}
|
|
|
|
.attached-note {
|
|
padding: 0.25rem 0.5rem;
|
|
}
|
|
.attached-note-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-radius: 10px;
|
|
padding: 0.15rem 0.4rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
.attached-note-remove {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
line-height: 1;
|
|
padding: 0 0.1rem;
|
|
}
|
|
.attached-note-remove:hover { color: #fff; }
|
|
|
|
.input-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
background: var(--color-input-bar-bg);
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px var(--color-shadow);
|
|
}
|
|
|
|
.chat-input-bar--pill .input-row {
|
|
border-radius: 24px;
|
|
padding: 0.6rem 0.6rem 0.6rem 1rem;
|
|
}
|
|
|
|
.input-textarea {
|
|
flex: 1;
|
|
resize: none;
|
|
padding: 0.35rem 0.5rem;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-input-bar-text);
|
|
outline: none;
|
|
font-family: inherit;
|
|
font-size: 0.9rem;
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
}
|
|
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
|
.input-textarea:disabled { opacity: 0.5; }
|
|
|
|
.btn-icon {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-input-bar-text);
|
|
opacity: 0.6;
|
|
padding: 0.2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: var(--radius-sm);
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-icon:hover { opacity: 1; }
|
|
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
|
|
|
.btn-mic.mic-recording {
|
|
opacity: 1;
|
|
/* White icon sits on top of the red halo so it stays legible at any
|
|
pulse size. */
|
|
color: #fff;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.btn-mic.mic-transcribing { opacity: 0.5; }
|
|
|
|
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
|
absolutely positioned behind the button, scaled/faded by the
|
|
computed `micGlowStyle` so the user gets unmistakable feedback
|
|
that their voice is being picked up while the mic icon itself
|
|
stays put and readable on top. */
|
|
.mic-wrapper {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.mic-glow {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(
|
|
circle,
|
|
rgba(239, 68, 68, 0.9) 0%,
|
|
rgba(239, 68, 68, 0.6) 55%,
|
|
rgba(239, 68, 68, 0) 100%
|
|
);
|
|
transform: translate(-50%, -50%) scale(1);
|
|
transform-origin: center;
|
|
pointer-events: none;
|
|
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
|
rather than stepping. */
|
|
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
|
z-index: 0;
|
|
}
|
|
|
|
.note-picker-wrapper { position: relative; }
|
|
.note-picker-dropdown {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 0;
|
|
width: 260px;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
z-index: 20;
|
|
overflow: hidden;
|
|
}
|
|
.note-picker-search {
|
|
width: 100%;
|
|
padding: 0.45rem 0.65rem;
|
|
border: none;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
outline: none;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
.note-picker-results { max-height: 180px; overflow-y: auto; }
|
|
.note-picker-item {
|
|
padding: 0.4rem 0.65rem;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.note-picker-item:hover { background: var(--color-bg-secondary); }
|
|
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }
|
|
|
|
.btn-send {
|
|
width: 30px;
|
|
min-width: 30px;
|
|
height: 30px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--gradient-cta);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
flex-shrink: 0;
|
|
transition: box-shadow 0.15s;
|
|
}
|
|
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
|
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
|
|
|
|
.btn-abort-inline {
|
|
width: 28px;
|
|
min-width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
|
|
|
|
/* Speaker / listen-mode popover */
|
|
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
|
|
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
|
|
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
|
|
.speaker-popover {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
right: 0;
|
|
min-width: 220px;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
padding: 0.35rem;
|
|
z-index: 30;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
.speaker-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.45rem 0.6rem;
|
|
background: none;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
cursor: default;
|
|
text-align: left;
|
|
width: 100%;
|
|
}
|
|
.speaker-row--toggle { cursor: pointer; }
|
|
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
|
|
.speaker-row--active { color: var(--color-primary); }
|
|
.speaker-row--stop {
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
justify-content: flex-start;
|
|
}
|
|
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
|
|
.speaker-row-label { flex: 1; }
|
|
.speaker-volume-range { flex: 1; min-width: 0; }
|
|
.speaker-volume-pct {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
min-width: 2.5rem;
|
|
text-align: right;
|
|
}
|
|
.speaker-switch {
|
|
position: relative;
|
|
width: 28px;
|
|
height: 16px;
|
|
border-radius: 9999px;
|
|
background: var(--color-border);
|
|
transition: background 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.speaker-switch::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--color-bg-card);
|
|
transition: transform 0.15s;
|
|
}
|
|
.speaker-switch.on { background: var(--color-primary); }
|
|
.speaker-switch.on::after { transform: translateX(12px); }
|
|
</style>
|