// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CHAT_CSS_ID = "deepsearch-chat-css";
const RETRY_CODE = 4013;
const RETRY_MS = 200;
const RECONNECT_MS = 1500;
export class AppDeepsearchChat extends Component {
constructor() {
super();
this.ws = null;
this._shouldRun = true;
this._busy = false;
}
_ensureCss() {
if (document.getElementById(CHAT_CSS_ID)) return;
const link = document.createElement("link");
link.id = CHAT_CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/deepsearch.css");
document.head.appendChild(link);
}
connectedCallback() {
if (this._built) return;
this._built = true;
this._ensureCss();
this.uid = this.attr("uid");
this.chatPath = this.attr("chat-ws") || `/tools/deepsearch/${this.uid}/chat`;
this._build();
this._open();
}
disconnectedCallback() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_build() {
this.innerHTML = "";
this.log = document.createElement("div");
this.log.className = "ds-chat-log";
this.log.setAttribute("role", "log");
this.log.setAttribute("aria-live", "polite");
this.statusEl = document.createElement("div");
this.statusEl.className = "ds-chat-status";
this.statusEl.hidden = true;
this.form = document.createElement("form");
this.form.className = "ds-chat-input";
this.field = document.createElement("textarea");
this.field.className = "ds-chat-field";
this.field.rows = 1;
this.field.placeholder = "Ask about the gathered evidence...";
this.send = document.createElement("button");
this.send.type = "submit";
this.send.className = "ds-chat-send";
this.send.textContent = "Ask";
this.form.append(this.field, this.send);
this.append(this.log, this.statusEl, this.form);
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this._submit(this.field.value);
});
this.field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this._submit(this.field.value);
}
});
this.field.addEventListener("input", () => this._autosize());
}
_wsUrl() {
const scheme = location.protocol === "https:" ? "wss://" : "ws://";
return `${scheme}${location.host}${this.chatPath}`;
}
_open() {
this.ws = new WebSocket(this._wsUrl());
this.ws.addEventListener("message", (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch {
return;
}
this._onMessage(frame);
});
this.ws.addEventListener("close", (event) => {
if (event.code === RETRY_CODE && this._shouldRun) {
window.setTimeout(() => this._open(), RETRY_MS);
return;
}
if (this._shouldRun) {
this._setStatus("Reconnecting...");
window.setTimeout(() => this._open(), RECONNECT_MS);
}
});
this.ws.addEventListener("error", () => this._setStatus("Connection error."));
}
_onMessage(frame) {
if (frame.type === "ready") {
this._setStatus("");
this._renderHistory(frame.history || []);
} else if (frame.type === "status") {
this._setStatus(frame.text);
} else if (frame.type === "reply") {
this._busy = false;
this._setStatus("");
this._agent(frame.text, frame.citations);
} else if (frame.type === "error") {
this._busy = false;
this._setStatus("");
this._errorLine(frame.text);
}
}
_submit(raw) {
const text = (raw || "").trim();
if (!text || this._busy) return;
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this._errorLine("Not connected.");
return;
}
this._busy = true;
this._userLine(text);
this.field.value = "";
this._autosize();
this.ws.send(JSON.stringify({ type: "input", text }));
this._setStatus("Searching the research collection");
}
_renderHistory(messages) {
this.log.innerHTML = "";
messages.forEach((message) => {
if (message.role === "user") {
this._userLine(message.content);
} else if (message.role === "assistant" && message.content) {
this._agent(message.content, []);
}
});
}
_userLine(text) {
const row = document.createElement("div");
row.className = "ds-chat-msg user";
const bubble = document.createElement("div");
bubble.className = "ds-chat-bubble";
bubble.textContent = text;
row.appendChild(bubble);
this._append(row);
}
_agent(text, citations) {
const row = document.createElement("div");
row.className = "ds-chat-msg agent";
const bubble = document.createElement("div");
bubble.className = "ds-chat-bubble";
const content = document.createElement("dp-content");
content.setAttribute("data-render", "");
content.textContent = text;
bubble.appendChild(content);
if (citations && citations.length) {
const list = document.createElement("div");
list.className = "ds-chat-citations";
citations.forEach((cite) => {
const link = document.createElement("a");
link.href = cite.url;
link.target = "_blank";
link.rel = "noopener nofollow";
link.textContent = `[${cite.index}] ${cite.title}`;
list.appendChild(link);
});
bubble.appendChild(list);
}
row.appendChild(bubble);
this._append(row);
}
_errorLine(text) {
const row = document.createElement("div");
row.className = "ds-chat-msg error";
const bubble = document.createElement("div");
bubble.className = "ds-chat-bubble";
bubble.textContent = text;
row.appendChild(bubble);
this._append(row);
}
_append(node) {
const pinned = this.log.scrollHeight - this.log.scrollTop - this.log.clientHeight < 80;
this.log.appendChild(node);
if (pinned) this.log.scrollTop = this.log.scrollHeight;
}
_autosize() {
this.field.style.height = "auto";
this.field.style.height = `${Math.min(this.field.scrollHeight, 160)}px`;
}
_setStatus(text) {
this.statusEl.textContent = text || "";
this.statusEl.hidden = !text;
}
}
customElements.define("dp-deepsearch-chat", AppDeepsearchChat);