Option 1 · JavaScript streaming

A chat bubble from one script tag

The client pastes a single self-contained snippet. It renders the bubble below and calls the Omnimas public API directly from the visitor's browser — no plugin, no PHP, no proxy.

Live preview

This is the running javascript streaming widget. Click the bubble to open it.

Paste this snippet

Replace PASTE_RAW_WIDGET_KEY with the site's key. Only paste it on a verified domain.

<div id="omnimas-chat">
  <div id="omnimas-chat-log" style="border:1px solid #ddd; padding:12px; min-height:120px;"></div>
  <form id="omnimas-chat-form" style="display:flex; gap:8px; margin-top:8px;">
    <input id="omnimas-chat-input" name="message" style="flex:1;" autocomplete="off" />
    <button type="submit">Send</button>
  </form>
</div>

<script>
(() => {
  const apiBase = "https://omnimas.jadranpro.ai";
  const transportMode = "streaming";
  const widgetKey = "PASTE_RAW_WIDGET_KEY";
  const visitorRef = `visitor-${Math.random().toString(36).slice(2)}`;
  const log = document.querySelector("#omnimas-chat-log");
  const form = document.querySelector("#omnimas-chat-form");
  const input = document.querySelector("#omnimas-chat-input");
  let sessionId = null;

  function append(label, text) {
    const row = document.createElement("p");
    row.textContent = `${label}: ${text}`;
    log.appendChild(row);
    return row;
  }

  function normalizeNewlines(text) {
    return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  }

  function parseFrame(frame) {
    let event = "message";
    const dataLines = [];

    frame.split("\n").forEach((line) => {
      if (!line || line.startsWith(":")) return;

      const separatorIndex = line.indexOf(":");
      const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
      let value = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
      if (value.startsWith(" ")) value = value.slice(1);

      if (field === "event") event = value;
      if (field === "data") dataLines.push(value);
    });

    return {
      event,
      data: dataLines.length ? JSON.parse(dataLines.join("\n")) : {}
    };
  }

  async function parseJson(response) {
    const text = await response.text();
    if (!text) return {};

    try {
      return JSON.parse(text);
    } catch (_error) {
      return {error: text};
    }
  }

  async function createSession() {
    const response = await fetch(`${apiBase}/api/public/chat/sessions`, {
      method: "POST",
      headers: {"content-type": "application/json"},
      body: JSON.stringify({
        widget_key: widgetKey,
        visitor_ref: visitorRef,
        page_url: window.location.href
      })
    });
    const data = await response.json();
    if (!response.ok) throw new Error(data.code || "session_failed");
    sessionId = data.session_id;
  }

  async function sendMessageStream(message, assistantRow) {
    const response = await fetch(`${apiBase}/api/public/chat/messages/stream`, {
      method: "POST",
      headers: {
        "accept": "text/event-stream",
        "content-type": "application/json"
      },
      body: JSON.stringify({
        widget_key: widgetKey,
        session_id: sessionId,
        message
      })
    });

    if (!response.ok) {
      const data = await parseJson(response);
      throw new Error(data.code || data.error || "message_failed");
    }

    if (!response.body) {
      const data = await sendMessageJson(message);
      assistantRow.textContent = `assistant: ${data.answer || "No answer returned."}`;
      return;
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    let answer = "";

    const dispatch = (frame) => {
      if (!frame.trim()) return;

      const {event, data} = parseFrame(frame.trim());

      if (event === "token") {
        answer += data.delta || "";
        assistantRow.textContent = `assistant: ${answer}`;
      }

      if (event === "done") {
        assistantRow.textContent = `assistant: ${data.answer || answer || "No answer returned."}`;
      }

      if (event === "error") {
        throw new Error(data.code || data.error || "stream_error");
      }
    };

    while (true) {
      const {value, done} = await reader.read();
      if (done) break;

      buffer = normalizeNewlines(buffer + decoder.decode(value, {stream: true}));
      const frames = buffer.split("\n\n");
      buffer = frames.pop() || "";
      frames.forEach(dispatch);
    }

    buffer = normalizeNewlines(buffer + decoder.decode());
    if (buffer.trim()) dispatch(buffer);
  }

  async function sendMessageJson(message) {
    const response = await fetch(`${apiBase}/api/public/chat/messages`, {
      method: "POST",
      headers: {"content-type": "application/json"},
      body: JSON.stringify({
        widget_key: widgetKey,
        session_id: sessionId,
        message
      })
    });

    const data = await parseJson(response);
    if (!response.ok) throw new Error(data.code || data.error || "message_failed");
    return data;
  }

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const message = input.value.trim();
    if (!message) return;

    append("visitor", message);
    input.value = "";
    input.disabled = true;
    const assistantRow = append("assistant", "");

    try {
      if (!sessionId) await createSession();
      if (transportMode === "classic") {
        const data = await sendMessageJson(message);
        assistantRow.textContent = `assistant: ${data.answer || "No answer returned."}`;
      } else {
        await sendMessageStream(message, assistantRow);
      }
    } catch (error) {
      if (!assistantRow.textContent.trim()) assistantRow.remove();
      append("error", error.message);
    } finally {
      input.disabled = false;
      input.focus();
    }
  });
})();
</script>

Notes

  • The widget key is browser-facing by design, but still a rotatable credential — keep it out of screenshots and tickets.
  • The page domain must be a verified domain for the Omnimas site, or requests fail with domain_not_allowed.
  • Streaming mode uses the public SSE/fetch endpoint. The non-streaming JSON endpoint remains available as a fallback.