Files
LLeMbas/src/lembas/web/static/js/audio.js
T
Jaroslav Beneš 439f1a5d84 The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote
list.innerHTML before calling it. `list` is null until build() has run, so the
first `/` or `@` ever typed threw a TypeError and took the handler with it. The
menu has never appeared in any browser. That is why /compact "isn't there":
nothing was. I shipped it having only run `node --check`, which parses the file
happily.

So this also brings the thing that catches it: a DOM stub driven under node --
not committed, hard rule 1 stands, it is an instrument like curl. It reproduced
the crash in one run and immediately found two more: choosing a command from the
menu left `/help` sitting in the box so the next Enter ran it again, and Tab
completed nothing. Tab now completes and Enter runs, which is the split that
matters for a command taking an argument.

`.select--sm` was used three times and defined nowhere. I deleted the copy in
chat.css and left a comment saying it "is defined once, in app.css", where it
did not exist -- so those selects fell back to plain `.select`: width 100% in a
flex row where four siblings wanted the same, all of them shrinking together
until each was a few characters wide, and half a rem taller than everything
beside them. That was the whole of "the connection switch needs to be wider".

The connection and directory move to the topbar. They cannot change -- update_chat
refuses both with a 409 -- so they are facts about the chat, of a kind with the
Temporary badge, not controls on the message. The mode stays by the box.

Compaction says it is working. It makes a model call that takes seconds and had
no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the
Generation.status channel that says "Summarising earlier messages…" for the
automatic path cannot be borrowed, because it lives in the streaming bubble and
this endpoint refuses to run while any message is unfinished. The overflow menu
now runs the same code as /compact rather than posting for itself, so there is
one implementation, one spinner, and one place the endpoint's four carefully
written 409s finally reach somebody.

/effort, low medium high, per chat with a per-model default. It goes out twice
because there is no field that works everywhere: OpenAI and vLLM read
reasoning_effort, llama.cpp's own docs say other values "have no effect" and its
maintainer says the field "simply gets dropped without error or logging" -- what
reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once
an effort has been chosen, so a provider strict about unknown parameters sees
exactly the request it always did until somebody opts in. The control appears
only on a model marked `reasoning`, a flag that has existed since the beginning
with no reader at all.

Mentions and recognised commands are marked as you type -- a mirror behind the
textarea holding the same text with every character transparent, contributing
nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle
rather than a doubled glyph. A command is marked only when it resolves, so
`/thoughts on this` visibly is not one before you send it. And again in the
transcript, where user turns had no render step at all and now escape before
they inject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:17:04 +02:00

211 lines
7.2 KiB
JavaScript

/*
Dictation and read-aloud.
Both halves are progressive: without this file the composer and the message
bubbles still work, they simply have two buttons that do nothing. Neither
feature's markup is rendered at all unless an administrator has configured an
endpoint for it, so that state is rare rather than normal.
*/
(function () {
"use strict";
function notify(message, kind) {
if (window.lembas && window.lembas.notify) {
window.lembas.notify(message, { kind: kind || "info" });
}
}
/* --- Dictation ---------------------------------------------------------
MediaRecorder writes whatever container the browser prefers -- webm/opus
almost everywhere, mp4 on Safari. The file is passed upstream with the
type the browser reported rather than being converted here: whisper.cpp
and friends decode through ffmpeg and take all of them, and converting in
the browser would mean shipping an encoder. */
var recorder = null;
var chunks = [];
var micButton = null;
function setMicState(button, state) {
if (!button) return;
button.dataset.micState = state;
button.disabled = state === "working";
button.setAttribute(
"aria-label",
state === "recording" ? "Stop recording" : "Dictate a message"
);
button.title = button.getAttribute("aria-label");
}
function composerInput() {
return document.querySelector("[data-composer-input]");
}
function insertTranscript(text) {
var input = composerInput();
if (!input || !text) return;
// Appended rather than replacing: dictation is usually finishing a thought
// that was already half typed.
var existing = input.value.trim();
input.value = existing ? existing + " " + text : text;
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
input.focus();
input.selectionStart = input.selectionEnd = input.value.length;
}
function upload(blob, button) {
var body = new FormData();
// The extension only has to be something the server can name the part;
// the endpoint sniffs the container itself.
var extension = (blob.type.indexOf("mp4") !== -1) ? "mp4" : "webm";
body.append("file", blob, "dictation." + extension);
setMicState(button, "working");
fetch("/api/audio/transcribe", {
method: "POST",
body: body,
credentials: "same-origin",
})
.then(function (response) {
if (!response.ok) {
return response.json()
.catch(function () { return {}; })
.then(function (payload) {
throw new Error(payload.detail || "Transcription failed.");
});
}
return response.text();
})
.then(function (text) {
setMicState(button, "idle");
if (!text.trim()) {
notify("Nothing was heard in that recording.", "info");
return;
}
insertTranscript(text.trim());
})
.catch(function (error) {
setMicState(button, "idle");
notify(error.message || "Transcription failed.", "error");
});
}
function startRecording(button) {
/* getUserMedia is undefined on plain http, which a self-hosted install on
a LAN address often is. Saying so beats a button that silently does
nothing -- the fix is not something the page can apply for them. */
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia ||
typeof MediaRecorder === "undefined") {
notify(
"The microphone needs HTTPS or localhost. This page is served over " +
"plain HTTP, so the browser will not grant it.",
"error"
);
return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
chunks = [];
recorder = new MediaRecorder(stream);
micButton = button;
recorder.addEventListener("dataavailable", function (event) {
if (event.data && event.data.size) chunks.push(event.data);
});
recorder.addEventListener("stop", function () {
// Release the microphone immediately: leaving the track live keeps the
// browser's recording indicator on long after anyone is talking.
stream.getTracks().forEach(function (track) { track.stop(); });
var blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
recorder = null;
if (blob.size) upload(blob, button); else setMicState(button, "idle");
});
recorder.start();
setMicState(button, "recording");
}).catch(function () {
notify("The microphone could not be opened. Permission may be blocked.", "error");
});
}
function stopRecording() {
if (recorder && recorder.state !== "inactive") recorder.stop();
}
/* --- Reading a reply aloud ---------------------------------------------
One <audio> element for the whole page. Two replies talking over each
other is never what was wanted, and a shared element makes that
impossible rather than merely unlikely. */
var player = null;
var speaking = null;
function audioPlayer() {
if (!player) {
player = new Audio();
player.addEventListener("ended", function () { markSpeaking(null); });
player.addEventListener("error", function () {
if (speaking) notify("That reply could not be read out.", "error");
markSpeaking(null);
});
}
return player;
}
function markSpeaking(button) {
document.querySelectorAll("[data-speak]").forEach(function (el) {
el.classList.toggle("is-speaking", el === button);
});
speaking = button;
}
function speak(button) {
var element = audioPlayer();
if (speaking === button) {
element.pause();
markSpeaking(null);
return;
}
element.pause();
element.src = button.dataset.speak;
markSpeaking(button);
element.play().catch(function () {
/* Autoplay policies reject a play() the reader did not ask for. That is
the browser working as intended, so it is not reported as an error. */
markSpeaking(null);
});
}
/* --- Wiring ------------------------------------------------------------ */
document.addEventListener("click", function (event) {
var mic = event.target.closest("[data-mic]");
if (mic) {
event.preventDefault();
if (mic.dataset.micState === "recording") stopRecording();
else if (mic.dataset.micState === "idle") startRecording(mic);
return;
}
var speaker = event.target.closest("[data-speak]");
if (speaker) {
event.preventDefault();
speak(speaker);
}
});
/* A reply that has just finished streaming carries data-speak-auto, set only
on that one frame. Any swap can bring it in, so this watches them all and
clears the attribute after acting -- a later swap of the same bubble must
not start it again. */
function playArrivals() {
document.querySelectorAll("[data-speak-auto]").forEach(function (button) {
button.removeAttribute("data-speak-auto");
speak(button);
});
}
document.addEventListener("DOMContentLoaded", playArrivals);
if (document.body) {
document.body.addEventListener("htmx:afterSettle", playArrivals);
}
})();