AkurAI Build
Menu

Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8

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;