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.