439f1a5d84
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>
211 lines
7.2 KiB
JavaScript
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);
|
|
}
|
|
})();
|