Menu
Public repository / master
AkurAI-Framework / site/frontend/ai/chat/composer.js
Latest change f78c65c17425a60672d123a11f89e8211c141393 - feat(ai/chat): native-ESM port of assistant-ui chat primitives by Ólafur Búi Ólafsson
// composer.js — Composer primitive (native port of assistant-ui's Composer).
//
// The chat input: an auto-growing textarea, a Send button, Enter-to-send with
// Shift+Enter for a newline, a disabled state while the assistant streams, an
// optional attachment button (emits an event for the host to wire to uploads),
// and a voice-dictation button using the browser Web Speech API
// (`webkitSpeechRecognition` / `SpeechRecognition`). Voice is feature-detected:
// if the API is absent, the button is simply omitted — no errors, no no-op stub.
//
// Native ES module — pure DOM, zero dependencies.
//
// Public API:
// createComposer({ onSend, onAttach, placeholder, voice, lang }) -> handle
// handle.el the composer element
// handle.focus() focus the textarea
// handle.getValue() current text
// handle.setValue(s) set text + resize
// handle.clear() empty the textarea
// handle.setDisabled(bool) disable input + Send (streaming)
// onSend(text) is called when the user sends a non-empty message.
// onAttach() is called when the attachment button is clicked; the composer
// also dispatches a `chat:attach` event so a host can open a file picker.
function getSpeechRecognition() {
if (typeof window === "undefined") return null;
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
}
function createComposer(options = {}) {
const onSend = typeof options.onSend === "function" ? options.onSend : () => {};
const onAttach = typeof options.onAttach === "function" ? options.onAttach : null;
const placeholder = options.placeholder || "Send a message…";
const showAttach = options.attach !== false;
const wantVoice = options.voice !== false;
const lang = options.lang || "en-US";
const el = document.createElement("form");
el.className = "chat-composer";
el.setAttribute("autocomplete", "off");
const textarea = document.createElement("textarea");
textarea.className = "chat-composer-input";
textarea.placeholder = placeholder;
textarea.rows = 1;
textarea.setAttribute("aria-label", "Message");
const tools = document.createElement("div");
tools.className = "chat-composer-tools";
// Attachment button (wire to the existing blob upload later).
if (showAttach) {
const attachBtn = button("📎", "Attach a file", "chat-composer-attach");
attachBtn.addEventListener("click", () => {
el.dispatchEvent(new CustomEvent("chat:attach", { bubbles: true }));
if (onAttach) onAttach();
console.log("[chat] attach clicked");
});
tools.appendChild(attachBtn);
}
// Voice dictation — only mounted when the Web Speech API exists.
let recognition = null;
let listening = false;
const Speech = wantVoice ? getSpeechRecognition() : null;
if (Speech) {
const voiceBtn = button("🎤", "Dictate", "chat-composer-voice");
tools.appendChild(voiceBtn);
voiceBtn.addEventListener("click", () => {
if (listening) {
stopVoice();
return;
}
startVoice(voiceBtn);
});
var startVoice = function (btn) {
recognition = new Speech();
recognition.lang = lang;
recognition.interimResults = true;
recognition.continuous = false;
const base = textarea.value;
recognition.onresult = (event) => {
let transcript = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
textarea.value = (base ? base + " " : "") + transcript;
resize();
};
recognition.onend = () => {
listening = false;
btn.classList.remove("is-listening");
textarea.focus();
console.log("[chat] voice ended");
};
recognition.onerror = (e) => {
console.log("[chat] voice error", e && e.error);
};
try {
recognition.start();
listening = true;
btn.classList.add("is-listening");
console.log("[chat] voice started");
} catch (err) {
console.log("[chat] voice start failed", err);
}
};
var stopVoice = function () {
if (recognition) recognition.stop();
};
}
const sendBtn = document.createElement("button");
sendBtn.type = "submit";
sendBtn.className = "btn btn-primary btn-sm chat-composer-send";
sendBtn.textContent = "Send";
sendBtn.setAttribute("aria-label", "Send message");
tools.appendChild(sendBtn);
el.append(textarea, tools);
// ── behaviour ──────────────────────────────────────────────────────────────
function resize() {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
}
function submit() {
const value = textarea.value.trim();
if (!value || textarea.disabled) return;
textarea.value = "";
resize();
console.log("[chat] send", JSON.stringify(value.slice(0, 60)));
onSend(value);
}
textarea.addEventListener("input", resize);
textarea.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
});
el.addEventListener("submit", (event) => {
event.preventDefault();
submit();
});
return {
el,
textarea,
focus() {
textarea.focus();
},
getValue() {
return textarea.value;
},
setValue(text) {
textarea.value = String(text ?? "");
resize();
},
clear() {
textarea.value = "";
resize();
},
setDisabled(on) {
textarea.disabled = !!on;
sendBtn.disabled = !!on;
el.classList.toggle("is-disabled", !!on);
},
};
}
function button(glyph, label, cls) {
const b = document.createElement("button");
b.type = "button";
b.className = `btn btn-icon btn-icon-sm btn-ghost ${cls}`;
b.title = label;
b.setAttribute("aria-label", label);
b.textContent = glyph;
return b;
}
export { createComposer, getSpeechRecognition };
export default createComposer;