Files
FabledScribe/frontend/src/components/ChatInputBar.vue
T
bvandeusen 730dbfaf7b feat(voice): pulse mic button with live amplitude
The mic button now scales and glows proportional to the silence
detector's RMS amplitude instead of sitting static. Gives obvious
feedback that audio is actually being picked up — silence still
breathes via a 0.1 floor, loud input caps at ~1.18x scale so it
doesn't punch through the input bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:37 -04:00

442 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useSilenceDetector } from '@/composables/useSilenceDetector'
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)
// ── 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 + silence detection) ─────────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
const silenceDetector = useSilenceDetector()
// Live mic pulse. `silenceDetector.amplitude` is 0..1 RMS; we floor at 0.1
// so the button still breathes on silence and cap the scale growth so loud
// input doesn't punch through the input bar.
const micStyle = computed(() => {
if (!recorder.recording.value) return {}
const pulse = 0.1 + Math.min(silenceDetector.amplitude.value, 1) * 0.9
return {
transform: `scale(${1 + pulse * 0.18})`,
boxShadow: `0 0 ${6 + pulse * 14}px ${2 + pulse * 4}px rgba(239, 68, 68, ${0.2 + pulse * 0.3})`,
}
})
async function toggleVoice() {
if (transcribingVoice.value) return
if (recorder.recording.value) {
await stopRecording()
} 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
}
await recorder.startRecording()
if (recorder.error.value) {
useToastStore().show(recorder.error.value, 'error')
return
}
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, () => stopRecording())
}
}
async function stopRecording() {
silenceDetector.stop()
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
// 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 }
}
// ── 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">&times;</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>
<!-- PTT mic -->
<button
v-if="voiceEnabled"
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
:style="micStyle"
@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>
<!-- 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"
>&uarr;</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;
color: #ef4444;
border-radius: 50%;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
.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; }
</style>