AkurAI Build
Menu

Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f

AkurAI-Framework / site/frontend/ai/chat/actionbar.js

Latest change f78c65c17425a60672d123a11f89e8211c141393 - feat(ai/chat): native-ESM port of assistant-ui chat primitives by Ólafur Búi Ólafsson

// actionbar.js — ActionBar primitive (native port of assistant-ui's ActionBar).
//
// A row of per-message actions: copy, retry, edit. The primitive makes no
// backend assumptions — it dispatches a `chat:action` CustomEvent on its root
// element (bubbling) and also invokes an optional `onAction(name, detail)`
// callback. Consumers decide what copy/retry/edit actually do.
//
// Native ES module — pure DOM, zero dependencies.
//
// Public API:
//   createActionBar({ actions, message, onAction }) -> handle
//     handle.el                the action-bar element
//     handle.setVisible(bool)  show/hide the bar
//   actions defaults to ["copy", "retry", "edit"]; pass a subset to trim it.
//   The "copy" action copies the message's text to the clipboard itself (best
//   effort) and still emits the event so callers can react (e.g. toast).

const ICONS = {
  copy: "⧉",
  retry: "↻",
  edit: "✎",
};

const TITLES = {
  copy: "Copy message",
  retry: "Retry",
  edit: "Edit",
};

function createActionBar(options = {}) {
  const actions = Array.isArray(options.actions) && options.actions.length
    ? options.actions
    : ["copy", "retry", "edit"];
  const message = options.message || null;
  const onAction = typeof options.onAction === "function" ? options.onAction : null;

  const el = document.createElement("div");
  el.className = "chat-actionbar";
  el.setAttribute("role", "toolbar");
  el.setAttribute("aria-label", "Message actions");

  for (const name of actions) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "btn btn-icon btn-icon-sm btn-ghost chat-action";
    btn.dataset.action = name;
    btn.title = TITLES[name] || name;
    btn.setAttribute("aria-label", TITLES[name] || name);
    btn.textContent = ICONS[name] || name;
    btn.addEventListener("click", () => fire(name, btn));
    el.appendChild(btn);
  }

  function fire(name, btn) {
    const detail = { action: name, message, text: message ? message.getText() : undefined };

    if (name === "copy" && detail.text != null) {
      copyText(detail.text).then((ok) => {
        if (ok) flash(btn, "✓");
        console.log("[chat] copy", ok ? "ok" : "failed");
      });
    }

    el.dispatchEvent(new CustomEvent("chat:action", { detail, bubbles: true }));
    if (onAction) onAction(name, detail);
  }

  return {
    el,
    setVisible(on) {
      el.classList.toggle("is-hidden", !on);
    },
  };
}

function flash(btn, glyph) {
  const original = btn.textContent;
  btn.textContent = glyph;
  setTimeout(() => {
    btn.textContent = original;
  }, 1100);
}

function copyText(text) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    return navigator.clipboard.writeText(text).then(
      () => true,
      () => false
    );
  }
  // Fallback for non-secure contexts: a throwaway textarea + execCommand.
  try {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    document.body.removeChild(ta);
    return Promise.resolve(ok);
  } catch {
    return Promise.resolve(false);
  }
}

export { createActionBar };
export default createActionBar;