v1.6.0

<bmx-chat>

A conversation, for an AI assistant or a support desk: answers written as they stream in, in Markdown with highlighted, copyable code, tables, cited sources and the steps the assistant took; files sent with a message; suggestions; asking again, editing and resending, rating an answer; and the whole conversation saved as Markdown, text or JSON.

25 properties · 7 events · 14 methods · 9 parts

Example

Show markup
<bmx-chat
  id="ex-chat"
  lang="en-GB"
  label="Support assistant"
  heading="How can I help?"
  description="Ask about orders, refunds or the API. This demonstration answers from the page itself - point the handler at your own service."
  assistant-name="Nova"
  suggestions="How do refunds work?,Show me the API in TypeScript,Compare the plans"
  attachments
  style="--bmx-chat-height: 34rem"
></bmx-chat>

<div class="row" style="margin-block-start: 1rem; gap: 0.5rem; flex-wrap: wrap">
  <button type="button" id="ex-chat-md">Save as Markdown</button>
  <button type="button" id="ex-chat-clear">Clear</button>
</div>

<script type="module">
  await customElements.whenDefined('bmx-chat');

  const chat = document.getElementById('ex-chat');

  // A stand-in for your own service: it streams a canned answer a few characters at a time.
  // In an application: `chat.handler = ({ messages, signal }) => fetch('/api/chat', { method: 'POST', body: JSON.stringify(messages), signal });`
  const answers = [
    {
      match: /refund/i,
      tools: [{ name: 'search', status: 'done', summary: 'Searched the help centre', detail: 'query: "refund policy"\n3 articles found' }],
      sources: [
        { title: 'Refunds and returns', url: 'https://binarymission.co.uk/support.html', snippet: 'Refunds are paid within 30 days of the return arriving.' },
        { title: 'Licence terms', url: 'https://binarymission.co.uk/eula.html' },
      ],
      text: 'Refunds are paid to the card you used, **within 30 days** of the return arriving [1].\n\n1. Open *Orders* and choose the order.\n2. Press **Return items** and pick a reason.\n3. Print the label and send the parcel.\n\nSoftware licences follow the licence terms instead [2].',
    },
    {
      match: /api|typescript|code/i,
      text: 'Here is the whole round trip in TypeScript:\n\n```ts\nconst chat = document.querySelector("bmx-chat");\nchat.handler = async function* ({ messages, signal }) {\n  const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify(messages), signal });\n  // Stream the body as it arrives.\n  const reader = res.body.getReader();\n  const decoder = new TextDecoder();\n  for (;;) {\n    const { value, done } = await reader.read();\n    if (done) break;\n    yield decoder.decode(value, { stream: true });\n  }\n};\n```\n\nThe chat calls it for every message and **Stop** aborts the `signal`.',
    },
    {
      match: /plan|compare|price/i,
      text: '| Plan | Components | Source licence |\n| --- | --- | --- |\n| Web | 43 | Separate |\n| Pro | 54 | Separate |\n| Gold | All | Separate |\n\nEvery plan includes the same support; Gold adds the grids, canvas and logs.',
    },
  ];

  chat.handler = async function* ({ text, signal, update }) {
    const answer = answers.find(a => a.match.test(text)) ?? { text: `You asked: *${text.replace(/[*_`]/g, '')}*. Try one of the suggestions to see sources, code and tables.` };
    if (answer.tools) {
      update({ tools: answer.tools.map(t => ({ ...t, status: 'running' })) });
      await new Promise(r => setTimeout(r, 700));
      update({ tools: answer.tools });
    }
    if (answer.sources) update({ sources: answer.sources });
    for (let i = 0; i < answer.text.length; i += 6) {
      if (signal.aborted) return;
      await new Promise(r => setTimeout(r, 18));
      yield answer.text.slice(i, i + 6);
    }
  };

  document.getElementById('ex-chat-md').addEventListener('click', () => chat.download('md', 'support-conversation'));
  document.getElementById('ex-chat-clear').addEventListener('click', () => chat.clear());
</script>

The chat holds no model and sends nothing anywhere. Give it a handler that asks your own service - your server, or a model you host - and returns the reply as text, a stream or a fetch response; the chat calls it for each message and streams what comes back. Or leave the handler out, listen for bmxChatSend, and write replies with startReply, appendToMessage and finishReply.

chat.handler = async function* ({ messages, signal }) { const res = await fetch('/my/chat', { method: 'POST', body: JSON.stringify(messages), signal }); yield* readMyStream(res); };

Answers are sanitised before they are shown, and images in them are left out unless allow-images is set, so an answer cannot load anything.

Properties

PropertyAttributeTypeDefaultDescription
accept accept string — The files allowed, as an <input type="file"> accept ("image/*,.pdf").
allowImages allow-images boolean false Show images an answer's Markdown points to. Off by default, so an answer cannot load anything.
assistantAvatar assistant-avatar string — The assistant's picture. Default: a mark drawn by the library.
assistantName assistant-name string — The assistant's name.
attachments attachments boolean false Let the reader send files.
busy busy boolean false A reply is being written. Set by the chat while its handler runs; set it yourself when writing replies without one.
description description string — A line under the title.
disabled disabled boolean false Show the text box, but disabled.
feedback feedback boolean true Offer good and poor buttons on answers.
handler property only BmxChatHandler — Writes each reply: given the conversation and a signal, returns text, a stream, or a fetch response.
heading heading string — A title shown before the first message ("How can I help?").
label label string — What the conversation is, for screen readers.
locale locale string — A BCP 47 locale for times and dates. Default: the page's lang.
maxAttachmentSize max-attachment-size number 20 * 1024 * 1024 The largest file, in bytes. Default 20 MB.
maxAttachments max-attachments number 5 At most this many files per message.
maxLength max-length number — At most this many characters in a message.
messages messages readonly BmxChatMessage[] | string [] The conversation. JSON in markup. Updated as messages are sent and answered.
placeholder placeholder string — Shown in the text box while it is empty.
readonly readonly boolean false Show the conversation without the text box.
sendOnEnter send-on-enter boolean true Enter sends (Shift+Enter is a new line). Off: Ctrl+Enter or Cmd+Enter sends.
strings strings Partial<BmxChatStrings> | string — Words the chat shows or says: any of BmxChatStrings. JSON in markup.
suggestions suggestions readonly string[] | string — Messages the reader can send with one click: a list, or comma-separated in markup.
userAvatar user-avatar string — The reader's picture.
userName user-name string — The reader's name, on their own messages.
voice voice boolean false Offer a microphone button that writes what is said, using the browser's speech recognition (some browsers send the audio to their maker's service).

Events

EventDetailDescription
bmxChatError BmxChatErrorDetail A reply failed.
bmxChatFeedback BmxChatFeedbackDetail The reader rated an answer.
bmxChatMessagesChange BmxChatMessagesDetail The conversation changed. messages is the whole new list.
bmxChatReply BmxChatMessageDetail A reply was finished.
bmxChatRetry BmxChatMessageDetail The reader asked for an answer again (with no handler, write it yourself).
bmxChatSend BmxChatSendDetail The reader sent a message (cancelable).
bmxChatStop BmxChatMessageDetail | null The reader pressed Stop.

Methods

MethodSignatureDescription
addMessage addMessage(message: Partial<BmxChatMessage>) => Promise<string> Adds a message (any role) and returns its id.
appendToMessage appendToMessage(id: string, text: string) => Promise<void> Adds text to the end of a message, as a reply streams in.
clear clear() => Promise<void> Empties the conversation.
download download(format?: "md" | "txt" | "json", filename?: string) => Promise<void> Saves the conversation as md, txt or json.
finishReply finishReply(id: string, status?: "done" | "error" | "stopped", error?: string) => Promise<void> Ends a reply started with startReply.
getMessages getMessages() => Promise<readonly BmxChatMessage[]> The conversation.
scrollToLatest scrollToLatest() => Promise<void> Scrolls to the latest message.
send send(text: string, files?: readonly File[]) => Promise<void> Sends a message as the reader, as if typed. Resolves when its reply (if any) is done.
setFocus setFocus() => Promise<void> Moves focus to the text box.
startReply startReply(message?: Partial<BmxChatMessage>) => Promise<string> Starts a reply written by the application (with no handler): returns its id, and marks the chat busy.
stop stop() => Promise<void> Stops the reply being written.
toMarkdown toMarkdown() => Promise<string> The conversation as Markdown.
toText toText() => Promise<string> The conversation as plain text.
updateMessage updateMessage(id: string, patch: Partial<BmxChatMessage>) => Promise<void> Changes a message.

CSS shadow parts

PartDescription
avatar an author's picture or initials.
bubble a message's text.
composer where the reader writes.
empty what shows before the first message.
input the text box.
log the conversation.
message one message.
send the send (and stop) button.
suggestions the suggested messages.

CSS custom properties

PropertyDescription
--bmx-chat-background Behind the conversation.
--bmx-chat-bubble Everyone else's messages.
--bmx-chat-height How tall the chat is. Default 32rem.
--bmx-chat-max-width The widest a message grows. Default 46rem.
--bmx-chat-user-bubble The reader's own messages.
--bmx-chat-user-text Text on the reader's messages.