import { state } from "./state.js";
import { t, applyI18nStatic, LANG_KEY, I18N } from "./i18n/index.js";
/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */
const PAGE_SIZE = 35;
/** Initial / page size for session history (older messages load on scroll-up). */
const CHAT_MESSAGES_FETCH_LIMIT = 80;
/** Chat standalone: separate keys from /admin so two accounts can stay signed in on the same origin. */
const AUTH_TOKEN_KEY = "ops_chat_token";
const AUTH_SESSION_KEY = "ops_chat_session";
/** 与 URL 中 ?session_id= 联动:切换登录用户后丢弃旧 session,避免误开上一账号的链接会话 */
const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope";
const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref";
const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode";
const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode";
const CHAT_EXECUTION_MODE_KEY = "ops_chat_execution_mode";
const CHAT_USER_MENU_MODE_KEY = "ops_chat_user_menu_mode";
const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle";
const EXECUTION_MODE_AGENT = "agent";
const EXECUTION_MODE_PLAN = "plan";
/** Default on: reasoning/tool fold matches streamed behavior; new browsers have no localStorage yet. */
const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = true;
const REASONING_BLOCK_MAX_CHARS = 12000;
const CHAT_ENABLE_WIKI_EVENT_POLLER = false;
function _toolSummaryTitle(role) {
const r = String(role || "").toLowerCase();
if (r === "tool" || r === "function") return t("chat.toolResult");
return t("chat.toolCall");
}
function _normalizeEventType(v) {
return String(v || "").trim().toLowerCase();
}
function _isAssistantBodyEventType(eventType) {
const et = _normalizeEventType(eventType);
return !et || et === "assistant_text" || et === "assistant" || et === "scheduled_reminder";
}
function _parseEventPayload(raw) {
let ep = raw;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
return ep && typeof ep === "object" && !Array.isArray(ep) ? ep : null;
}
function _isScheduledProactiveMessage(m) {
const eventType = _normalizeEventType(m && m.event_type);
if (eventType === "scheduled_reminder") return true;
const ep = _parseEventPayload(m && m.event_payload);
if (ep && ep.scheduled_proactive) return true;
const rc = String((ep && ep.reasoning_content) || "").trim();
if (!rc) return false;
if (!/定时主动提醒|定时任务模式|scheduled reminder|proactive reminder/i.test(rc)) return false;
return eventType === "assistant_text" || eventType === "assistant" || !eventType;
}
function _messageTurnUuid(m) {
return String((m && m.turn_uuid) || "").trim();
}
function _pushScheduledAssistantRow(rows, m, content, eventType) {
const body = String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (!body && !attsParsed.length) return;
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
rows.push({
role: "assistant",
content: body,
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: m && m.attachments ? m.attachments : null,
_items: [piece],
_message_ids: m && m.id != null ? [m.id] : [],
});
}
function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
const suffix =
raw.length > REASONING_BLOCK_MAX_CHARS
? `\n\n[truncated ${raw.length - REASONING_BLOCK_MAX_CHARS} chars to keep UI responsive]`
: "";
const box = document.createElement("div");
box.className = "chat-msg__reasoning-block";
box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") }));
const pre = el("pre", { class: "chat-msg__reasoning-pre", text: (clipped + suffix) || "—" });
box.appendChild(pre);
if (raw.length > REASONING_BLOCK_MAX_CHARS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.style.marginTop = "6px";
btn.textContent = t("chat.expandFull");
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = t("chat.loadingDots");
requestAnimationFrame(() => {
pre.textContent = raw || "—";
btn.remove();
});
});
box.appendChild(btn);
}
return box;
}
function _appendCollapsedBundle(inner, items) {
const list = Array.isArray(items) ? items : [];
if (!list.length) return;
const det = document.createElement("details");
det.className = "chat-msg__reasoning";
const sum = document.createElement("summary");
sum.textContent = t("reasoning.summary");
det.appendChild(sum);
for (const it of list) {
const title = String((it && it.title) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text));
}
inner.appendChild(det);
}
function _appendAssistantTextSegments(inner, rawText, collapsedItems) {
const sourceText = state.adminChatShowToolOutput
? decodeEscapedNewlines(rawText)
: stripReasoningTagsFromText(decodeEscapedNewlines(rawText), { mode: "strict" });
const segs = parseReasoningSegments(sourceText);
const onlyText = segs.length === 1 && segs[0].type === "text";
if (onlyText) {
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(segs[0].text) }));
return;
}
for (const seg of segs) {
if (seg.type === "text") {
let body = String(seg.text || "");
const prev = inner.lastElementChild;
if (prev && prev.classList && prev.classList.contains("chat-msg__reasoning")) {
body = body.replace(/^\s+/, "");
}
if (!body.trim()) continue;
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(body) }));
} else {
if (!state.adminChatShowToolOutput) continue;
if (Array.isArray(collapsedItems)) {
collapsedItems.push({ title: t("reasoning.summary"), text: seg.text || "—" });
} else {
_appendCollapsedBundle(inner, [{ title: t("reasoning.summary"), text: seg.text || "—" }]);
}
}
}
}
function _normFoldDedupText(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/** True when ``processText`` is already covered by text already queued for the reasoning fold. */
function _foldProcessTextRedundant(processText, collapsedItems) {
const p = _normFoldDedupText(processText);
if (!p) return true;
const hay = (Array.isArray(collapsedItems) ? collapsedItems : [])
.map((x) => _normFoldDedupText((x && x.text) || ""))
.join("\n\n");
if (!hay) return false;
if (hay.includes(p)) return true;
return false;
}
/**
* Prefer a single render of each attachment_id inside one aggregated bubble.
* When both generate + save_deliverable_attachment persist the same id, keep the
* deliverable row (or the first occurrence if none is marked deliverable).
*/
function _preferredAttachmentOwnerById(items) {
const preferred = new Map();
(Array.isArray(items) ? items : []).forEach((it, itemIdx) => {
const list = parseAttachments(it && it.attachments);
for (const att of list) {
if (!att || typeof att !== "object") continue;
const aid = String(att.attachment_id || att.attachmentId || "")
.trim()
.toLowerCase();
if (!aid) continue;
const prev = preferred.get(aid);
if (prev === undefined) {
preferred.set(aid, itemIdx);
continue;
}
if (att.deliverable === true) {
const prevAtts = parseAttachments((items[prev] && items[prev].attachments) || null);
const prevAtt = prevAtts.find((a) => {
const id = String((a && (a.attachment_id || a.attachmentId)) || "")
.trim()
.toLowerCase();
return id === aid;
});
if (!(prevAtt && prevAtt.deliverable === true)) preferred.set(aid, itemIdx);
}
}
});
return preferred;
}
function _attachmentsForBubbleItem(raw, itemIdx, preferredById) {
const list = parseAttachments(raw);
if (!list.length) return null;
const kept = [];
for (const att of list) {
if (!att || typeof att !== "object") continue;
const aid = String(att.attachment_id || att.attachmentId || "")
.trim()
.toLowerCase();
if (aid && preferredById instanceof Map) {
const owner = preferredById.get(aid);
if (owner != null && owner !== itemIdx) continue;
}
kept.push(att);
}
return kept.length ? kept : null;
}
async function _buildAggregatedAssistantBubble(tsIso, items) {
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
const preferredAttOwner = _preferredAttachmentOwnerById(items);
const itemList = Array.isArray(items) ? items : [];
for (let itemIdx = 0; itemIdx < itemList.length; itemIdx++) {
const it = itemList[itemIdx];
const kind = String((it && it.kind) || "");
const text = String((it && it.text) || "");
const filteredAtts = _attachmentsForBubbleItem(it && it.attachments, itemIdx, preferredAttOwner);
const hasInlineAtt = Array.isArray(filteredAtts) && filteredAtts.length > 0;
if (
!text.trim() &&
!(kind === "tool_result" && hasInlineAtt) &&
!(kind === "assistant_text" && hasInlineAtt)
) {
continue;
}
if (kind === "assistant_text") {
const aet = String((it && it.assistantEventType) || "assistant_text").toLowerCase();
if (aet === "tool_call") {
// Main-channel process notes belong in the reasoning fold only — never in visible body text.
if (state.adminChatShowToolOutput) {
const t0 = String(text || "").trim();
if (t0 && !_foldProcessTextRedundant(t0, collapsedItems)) {
collapsedItems.push({ title: t("reasoning.processNotes"), text: t0 });
}
}
const attsAssistant = await renderAttachmentsEl(filteredAtts);
if (attsAssistant) inner.appendChild(attsAssistant);
} else {
_appendAssistantTextSegments(inner, text, collapsedItems);
const attsAssistant = await renderAttachmentsEl(filteredAtts);
if (attsAssistant) inner.appendChild(attsAssistant);
}
} else if (kind === "reasoning") {
if (state.adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text });
} else if (kind === "tool_call") {
if (state.adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool_call"), text });
} else if (kind === "tool_result") {
if (state.adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text });
// 支持两类附件:base64(image/input_image)和引用型(image_ref)。
// image_ref 需要异步拉取 blob,因此改为走 renderAttachmentsEl()。
// Same attachment_id from generate + save_deliverable is shown once (prefer deliverable).
const attsEl = await renderAttachmentsEl(filteredAtts);
if (attsEl) inner.appendChild(attsEl);
} else {
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
}
}
if (collapsedItems.length) {
const det = document.createElement("details");
det.className = "chat-msg__reasoning";
const sum = document.createElement("summary");
sum.textContent = t("reasoning.summary");
det.appendChild(sum);
for (const it of collapsedItems) {
const title = String((it && it.title) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text));
}
inner.insertBefore(det, inner.firstChild);
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
!!inner.querySelector(".chat-att-wrap, img.chat-att-img, a.chat-att-ref__link, a.chat-att-ref__action, .chat-att-chip") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
function _buildRenderRows(msgs) {
const src = Array.isArray(msgs) ? msgs : [];
const ordered = src
.map((m, idx) => ({ m, idx }))
.sort((a, b) => {
const ta = Date.parse(String((a.m && a.m.timestamp) || "")) || 0;
const tb = Date.parse(String((b.m && b.m.timestamp) || "")) || 0;
if (ta !== tb) return ta - tb;
const ia = parseInt(String((a.m && a.m.id) || "0"), 10);
const ib = parseInt(String((b.m && b.m.id) || "0"), 10);
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
return a.idx - b.idx;
})
.map((x) => x.m);
const rows = [];
let agg = null;
const flush = () => {
if (!agg) return;
if (!Array.isArray(agg._items) || !agg._items.length) {
agg = null;
return;
}
rows.push(agg);
agg = null;
};
for (const m of ordered) {
const role = String((m && m.role) || "").toLowerCase();
const content = String((m && m.content) || "");
const eventType = _normalizeEventType(m && m.event_type);
if (role === "user") {
flush();
rows.push(m);
continue;
}
if (role === "assistant" || role === "tool" || role === "function") {
const turnUuid = _messageTurnUuid(m);
if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) {
flush();
}
if (role === "assistant" && _isScheduledProactiveMessage(m)) {
flush();
_pushScheduledAssistantRow(rows, m, content, eventType);
continue;
}
if (!agg) {
agg = {
role: "assistant",
content: "",
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: null,
_items: [],
_message_ids: [],
_turn_uuid: turnUuid,
};
} else if (turnUuid && !agg._turn_uuid) {
agg._turn_uuid = turnUuid;
}
if (m && m.id != null) agg._message_ids.push(m.id);
if (role === "assistant") {
// thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows).
const ep = _parseEventPayload(m && m.event_payload);
if (ep) {
const rc = String(ep.reasoning_content || "").trim();
if (rc) {
agg._items.push({ kind: "reasoning", text: rc });
}
}
if (eventType === "reasoning") {
if (String(content || "").trim()) {
agg._items.push({ kind: "reasoning", text: content });
}
} else if (eventType === "tool_call") {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = { kind: "assistant_text", text: content, assistantEventType: "tool_call" };
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
} else if (_isAssistantBodyEventType(eventType)) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
}
const tc = m.tool_calls;
if (tc != null && tc !== "") {
let line = "";
try {
line = typeof tc === "string" ? tc : JSON.stringify(tc, null, 0);
} catch (_) {
line = String(tc);
}
if (line) agg._items.push({ kind: "tool_call", text: line });
}
if (m.attachments) agg.attachments = m.attachments;
} else {
const hasToolText = !!String(content || "").trim();
const toolAtts = parseAttachments(m && m.attachments);
if (hasToolText || toolAtts.length) {
const piece = { kind: "tool_result", text: content };
if (toolAtts.length) piece.attachments = toolAtts;
else if (m.attachments) piece.attachments = m.attachments;
agg._items.push(piece);
if (m.attachments) agg.attachments = m.attachments;
}
}
continue;
}
flush();
rows.push(m);
}
flush();
return rows;
}
/** True when persisted history does not show a completed assistant reply for the latest turn (e.g. ends with user only). */
function _needsWsTextFallbackFromRenderRows(renderRows) {
const rows = Array.isArray(renderRows) ? renderRows : [];
if (!rows.length) return true;
const last = rows[rows.length - 1];
const lr = String((last && last.role) || "").toLowerCase();
if (lr === "user") return true;
if (lr !== "assistant") return true;
const items = last && Array.isArray(last._items) ? last._items : [];
for (const it of items) {
const k = String((it && it.kind) || "").toLowerCase();
if (k === "assistant_text" && String((it && it.text) || "").trim()) return false;
if (k === "reasoning" && String((it && it.text) || "").trim()) return false;
if (k === "tool_result") {
if (String((it && it.text) || "").trim()) return false;
if (Array.isArray(it.attachments) && it.attachments.length) return false;
}
}
if (String((last && last.content) || "").trim()) return false;
return true;
}
function interactionModeLabel(mode) {
const m = String(mode || "").toLowerCase();
if (m === "expert") return t("chat.modeExpertShort");
return t("chat.modeComprehensiveShort");
}
function specialistLabel(specialist) {
const s = String(specialist || "").toLowerCase();
// Legacy manager / manager_self / main aliases display as generalist.
if (s === "manager" || s === "manager_self" || s === "main" || s === "comprehensive") {
return t("chat.specialistGeneralistShort");
}
if (s === "ops") return t("chat.specialistOpsShort");
if (s === "image") return t("chat.specialistImageShort");
if (s === "video") return t("chat.specialistVideoShort");
if (s === "memory") return t("chat.specialistMemoryShort");
return t("chat.specialistGeneralistShort");
}
function memoryModeShortLabel(memoryMode) {
const mm = String(memoryMode || "default").toLowerCase();
if (mm === "store_only") return t("chat.memoryModeStoreOnlyShort");
return t("chat.memoryModeDefaultShort");
}
function _ensureToastHost() {
let host = document.querySelector(".chat-toast-host");
if (host) return host;
host = el("div", { class: "chat-toast-host" });
document.body.appendChild(host);
return host;
}
function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
const host = _ensureToastHost();
const node = el("div", {
class: kind === "error" ? "chat-toast chat-toast--error" : "chat-toast",
text: String(text || ""),
});
host.appendChild(node);
const kill = () => {
try {
node.style.opacity = "0";
node.style.transform = "translateY(6px)";
} catch (_) {}
setTimeout(() => {
try {
node.remove();
} catch (_) {}
}, 220);
};
setTimeout(kill, Math.max(1200, Math.min(Number(ttlMs || 4200), 20000)));
return node;
}
let _jobsPanelPollTimer = null;
let _jobsBadgePollTimer = null;
function _jobStatusLabel(status) {
const s = String(status || "").trim().toLowerCase();
const key = `chat.jobsStatus.${s}`;
const labeled = t(key);
return labeled === key ? s || "-" : labeled;
}
function _fmtJobTs(ts) {
const n = Number(ts || 0);
if (!n) return "-";
try {
return new Date(n * 1000).toLocaleString(state.currentLang === "zh" ? "zh-CN" : "en-US");
} catch (_) {
return String(n);
}
}
function updateJobsBadge(runningCount) {
const n = Math.max(0, parseInt(String(runningCount || 0), 10) || 0);
if (state.jobsBtnLabelEl) {
state.jobsBtnLabelEl.textContent = t("chat.jobs");
}
if (!state.jobsBadgeEl) return;
state.jobsBadgeEl.textContent = String(n);
state.jobsBadgeEl.hidden = false;
state.jobsBadgeEl.setAttribute("aria-label", t("chat.jobsRunning", { n }));
if (n > 0) {
state.jobsBadgeEl.classList.add("chat-jobs-badge--hot");
state.jobsBadgeEl.classList.remove("chat-jobs-badge--idle");
} else {
state.jobsBadgeEl.classList.remove("chat-jobs-badge--hot");
state.jobsBadgeEl.classList.add("chat-jobs-badge--idle");
}
const btn = state.jobsBadgeEl.closest && state.jobsBadgeEl.closest(".chat-nav__jobs");
if (btn) {
if (n > 0) btn.classList.add("chat-nav__jobs--active");
else btn.classList.remove("chat-nav__jobs--active");
btn.title = t("chat.jobsRunning", { n });
}
}
async function refreshJobsBadge() {
try {
const r = await apiGet("/admin/api/chat/jobs?limit=50");
updateJobsBadge(r && r.running_count);
} catch (_) {}
}
function startJobsBadgePoller() {
stopJobsBadgePoller();
refreshJobsBadge();
// Keep sidebar count fresh without opening the panel.
_jobsBadgePollTimer = setInterval(refreshJobsBadge, 4000);
}
function stopJobsBadgePoller() {
if (_jobsBadgePollTimer) {
clearInterval(_jobsBadgePollTimer);
_jobsBadgePollTimer = null;
}
}
async function openBackgroundJobsPanel() {
document.querySelectorAll(".chat-jobs-backdrop").forEach((n) => n.remove());
if (_jobsPanelPollTimer) {
clearInterval(_jobsPanelPollTimer);
_jobsPanelPollTimer = null;
}
const backdrop = el("div", {
class: "chat-confirm-backdrop chat-jobs-backdrop u-z-modal",
});
const card = el("div", {
class: "chat-confirm-card chat-jobs-card u-jobs-card",
});
const title = el("div", { class: "card__title", text: t("chat.jobsTitle") });
const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsClose") });
const refreshBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsRefresh") });
const killAllBtn = el("button", {
type: "button",
class: "btn btn--danger",
text: t("chat.jobsKillAll"),
});
const head = el("div", { class: "row u-row-between" }, [
title,
el("div", { class: "row u-gap-8" }, [refreshBtn, killAllBtn, closeBtn]),
]);
const hint = el("div", { class: "muted u-hint", text: t("chat.jobsHint") });
const summary = el("div", { class: "muted u-fs-12" });
const list = el("div", {
class: "chat-jobs-list u-jobs-list",
});
const close = () => {
if (_jobsPanelPollTimer) {
clearInterval(_jobsPanelPollTimer);
_jobsPanelPollTimer = null;
}
try {
backdrop.remove();
} catch (_) {}
refreshJobsBadge();
};
closeBtn.addEventListener("click", close);
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) close();
});
const paint = async () => {
list.innerHTML = "";
list.appendChild(el("div", { class: "muted", text: t("chat.loading") }));
try {
const r = await apiGet("/admin/api/chat/jobs?limit=50");
const jobs = Array.isArray(r && r.jobs) ? r.jobs : [];
const running = Number((r && r.running_count) || 0);
updateJobsBadge(running);
summary.textContent = t("chat.jobsRunning", { n: running }) + ` · total ${Number((r && r.total) || jobs.length)}`;
list.innerHTML = "";
if (!jobs.length) {
list.appendChild(el("div", { class: "muted", text: t("chat.jobsEmpty") }));
return;
}
for (const job of jobs) {
const status = String(job.status || "");
const runningJob = status === "running";
const row = el("div", { class: `chat-jobs-row chat-jobs-row--${status || "unknown"}` });
const top = el("div", { class: "chat-jobs-row__top" }, [
el("span", { class: "chat-jobs-row__name", text: String(job.name || job.job_id || "-") }),
el("span", { class: `chat-jobs-pill chat-jobs-pill--${status}`, text: _jobStatusLabel(status) }),
]);
const meta = el(
"div",
{ class: "chat-jobs-row__meta muted" },
[
el("div", { text: `id: ${job.job_id || "-"}` }),
el("div", { text: `pid: ${job.pid != null ? job.pid : "-"} · timeout: ${job.timeout_s || "-"}s` }),
el("div", { text: `created: ${_fmtJobTs(job.created_at)} · finished: ${_fmtJobTs(job.finished_at)}` }),
el("div", {
class: "chat-jobs-row__cmd",
text: String(job.command || ""),
title: String(job.command || ""),
}),
],
);
const actions = el("div", { class: "chat-jobs-row__actions row u-gap-8" });
if (runningJob) {
const killBtn = el("button", {
type: "button",
class: "btn btn--danger",
text: t("chat.jobsKill"),
onclick: async () => {
if (!window.confirm(t("chat.jobsKillConfirm"))) return;
killBtn.disabled = true;
try {
await apiPost(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}/cancel`, {});
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
killBtn.disabled = false;
}
},
});
actions.appendChild(killBtn);
} else {
const purgeBtn = el("button", {
type: "button",
class: "btn",
text: t("chat.jobsPurge"),
onclick: async () => {
if (!window.confirm(t("chat.jobsPurgeConfirm"))) return;
purgeBtn.disabled = true;
try {
await apiDelete(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}`);
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
purgeBtn.disabled = false;
}
},
});
actions.appendChild(purgeBtn);
}
const detailBtn = el("button", {
type: "button",
class: "btn",
text: t("chat.logs"),
onclick: async () => {
detailBtn.disabled = true;
try {
const d = await apiGet(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}?log_tail_chars=6000`);
const pre = el("pre", {
class: "chat-jobs-log",
text:
`status=${d.status} exit=${d.exit_code}\n\n--- stdout ---\n${d.stdout_tail || ""}\n\n--- stderr ---\n${d.stderr_tail || ""}`,
});
const existing = row.querySelector(".chat-jobs-log");
if (existing) existing.remove();
row.appendChild(pre);
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
detailBtn.disabled = false;
}
},
});
actions.appendChild(detailBtn);
row.appendChild(top);
row.appendChild(meta);
row.appendChild(actions);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = "";
list.appendChild(el("div", { class: "muted", text: `${t("chat.error")}: ${String(e)}` }));
}
};
refreshBtn.addEventListener("click", () => paint());
killAllBtn.addEventListener("click", async () => {
if (!window.confirm(t("chat.jobsKillAllConfirm"))) return;
killAllBtn.disabled = true;
try {
await apiPost("/admin/api/chat/jobs/cancel-running", {});
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
killAllBtn.disabled = false;
}
});
card.appendChild(head);
card.appendChild(hint);
card.appendChild(summary);
card.appendChild(list);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
await paint();
_jobsPanelPollTimer = setInterval(paint, 4000);
}
async function openWikiPreviewModal({ sessionId, path }) {
const sid = String(sessionId || "");
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
if (!sid || !p) return;
const backdrop = el("div", {
class: "chat-confirm-backdrop u-z-modal",
});
const card = el("div", {
class: "chat-confirm-card u-profile-card",
});
const title = el("div", { class: "card__title", text: t("chat.wikiPreviewTitle", { path: p }) });
const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.wikiPreviewClose") });
const head = el("div", { class: "row u-row-between" }, [
title,
closeBtn,
]);
const body = el("div", { class: "chat-msg__md", html: `
${escapeHtml(t("chat.loading"))}
` });
const close = () => {
try {
backdrop.remove();
} catch (_) {}
};
closeBtn.addEventListener("click", close);
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) close();
});
card.appendChild(head);
card.appendChild(body);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
try {
const resp = await apiGet(
`/admin/api/chat/sessions/${encodeURIComponent(sid)}/wiki-file?path=${encodeURIComponent(p)}&max_chars=120000`,
);
body.innerHTML = renderMarkdownHtml(String((resp && resp.content) || ""));
} catch (e) {
body.innerHTML = `${escapeHtml(`${t("chat.error")}: ${String(e)}`)}
`;
}
}
function reasonLabel(reason) {
const r = String(reason || "").trim();
if (!r) return "-";
const key = `chat.reason.${r}`;
const localized = t(key);
return localized === key ? r : localized;
}
async function fetchDynamicExpertStats() {
try {
const r = await apiGet("/admin/api/chat/admin/dynamic-expert-stats?limit=200");
if (!r || !r.ok) return null;
return {
dynamic: parseInt(String(r.dynamic_used_count || "0"), 10) || 0,
fallback: parseInt(String(r.fallback_generalist_count || "0"), 10) || 0,
rate: Number(r.dynamic_used_rate || 0),
reasons: r.dispatch_reasons && typeof r.dispatch_reasons === "object" ? r.dispatch_reasons : {},
reasonLabels:
r.dispatch_reason_labels && typeof r.dispatch_reason_labels === "object" ? r.dispatch_reason_labels : {},
};
} catch (_) {
return null;
}
}
async function getDispatchReasonLabelsConfig() {
try {
const r = await apiGet("/admin/api/chat/settings/dispatch-reason-labels");
if (!r || !r.ok) return { overrides: {}, effective: {} };
return {
overrides: r.overrides && typeof r.overrides === "object" ? r.overrides : {},
effective: r.effective && typeof r.effective === "object" ? r.effective : {},
};
} catch (_) {
return { overrides: {}, effective: {} };
}
}
async function setDispatchReasonLabelsConfig(overridesOrNull) {
return await apiPost("/admin/api/chat/settings/dispatch-reason-labels", {
overrides: overridesOrNull,
});
}
async function openDispatchLabelsEditor(statusEl) {
const cfg = await getDispatchReasonLabelsConfig();
const initText = Object.keys(cfg.overrides || {}).length
? JSON.stringify(cfg.overrides, null, 2)
: "";
const backdrop = el("div", {
class: "chat-confirm-backdrop u-z-modal-top",
});
const card = el("div", {
class: "card u-json-card",
});
const title = el("div", { class: "card__title", text: t("chat.dispatchLabelsTitle") });
const tip = el("div", { class: "muted", text: t("chat.dispatchLabelsPrompt") });
const err = el("div", { class: "muted u-text-danger" });
const ta = el("textarea", {
class: "input u-mono-area",
});
ta.value = initText;
const previewTitle = el("div", { class: "muted", text: t("chat.dispatchLabelsEffectivePreview") });
const diffOnlyWrap = el("label", { class: "muted u-check-row" });
const diffOnlyCb = el("input", { type: "checkbox" });
diffOnlyWrap.appendChild(diffOnlyCb);
diffOnlyWrap.appendChild(el("span", { text: t("chat.dispatchLabelsDiffOnly") }));
const preview = el("textarea", {
class: "input u-mono-area-sm",
readonly: "readonly",
});
const btnSave = el("button", { type: "button", class: "btn btn--primary", text: t("chat.dispatchLabelsSave") });
const btnClear = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsClear") });
const btnDefaults = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsRestoreDefaults") });
const btnExport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsExport") });
const btnImport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsImport") });
const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") });
const fileInput = el("input", { class: "u-hidden", type: "file", accept: "application/json,.json" });
const close = () => backdrop.remove();
const saveCurrent = async () => {
const raw = String(ta.value || "").trim();
try {
if (!raw) {
await setDispatchReasonLabelsConfig(null);
} else {
const obj = JSON.parse(raw);
await setDispatchReasonLabelsConfig(obj);
}
if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved");
close();
} catch (e) {
const msg = String(e || "");
err.textContent = msg.toLowerCase().includes("json")
? t("chat.dispatchLabelsInvalidJson")
: `${t("chat.error")}: ${msg}`;
}
};
const hasUnsavedChanges = () => String(ta.value || "").trim() !== initText.trim();
const closeWithGuard = () => {
if (hasUnsavedChanges()) {
if (!window.confirm(t("chat.dispatchLabelsUnsavedConfirm"))) return;
}
close();
};
const refreshPreview = () => {
const raw = String(ta.value || "").trim();
if (!raw) {
preview.value = JSON.stringify(cfg.effective || {}, null, 2);
return;
}
try {
const userObj = JSON.parse(raw);
const base = cfg.effective && typeof cfg.effective === "object" ? cfg.effective : {};
const merged = { ...base };
const changed = {};
if (userObj && typeof userObj === "object") {
for (const [k, v] of Object.entries(userObj)) {
if (!v || typeof v !== "object") continue;
const next = {
zh: String((v && v.zh) || (merged[String(k)] && merged[String(k)].zh) || ""),
en: String((v && v.en) || (merged[String(k)] && merged[String(k)].en) || ""),
};
const prev = merged[String(k)] && typeof merged[String(k)] === "object" ? merged[String(k)] : {};
merged[String(k)] = next;
if (String(next.zh || "") !== String(prev.zh || "") || String(next.en || "") !== String(prev.en || "")) {
changed[String(k)] = next;
}
}
}
preview.value = JSON.stringify(diffOnlyCb.checked ? changed : merged, null, 2);
} catch (_) {
preview.value = t("chat.dispatchLabelsInvalidJson");
}
};
ta.addEventListener("input", refreshPreview);
diffOnlyCb.addEventListener("change", refreshPreview);
refreshPreview();
btnCancel.addEventListener("click", closeWithGuard);
btnExport.addEventListener("click", () => {
const raw = String(preview.value || "").trim();
const content = raw || "{}";
const blob = new Blob([content], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
const now = new Date();
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
a.href = URL.createObjectURL(blob);
a.download = `dispatch-reason-labels-effective-${ts}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
btnImport.addEventListener("click", () => fileInput.click());
btnDefaults.addEventListener("click", () => {
ta.value = JSON.stringify(cfg.effective || {}, null, 2);
err.textContent = "";
refreshPreview();
});
fileInput.addEventListener("change", async () => {
const f = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!f) return;
try {
const txt = await f.text();
const obj = JSON.parse(String(txt || ""));
ta.value = JSON.stringify(obj, null, 2);
err.textContent = "";
refreshPreview();
} catch (_) {
err.textContent = t("chat.dispatchLabelsInvalidJson");
}
});
btnClear.addEventListener("click", async () => {
try {
await setDispatchReasonLabelsConfig(null);
if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved");
close();
} catch (e) {
err.textContent = `${t("chat.error")}: ${String(e)}`;
}
});
btnSave.addEventListener("click", saveCurrent);
ta.addEventListener("keydown", (ev) => {
const isSave = (ev.ctrlKey || ev.metaKey) && String(ev.key || "").toLowerCase() === "s";
if (!isSave) return;
ev.preventDefault();
saveCurrent();
});
card.appendChild(title);
card.appendChild(tip);
card.appendChild(el("div", { class: "u-h-8" }));
card.appendChild(ta);
card.appendChild(el("div", { class: "u-h-8" }));
card.appendChild(previewTitle);
card.appendChild(diffOnlyWrap);
card.appendChild(preview);
card.appendChild(el("div", { class: "u-h-8" }));
card.appendChild(err);
card.appendChild(
el("div", { class: "row u-row-end-sm" }, [
btnDefaults,
btnImport,
btnExport,
btnClear,
btnCancel,
btnSave,
]),
);
backdrop.appendChild(card);
backdrop.appendChild(fileInput);
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) closeWithGuard();
});
document.body.appendChild(backdrop);
}
function getSessionIdFromUrl() {
return String(new URLSearchParams(location.search).get("session_id") || "").trim();
}
function forceReloginRequested() {
return String(new URLSearchParams(location.search).get("force_relogin") || "").trim() === "1";
}
function clearAuthAndReloginFlagFromUrl() {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
const u = new URL(window.location.href);
u.searchParams.delete("force_relogin");
history.replaceState(null, "", `${u.pathname}${u.search}${u.hash}`);
}
function replaceSessionUrl(sessionId) {
const path = "/chat";
const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
history.replaceState(null, "", path + q);
}
function withTimeout(promise, ms, label) {
let timer = null;
const timeoutMs = Math.max(200, Number(ms) || 0);
return Promise.race([
Promise.resolve(promise),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(label || `timeout:${timeoutMs}`)), timeoutMs);
}),
]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function invokeChatBoot() {
const fn = state.boot;
if (typeof fn === "function") return fn();
return Promise.reject(new Error("chat_boot_uninitialized"));
}
let _chatReauthTimer = null;
function scheduleChatReauth() {
if (_chatReauthTimer != null) return;
_chatReauthTimer = setTimeout(() => {
_chatReauthTimer = null;
invokeChatBoot().catch(() => {});
}, 0);
}
async function apiGet(path) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const headers = { accept: "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(path, { headers });
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`GET ${path} ${res.status}`);
return await res.json();
}
async function apiPost(path, body) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const headers = { "content-type": "application/json", accept: "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(path, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = null;
}
if (res.status === 401) {
const u = String(path || "");
if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) {
return data && typeof data === "object" ? data : { ok: false, error: "unauthorized" };
}
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`POST ${path} ${res.status}`);
return data ?? {};
}
async function apiPatch(path, body) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const headers = { "content-type": "application/json", accept: "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(path, {
method: "PATCH",
headers,
body: JSON.stringify(body ?? {}),
});
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`);
return await res.json();
}
async function apiDelete(path) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const headers = { accept: "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(path, { method: "DELETE", headers });
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`);
return await res.json();
}
function el(tag, attrs = {}, children = []) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k === "text") e.textContent = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v);
}
for (const c of children) e.appendChild(c);
return e;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
/** Pretty-print channel session titles like `whatsapp|AI nms · Oliver · wa-default+86…@s.whatsapp.net`. */
function formatChatSessionTitle(raw, peerNameHint) {
const full = String(raw || "").trim();
const badgeMap = {
whatsapp: "WA",
wechat: "WX",
weixin: "WX",
wecom: "WC",
telegram: "TG",
discord: "DC",
slack: "SL",
local: "Local",
};
if (!full) {
return { channel: "local", badge: badgeMap.local, label: "—", sublabel: "", full: "" };
}
const m = full.match(/^(whatsapp|wechat|weixin|wecom|telegram|discord|slack)\|(.+)$/i);
if (!m) {
return { channel: "local", badge: badgeMap.local, label: full, sublabel: "", full };
}
const channel = String(m[1] || "").toLowerCase();
const rest = String(m[2] || "").trim();
const segments = rest
.split(/\s*·\s*/)
.map((s) => String(s || "").trim())
.filter(Boolean);
const isTech = (s) => /[+]/.test(s) || /@/.test(s) || /^wa-default/i.test(s);
let techIdx = -1;
for (let i = 0; i < segments.length; i += 1) {
if (isTech(segments[i])) techIdx = i;
}
let labels = [];
let tech = rest;
if (techIdx >= 0) {
labels = segments.slice(0, techIdx);
tech = segments.slice(techIdx).join(" · ");
} else if (segments.length > 1) {
labels = segments.slice(0, -1);
tech = segments[segments.length - 1] || "";
}
// Legacy titles buried group name after the JID: account+jid@domain+GroupName
let techBody = String(tech || "").replace(/^wa-default\+/i, "");
const plusParts = techBody.split("+").map((s) => String(s || "").trim()).filter(Boolean);
const idParts = [];
const legacyNames = [];
for (const p of plusParts) {
if (p.includes("@") || /^[0-9]{8,}$/.test(p) || /^[0-9a-f]{16,}$/i.test(p)) idParts.push(p);
else legacyNames.push(p);
}
for (let i = legacyNames.length - 1; i >= 0; i -= 1) {
const n = legacyNames[i];
if (n && !labels.includes(n)) labels.unshift(n); // group name first
}
let idRaw = idParts.join("+") || (legacyNames.length ? "" : techBody);
let idShort = idRaw
.replace(/@s\.whatsapp\.net$/i, "")
.replace(/@g\.us$/i, "")
.replace(/@lid$/i, "")
.replace(/@im\.bot[^\s]*/i, "")
.replace(/@[^\s]+$/i, "");
if (/^[0-9a-f]{20,}$/i.test(idShort)) idShort = `${idShort.slice(0, 8)}…`;
else if (idShort.length > 18) idShort = `${idShort.slice(0, 16)}…`;
const hint = String(peerNameHint || "").trim();
if (hint && !labels.some((l) => l.toLowerCase() === hint.toLowerCase())) {
// Prefer keeping server-provided group/nick labels; append contact hint when useful.
if (!labels.length) labels.push(hint);
else if (labels.length === 1 && labels[0] !== hint) labels.push(hint);
}
const badge = badgeMap[channel] || channel.slice(0, 2).toUpperCase();
let label = "";
if (labels.length) {
label = labels.join(" · ");
// DM / unknown: single nick still benefits from a short id.
if (labels.length === 1 && idShort && labels[0] === hint) {
label = `${labels[0]} · ${idShort}`;
}
} else {
label = idShort || full;
}
return {
channel,
badge,
label,
sublabel: "",
full,
idKey: idRaw || techBody,
};
}
function _peerNameLookupKeys(title, pretty) {
const keys = [];
const push = (v) => {
const s = String(v || "").trim().toLowerCase();
if (s) keys.push(s);
};
push(pretty && pretty.idKey);
const raw = String(title || "");
const m = raw.match(/^(whatsapp|wechat|weixin|wecom)\|(.+)$/i);
if (m) {
let body = String(m[2] || "");
const segs = body.split(/\s*·\s*/).map((s) => String(s || "").trim()).filter(Boolean);
let tech = body;
for (let i = segs.length - 1; i >= 0; i -= 1) {
if (/[+]/.test(segs[i]) || /@/.test(segs[i]) || /^wa-default/i.test(segs[i])) {
tech = segs[i];
break;
}
}
push(tech);
push(tech.replace(/^wa-default\+/i, ""));
const noAt = tech.replace(/@.*$/, "");
push(noAt);
push(noAt.replace(/^wa-default\+/i, ""));
// Drop trailing +GroupName legacy suffix for contact id matching.
const core = noAt.replace(/^wa-default\+/i, "").split("+")[0] || "";
push(core);
push(core.replace(/\D/g, ""));
}
return keys;
}
let _channelPeerNameMap = new Map();
function lookupChannelPeerName(title, pretty) {
for (const k of _peerNameLookupKeys(title, pretty)) {
const hit = _channelPeerNameMap.get(k);
if (hit) return hit;
}
return "";
}
async function refreshChannelPeerNameMap() {
const next = new Map();
try {
const r = await apiGet("/admin/api/whatsapp/access");
const contacts = Array.isArray(r && r.contacts) ? r.contacts : [];
for (const c of contacts) {
const name = String((c && c.push_name) || "").trim();
if (!name) continue;
const eid = String((c && c.external_user_id) || "").trim();
const phone = String((c && c.phone) || "").trim();
for (const raw of [eid, phone, eid.replace(/@.*$/, ""), phone.replace(/\D/g, "")]) {
const k = String(raw || "").trim().toLowerCase();
if (k) next.set(k, name);
}
}
} catch (_) {}
_channelPeerNameMap = next;
}
let _chatLightboxKeyHandler = null;
let _chatLightboxPrevOverflow = "";
function closeChatImageLightbox() {
if (_chatLightboxKeyHandler) {
document.removeEventListener("keydown", _chatLightboxKeyHandler);
_chatLightboxKeyHandler = null;
}
document.querySelector(".chat-img-lightbox")?.remove();
document.body.style.overflow = _chatLightboxPrevOverflow || "";
}
function dismissChatMenus() {
document.querySelectorAll(".chat-sess-menu-pop, .chat-menu-scrim").forEach((n) => {
try {
n.remove();
} catch (_) {}
});
}
function isChatStreaming() {
return !!document.querySelector(".chat-composer-shell--busy");
}
/** Remove full-screen layers on ``document.body`` that survive ``#app`` remounts (boot/lang/popstate). */
function clearChatPageBlockers() {
closeChatImageLightbox();
dismissChatMenus();
document.body.style.overflow = "";
document.querySelectorAll(".chat-confirm-backdrop").forEach((n) => {
try {
n.remove();
} catch (_) {}
});
}
function attachChatMenuDismiss(menu) {
const scrim = el("div", {
class: "chat-menu-scrim u-overlay-scrim",
});
const closeAll = () => {
dismissChatMenus();
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKey, true);
};
const onDocClick = (e) => {
if (menu.contains(e.target)) return;
closeAll();
};
const onKey = (e) => {
if (e.key === "Escape") closeAll();
};
scrim.addEventListener("click", closeAll);
document.body.appendChild(scrim);
document.body.appendChild(menu);
setTimeout(() => {
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKey, true);
}, 0);
return closeAll;
}
function openChatImageLightbox(src, alt) {
if (!src || !String(src).trim()) return;
closeChatImageLightbox();
_chatLightboxPrevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
_chatLightboxKeyHandler = (ev) => {
if (ev.key === "Escape") closeChatImageLightbox();
};
document.addEventListener("keydown", _chatLightboxKeyHandler);
const backdrop = el("div", {
class: "chat-img-lightbox",
role: "dialog",
"aria-modal": "true",
"aria-label": t("chat.imageViewerHint"),
});
const inner = el("div", { class: "chat-img-lightbox__inner" });
let scale = 1.0;
const clamp = (v) => Math.max(0.2, Math.min(5.0, Number(v || 1)));
const applyScale = () => {
scale = clamp(scale);
viewport.style.transform = `scale(${scale})`;
zoomText.textContent = `${Math.round(scale * 100)}%`;
};
const fileStem = (() => {
const raw = String(alt || "").trim() || "image";
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, "_").slice(0, 64);
return safe || "image";
})();
const ext = (() => {
const s = String(src || "");
if (/^data:image\/png/i.test(s)) return ".png";
if (/^data:image\/webp/i.test(s)) return ".webp";
if (/^data:image\/gif/i.test(s)) return ".gif";
if (/^data:image\/bmp/i.test(s)) return ".bmp";
if (/^data:image\/jpeg/i.test(s) || /^data:image\/jpg/i.test(s)) return ".jpg";
return ".png";
})();
const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" });
const btnMinus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "−",
title: t("chat.zoomOut"),
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale - 0.1);
applyScale();
},
});
const btnReset = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "100%",
title: t("chat.zoomReset"),
onclick: (e) => {
e.stopPropagation();
scale = 1.0;
applyScale();
wrap.scrollLeft = 0;
wrap.scrollTop = 0;
},
});
const btnPlus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "+",
title: t("chat.zoomIn"),
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale + 0.1);
applyScale();
},
});
const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" });
const saveBtn = el("a", {
class: "chat-mermaid-lightbox__btn chat-img-lightbox__btn--icon",
href: String(src),
download: `${fileStem}${ext}`,
text: "⤓",
title: t("chat.imageViewerDownload"),
"aria-label": t("chat.imageViewerDownload"),
onclick: (e) => e.stopPropagation(),
});
const wrap = el("div", { class: "chat-img-lightbox__viewportWrap" });
const viewport = el("div", { class: "chat-img-lightbox__viewport" });
const big = el("img", {
class: "chat-img-lightbox__img",
src: String(src),
alt: alt || "",
decoding: "async",
});
const closeBtn = el("button", {
type: "button",
class: "chat-img-lightbox__close",
text: "×",
"aria-label": t("chat.imageViewerClose"),
onclick: (e) => {
e.stopPropagation();
closeChatImageLightbox();
},
});
// Keep native image context menu so users can right-click save.
big.addEventListener("contextmenu", (e) => {
e.stopPropagation();
});
toolbar.appendChild(btnMinus);
toolbar.appendChild(btnReset);
toolbar.appendChild(btnPlus);
toolbar.appendChild(zoomText);
toolbar.appendChild(saveBtn);
viewport.appendChild(big);
wrap.appendChild(viewport);
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragScrollLeft = 0;
let dragScrollTop = 0;
const onDragStart = (e) => {
if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return;
dragging = true;
wrap.classList.add("chat-img-lightbox__viewportWrap--dragging");
dragStartX = Number(e.clientX || 0);
dragStartY = Number(e.clientY || 0);
dragScrollLeft = wrap.scrollLeft;
dragScrollTop = wrap.scrollTop;
};
const onDragMove = (e) => {
if (!dragging) return;
const x = Number(e.clientX || 0);
const y = Number(e.clientY || 0);
wrap.scrollLeft = dragScrollLeft - (x - dragStartX);
wrap.scrollTop = dragScrollTop - (y - dragStartY);
};
const onDragEnd = () => {
dragging = false;
wrap.classList.remove("chat-img-lightbox__viewportWrap--dragging");
};
wrap.addEventListener("mousedown", (e) => onDragStart(e));
window.addEventListener("mousemove", (e) => onDragMove(e));
window.addEventListener("mouseup", () => onDragEnd());
wrap.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const dy = Number(e.deltaY || 0);
const step = dy > 0 ? -0.08 : 0.08;
scale = clamp(scale + step);
applyScale();
},
{ passive: false },
);
inner.appendChild(toolbar);
inner.appendChild(closeBtn);
inner.appendChild(wrap);
backdrop.appendChild(inner);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeChatImageLightbox();
});
document.body.appendChild(backdrop);
applyScale();
}
function openChatMermaidLightbox(svg) {
const raw = String(svg || "").trim();
if (!raw) return;
closeChatImageLightbox();
_chatLightboxPrevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
_chatLightboxKeyHandler = (ev) => {
if (ev.key === "Escape") closeChatImageLightbox();
};
document.addEventListener("keydown", _chatLightboxKeyHandler);
const backdrop = el("div", {
class: "chat-img-lightbox",
role: "dialog",
"aria-modal": "true",
"aria-label": "Mermaid diagram viewer",
});
const inner = el("div", { class: "chat-img-lightbox__inner" });
let scale = 1.0;
const clamp = (v) => Math.max(0.2, Math.min(3.0, Number(v || 1)));
const applyScale = () => {
scale = clamp(scale);
viewport.style.transform = `scale(${scale})`;
zoomText.textContent = `${Math.round(scale * 100)}%`;
};
const closeBtn = el("button", {
type: "button",
class: "chat-img-lightbox__close",
text: "×",
"aria-label": t("chat.imageViewerClose"),
onclick: (e) => {
e.stopPropagation();
closeChatImageLightbox();
},
});
const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" });
const btnMinus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "−",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale - 0.1);
applyScale();
},
});
const btnPlus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "+",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale + 0.1);
applyScale();
},
});
const btnReset = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "100%",
onclick: (e) => {
e.stopPropagation();
scale = 1.0;
applyScale();
},
});
const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" });
toolbar.appendChild(btnMinus);
toolbar.appendChild(btnReset);
toolbar.appendChild(btnPlus);
toolbar.appendChild(zoomText);
const wrap = el("div", { class: "chat-mermaid-lightbox__svg" });
const viewport = el("div", { class: "chat-mermaid-lightbox__viewport" });
viewport.innerHTML = raw;
wrap.appendChild(viewport);
// Drag-to-pan (scroll) inside the zoomable viewport.
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragScrollLeft = 0;
let dragScrollTop = 0;
const onDragStart = (e) => {
// Ignore drags started on toolbar/buttons.
if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return;
dragging = true;
wrap.classList.add("chat-mermaid-lightbox__svg--dragging");
dragStartX = Number(e.clientX || 0);
dragStartY = Number(e.clientY || 0);
dragScrollLeft = wrap.scrollLeft;
dragScrollTop = wrap.scrollTop;
};
const onDragMove = (e) => {
if (!dragging) return;
const x = Number(e.clientX || 0);
const y = Number(e.clientY || 0);
wrap.scrollLeft = dragScrollLeft - (x - dragStartX);
wrap.scrollTop = dragScrollTop - (y - dragStartY);
};
const onDragEnd = () => {
dragging = false;
wrap.classList.remove("chat-mermaid-lightbox__svg--dragging");
};
wrap.addEventListener("mousedown", (e) => onDragStart(e));
window.addEventListener("mousemove", (e) => onDragMove(e));
window.addEventListener("mouseup", () => onDragEnd());
wrap.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const dy = Number(e.deltaY || 0);
const step = dy > 0 ? -0.08 : 0.08;
scale = clamp(scale + step);
applyScale();
},
{ passive: false },
);
inner.appendChild(closeBtn);
inner.appendChild(toolbar);
inner.appendChild(wrap);
backdrop.appendChild(inner);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeChatImageLightbox();
});
document.body.appendChild(backdrop);
applyScale();
}
function bindChatImageViewer(messagesEl) {
messagesEl.addEventListener("click", (ev) => {
const img = ev.target && ev.target.closest && ev.target.closest("img");
if (!img || !messagesEl.contains(img)) return;
const src = img.currentSrc || img.getAttribute("src") || "";
if (!src.trim()) return;
ev.preventDefault();
ev.stopPropagation();
openChatImageLightbox(src, img.getAttribute("alt") || "");
});
}
function bindChatMermaidViewer(messagesEl) {
messagesEl.addEventListener("click", (ev) => {
const svg = ev.target && ev.target.closest && ev.target.closest(".mermaid svg");
if (!svg || !messagesEl.contains(svg)) return;
ev.preventDefault();
ev.stopPropagation();
openChatMermaidLightbox(svg.outerHTML || "");
});
}
/** Keep markdown output safe but allow images (USE_PROFILES html strips img in many DOMPurify builds). */
function renderMarkdownHtml(src) {
const raw = String(src ?? "");
if (typeof marked !== "undefined" && typeof DOMPurify !== "undefined") {
const html =
typeof marked.parse === "function"
? marked.parse(raw, { breaks: true, mangle: false, headerIds: false })
: marked(raw, { breaks: true });
const opts = {
ADD_TAGS: ["img", "picture", "source"],
ADD_ATTR: ["src", "alt", "title", "loading", "class", "width", "height", "decoding", "referrerpolicy", "sizes", "srcset"],
};
try {
return DOMPurify.sanitize(html, opts);
} catch (_) {
return DOMPurify.sanitize(html);
}
}
return `${escapeHtml(raw).replace(/\n/g, "
")}
`;
}
let _mermaidBootstrapped = false;
let _mermaidRetryTimer = null;
window.__oclawHydrateMermaidAll = () => {
try {
const root = document.getElementById("app") || document.body;
hydrateMermaidIn(root);
} catch (_) {}
};
function hydrateMermaidIn(root) {
const host = root && root.querySelectorAll ? root : null;
if (!host) return;
const codeNodes = host.querySelectorAll("pre > code.language-mermaid, pre > code.lang-mermaid");
for (const code of codeNodes) {
const pre = code.parentElement;
if (!pre || !pre.parentElement) continue;
const txt = String(code.textContent || "").trim();
if (!txt) continue;
const box = document.createElement("div");
box.className = "mermaid";
box.setAttribute("data-mermaid-raw", txt);
box.textContent = txt;
pre.parentElement.replaceChild(box, pre);
}
const _attachFallback = (node, errText = "") => {
if (!node || node.querySelector(".mermaid-fallback")) return;
const raw = String(node.getAttribute("data-mermaid-raw") || "").trim() || String(node.textContent || "").trim();
const msg = String(errText || "").trim();
node.innerHTML = `${msg ? `
${escapeHtml(msg)}
` : ""}
${escapeHtml(raw)} `;
};
if (typeof mermaid === "undefined") {
if (_mermaidRetryTimer != null) return;
_mermaidRetryTimer = setTimeout(() => {
_mermaidRetryTimer = null;
try {
window.__oclawHydrateMermaidAll();
} catch (_) {}
}, 350);
return;
}
try {
if (!_mermaidBootstrapped && typeof mermaid.initialize === "function") {
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: "base",
themeVariables: {
background: "transparent",
primaryColor: "#1f2937",
primaryBorderColor: "#94a3b8",
primaryTextColor: "#e5e7eb",
lineColor: "#94a3b8",
textColor: "#e5e7eb",
fontFamily: "Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
},
});
_mermaidBootstrapped = true;
}
const nodes = Array.from(host.querySelectorAll(".mermaid")).filter((n) => n && n.isConnected);
if (!nodes.length) return;
// Mermaid skips already processed nodes. In streaming / rerender scenarios, DOM can be replaced.
// Remove the marker so Mermaid treats these nodes as fresh.
for (const n of nodes) {
try {
n.removeAttribute("data-processed");
} catch (_) {}
}
if (typeof mermaid.run === "function") {
Promise.resolve(mermaid.run({ nodes }))
.then(() => {
for (const n of nodes) {
if (!n || !n.isConnected) continue;
if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)");
}
})
.catch((e) => {
const msg = String((e && e.message) || e || "Mermaid render failed");
for (const n of nodes) _attachFallback(n, msg);
});
} else if (typeof mermaid.init === "function") {
try {
mermaid.init(undefined, nodes);
for (const n of nodes) {
if (!n || !n.isConnected) continue;
if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)");
}
} catch (e) {
const msg = String((e && e.message) || e || "Mermaid render failed");
for (const n of nodes) _attachFallback(n, msg);
}
}
} catch (e) {
const msg = String((e && e.message) || e || "Mermaid render failed");
const nodes = host.querySelectorAll(".mermaid");
for (const n of nodes) _attachFallback(n, msg);
}
}
const RE_REDACTED_THINKING = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
const RE_THINK_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
const RE_THINKING_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
const RE_THOUGHT_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
function _findEarliestReasoningBlock(remaining) {
let best = null;
for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) {
const m = re.exec(remaining);
re.lastIndex = 0;
if (!m) continue;
const start = m.index;
if (!best || start < best.start) {
best = { start, end: start + m[0].length, inner: (m[1] || "").trim() };
}
}
return best;
}
function parseReasoningSegments(raw) {
const parts = [];
let remaining = String(raw ?? "");
while (remaining.length) {
const hit = _findEarliestReasoningBlock(remaining);
if (!hit) {
parts.push({ type: "text", text: remaining });
break;
}
if (hit.start > 0) parts.push({ type: "text", text: remaining.slice(0, hit.start) });
parts.push({ type: "reasoning", text: hit.inner });
remaining = remaining.slice(hit.end);
}
return parts.length ? parts : [{ type: "text", text: "" }];
}
// Oclaw-style: strip // blocks from visible text,
// while keeping code fences intact. This avoids "reasoning leaking" into正文 and
// handles truncated streams (unfinished tags) safely.
const _OC_QUICK_TAG_RE = /<\s*\/?\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking|final)\b/i;
const _OC_FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/gi;
const _OC_THINKING_TAG_RE = /<\s*(\/?)\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/gi;
function _findCodeFenceRegions(text) {
const regions = [];
const re = /```/g;
let start = null;
for (;;) {
const m = re.exec(text);
if (!m) break;
if (start == null) start = m.index;
else {
regions.push([start, m.index + 3]);
start = null;
}
}
return regions;
}
function _isInsideRegion(idx, regions) {
for (const [a, b] of regions) {
if (idx >= a && idx < b) return true;
}
return false;
}
function stripReasoningTagsFromText(text, { mode = "strict" } = {}) {
const raw = String(text || "");
if (!raw || !_OC_QUICK_TAG_RE.test(raw)) return raw;
_OC_QUICK_TAG_RE.lastIndex = 0;
// Remove tags (but keep content).
let cleaned = raw;
if (_OC_FINAL_TAG_RE.test(cleaned)) {
_OC_FINAL_TAG_RE.lastIndex = 0;
const fences = _findCodeFenceRegions(cleaned);
const matches = [];
for (const m of cleaned.matchAll(_OC_FINAL_TAG_RE)) {
const idx = m.index ?? 0;
matches.push({ idx, len: m[0].length, inCode: _isInsideRegion(idx, fences) });
}
for (let i = matches.length - 1; i >= 0; i -= 1) {
const mm = matches[i];
if (mm.inCode) continue;
cleaned = cleaned.slice(0, mm.idx) + cleaned.slice(mm.idx + mm.len);
}
} else {
_OC_FINAL_TAG_RE.lastIndex = 0;
}
const fences = _findCodeFenceRegions(cleaned);
_OC_THINKING_TAG_RE.lastIndex = 0;
let out = "";
let last = 0;
let inThinking = false;
for (const m of cleaned.matchAll(_OC_THINKING_TAG_RE)) {
const idx = m.index ?? 0;
const isClose = m[1] === "/";
if (_isInsideRegion(idx, fences)) continue;
if (!inThinking) {
out += cleaned.slice(last, idx);
if (!isClose) inThinking = true;
} else if (isClose) {
inThinking = false;
}
last = idx + m[0].length;
}
if (!inThinking || mode === "preserve") out += cleaned.slice(last);
return out;
}
function extractThinkingFromText(text) {
const raw = String(text || "");
if (!raw) return "";
const blocks = [];
for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) {
const rr = new RegExp(re.source, "gi");
for (const m of raw.matchAll(rr)) {
const inner = String(m[1] || "").trim();
if (inner) blocks.push(inner);
}
}
return blocks.join("\n");
}
function extractWsAssistantText(message) {
if (!message || typeof message !== "object") return "";
const m = message;
if (typeof m.content === "string") return decodeEscapedNewlines(String(m.content || ""));
if (typeof m.text === "string") return decodeEscapedNewlines(String(m.text || ""));
const c = Array.isArray(m.content) ? m.content : [];
const parts = c
.map((x) => {
if (!x || typeof x !== "object") return "";
if (String(x.type || "") === "text") return decodeEscapedNewlines(String(x.text || ""));
return "";
})
.filter(Boolean);
return parts.join("\n");
}
function decodeEscapedNewlines(text) {
return String(text || "").replace(/\\n/g, "\n").replace(/\\N/g, "\n");
}
function normalizeStreamText(raw) {
return String(raw || "")
.replace(/^\s*\n+/, "")
.replace(/\n{3,}/g, "\n\n");
}
// Streaming surface should be smooth and avoid newline explosions.
function normalizeStreamDisplayText(raw) {
return String(raw || "")
.replace(/\r/g, "")
.replace(/^\s*\n+/, "")
.replace(/\n+/g, " ")
.replace(/[ \t]{2,}/g, " ")
.trimStart();
}
// Streaming-only: user prefers a more "natural" flow without many newlines.
function normalizeStreamBodyForUi(raw) {
return String(raw || "")
.replace(/^\s*\n+/, "")
.replace(/\r/g, "")
// Collapse pathological newline runs but keep paragraph breaks.
// 3+ newlines => 2 newlines; trim leading newlines.
.replace(/\n{3,}/g, "\n\n");
}
// Oclaw-style streaming stitcher: reduce boundary artifacts without destroying layout.
function createStreamStitcher() {
let prevTail = "";
return {
push(delta) {
let s = String(delta || "");
if (!s) return "";
s = s.replace(/\r/g, "");
// If previous ended with newline and delta starts with newline(s), drop the leading ones.
if (prevTail.endsWith("\n") && s.startsWith("\n")) {
s = s.replace(/^\n+/, "\n");
}
// If previous ended with a space and delta starts with space/newline, trim the head.
if (/[ \t]$/.test(prevTail) && /^[ \t\n]/.test(s)) {
s = s.replace(/^[ \t\n]+/, " ");
}
// Avoid 3+ newlines caused by chunk boundaries.
s = s.replace(/\n{3,}/g, "\n\n");
prevTail = (prevTail + s).slice(-12);
return s;
},
};
}
function formatToolPanelText(name, payload, options = {}) {
const streamMode = !!(options && options.streamMode);
const truncateToolPanel = (s) => {
const raw = String(s || "").trim();
if (streamMode) return raw;
const maxChars = 4000;
if (raw.length <= maxChars) return raw;
return `${raw.slice(0, maxChars)}\n\n${t("chat.truncatedNote", { n: raw.length - maxChars })}`;
};
const n = String(name || "").trim() || "tool";
const p = payload && typeof payload === "object" ? payload : {};
if (String(p.phase || "") === "call" && (p.tool_name != null || p.arguments !== undefined)) {
const tn = String(p.tool_name || "").trim() || "tool";
let argsStr = "";
try {
argsStr =
p.arguments && typeof p.arguments === "object"
? JSON.stringify(p.arguments, null, 2)
: String(p.arguments ?? "");
} catch (_) {
argsStr = String(p.arguments ?? "");
}
const tid = String(p.tool_call_id || "").trim();
const head = t("chat.callBracket", { name: tn });
return normalizeStreamText([head, tid ? `tool_call_id: ${tid}` : "", "", argsStr].filter(Boolean).join("\n"));
}
const r = p.result && typeof p.result === "object" ? p.result : p;
// SQL audit payload: keep line breaks and key fields visible.
if (r && typeof r === "object" && (r.input_sql || r.executed_sql || r.sql_guard)) {
const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {};
const lines = [
`${n}`,
`[SQL] input`,
String(r.input_sql || ""),
"",
`[SQL] executed`,
String(r.executed_sql || ""),
"",
`[Guard]`,
`readonly_enforced=${String(!!guard.readonly_enforced)}`,
`multi_statement_forbidden=${String(!!guard.multi_statement_forbidden)}`,
`auto_limit_applied=${String(!!guard.auto_limit_applied)}`,
`result_row_cap=${String(guard.result_row_cap != null ? guard.result_row_cap : "")}`,
`engine=${String(r.engine || "")}`,
`rows_returned=${String(r.rows_returned != null ? r.rows_returned : "")}`,
];
return truncateToolPanel(lines.join("\n").trim());
}
let body = "";
// Prefer human-readable text from tool content blocks when available.
try {
const content = Array.isArray(r.content) ? r.content : [];
const textBlock = content.find((x) => x && typeof x === "object" && String(x.type || "") === "text");
const text = textBlock ? String(textBlock.text || "") : "";
if (text.trim()) body = text;
} catch (_) {}
if (!body) {
try {
body = JSON.stringify(r, null, 2);
} catch (_) {
body = String(r || "");
}
}
body = normalizeStreamText(body);
return `${n}\n${truncateToolPanel(body)}`.trim();
}
function extractSqlAuditPayload(payload) {
const p = payload && typeof payload === "object" ? payload : {};
const r = p.result && typeof p.result === "object" ? p.result : p;
if (!r || typeof r !== "object") return null;
const inputSql = String(r.input_sql || "");
const executedSql = String(r.executed_sql || "");
if (!inputSql && !executedSql && !(r.sql_guard && typeof r.sql_guard === "object")) return null;
const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {};
return {
inputSql,
executedSql,
guard,
engine: String(r.engine || ""),
rowsReturned: r.rows_returned != null ? Number(r.rows_returned) : null,
};
}
function extractToolImageItems(payload) {
const _toObj = (v) => {
if (v && typeof v === "object") return v;
if (typeof v !== "string") return null;
const s = String(v || "").trim();
if (!s) return null;
try {
const j = JSON.parse(s);
return j && typeof j === "object" ? j : null;
} catch (_) {
return null;
}
};
const p = _toObj(payload) || {};
const cands = [p, _toObj(p.payload), _toObj(p.result), _toObj(p.payload && p.payload.result), _toObj(p.result && p.result.result)].filter(Boolean);
const out = [];
for (const cand of cands) {
const content = Array.isArray(cand.content) ? cand.content : [];
for (const item of content) {
if (!item || typeof item !== "object") continue;
const typ = String(item.type || "").trim().toLowerCase();
if (typ === "image" || typ === "input_image") {
const srcObj = item.source && typeof item.source === "object" ? item.source : {};
const b64 = String(item.image_base64 || item.data || srcObj.data || "").trim();
if (!b64) continue;
const mime = String(item.mime_type || item.mime || srcObj.media_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
continue;
}
if (typ === "image_url") {
const urlObj = item.image_url && typeof item.image_url === "object" ? item.image_url : {};
const url = String(item.url || item.image_url || urlObj.url || "").trim();
if (!url) continue;
out.push({ type: "image_url", src: url });
}
}
}
// Fallback: direct attachment-like payload shape.
const direct = _toObj(p.attachments);
const atts = Array.isArray(direct) ? direct : [];
for (const a of atts) {
if (!a || typeof a !== "object") continue;
const t = String(a.type || "").toLowerCase();
if (t === "image" || t === "input_image") {
const b64 = String(a.image_base64 || a.data || "").trim();
if (!b64) continue;
const mime = String(a.mime || a.mime_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
} else if (t === "image_url") {
const src = String(a.url || a.image_url || "").trim();
if (src) out.push({ type: "image_url", src });
}
}
// De-dup by src.
const uniq = [];
const seen = new Set();
for (const it of out) {
const k = String((it && it.src) || "");
if (!k || seen.has(k)) continue;
seen.add(k);
uniq.push(it);
}
return uniq;
}
function _sqlLimitSuffix(inputSql, executedSql) {
const a = String(inputSql || "").trim();
const b = String(executedSql || "").trim();
if (!a || !b || b.length <= a.length) return "";
const lowerA = a.toLowerCase();
const lowerB = b.toLowerCase();
if (lowerB.startsWith(lowerA)) {
const suffix = b.slice(a.length);
if (/^\s*limit\s+\d+\s*$/i.test(suffix)) return suffix.trim();
}
return "";
}
function _tokenizeSqlForDiff(sqlText) {
const s = String(sqlText || "");
return s.match(/\s+|[^\s]+/g) || [];
}
function _renderExecutedSqlWithAddedHighlight(inputSql, executedSql) {
const a = _tokenizeSqlForDiff(inputSql);
const b = _tokenizeSqlForDiff(executedSql);
if (!a.length) return escapeHtml(String(executedSql || ""));
// LCS-based diff: mark tokens present only in executed SQL.
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i -= 1) {
for (let j = n - 1; j >= 0; j -= 1) {
if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
let i = 0;
let j = 0;
const out = [];
while (i < m && j < n) {
if (a[i] === b[j]) {
out.push(escapeHtml(b[j]));
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i += 1;
} else {
out.push(
`${escapeHtml(b[j])}`,
);
j += 1;
}
}
while (j < n) {
out.push(
`${escapeHtml(b[j])}`,
);
j += 1;
}
return out.join("");
}
function formatChatTimestamp(iso) {
const s = String(iso || "").trim();
if (!s) return "";
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
const loc = state.currentLang === "zh" ? "zh-CN" : "en-US";
try {
return d.toLocaleString(loc, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
} catch (_) {
return s;
}
}
function prependMessageTime(node, tsIso) {
const txt = formatChatTimestamp(tsIso);
if (!txt || !node) return;
node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild);
}
const CHAT_BOT_LOGO_SRC = "/admin/brand-assets/logo.svg";
const CHAT_BOT_LOGO_FALLBACK_SRC = "/admin/assets/oliver.svg";
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
async function loadMeProfile() {
try {
const r = await apiGet("/admin/api/chat/profile");
state.meProfile = r && r.ok && r.profile ? r.profile : null;
} catch (_) {
state.meProfile = null;
}
}
function buildBotAvatarImg() {
const img = el("img", {
class: "chat-avatar chat-avatar--bot",
src: CHAT_BOT_LOGO_SRC,
alt: "",
loading: "lazy",
});
img.addEventListener(
"error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return img;
}
function buildUserAvatarSlot() {
const wrap = el("div", { class: "chat-avatar-slot" });
const cur = el("img", {
class: "chat-avatar chat-avatar--img chat-avatar--userBuiltin",
src: DEFAULT_USER_AVATAR_SRC,
alt: "",
loading: "lazy",
});
wrap.appendChild(cur);
const aid = state.meProfile && String(state.meProfile.avatar_attachment_id || "").trim();
if (aid) {
fetchAttachmentBlobUrl(aid).then((url) => {
if (!url || !cur.parentNode) return;
cur.classList.remove("chat-avatar--userBuiltin");
cur.classList.add("chat-avatar--userPhoto");
cur.src = url;
});
}
return wrap;
}
function wrapAssistantMessage(innerRoot, tsIso) {
const col = el("div", { class: "chat-msg-col chat-msg-col--assistant" });
prependMessageTime(col, tsIso);
col.appendChild(innerRoot);
const row = el("div", { class: "chat-row chat-row--assistant" });
row.appendChild(buildBotAvatarImg());
row.appendChild(col);
return row;
}
function wrapUserMessage(innerRoot, tsIso) {
const col = el("div", { class: "chat-msg-col chat-msg-col--user" });
prependMessageTime(col, tsIso);
col.appendChild(innerRoot);
const row = el("div", { class: "chat-row chat-row--user" });
row.appendChild(col);
row.appendChild(buildUserAvatarSlot());
return row;
}
async function buildMessageBubble(role, content, tsIso) {
const r = String(role || "").toLowerCase();
const rawText = decodeEscapedNewlines(String(content ?? ""));
const text =
r === "assistant" && !state.adminChatShowToolOutput ? stripReasoningTagsFromText(rawText, { mode: "strict" }) : rawText;
let inner;
if (r === "user") {
inner = el("div", { class: "chat-msg chat-msg--user" });
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
return wrapUserMessage(inner, tsIso);
}
if (r === "tool" || r === "function") {
inner = el("div", { class: "chat-msg chat-msg--tool" });
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
return wrapAssistantMessage(inner, tsIso);
}
if (r !== "assistant") {
inner = el("div", { class: "chat-msg chat-msg--assistant" });
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
return wrapAssistantMessage(inner, tsIso);
}
const segs = parseReasoningSegments(text);
const onlyText = segs.length === 1 && segs[0].type === "text";
if (onlyText) {
inner = el("div", { class: "chat-msg chat-msg--assistant" });
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(segs[0].text) }));
} else {
inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
for (const seg of segs) {
if (seg.type === "text") {
let body = String(seg.text || "");
const prev = inner.lastElementChild;
if (prev && prev.classList && prev.classList.contains("chat-msg__reasoning")) {
body = body.replace(/^\s+/, "");
}
if (!body.trim()) continue;
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(body) }));
} else {
if (state.adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text: seg.text || "—" });
}
}
if (collapsedItems.length) {
const det = document.createElement("details");
det.className = "chat-msg__reasoning";
const sum = document.createElement("summary");
sum.textContent = t("reasoning.summary");
det.appendChild(sum);
for (const it of collapsedItems) {
const title = String((it && it.title) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text));
}
inner.insertBefore(det, inner.firstChild);
}
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
const _blobUrlCache = new Map();
const _attachmentTextPreviewCache = new Map();
async function fetchAttachmentBlobUrl(attachmentId) {
const aid = String(attachmentId || "").trim();
if (!aid) return null;
if (_blobUrlCache.has(aid)) return _blobUrlCache.get(aid);
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const res = await fetch(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_blobUrlCache.set(aid, url);
return url;
}
async function fetchAttachmentTextPreview(attachmentId, maxChars = 1800) {
const aid = String(attachmentId || "").trim();
if (!aid) return "";
if (_attachmentTextPreviewCache.has(aid)) return _attachmentTextPreviewCache.get(aid) || "";
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const res = await fetch(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`preview_http_${res.status}`);
const blob = await res.blob();
const txt = String(await blob.text());
const out = txt.length > maxChars ? `${txt.slice(0, maxChars)}\n…` : txt;
_attachmentTextPreviewCache.set(aid, out);
return out;
}
function parseAttachments(raw) {
if (raw == null || raw === "") return [];
if (Array.isArray(raw)) return raw.filter((x) => x && typeof x === "object");
if (typeof raw === "object" && !Array.isArray(raw)) {
return [raw];
}
if (typeof raw === "string") {
const s = raw.trim();
if (!s || s === "null") return [];
try {
const j = JSON.parse(s);
if (Array.isArray(j)) return j.filter((x) => x && typeof x === "object");
if (j && typeof j === "object") return [j];
} catch (_) {
return [];
}
}
return [];
}
async function renderAttachmentsEl(raw) {
const list = parseAttachments(raw);
if (!list.length) return null;
const wrap = el("div", { class: "chat-att-wrap" });
const isTextLikeMime = (mime) => {
const m = String(mime || "").trim().toLowerCase();
if (!m) return false;
if (m.startsWith("text/")) return true;
return (
m.includes("json") ||
m.includes("xml") ||
m.includes("yaml") ||
m.includes("yml") ||
m.includes("csv") ||
m.includes("javascript") ||
m.includes("typescript")
);
};
const buildRefCard = async (att, typ) => {
const aid = String(att.attachment_id || att.attachmentId || "").trim();
const mime = String(att.mime || att.mime_type || "application/octet-stream").trim();
const name = String(att.name || `${typ || "attachment"}`).trim();
const bytes = Number(att.bytes || 0);
const sizeLabel = bytes > 0 ? `${Math.round((bytes / 1024) * 10) / 10} KB` : "";
const card = el("div", { class: "chat-att-ref" });
if (aid) card.title = `id: ${aid}`;
card.appendChild(el("div", { class: "chat-att-ref__name", text: name }));
card.appendChild(el("div", { class: "chat-att-ref__meta", text: `${typ} · ${mime}${sizeLabel ? ` · ${sizeLabel}` : ""}` }));
const actions = el("div", { class: "chat-att-ref__actions" });
if (aid) {
const url = await fetchAttachmentBlobUrl(aid);
if (url) {
actions.appendChild(
el("a", {
class: "chat-att-ref__action",
href: url,
target: "_blank",
rel: "noopener noreferrer",
download: name || undefined,
text: t("chat.attachment.download"),
}),
);
if (String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video u-media-preview",
controls: true,
preload: "metadata",
src: url,
}),
);
}
}
} else {
const remote = String(att.url || "").trim();
if (remote && String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video u-media-preview",
controls: true,
preload: "metadata",
src: remote,
}),
);
actions.appendChild(
el("a", {
class: "chat-att-ref__action",
href: remote,
target: "_blank",
rel: "noopener noreferrer",
text: t("chat.attachment.download"),
}),
);
}
}
const canPreviewText = !!aid && (String(typ || "") === "text_ref" || isTextLikeMime(mime));
if (canPreviewText) {
const preview = el("button", {
type: "button",
class: "chat-att-ref__action",
text: t("chat.attachment.preview"),
});
const pre = el("pre", { class: "chat-att-ref__preview" });
preview.addEventListener("click", async () => {
if (!pre.hidden) {
pre.hidden = true;
preview.textContent = t("chat.attachment.preview");
return;
}
preview.disabled = true;
preview.textContent = t("chat.attachment.previewLoading");
try {
const txt = await fetchAttachmentTextPreview(aid);
pre.textContent = txt || t("chat.attachment.previewEmpty");
} catch (_) {
pre.textContent = t("chat.attachment.previewError");
} finally {
pre.hidden = false;
preview.disabled = false;
preview.textContent = t("chat.attachment.preview");
}
});
actions.appendChild(preview);
card.appendChild(actions);
card.appendChild(pre);
} else if (actions.childNodes.length) {
card.appendChild(actions);
}
return card;
};
for (const att of list) {
if (!att || typeof att !== "object") continue;
const typ = String(att.type || "");
if (typ === "image_ref") {
const aid = String(att.attachment_id || att.attachmentId || "").trim();
const url = aid ? await fetchAttachmentBlobUrl(aid) : null;
if (url) {
const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || "") });
wrap.appendChild(img);
} else {
wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") }));
}
} else if (typ === "relay_pointer") {
let aid = String(att.attachment_id || att.attachmentId || "").trim();
if (!aid) {
const p = String(att.pointer_uri || "").trim();
const m = p.match(/^relay:\/\/attachments\/[^/]+\/([a-f0-9]{8,64})$/i);
if (m && m[1]) aid = String(m[1]).toLowerCase();
}
const mime = String(att.mime || att.mime_type || "").toLowerCase();
const url = aid ? await fetchAttachmentBlobUrl(aid) : null;
if (url && (!mime || mime.startsWith("image/"))) {
const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || att.rel_path || "relay image") });
wrap.appendChild(img);
} else {
const title = String(att.name || att.rel_path || att.pointer_uri || "relay pointer");
wrap.appendChild(el("span", { class: "chat-att-chip", text: `📎 ${title}` }));
}
} else if (typ === "image_url") {
const src = String(att.url || att.image_url || "").trim();
if (!src) continue;
const img = el("img", {
class: "chat-att-img",
src,
alt: String(att.name || "generated image"),
decoding: "async",
referrerPolicy: "no-referrer",
});
img.addEventListener(
"error",
() => {
try {
const parent = img.parentNode;
if (!parent) return;
parent.replaceChild(
el("a", {
class: "chat-att-ref__action",
href: src,
target: "_blank",
rel: "noopener noreferrer",
text:
t("chat.imageBlockedHint"),
}),
img,
);
} catch (_) {}
},
{ once: true },
);
wrap.appendChild(img);
} else if (typ === "video_ref" || typ === "text_ref" || typ === "binary_ref") {
wrap.appendChild(await buildRefCard(att, typ));
} else if (typ === "image" || typ === "input_image") {
const b64 = att.image_base64 || att.data;
const mime = String(att.mime || "image/jpeg");
if (b64) {
const src = `data:${mime};base64,${String(b64).replace(/\s/g, "")}`;
wrap.appendChild(el("img", { class: "chat-att-img", src, alt: String(att.name || "") }));
} else {
wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") }));
}
} else {
const maybeAid = String(att.attachment_id || att.attachmentId || "").trim();
if (maybeAid) wrap.appendChild(await buildRefCard(att, typ || "attachment_ref"));
else wrap.appendChild(el("span", { class: "chat-att-chip", text: `📄 ${String(att.name || "file")}` }));
}
}
return wrap.children.length ? wrap : null;
}
async function appendMessageRow(messagesEl, m, options = {}) {
const role = String(m.role || "");
const content = String(m.content || "");
const ts = m.timestamp != null ? m.timestamp : "";
const bubble = Array.isArray(m._items)
? await _buildAggregatedAssistantBubble(ts, m._items)
: await buildMessageBubble(role, content, ts);
// For aggregated assistant bubbles, attachments should be rendered inline
// at tool_result positions, not appended at bubble tail.
const att = Array.isArray(m._items) ? null : await renderAttachmentsEl(m.attachments);
if (att) {
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) innerBubble.appendChild(att);
else bubble.appendChild(att);
}
const colNode = bubble.querySelector(".chat-msg-col");
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) {
const copyText = (() => {
const v = String(innerBubble.innerText || "").trim();
return v;
})();
const ids = Array.isArray(m._message_ids) ? m._message_ids.filter((x) => x != null) : (m.id != null ? [m.id] : []);
const canDelete = ids.length === 1 && typeof options.onDeleteMessage === "function";
if (copyText || canDelete) {
const bar = el("div", { class: "chat-msg__actions" });
if (copyText) {
bar.appendChild(
el("button", {
type: "button",
class: "chat-msg__action-btn",
text: "⧉",
title: t("chat.copy"),
"aria-label": t("chat.copy"),
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(copyText);
if (typeof options.onActionStatus === "function") options.onActionStatus(t("chat.copyOk"));
} catch (_) {
if (typeof options.onActionStatus === "function") options.onActionStatus(t("chat.copyFail"));
}
},
}),
);
}
if (canDelete) {
bar.appendChild(
el("button", {
type: "button",
class: "chat-msg__action-btn chat-msg__action-btn--danger",
text: "🗑",
title: t("chat.deleteMessage"),
"aria-label": t("chat.deleteMessage"),
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
let ok = false;
if (typeof options.onConfirm === "function") {
ok = await options.onConfirm(t("chat.deleteMessageConfirm"));
} else {
ok = window.confirm(t("chat.deleteMessageConfirm"));
}
if (!ok) return;
await options.onDeleteMessage(ids[0]);
},
}),
);
}
if (colNode) colNode.appendChild(bar);
else innerBubble.appendChild(bar);
}
}
if (options.prependBefore && options.prependBefore.parentNode === messagesEl) {
messagesEl.insertBefore(bubble, options.prependBefore);
} else if (options.prepend) {
messagesEl.insertBefore(bubble, messagesEl.firstChild);
} else {
messagesEl.appendChild(bubble);
}
hydrateMermaidIn(bubble);
}
function mount(node) {
clearChatPageBlockers();
const c = document.getElementById("app");
c.innerHTML = "";
c.appendChild(node);
}
function buildChatBrandLogoNode() {
const img = el("img", {
class: "chat-nav__brandLogo",
src: CHAT_BOT_LOGO_SRC,
alt: "site logo",
});
img.addEventListener(
"error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return el("div", { class: "chat-nav__brandWrap" }, [img]);
}
function resolveAdminHashUrl(hashPath, sessionId) {
const path = (location.pathname || "").replace(/\/+$/, "") || "/";
const cleanHash = String(hashPath || "").replace(/^#?\/?/, "");
const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
if (path.endsWith("/chat")) {
const base = path.slice(0, -5);
return base.endsWith("/admin") ? `${base}#/${cleanHash}${q}` : `${base}/admin#/${cleanHash}${q}`;
}
return `/admin#/${cleanHash}${q}`;
}
function openAdminFromChat(hashPath, sessionId) {
const target = resolveAdminHashUrl(hashPath, sessionId);
// Keep navigation in the same window so desktop shell feels native.
window.location.assign(target);
}
function syncAuthUserLabel() {
const user = document.getElementById("authUser");
if (!user) return;
user.innerHTML = "";
const name = String((state.authSession && (state.authSession.display_name || state.authSession.username || state.authSession.user_id)) || "");
const isAdminViewer = String((state.authSession && state.authSession.username) || "")
.trim()
.toLowerCase() === "administrator";
if (!name) return;
const openUserMenu = (anchorEl) => {
clearChatPageBlockers();
const items = [
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "profile",
text: t("chat.myProfile"),
}),
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "jobs",
text: t("chat.jobs"),
}),
];
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("div", { class: "muted u-menu-label", text: t("theme.label") }),
);
const themeSelMenu = el("select", {
class: "input u-menu-select",
});
try {
(window.OclawAdminTheme && window.OclawAdminTheme.THEMES ? window.OclawAdminTheme.THEMES : ["netx"]).forEach((tid) => {
themeSelMenu.appendChild(el("option", { value: tid, text: t(`theme.${tid}`) }));
});
themeSelMenu.value = window.OclawAdminTheme ? window.OclawAdminTheme.currentAdminTheme() : "netx";
} catch (_) {}
themeSelMenu.addEventListener("change", () => {
try {
if (window.OclawAdminTheme) window.OclawAdminTheme.persistAdminTheme(themeSelMenu.value);
} catch (_) {}
});
items.push(themeSelMenu);
const bridge = window.__chatUserMenuPrefs;
if (bridge && typeof bridge === "object") {
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(el("div", { class: "muted u-menu-label", text: t("chat.modeLabel") }));
const modeSel = el("select", { class: "input u-menu-select" });
try {
const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : [];
rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") })));
modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || "");
} catch (_) {}
modeSel.addEventListener("change", async () => {
const v = modeSel.value;
try {
modeSel.disabled = true;
if (bridge.setModeValue) await bridge.setModeValue(v);
} catch (_) {
// errors are surfaced by saveUserGlobalModePreference()
} finally {
modeSel.disabled = false;
}
});
items.push(modeSel);
const reasonWrap = el("label", { class: "switch-wrap u-switch-menu" }, [
el("input", { type: "checkbox", class: "switch-input" }),
el("span", { class: "switch-slider" }),
el("span", { class: "muted", text: t("chat.tools") }),
]);
const reasonCb = reasonWrap.querySelector("input.switch-input");
try {
reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible());
} catch (_) {}
reasonCb.addEventListener("change", () => {
try {
Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {});
} catch (_) {}
});
items.push(reasonWrap);
}
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "lang",
text: t("lang.switch"),
}),
);
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "logout",
text: t("auth.logout"),
}),
);
const menu = el("div", {
class: "chat-sess-menu-pop u-pop-menu",
}, items);
// Inline position/z-index: chat.html used to override class z-index below the scrim.
menu.style.position = "fixed";
menu.style.zIndex = "300";
const rect = (anchorEl || moreBtn).getBoundingClientRect();
attachChatMenuDismiss(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
};
const nameBtn = el("button", {
type: "button",
class: "chat-sess-btn",
text: name,
title: name,
onclick: (ev) => {
ev.stopPropagation();
openUserMenu(ev.currentTarget);
},
});
const moreBtn = el("button", {
type: "button",
class: "chat-sess-more",
text: "⋯",
title: t("chat.sessionMenu"),
onclick: (ev) => {
ev.stopPropagation();
openUserMenu(ev.currentTarget);
},
});
const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]);
user.appendChild(row);
}
async function fileToPayloadEntry(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const dataUrl = String(r.result || "");
const idx = dataUrl.indexOf(",");
const data_base64 = idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl;
resolve({ name: file.name || "file", data_base64 });
};
r.onerror = () => reject(r.error || new Error("file_read_failed"));
r.readAsDataURL(file);
});
}
async function renderLogin() {
applyI18nStatic();
if (!state.loginDraft) state.loginDraft = { username: "", password: "" };
const draft = state.loginDraft;
const username = el("input", {
class: "input",
placeholder: t("auth.username"),
autocomplete: "username",
spellcheck: "false",
});
username.value = draft.username || "";
const password = el("input", {
class: "input",
type: "password",
placeholder: t("auth.password"),
autocomplete: "current-password",
});
password.value = draft.password || "";
const status = el("div", { class: "muted", text: "" });
const btn = el("button", {
class: "btn btn--primary",
type: "button",
text: t("auth.login"),
});
let busy = false;
const syncDraft = () => {
draft.username = String(username.value || "");
draft.password = String(password.value || "");
};
username.addEventListener("input", syncDraft);
password.addEventListener("input", syncDraft);
const setBusy = (on) => {
busy = !!on;
btn.disabled = busy;
btn.textContent = busy ? t("auth.loggingIn") : t("auth.login");
};
const doLogin = async () => {
if (busy) return;
syncDraft();
setBusy(true);
status.textContent = t("auth.loggingIn");
try {
const resp = await apiPost("/admin/api/auth/login", {
tenant_id: "",
username: username.value.trim().toLowerCase(),
password: password.value.trim(),
purpose: "chat",
});
if (!resp || !resp.ok || !resp.token) {
const err = String((resp && resp.error) || "");
status.textContent =
err === "user_disabled"
? t("auth.disabled")
: err === "admin_role_required"
? t("auth.chatLoginDenied")
: t("auth.invalid");
setBusy(false);
return;
}
draft.username = "";
draft.password = "";
localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token));
localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
state.authSession = resp.session || null;
mount(
el("div", { class: "chat-app--login" }, [
el("div", { class: "card u-modal-card-sm" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "muted", text: t("chat.loading") }),
]),
]),
);
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
await invokeChatBoot();
} catch (err) {
if (err && err.authRequired) return;
mount(
el("div", { class: "chat-app--login" }, [
el("div", { class: "card u-modal-card-sm" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "muted", text: String((err && err.message) || err || t("auth.invalid")) }),
el("div", { class: "row" }, [
el("button", {
class: "btn btn--primary",
type: "button",
text: t("auth.login"),
onclick: () => invokeChatBoot().catch(() => {}),
}),
]),
]),
]),
);
}
};
const onEnterLogin = (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
doLogin();
};
username.addEventListener("keydown", onEnterLogin);
password.addEventListener("keydown", onEnterLogin);
btn.addEventListener("click", (ev) => {
ev.preventDefault();
doLogin();
});
const card = el("div", { class: "card u-modal-card-sm", "data-chat-login": "1" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "chat-login-fields" }, [username, password]),
el("div", { class: "row chat-login-actions" }, [btn]),
status,
]);
setTimeout(() => {
try {
if (document.activeElement === username || document.activeElement === password) return;
if (draft.username && !draft.password) password.focus();
else username.focus();
} catch (_) {}
}, 0);
return el("div", { class: "chat-app--login" }, [card]);
}
async function downloadExport(sessionId, format) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const q = format === "json" ? "format=json" : "format=md";
const url = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`;
const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `chat-${sessionId.slice(0, 8)}.${format === "json" ? "json" : "md"}`;
a.click();
URL.revokeObjectURL(a.href);
}
const CHAT_ICON_CLIP =
'';
const CHAT_ICON_SEND =
'';
const CHAT_ICON_STOP =
'';
export {
PAGE_SIZE,
CHAT_MESSAGES_FETCH_LIMIT,
AUTH_TOKEN_KEY,
AUTH_SESSION_KEY,
CHAT_URL_SCOPE_KEY,
CHAT_SPECIALIST_PREF_KEY,
CHAT_INTERACTION_MODE_KEY,
CHAT_MEMORY_MODE_KEY,
CHAT_EXECUTION_MODE_KEY,
CHAT_USER_MENU_MODE_KEY,
CHAT_REASONING_TOGGLE_KEY,
EXECUTION_MODE_AGENT,
EXECUTION_MODE_PLAN,
ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT,
REASONING_BLOCK_MAX_CHARS,
CHAT_ENABLE_WIKI_EVENT_POLLER,
_toolSummaryTitle,
_normalizeEventType,
_isAssistantBodyEventType,
_parseEventPayload,
_isScheduledProactiveMessage,
_messageTurnUuid,
_pushScheduledAssistantRow,
_collapsedBlockNode,
_appendCollapsedBundle,
_appendAssistantTextSegments,
_normFoldDedupText,
_foldProcessTextRedundant,
_preferredAttachmentOwnerById,
_attachmentsForBubbleItem,
_buildAggregatedAssistantBubble,
_buildRenderRows,
_needsWsTextFallbackFromRenderRows,
interactionModeLabel,
specialistLabel,
memoryModeShortLabel,
_ensureToastHost,
showToast,
_jobsPanelPollTimer,
_jobsBadgePollTimer,
_jobStatusLabel,
_fmtJobTs,
updateJobsBadge,
refreshJobsBadge,
startJobsBadgePoller,
stopJobsBadgePoller,
openBackgroundJobsPanel,
openWikiPreviewModal,
reasonLabel,
fetchDynamicExpertStats,
getDispatchReasonLabelsConfig,
setDispatchReasonLabelsConfig,
openDispatchLabelsEditor,
getSessionIdFromUrl,
forceReloginRequested,
clearAuthAndReloginFlagFromUrl,
replaceSessionUrl,
withTimeout,
apiGet,
apiPost,
apiPatch,
apiDelete,
el,
escapeHtml,
formatChatSessionTitle,
_peerNameLookupKeys,
_channelPeerNameMap,
lookupChannelPeerName,
refreshChannelPeerNameMap,
_chatLightboxKeyHandler,
_chatLightboxPrevOverflow,
closeChatImageLightbox,
dismissChatMenus,
isChatStreaming,
clearChatPageBlockers,
attachChatMenuDismiss,
openChatImageLightbox,
openChatMermaidLightbox,
bindChatImageViewer,
bindChatMermaidViewer,
renderMarkdownHtml,
_mermaidBootstrapped,
_mermaidRetryTimer,
hydrateMermaidIn,
RE_REDACTED_THINKING,
RE_THINK_TAG,
RE_THINKING_TAG,
RE_THOUGHT_TAG,
_findEarliestReasoningBlock,
parseReasoningSegments,
_OC_QUICK_TAG_RE,
_OC_FINAL_TAG_RE,
_OC_THINKING_TAG_RE,
_findCodeFenceRegions,
_isInsideRegion,
stripReasoningTagsFromText,
extractThinkingFromText,
extractWsAssistantText,
decodeEscapedNewlines,
normalizeStreamText,
normalizeStreamDisplayText,
normalizeStreamBodyForUi,
createStreamStitcher,
formatToolPanelText,
extractSqlAuditPayload,
extractToolImageItems,
_sqlLimitSuffix,
_tokenizeSqlForDiff,
_renderExecutedSqlWithAddedHighlight,
formatChatTimestamp,
prependMessageTime,
CHAT_BOT_LOGO_SRC,
CHAT_BOT_LOGO_FALLBACK_SRC,
DEFAULT_USER_AVATAR_SRC,
loadMeProfile,
buildBotAvatarImg,
buildUserAvatarSlot,
wrapAssistantMessage,
wrapUserMessage,
buildMessageBubble,
_blobUrlCache,
_attachmentTextPreviewCache,
fetchAttachmentBlobUrl,
fetchAttachmentTextPreview,
parseAttachments,
renderAttachmentsEl,
appendMessageRow,
mount,
buildChatBrandLogoNode,
resolveAdminHashUrl,
openAdminFromChat,
syncAuthUserLabel,
fileToPayloadEntry,
renderLogin,
downloadExport,
CHAT_ICON_CLIP,
CHAT_ICON_SEND,
CHAT_ICON_STOP,
state,
t,
applyI18nStatic,
LANG_KEY,
I18N,
};