feat: unify voice PTT across briefing and chat views
- ChatView: add PTT mic button in input bar (hold to speak → transcribe → send) - BriefingView: restyle input bar to match ChatView (floating pill, circle send) - BriefingView: move listen/mic controls into input bar, remove from header - BriefingView: consistent icon-button style for speaker/mic matching ChatView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+101
-112
@@ -368,29 +368,6 @@ onMounted(async () => {
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
<!-- Listen button: reads latest assistant message; toggles auto-TTS mode -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-voice-header"
|
||||
:class="{ 'btn-voice-active': listenMode, 'btn-voice-busy': synthesising || audio.playing.value }"
|
||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||
:disabled="synthesising"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Listen to briefing'"
|
||||
>
|
||||
<svg v-if="!synthesising && !audio.playing.value" width="15" height="15" 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="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
{{ listenMode ? 'Listening' : 'Listen' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceEnabled && (synthesising || audio.playing.value)"
|
||||
class="btn-trigger"
|
||||
@click="audio.stop()"
|
||||
title="Stop playback"
|
||||
>Stop</button>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerNow"
|
||||
@@ -450,19 +427,47 @@ onMounted(async () => {
|
||||
|
||||
<!-- Input bar (today only) -->
|
||||
<div v-if="isToday" class="briefing-input-bar">
|
||||
<!-- Listen toggle -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon"
|
||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read replies 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>
|
||||
<!-- Stop TTS playback -->
|
||||
<button
|
||||
v-if="voiceEnabled && (synthesising || audio.playing.value)"
|
||||
class="btn-icon btn-icon-danger"
|
||||
@click="audio.stop(); synthesising = false"
|
||||
title="Stop playback"
|
||||
aria-label="Stop playback"
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="briefing-input"
|
||||
class="briefing-textarea"
|
||||
:placeholder="transcribing ? 'Transcribing…' : recorder.recording.value ? 'Recording…' : 'Reply to your briefing…'"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
:disabled="transcribing || recorder.recording.value"
|
||||
></textarea>
|
||||
<!-- Mic PTT button (hold to record) -->
|
||||
<!-- Mic PTT button -->
|
||||
<button
|
||||
v-if="voiceEnabled && recorder.isSupported"
|
||||
class="btn-mic"
|
||||
:class="{ 'btn-mic-active': recorder.recording.value, 'btn-mic-busy': transcribing }"
|
||||
class="btn-icon btn-mic-ptt"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribing }"
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@@ -471,10 +476,10 @@ onMounted(async () => {
|
||||
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
|
||||
aria-label="Push to talk"
|
||||
>
|
||||
<svg v-if="!transcribing" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg v-if="!transcribing" 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="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -483,11 +488,7 @@ onMounted(async () => {
|
||||
@click="send"
|
||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -675,46 +676,53 @@ onMounted(async () => {
|
||||
|
||||
.briefing-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
margin: 0 0.75rem 1rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px var(--color-shadow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.briefing-input {
|
||||
.briefing-textarea {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.briefing-input:focus { border-color: var(--color-primary); }
|
||||
.briefing-textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
||||
.briefing-textarea:disabled { opacity: 0.5; }
|
||||
|
||||
.btn-send {
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn-send:not(:disabled):hover { box-shadow: 0 0 14px rgba(129, 140, 248, 0.5); }
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -837,72 +845,53 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Voice buttons ──────────────────────────────────────────────────────── */
|
||||
/* ─── Icon buttons (input bar) ───────────────────────────────────────────── */
|
||||
|
||||
.btn-voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-voice-header:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-voice-header:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-voice-active {
|
||||
border-color: var(--color-primary) !important;
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent) !important;
|
||||
}
|
||||
.btn-voice-busy {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
animation: pulse-border 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.btn-mic {
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
color: var(--color-text-muted);
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.5;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-icon:hover:not(:disabled) { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.25; cursor: default; }
|
||||
.btn-icon-active {
|
||||
color: var(--color-primary) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.btn-icon-busy {
|
||||
color: var(--color-primary) !important;
|
||||
opacity: 0.8 !important;
|
||||
animation: icon-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.btn-icon-danger {
|
||||
color: #ef4444 !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
|
||||
.btn-mic-ptt {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.btn-mic:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-mic:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-mic-active {
|
||||
background: color-mix(in srgb, #ef4444 15%, transparent) !important;
|
||||
border-color: #ef4444 !important;
|
||||
.btn-mic-ptt.mic-recording {
|
||||
color: #ef4444 !important;
|
||||
animation: pulse-border 0.8s ease-in-out infinite;
|
||||
opacity: 1 !important;
|
||||
animation: icon-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
.btn-mic-busy {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
.btn-mic-ptt.mic-transcribing {
|
||||
color: var(--color-primary) !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
@keyframes icon-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -3,8 +3,9 @@ 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 } from "@/api/client";
|
||||
import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
||||
import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
|
||||
@@ -22,6 +23,38 @@ const sending = ref(false);
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
// Voice PTT
|
||||
const voiceEnabled = ref(false);
|
||||
const transcribingVoice = ref(false);
|
||||
const recorder = useVoiceRecorder();
|
||||
|
||||
async function checkVoice() {
|
||||
try {
|
||||
const status = await getVoiceStatus();
|
||||
voiceEnabled.value = status.enabled && status.stt;
|
||||
} catch { /* voice absent */ }
|
||||
}
|
||||
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || recorder.recording.value) return;
|
||||
await recorder.startRecording();
|
||||
}
|
||||
|
||||
async function stopPtt() {
|
||||
if (!recorder.recording.value) return;
|
||||
transcribingVoice.value = true;
|
||||
try {
|
||||
const blob = await recorder.stopRecording();
|
||||
const { transcript } = await transcribeAudio(blob);
|
||||
if (transcript.trim()) {
|
||||
messageInput.value = transcript.trim();
|
||||
autoResize();
|
||||
await sendMessage();
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally { transcribingVoice.value = false; }
|
||||
}
|
||||
|
||||
// Research modal state
|
||||
const showResearchModal = ref(false);
|
||||
const researchTopic = ref("");
|
||||
@@ -145,7 +178,7 @@ const inputPlaceholder = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
await Promise.all([store.fetchConversations(), loadProjects()]);
|
||||
await Promise.all([store.fetchConversations(), loadProjects(), checkVoice()]);
|
||||
if (convId.value) {
|
||||
if (store.currentConversation?.id !== convId.value) {
|
||||
await store.fetchConversation(convId.value);
|
||||
@@ -847,6 +880,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mic PTT button -->
|
||||
<button
|
||||
v-if="voiceEnabled && recorder.isSupported"
|
||||
class="btn-attach btn-mic-ptt"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
|
||||
aria-label="Push to talk"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="18" height="18" 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="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@@ -1626,6 +1680,25 @@ details[open] .thinking-summary::before {
|
||||
.input-area textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.btn-mic-ptt {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-mic-ptt.mic-recording {
|
||||
color: #ef4444 !important;
|
||||
opacity: 1 !important;
|
||||
animation: mic-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
.btn-mic-ptt.mic-transcribing {
|
||||
color: var(--color-primary) !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
|
||||
Reference in New Issue
Block a user