730dbfaf7b
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>
442 lines
13 KiB
Vue
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">×</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"
|
|
>↑</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>
|