feat(mi): unify voice into single mic toggle with succinct TTS responses
Replace three separate mic controls (bar dictation, bar miC, panel miC)
with a single 🎤 toggle in the bar that activates the full voice loop:
speech-to-text → auto-submit after 1.5s silence → TTS response.
- Remove standalone dictation mode (#dictation, #interimText)
- Remove panel header miC button
- Single mic button uses voice mode state animations (pulse red = listening,
spin amber = thinking, pulse cyan = speaking)
- Tighten TTS output to ~2 sentences for succinct responses
- Voice strip still shows in panel with waveform, status, and stop button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
48c7f15de4
commit
c91921592b
|
|
@ -35,8 +35,6 @@ export class RStackMi extends HTMLElement {
|
||||||
#shadow: ShadowRoot;
|
#shadow: ShadowRoot;
|
||||||
#messages: MiMessage[] = [];
|
#messages: MiMessage[] = [];
|
||||||
#abortController: AbortController | null = null;
|
#abortController: AbortController | null = null;
|
||||||
#dictation: SpeechDictation | null = null;
|
|
||||||
#interimText = "";
|
|
||||||
#preferredModel: string = "";
|
#preferredModel: string = "";
|
||||||
#minimized = false;
|
#minimized = false;
|
||||||
#availableModels: MiModelConfig[] = [];
|
#availableModels: MiModelConfig[] = [];
|
||||||
|
|
@ -154,8 +152,7 @@ export class RStackMi extends HTMLElement {
|
||||||
<span class="mi-icon">✧</span>
|
<span class="mi-icon">✧</span>
|
||||||
<input class="mi-input-bar" id="mi-bar-input" type="text"
|
<input class="mi-input-bar" id="mi-bar-input" type="text"
|
||||||
placeholder="Ask mi anything..." autocomplete="off" />
|
placeholder="Ask mi anything..." autocomplete="off" />
|
||||||
${SpeechDictation.isSupported() ? '<button class="mi-mic-btn" id="mi-mic" title="Voice dictation">🎤</button>' : ''}
|
${SpeechDictation.isSupported() ? '<button class="mi-mic-btn" id="mi-voice-btn" title="Voice mode">🎤</button>' : ''}
|
||||||
${SpeechDictation.isSupported() ? '<button class="mi-voice-btn" id="mi-voice-btn" title="miC voice conversation">🎙 miC</button>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mi-panel" id="mi-panel">
|
<div class="mi-panel" id="mi-panel">
|
||||||
<div class="mi-panel-header">
|
<div class="mi-panel-header">
|
||||||
|
|
@ -163,7 +160,6 @@ export class RStackMi extends HTMLElement {
|
||||||
<span class="mi-panel-title">mi</span>
|
<span class="mi-panel-title">mi</span>
|
||||||
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
|
<select class="mi-model-select" id="mi-model-select" title="Select AI model"></select>
|
||||||
<div class="mi-panel-spacer"></div>
|
<div class="mi-panel-spacer"></div>
|
||||||
${SpeechDictation.isSupported() ? '<button class="mi-voice-panel-btn" id="mi-voice-panel-btn" title="miC voice mode">🎙 miC</button>' : ''}
|
|
||||||
<button class="mi-panel-btn" id="mi-minimize" title="Minimize (Escape)">−</button>
|
<button class="mi-panel-btn" id="mi-minimize" title="Minimize (Escape)">−</button>
|
||||||
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,51 +311,11 @@ export class RStackMi extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Voice dictation
|
// Voice mode — single mic toggle (STT input → auto-submit → TTS response)
|
||||||
const micBtn = this.#shadow.getElementById("mi-mic") as HTMLButtonElement | null;
|
const voiceBtn = this.#shadow.getElementById("mi-voice-btn");
|
||||||
if (micBtn) {
|
|
||||||
let baseText = "";
|
|
||||||
this.#dictation = new SpeechDictation({
|
|
||||||
onInterim: (text) => {
|
|
||||||
this.#interimText = text;
|
|
||||||
barInput.value = baseText + (baseText ? " " : "") + text;
|
|
||||||
},
|
|
||||||
onFinal: (text) => {
|
|
||||||
this.#interimText = "";
|
|
||||||
baseText += (baseText ? " " : "") + text;
|
|
||||||
barInput.value = baseText;
|
|
||||||
},
|
|
||||||
onStateChange: (recording) => {
|
|
||||||
micBtn.classList.toggle("recording", recording);
|
|
||||||
if (!recording) {
|
|
||||||
baseText = barInput.value;
|
|
||||||
this.#interimText = "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => console.warn("MI dictation:", err),
|
|
||||||
});
|
|
||||||
|
|
||||||
micBtn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (this.#voiceMode) return; // Bar mic disabled during voice mode
|
|
||||||
if (!this.#dictation!.isRecording) {
|
|
||||||
baseText = barInput.value;
|
|
||||||
}
|
|
||||||
this.#dictation!.toggle();
|
|
||||||
barInput.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Voice mode buttons
|
|
||||||
const voiceBarBtn = this.#shadow.getElementById("mi-voice-btn");
|
|
||||||
const voicePanelBtn = this.#shadow.getElementById("mi-voice-panel-btn");
|
|
||||||
const voiceStopBtn = this.#shadow.getElementById("mi-voice-stop");
|
const voiceStopBtn = this.#shadow.getElementById("mi-voice-stop");
|
||||||
|
|
||||||
voiceBarBtn?.addEventListener("click", (e) => {
|
voiceBtn?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
|
||||||
this.#toggleVoiceMode();
|
|
||||||
});
|
|
||||||
voicePanelBtn?.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.#toggleVoiceMode();
|
this.#toggleVoiceMode();
|
||||||
});
|
});
|
||||||
|
|
@ -487,9 +443,6 @@ export class RStackMi extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
#activateVoiceMode() {
|
#activateVoiceMode() {
|
||||||
// Stop existing bar dictation if recording
|
|
||||||
if (this.#dictation?.isRecording) this.#dictation.stop();
|
|
||||||
|
|
||||||
this.#voiceMode = true;
|
this.#voiceMode = true;
|
||||||
this.#voiceAccumulated = "";
|
this.#voiceAccumulated = "";
|
||||||
|
|
||||||
|
|
@ -558,8 +511,7 @@ export class RStackMi extends HTMLElement {
|
||||||
|
|
||||||
const strip = this.#shadow.getElementById("mi-voice-strip");
|
const strip = this.#shadow.getElementById("mi-voice-strip");
|
||||||
const label = this.#shadow.getElementById("mi-voice-label");
|
const label = this.#shadow.getElementById("mi-voice-label");
|
||||||
const barBtn = this.#shadow.getElementById("mi-voice-btn");
|
const btn = this.#shadow.getElementById("mi-voice-btn");
|
||||||
const panelBtn = this.#shadow.getElementById("mi-voice-panel-btn");
|
|
||||||
|
|
||||||
// Update strip
|
// Update strip
|
||||||
if (strip) {
|
if (strip) {
|
||||||
|
|
@ -576,9 +528,8 @@ export class RStackMi extends HTMLElement {
|
||||||
label.textContent = labels[state];
|
label.textContent = labels[state];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update buttons
|
// Update button
|
||||||
for (const btn of [barBtn, panelBtn]) {
|
if (btn) {
|
||||||
if (!btn) continue;
|
|
||||||
btn.classList.remove("v-listening", "v-thinking", "v-speaking", "v-active");
|
btn.classList.remove("v-listening", "v-thinking", "v-speaking", "v-active");
|
||||||
if (this.#voiceMode) {
|
if (this.#voiceMode) {
|
||||||
btn.classList.add("v-active");
|
btn.classList.add("v-active");
|
||||||
|
|
@ -646,10 +597,10 @@ export class RStackMi extends HTMLElement {
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Truncate to ~4 sentences for snappy TTS
|
// Truncate to ~2 sentences for succinct TTS
|
||||||
const sentences = stripped.match(/[^.!?]+[.!?]+/g) || [stripped];
|
const sentences = stripped.match(/[^.!?]+[.!?]+/g) || [stripped];
|
||||||
if (sentences.length > 4) {
|
if (sentences.length > 2) {
|
||||||
stripped = sentences.slice(0, 4).join(" ").trim();
|
stripped = sentences.slice(0, 2).join(" ").trim();
|
||||||
}
|
}
|
||||||
return stripped;
|
return stripped;
|
||||||
}
|
}
|
||||||
|
|
@ -1065,18 +1016,20 @@ const STYLES = `
|
||||||
.mi-input-bar::placeholder { color: var(--rs-text-muted); }
|
.mi-input-bar::placeholder { color: var(--rs-text-muted); }
|
||||||
|
|
||||||
.mi-mic-btn {
|
.mi-mic-btn {
|
||||||
background: none; border: none; cursor: pointer; padding: 2px 4px;
|
background: none; border: none; cursor: pointer; padding: 2px 6px;
|
||||||
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
|
font-size: 0.9rem; border-radius: 6px; transition: all 0.2s;
|
||||||
flex-shrink: 0; line-height: 1;
|
flex-shrink: 0; line-height: 1; opacity: 0.7;
|
||||||
}
|
}
|
||||||
.mi-mic-btn:hover, .mi-mic-btn:active { background: var(--rs-bg-hover); }
|
.mi-mic-btn:hover, .mi-mic-btn:active { background: var(--rs-bg-hover); opacity: 1; }
|
||||||
.mi-mic-btn.recording {
|
.mi-mic-btn.v-active { opacity: 1; }
|
||||||
animation: micPulse 1.5s infinite;
|
.mi-mic-btn.v-listening {
|
||||||
filter: saturate(2) brightness(1.1);
|
animation: voicePulseRed 1.5s infinite;
|
||||||
}
|
}
|
||||||
@keyframes micPulse {
|
.mi-mic-btn.v-thinking {
|
||||||
0%, 100% { transform: scale(1); }
|
animation: voiceSpinAmber 2s linear infinite;
|
||||||
50% { transform: scale(1.15); }
|
}
|
||||||
|
.mi-mic-btn.v-speaking {
|
||||||
|
animation: voicePulseCyan 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Rich panel ── */
|
/* ── Rich panel ── */
|
||||||
|
|
@ -1313,24 +1266,6 @@ const STYLES = `
|
||||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Voice mode buttons ── */
|
|
||||||
.mi-voice-btn, .mi-voice-panel-btn {
|
|
||||||
background: none; border: none; cursor: pointer; padding: 2px 4px;
|
|
||||||
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
|
|
||||||
flex-shrink: 0; line-height: 1; opacity: 0.7;
|
|
||||||
}
|
|
||||||
.mi-voice-btn:hover, .mi-voice-panel-btn:hover { background: var(--rs-bg-hover); opacity: 1; }
|
|
||||||
.mi-voice-btn.v-active, .mi-voice-panel-btn.v-active { opacity: 1; }
|
|
||||||
.mi-voice-btn.v-listening, .mi-voice-panel-btn.v-listening {
|
|
||||||
animation: voicePulseRed 1.5s infinite;
|
|
||||||
}
|
|
||||||
.mi-voice-btn.v-thinking, .mi-voice-panel-btn.v-thinking {
|
|
||||||
animation: voiceSpinAmber 2s linear infinite;
|
|
||||||
}
|
|
||||||
.mi-voice-btn.v-speaking, .mi-voice-panel-btn.v-speaking {
|
|
||||||
animation: voicePulseCyan 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes voicePulseRed {
|
@keyframes voicePulseRed {
|
||||||
0%, 100% { filter: drop-shadow(0 0 2px transparent); }
|
0%, 100% { filter: drop-shadow(0 0 2px transparent); }
|
||||||
50% { filter: drop-shadow(0 0 6px rgba(239,68,68,0.7)); }
|
50% { filter: drop-shadow(0 0 6px rgba(239,68,68,0.7)); }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue