fix: voice — recorder codec chain with iOS mp4 fallback, audio-session unlock on voice gestures, unbounded image poll until completion
This commit is contained in:
parent
6760f34143
commit
8a03fbb689
3 changed files with 30 additions and 11 deletions
|
|
@ -9,7 +9,7 @@
|
|||
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
|
||||
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
||||
.assistant-status.error .assistant-dot { background:var(--red); }
|
||||
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
||||
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; }
|
||||
.assistant-layout > * { min-width:0; }
|
||||
.assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; }
|
||||
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
|
||||
|
|
@ -117,8 +117,8 @@
|
|||
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
|
||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
|
||||
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
|
||||
/* Sources own the full right column now that the image panel is gone */
|
||||
.assistant-side { display:flex; flex-direction:column; gap:12px; min-height:0; }
|
||||
/* Sources: a full-page right column; the panel fills the height and scrolls */
|
||||
.assistant-side { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; }
|
||||
.assistant-side .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
|
||||
.assistant-generated-image { display:grid; gap:8px; }
|
||||
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
|
||||
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
|
||||
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
|
||||
.assistant-sources { padding:10px 12px; display:flex; flex-direction:column; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
|
||||
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
|
||||
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
|
||||
.assistant-saved-chat { border:1px solid transparent; border-radius:10px; padding:7px 10px; background:transparent; display:flex; flex-direction:column; gap:1px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; min-width:0; }
|
||||
.assistant-saved-chat:hover { background:var(--g100, #f3f4f6); border-color:transparent; }
|
||||
|
|
@ -241,7 +241,8 @@
|
|||
@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } }
|
||||
|
||||
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
|
||||
body.assistant-workspace .assistant-layout { height: 100vh; }
|
||||
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; }
|
||||
.assistant-layout > * { min-height: 0; height: 100%; }
|
||||
body.assistant-workspace .assistant-main { min-height: 0; }
|
||||
body.assistant-workspace #assistant-chat-view { min-height: 0; }
|
||||
body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh; overflow-y: auto; }
|
||||
|
|
|
|||
|
|
@ -635,9 +635,14 @@ AudioRecorder.prototype.start = function() {
|
|||
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
|
||||
.then(function(stream) {
|
||||
self.stream = stream;
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
// 32kbps Opus is excellent for speech — small files, fast upload, great quality
|
||||
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
// Codec chain: Opus webm, plain webm, then mp4 (iOS Safari), then the browser default.
|
||||
var options = { audioBitsPerSecond: 32000 };
|
||||
var mime = '';
|
||||
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) mime = 'audio/webm;codecs=opus';
|
||||
else if (MediaRecorder.isTypeSupported('audio/webm')) mime = 'audio/webm';
|
||||
else if (MediaRecorder.isTypeSupported('audio/mp4')) mime = 'audio/mp4';
|
||||
if (mime) options.mimeType = mime;
|
||||
self.mediaRecorder = new MediaRecorder(stream, options);
|
||||
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
||||
self.mediaRecorder.start(1000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -897,6 +897,7 @@ import {
|
|||
}
|
||||
|
||||
function startAssistantMic() {
|
||||
unlockAudioPlayback();
|
||||
var input = document.getElementById('assistant-input');
|
||||
assistantMic.finalText = String(input && input.value || '');
|
||||
assistantMic.sessionFinals = '';
|
||||
|
|
@ -982,7 +983,21 @@ import {
|
|||
else conversationStartListening();
|
||||
}
|
||||
|
||||
// iOS/Safari blocks programmatic audio playback until a user gesture
|
||||
// unlocks the audio session. Unlock on every tap of a voice control.
|
||||
var AUDIO_UNLOCK_WAV = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
|
||||
function unlockAudioPlayback() {
|
||||
try {
|
||||
var probe = new Audio(AUDIO_UNLOCK_WAV);
|
||||
probe.muted = true;
|
||||
var p = probe.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(function() {});
|
||||
setTimeout(function() { try { probe.pause(); probe.src = ''; } catch (e) {} }, 250);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function conversationStartListening() {
|
||||
unlockAudioPlayback();
|
||||
var status = document.getElementById('assistant-voice-status');
|
||||
if (status) status.textContent = 'Listening…';
|
||||
conversationMode.listening = true;
|
||||
|
|
@ -1191,10 +1206,8 @@ import {
|
|||
exporter.invalidate();
|
||||
if (typeof hooks.onStatus === 'function') hooks.onStatus('Generating image…');
|
||||
// The job runs server-side; closing the popup never cancels it.
|
||||
var attempts = 0;
|
||||
var poll = function () {
|
||||
attempts += 1;
|
||||
if (attempts > 60) { if (typeof hooks.onError === 'function') hooks.onError('Image is still generating — it will appear in your images shortly.'); return; }
|
||||
// Keep polling until the job finishes; image models can take minutes.
|
||||
fetchAssistantImageJob(data.jobId).then(function (job) {
|
||||
if (!job || !job.success) { setTimeout(poll, 2500); return; }
|
||||
if (job.imageUrl) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue