/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */
const PAGE_SIZE = 35;
const I18N = {
zh: {
"chat.pageTitle": "oliver",
"chat.backAdmin": "管理台",
"chat.newSession": "开启新对话",
"chat.send": "发送",
"chat.stop": "停止",
"chat.sessions": "会话",
"chat.modelProfile": "模型",
"chat.placeholder": "输入消息…",
"chat.empty": "暂无消息",
"chat.noSessions": "暂无会话,点击新建开始。",
"chat.loading": "加载中…",
"chat.sending": "发送中…",
"chat.error": "请求失败",
"chat.loadMore": "加载更多",
"chat.sessionMenu": "会话操作",
"chat.rename": "重命名",
"chat.delete": "删除",
"chat.deleteConfirm": "删除此会话?",
"chat.exportMd": "导出 Markdown",
"chat.exportJson": "导出 JSON",
"chat.fork": "复制会话",
"chat.audit": "审计与追踪",
"chat.myProfile": "设置",
"chat.attach": "附件",
"chat.tools": "推理",
"chat.tools.hidden": "推理已隐藏",
"chat.tools.visible": "推理已显示",
"chat.tools.off": "关",
"chat.tools.on": "开",
"chat.status.oclaw": "主控",
"chat.status.agent": "专家",
"chat.status.tool": "工具",
"chat.status.plan": "规划",
"chat.status.start": "启动",
"chat.status.running": "执行中",
"chat.status.end": "完成",
"chat.status.error": "异常",
"chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})",
"chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}",
"chat.marker.reclaimed": "本轮回收 turn 标记 {n}",
"chat.historyTruncated": "仅显示最近 {shown} 条,共 {total} 条",
"reasoning.summary": "模型推理片段",
"tool.streamTitle": "工具与进度(本轮)",
"auth.login": "登录",
"auth.logout": "退出登录",
"auth.username": "用户名",
"auth.password": "密码",
"auth.invalid": "登录失败,请检查凭据",
"auth.consoleAdminOnly": "仅管理员(admin 角色)可登录管理台。",
"auth.chatLoginDenied": "当前账号无法使用对话(请联系管理员)。",
"auth.disabled": "账号已被禁用,请联系管理员",
"common.error": "错误",
"lang.switch": "English",
"chat.imageViewerClose": "关闭",
"chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭",
"chat.specialistLabel": "专家",
"chat.modeLabel": "模式",
"chat.modeComprehensive": "综合",
"chat.modeExpert": "专家",
"chat.specialistGeneralist": "通用",
"chat.specialistOps": "运维",
"chat.specialistImage": "图像",
"chat.specialistMemoryCurator": "记忆整理",
"chat.memoryModeLabel": "记忆模式",
"chat.memoryModeDefault": "默认(可注入)",
"chat.memoryModeStoreOnly": "仅记录不注入",
"chat.memoryApplied": "记忆:{mode}",
"chat.memoryModeDefaultShort": "默认",
"chat.memoryModeStoreOnlyShort": "仅记录",
"chat.wikiToastMerged": "Wiki 已收录:+{merged}(去重跳过 {skipped})",
"chat.wikiToastFailed": "Wiki 收录失败:{error}",
"chat.wikiViewMerged": "查看 merged-turns",
"chat.wikiViewTopic": "查看 topic:{topic}",
"chat.wikiPreviewTitle": "Wiki 预览:{path}",
"chat.wikiPreviewClose": "关闭",
"chat.specialistManage": "专家开关",
"chat.specialistManagePromptOn": "是否启用 memory_curator 专家?",
"chat.specialistManagePromptOff": "是否关闭 memory_curator 专家?",
"chat.specialistManageDenied": "无权限修改专家开关",
"chat.attachmentLimits": "附件阈值",
"chat.attachmentLimitsPrompt": "编辑附件阈值 JSON(留空恢复默认)",
"chat.attachmentLimitsSaved": "附件阈值已更新",
"chat.attachmentLimitsDenied": "无权限修改附件阈值",
"chat.attachmentLimitsInvalidJson": "附件阈值 JSON 不合法",
"chat.attachmentLimitsTitle": "附件阈值配置",
"chat.attachmentLimitsRows": "最大读取行数",
"chat.attachmentLimitsCols": "最大保留列数",
"chat.attachmentLimitsCellChars": "单元格最大字符数",
"chat.attachmentLimitsMaxSheets": "Excel 最大 Sheet 数",
"chat.attachmentLimitsLargePreviewRows": "大表摘要预览行数",
"chat.attachmentLimitsToolEnabled": "启用大表工具模式",
"chat.attachmentLimitsToolMinRows": "触发工具模式最小行数",
"chat.attachmentLimitsToolMaxBytes": "工具模式最大文件字节数",
"chat.attachmentLimitsSqlTimeoutMs": "SQL 超时(毫秒)",
"chat.attachmentLimitsReset": "恢复默认",
"chat.attachmentLimitsCancel": "取消",
"chat.attachmentLimitsSave": "保存",
"chat.attachmentLimitsInvalidNumber": "请输入有效正整数",
"chat.attachmentLimitsHighPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?",
"chat.activeModelLabel": "模型",
"chat.execApplied": "本轮执行:{mode} / {specialist}",
"chat.dynamicStats": "动态专家近窗:命中 {dynamic},回退 {fallback}",
"chat.dynamicStatsDetail": "命中率 {rate}% · Top原因 {reason}",
"chat.dynamicTopReasons": "原因分布 {items}",
"chat.dynamicAllReasons": "查看完整分布",
"chat.dynamicAllReasonsTitle": "完整原因分布",
"chat.dispatchLabelsEdit": "编辑原因文案",
"chat.dispatchLabelsPrompt": "编辑 dispatch reason 覆盖 JSON(留空清空)",
"chat.dispatchLabelsSaved": "原因文案已更新",
"chat.dispatchLabelsTitle": "编辑原因文案覆盖",
"chat.dispatchLabelsSave": "保存",
"chat.dispatchLabelsClear": "清空",
"chat.dispatchLabelsCancel": "取消",
"chat.dispatchLabelsInvalidJson": "JSON 格式不合法",
"chat.dispatchLabelsEffectivePreview": "Effective 预览",
"chat.dispatchLabelsDiffOnly": "仅显示变更项",
"chat.dispatchLabelsExport": "导出 Effective",
"chat.dispatchLabelsImport": "导入 JSON",
"chat.dispatchLabelsRestoreDefaults": "恢复默认模板",
"chat.dispatchLabelsUnsavedConfirm": "当前有未保存变更,确认关闭?",
"chat.reason.manager_no_specialist_fallback": "无可用专家,回退通用",
"chat.reason.dynamic_agent_build_failed": "动态专家构建失败",
"chat.reason.manager_factory_failed": "专家执行器创建失败",
"chat.reason.manager_route_missing": "总控路由缺失",
"chat.reason.manager_select_failed": "总控决策失败",
"chat.reason.manager_model_missing": "总控模型不可用",
"chat.reason.dynamic_agent_selected": "动态专家命中",
"chat.modeComprehensiveShort": "综合",
"chat.modeExpertShort": "专家",
"chat.specialistGeneralistShort": "通用",
"chat.specialistOpsShort": "运维",
"chat.specialistImageShort": "图像",
"chat.specialistMemoryCuratorShort": "记忆整理",
},
en: {
"chat.pageTitle": "oliver",
"chat.backAdmin": "Admin console",
"chat.newSession": "New chat",
"chat.send": "Send",
"chat.stop": "Stop",
"chat.sessions": "Sessions",
"chat.modelProfile": "Model",
"chat.placeholder": "Message…",
"chat.empty": "No messages yet",
"chat.noSessions": "No sessions yet. Create one to start.",
"chat.loading": "Loading…",
"chat.sending": "Sending…",
"chat.error": "Request failed",
"chat.loadMore": "Load more",
"chat.sessionMenu": "Session actions",
"chat.rename": "Rename",
"chat.delete": "Delete",
"chat.deleteConfirm": "Delete this session?",
"chat.exportMd": "Export Markdown",
"chat.exportJson": "Export JSON",
"chat.fork": "Duplicate chat",
"chat.audit": "Audit & trace",
"chat.myProfile": "Settings",
"chat.attach": "Attach",
"chat.tools": "Reasoning",
"chat.tools.hidden": "Reasoning hidden",
"chat.tools.visible": "Reasoning visible",
"chat.tools.off": "Off",
"chat.tools.on": "On",
"chat.status.oclaw": "Controller",
"chat.status.agent": "Agent",
"chat.status.tool": "Tool",
"chat.status.plan": "Plan",
"chat.status.start": "Starting",
"chat.status.running": "Running",
"chat.status.end": "Done",
"chat.status.error": "Error",
"chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})",
"chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}",
"chat.marker.reclaimed": "Turn markers reclaimed {n}",
"chat.historyTruncated": "Showing last {shown} of {total} messages",
"reasoning.summary": "Model reasoning",
"tool.streamTitle": "Tools & progress (this turn)",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.username": "Username",
"auth.password": "Password",
"auth.invalid": "Login failed",
"auth.consoleAdminOnly": "Only users with the admin role may sign in to the console.",
"auth.chatLoginDenied": "This account cannot use chat (contact an admin).",
"auth.disabled": "Account is disabled",
"common.error": "Error",
"lang.switch": "中文",
"chat.imageViewerClose": "Close",
"chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close",
"chat.specialistLabel": "Specialist",
"chat.modeLabel": "Mode",
"chat.modeComprehensive": "Comprehensive",
"chat.modeExpert": "Expert",
"chat.specialistGeneralist": "Generalist",
"chat.specialistOps": "Ops",
"chat.specialistImage": "Image",
"chat.specialistMemoryCurator": "Memory Curator",
"chat.memoryModeLabel": "Memory Mode",
"chat.memoryModeDefault": "Default (inject allowed)",
"chat.memoryModeStoreOnly": "Store only (no inject)",
"chat.memoryApplied": "Memory: {mode}",
"chat.memoryModeDefaultShort": "default",
"chat.memoryModeStoreOnlyShort": "store_only",
"chat.wikiToastMerged": "Wiki captured: +{merged} (dedup skipped {skipped})",
"chat.wikiToastFailed": "Wiki capture failed: {error}",
"chat.wikiViewMerged": "View merged-turns",
"chat.wikiViewTopic": "View topic: {topic}",
"chat.wikiPreviewTitle": "Wiki preview: {path}",
"chat.wikiPreviewClose": "Close",
"chat.specialistManage": "Specialist Toggle",
"chat.specialistManagePromptOn": "Enable memory_curator specialist?",
"chat.specialistManagePromptOff": "Disable memory_curator specialist?",
"chat.specialistManageDenied": "Not authorized to change specialist flags",
"chat.attachmentLimits": "Attachment limits",
"chat.attachmentLimitsPrompt": "Edit attachment limits JSON (empty to restore defaults)",
"chat.attachmentLimitsSaved": "Attachment limits updated",
"chat.attachmentLimitsDenied": "Not authorized to change attachment limits",
"chat.attachmentLimitsInvalidJson": "Invalid attachment limits JSON",
"chat.attachmentLimitsTitle": "Attachment limits",
"chat.attachmentLimitsRows": "Max rows read",
"chat.attachmentLimitsCols": "Max columns kept",
"chat.attachmentLimitsCellChars": "Max chars per cell",
"chat.attachmentLimitsMaxSheets": "Max Excel sheets",
"chat.attachmentLimitsLargePreviewRows": "Large-table preview rows",
"chat.attachmentLimitsToolEnabled": "Enable large-table tool mode",
"chat.attachmentLimitsToolMinRows": "Min rows to trigger tool mode",
"chat.attachmentLimitsToolMaxBytes": "Max file bytes for tool mode",
"chat.attachmentLimitsSqlTimeoutMs": "SQL timeout (ms)",
"chat.attachmentLimitsReset": "Reset defaults",
"chat.attachmentLimitsCancel": "Cancel",
"chat.attachmentLimitsSave": "Save",
"chat.attachmentLimitsInvalidNumber": "Please enter valid positive integers",
"chat.attachmentLimitsHighPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?",
"chat.activeModelLabel": "Model",
"chat.execApplied": "Applied: {mode} / {specialist}",
"chat.dynamicStats": "Dynamic experts (recent): hit {dynamic}, fallback {fallback}",
"chat.dynamicStatsDetail": "Hit rate {rate}% · Top reason {reason}",
"chat.dynamicTopReasons": "Reason mix {items}",
"chat.dynamicAllReasons": "View full mix",
"chat.dynamicAllReasonsTitle": "Full reason mix",
"chat.dispatchLabelsEdit": "Edit reason labels",
"chat.dispatchLabelsPrompt": "Edit dispatch reason override JSON (empty to clear)",
"chat.dispatchLabelsSaved": "Reason labels updated",
"chat.dispatchLabelsTitle": "Edit reason label overrides",
"chat.dispatchLabelsSave": "Save",
"chat.dispatchLabelsClear": "Clear",
"chat.dispatchLabelsCancel": "Cancel",
"chat.dispatchLabelsInvalidJson": "Invalid JSON format",
"chat.dispatchLabelsEffectivePreview": "Effective preview",
"chat.dispatchLabelsDiffOnly": "Show changed only",
"chat.dispatchLabelsExport": "Export effective",
"chat.dispatchLabelsImport": "Import JSON",
"chat.dispatchLabelsRestoreDefaults": "Restore defaults template",
"chat.dispatchLabelsUnsavedConfirm": "You have unsaved changes. Close anyway?",
"chat.reason.manager_no_specialist_fallback": "No specialist; fallback to generalist",
"chat.reason.dynamic_agent_build_failed": "Dynamic agent build failed",
"chat.reason.manager_factory_failed": "Specialist executor factory failed",
"chat.reason.manager_route_missing": "Manager route missing",
"chat.reason.manager_select_failed": "Manager selection failed",
"chat.reason.manager_model_missing": "Manager model missing",
"chat.reason.dynamic_agent_selected": "Dynamic agent selected",
"chat.modeComprehensiveShort": "Comprehensive",
"chat.modeExpertShort": "Expert",
"chat.specialistGeneralistShort": "Generalist",
"chat.specialistOpsShort": "Ops",
"chat.specialistImageShort": "Image",
"chat.specialistMemoryCuratorShort": "Memory Curator",
},
};
const LANG_KEY = "ops_admin_lang";
let currentLang = (localStorage.getItem(LANG_KEY) || "zh").toLowerCase();
if (!I18N[currentLang]) currentLang = "zh";
/** 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 ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false;
let adminChatShowToolOutput = ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT;
let authSession = null;
function _toolSummaryTitle(role) {
const r = String(role || "").toLowerCase();
if (r === "tool" || r === "function") return "工具结果";
return "工具调用";
}
function _normalizeEventType(v) {
return String(v || "").trim().toLowerCase();
}
function _collapsedBlockNode(title, text) {
const box = document.createElement("div");
box.className = "chat-msg__reasoning-block";
box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") }));
box.appendChild(el("pre", { class: "chat-msg__reasoning-pre", text: String(text || "") || "—" }));
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 = 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 (!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 _buildAggregatedAssistantBubble(tsIso, items) {
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
for (const it of items || []) {
const kind = String((it && it.kind) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
if (kind === "assistant_text") {
_appendAssistantTextSegments(inner, text, collapsedItems);
} else if (kind === "reasoning") {
if (adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text });
} else if (kind === "tool_call") {
if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool_call"), text });
} else if (kind === "tool_result") {
if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text });
} 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);
}
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;
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") {
if (!agg) {
agg = {
role: "assistant",
content: "",
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: null,
_items: [],
};
}
if (role === "assistant") {
if (eventType === "reasoning") {
if (String(content || "").trim()) {
agg._items.push({ kind: "reasoning", text: content });
}
} else if (String(content || "").trim()) {
agg._items.push({ kind: "assistant_text", text: content });
}
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 if (String(content || "").trim()) {
agg._items.push({ kind: "tool_result", text: content });
}
continue;
}
flush();
rows.push(m);
}
flush();
return rows;
}
function t(key, vars) {
let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en && I18N.en[key]) || key;
if (vars && typeof s === "string") {
for (const [k, v] of Object.entries(vars)) {
s = s.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
}
return s;
}
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();
if (s === "ops") return t("chat.specialistOpsShort");
if (s === "image") return t("chat.specialistImageShort");
if (s === "memory_curator") return t("chat.specialistMemoryCuratorShort");
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",
style:
"position:fixed;right:14px;bottom:14px;z-index:9999;display:flex;flex-direction:column;gap:8px;max-width:420px;pointer-events:none;",
});
document.body.appendChild(host);
return host;
}
function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
const host = _ensureToastHost();
const bg = kind === "error" ? "rgba(160,40,40,0.96)" : "rgba(30,30,30,0.92)";
const node = el("div", {
class: "chat-toast",
text: String(text || ""),
style:
`pointer-events:auto;padding:10px 12px;border-radius:10px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);` +
`background:${bg};font-size:12.5px;line-height:1.35;white-space:pre-wrap;`,
});
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;
}
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",
style: "z-index:9998;",
});
const card = el("div", {
class: "chat-confirm-card",
style: "max-width:980px;width:92vw;max-height:78vh;overflow:auto;",
});
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", style: "gap:8px;justify-content:space-between;align-items:center;" }, [
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", {
style:
"position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;",
});
const card = el("div", {
class: "card",
style: "width:min(860px,96vw);max-height:86vh;overflow:auto;padding:12px;",
});
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", style: "color:#dc2626;" });
const ta = el("textarea", {
class: "input",
style: "width:100%;min-height:320px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;",
});
ta.value = initText;
const previewTitle = el("div", { class: "muted", text: t("chat.dispatchLabelsEffectivePreview") });
const diffOnlyWrap = el("label", { class: "muted", style: "display:flex;gap:6px;align-items:center;cursor:pointer;" });
const diffOnlyCb = el("input", { type: "checkbox" });
diffOnlyWrap.appendChild(diffOnlyCb);
diffOnlyWrap.appendChild(el("span", { text: t("chat.dispatchLabelsDiffOnly") }));
const preview = el("textarea", {
class: "input",
style:
"width:100%;min-height:220px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#f8fafc;",
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", { type: "file", accept: "application/json,.json", style: "display:none" });
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", { style: "height:8px;" }));
card.appendChild(ta);
card.appendChild(el("div", { style: "height:8px;" }));
card.appendChild(previewTitle);
card.appendChild(diffOnlyWrap);
card.appendChild(preview);
card.appendChild(el("div", { style: "height:8px;" }));
card.appendChild(err);
card.appendChild(
el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:8px;" }, [
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 applyI18nStatic() {
document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en";
document.querySelectorAll("[data-i18n]").forEach((node) => {
const key = node.getAttribute("data-i18n");
node.textContent = t(key);
});
}
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);
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);
}
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);
authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
}
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 ?? {}),
});
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
}
if (!res.ok) throw new Error(`POST ${path} ${res.status}`);
return await res.json();
}
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);
authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
}
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);
authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
}
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, """);
}
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 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" });
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();
},
});
inner.appendChild(closeBtn);
inner.appendChild(big);
backdrop.appendChild(inner);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeChatImageLightbox();
});
document.body.appendChild(backdrop);
}
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") || "");
});
}
/** 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, "
")}
`;
}
const RE_REDACTED_THINKING = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
const RE_THINK_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i");
function _findEarliestReasoningBlock(remaining) {
let best = null;
for (const re of [RE_REDACTED_THINKING, RE_THINK_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]) {
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) {
const n = String(name || "").trim() || "tool";
const p = payload && typeof payload === "object" ? payload : {};
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 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).replace(/\\n/g, "").replace(/\n/g, "");
return `${n}\n${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 _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 = 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/assets/oliver.svg";
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
let meProfile = null;
async function loadMeProfile() {
try {
const r = await apiGet("/admin/api/chat/profile");
meProfile = r && r.ok && r.profile ? r.profile : null;
} catch (_) {
meProfile = null;
}
}
function buildBotAvatarImg() {
return el("img", {
class: "chat-avatar chat-avatar--bot",
src: CHAT_BOT_LOGO_SRC,
alt: "",
loading: "lazy",
});
}
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 = meProfile && String(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" && !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 (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);
}
}
return wrapAssistantMessage(inner, tsIso);
}
const _blobUrlCache = 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;
}
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" });
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();
const img = el("img", { class: "chat-att-img", src, alt: "" });
wrap.appendChild(img);
} 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 {
wrap.appendChild(el("span", { class: "chat-att-chip", text: `📄 ${String(att.name || "file")}` }));
}
}
return wrap.children.length ? wrap : null;
}
async function appendMessageRow(messagesEl, m) {
const role = String(m.role || "");
const content = String(m.content || "");
const ts = m.timestamp != null ? m.timestamp : "";
const bubble = Array.isArray(m._items)
? _buildAggregatedAssistantBubble(ts, m._items)
: await buildMessageBubble(role, content, ts);
const att = await renderAttachmentsEl(m.attachments);
if (att) {
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) innerBubble.appendChild(att);
else bubble.appendChild(att);
}
messagesEl.appendChild(bubble);
}
function mount(node) {
const c = document.getElementById("app");
c.innerHTML = "";
c.appendChild(node);
}
function buildChatBrandLogoNode() {
return el("div", { class: "chat-nav__brandWrap" }, [
el("img", {
class: "chat-nav__brandLogo",
src: "/admin/assets/oliver.svg",
alt: "oliver logo",
}),
]);
}
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((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || "");
if (!name) return;
const nameBtn = el("button", {
type: "button",
class: "chat-sess-btn",
text: name,
title: name,
});
const moreBtn = el("button", {
type: "button",
class: "chat-sess-more",
text: "⋯",
title: t("chat.sessionMenu"),
onclick: (ev) => {
ev.stopPropagation();
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, [
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": "lang",
text: t("lang.switch"),
}),
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "dispatchLabels",
text: t("chat.dispatchLabelsEdit"),
}),
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "logout",
text: t("auth.logout"),
}),
]);
const rect = moreBtn.getBoundingClientRect();
menu.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`;
menu.style.top = `${Math.max(8, rect.top - 92)}px`;
document.body.appendChild(menu);
const close = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]);
user.appendChild(row);
}
async function fileToPayloadEntry(file) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
const data_base64 = btoa(binary);
return { name: file.name || "file", data_base64 };
}
async function renderLogin() {
applyI18nStatic();
const username = el("input", { class: "input", placeholder: t("auth.username") });
const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") });
const status = el("div", { class: "muted", text: "" });
const doLogin = async () => {
const resp = await apiPost("/admin/api/auth/login", {
tenant_id: "",
username: username.value.trim().toLowerCase(),
password: password.value.trim(),
purpose: "chat",
});
if (!resp.ok || !resp.token) {
const err = String(resp.error || "");
status.textContent =
err === "user_disabled"
? t("auth.disabled")
: err === "admin_role_required"
? t("auth.chatLoginDenied")
: t("auth.invalid");
return;
}
localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token));
localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
authSession = resp.session || null;
await boot();
};
const onEnterLogin = (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
doLogin();
};
username.addEventListener("keydown", onEnterLogin);
password.addEventListener("keydown", onEnterLogin);
const btn = el("button", {
class: "btn btn--primary",
text: t("auth.login"),
onclick: doLogin,
});
const card = el("div", { class: "card", style: "max-width:440px;width:100%" }, [
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 {
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 =
'';
let _statusReasonPairs = [];
async function renderChatUi() {
let sessionId = getSessionIdFromUrl();
try {
const curScope = JSON.stringify({
t: String((authSession && authSession.tenant_id) || ""),
u: String((authSession && authSession.user_id) || ""),
});
const prev = localStorage.getItem(CHAT_URL_SCOPE_KEY) || "";
if (prev && prev !== curScope) {
replaceSessionUrl("");
sessionId = "";
}
localStorage.setItem(CHAT_URL_SCOPE_KEY, curScope);
} catch (_) {}
const statusBar = el("div", { class: "chat-status", text: t("chat.loading") });
const sessionsListEl = el("div", { class: "chat-sessions__list" });
const loadMoreWrap = el("div", { class: "chat-load-more" });
const messagesEl = el("div", { class: "chat-messages" });
bindChatImageViewer(messagesEl);
const textarea = el("textarea", {
class: "chat-composer__field",
rows: "1",
placeholder: t("chat.placeholder"),
});
const fileInput = el("input", { type: "file", multiple: "multiple", style: "display:none", accept: "*/*" });
const attachBtn = el("button", {
type: "button",
class: "chat-composer-iconbtn",
html: CHAT_ICON_CLIP,
title: t("chat.attach"),
"aria-label": t("chat.attach"),
});
const pendingFilesEl = el("div", { class: "chat-pending-files" });
const btnSend = el("button", {
type: "button",
class: "chat-composer-send",
html: CHAT_ICON_SEND,
title: t("chat.send"),
"aria-label": t("chat.send"),
});
const btnStop = el("button", {
type: "button",
class: "chat-composer-stop",
html: CHAT_ICON_STOP,
title: t("chat.stop"),
"aria-label": t("chat.stop"),
disabled: "disabled",
});
const composerShell = el("div", { class: "chat-composer-shell" });
const toolToggleBtn = el("button", {
type: "button",
class: "btn",
style: "padding:4px 8px;min-height:auto;font-size:12px;",
});
const specialistSelect = el("select", {
class: "input",
style: "min-width:120px;max-width:160px;padding:6px 8px;",
});
let specialistFlags = {
generalist: true,
ops: true,
image: true,
memory_curator: true,
};
const modeSelect = el("select", {
class: "input",
style: "min-width:120px;max-width:160px;padding:6px 8px;",
});
modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") }));
modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") }));
const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "comprehensive").toLowerCase();
modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "comprehensive";
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
modeSelect.addEventListener("change", () => {
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive"));
saveSessionModePreference();
});
const applySpecialistOptions = () => {
specialistSelect.innerHTML = "";
const opts = [
{ value: "generalist", text: t("chat.specialistGeneralist"), enabled: true },
{ value: "ops", text: t("chat.specialistOps"), enabled: !!specialistFlags.ops },
{ value: "image", text: t("chat.specialistImage"), enabled: !!specialistFlags.image },
{ value: "memory_curator", text: t("chat.specialistMemoryCurator"), enabled: !!specialistFlags.memory_curator },
];
for (const op of opts) {
if (!op.enabled) continue;
specialistSelect.appendChild(el("option", { value: op.value, text: op.text }));
}
if (!specialistSelect.value) {
specialistSelect.value = "generalist";
}
if (!Array.from(specialistSelect.options).some((o) => String(o.value || "") === String(specialistSelect.value || ""))) {
specialistSelect.value = "generalist";
}
};
const loadSpecialistFlags = async () => {
try {
const r = await apiGet("/admin/api/chat/settings/specialist-flags");
if (r && r.ok && r.flags && typeof r.flags === "object") {
specialistFlags = {
...specialistFlags,
...r.flags,
};
}
} catch (_) {
// non-admin users or unavailable endpoint: keep defaults
}
applySpecialistOptions();
};
const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase();
specialistSelect.value = ["generalist", "ops", "image", "memory_curator"].includes(_sp) ? _sp : "generalist";
await loadSpecialistFlags();
specialistSelect.addEventListener("change", () => {
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
saveSessionModePreference();
});
const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` });
const refreshActiveModelText = async () => {
try {
const ms = await apiGet("/admin/api/models");
const profiles = Array.isArray(ms.profiles) ? ms.profiles : [];
const aid = String(ms.active_llm_profile_id || "");
const p = profiles.find((x) => String(x.id || "") === aid) || null;
const modelName = String((p && p.model) || "").trim() || "-";
activeModelText.textContent = `${t("chat.activeModelLabel")}: ${modelName}`;
} catch (_) {
activeModelText.textContent = `${t("chat.activeModelLabel")}: -`;
}
};
const loadSessionModePreference = async () => {
if (!activeId) return;
try {
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`);
const m = String((resp && resp.interaction_mode) || "").toLowerCase();
const s = String((resp && resp.specialist) || "").toLowerCase();
const mm = String((resp && resp.memory_mode) || "").toLowerCase();
if (["comprehensive", "expert"].includes(m)) modeSelect.value = m;
if (["generalist", "ops", "image", "memory_curator"].includes(s)) specialistSelect.value = s;
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
specialistSelect.value = "generalist";
}
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive"));
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
syncModeSpecialistUi();
} catch (_) {}
};
const saveSessionModePreference = async () => {
if (!activeId) return;
try {
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
interaction_mode: String(modeSelect.value || "comprehensive"),
specialist:
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
: "generalist",
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
});
} catch (_) {}
};
await refreshActiveModelText();
const specialistLabelEl = el("span", { class: "muted", text: t("chat.specialistLabel") });
const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [
el("span", { class: "muted", text: t("chat.modeLabel") }),
modeSelect,
specialistLabelEl,
specialistSelect,
toolToggleBtn,
]);
const syncModeSpecialistUi = () => {
const m = String(modeSelect.value || "comprehensive").toLowerCase();
const showSpecialist = m === "expert";
specialistLabelEl.style.display = showSpecialist ? "" : "none";
specialistSelect.style.display = showSpecialist ? "" : "none";
specialistSelect.disabled = !showSpecialist;
};
modeSelect.addEventListener("change", () => {
syncModeSpecialistUi();
});
syncModeSpecialistUi();
const fitComposerTextarea = () => {
textarea.style.height = "auto";
const h = Math.min(Math.max(textarea.scrollHeight, 44), 200);
textarea.style.height = `${h}px`;
};
const syncSendEnabled = () => {
const ok = String(textarea.value || "").trim().length > 0 || pendingFiles.length > 0;
btnSend.disabled = !ok;
};
textarea.addEventListener("input", () => {
fitComposerTextarea();
syncSendEnabled();
});
requestAnimationFrame(() => {
fitComposerTextarea();
syncSendEnabled();
});
let sessions = [];
let sessionTotal = 0;
let pendingFiles = [];
let activeId = sessionId;
let showToolOutput = adminChatShowToolOutput;
let wikiPollTimerId = null;
let wikiAfterFinishedAt = {};
const idsMatch = (a, b) => String(a || "") === String(b || "");
const confirmChatAction = (message) =>
new Promise((resolve) => {
const backdrop = el("div", { class: "chat-confirm-backdrop" });
const card = el("div", { class: "chat-confirm-card" });
const text = el("div", { class: "chat-confirm-text", text: String(message || "") });
const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") });
const btnOk = el("button", { type: "button", class: "btn btn--primary", text: t("chat.delete") });
const close = (ok) => {
try {
backdrop.remove();
} catch (_) {}
resolve(!!ok);
};
btnCancel.addEventListener("click", () => close(false));
btnOk.addEventListener("click", () => close(true));
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) close(false);
});
card.appendChild(text);
card.appendChild(el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [btnCancel, btnOk]));
backdrop.appendChild(card);
document.body.appendChild(backdrop);
});
const adoptCreatedSession = (resp) => {
const s = resp && resp.session ? resp.session : {};
const sid = String(s.id || "");
if (!sid) throw new Error("no_session_id");
sessions = [s, ...sessions.filter((x) => !idsMatch(x.id, sid))];
sessionTotal += 1;
activeId = sid;
replaceSessionUrl(sid);
wikiAfterFinishedAt[String(sid)] = "";
};
const _summarizeWikiEvent = (ev) => {
const status = String(ev && ev.status ? ev.status : "");
const ok = !!(ev && ev.ok);
const result = (ev && ev.result) || {};
if (status !== "done" || !ok) {
const err = String((result && (result.error || result.last_error)) || (ev && ev.error) || "failed");
return { kind: "error", text: t("chat.wikiToastFailed", { error: err.slice(0, 180) || "failed" }), actions: [] };
}
const dm = result.dedupMerge || {};
const merged = Number(dm.merged_count || 0) || 0;
const skipped = Number(dm.skipped_dup || 0) || 0;
const topicCounts = dm.topic_counts && typeof dm.topic_counts === "object" ? dm.topic_counts : {};
let topTopic = "";
let topN = -1;
for (const [k, v] of Object.entries(topicCounts)) {
const n = Number(v || 0) || 0;
if (n > topN) {
topN = n;
topTopic = String(k || "");
}
}
const actions = [
{
label: t("chat.wikiViewMerged"),
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: "inbox/merged-turns.md" }),
},
];
if (topTopic) {
actions.push({
label: t("chat.wikiViewTopic", { topic: topTopic }),
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: `topics/auto-${topTopic}.md` }),
});
}
return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions };
};
const pollWikiEvents = async () => {
const sid = String(activeId || "");
if (!sid) return;
const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || "");
try {
const q = after ? `?after=${encodeURIComponent(after)}&limit=20` : "?limit=20";
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/wiki-events${q}`);
if (!resp || !resp.ok) return;
const events = Array.isArray(resp.events) ? resp.events : [];
if (!events.length) return;
for (const ev of events) {
const fin = String((ev && ev.finished_at) || "");
if (fin && (!wikiAfterFinishedAt[sid] || fin > wikiAfterFinishedAt[sid])) {
wikiAfterFinishedAt[sid] = fin;
}
const msg = _summarizeWikiEvent(ev);
if (msg && msg.text) {
const node = showToast(msg.text, { kind: msg.kind, ttlMs: 6500 });
const acts = Array.isArray(msg.actions) ? msg.actions : [];
if (acts.length) {
const btnRow = el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;" });
for (const a of acts) {
btnRow.appendChild(
el("button", {
type: "button",
class: "btn",
style: "padding:4px 8px;min-height:auto;font-size:12px;",
text: String(a.label || "View"),
onclick: (e) => {
e.stopPropagation();
try {
if (typeof a.onClick === "function") a.onClick();
} catch (_) {}
},
}),
);
}
node.appendChild(btnRow);
}
}
}
} catch (_) {}
};
const startWikiPoller = () => {
if (wikiPollTimerId != null) return;
wikiPollTimerId = setInterval(pollWikiEvents, 2200);
// kick once
pollWikiEvents().catch(() => {});
};
const stopWikiPoller = () => {
if (wikiPollTimerId != null) {
clearInterval(wikiPollTimerId);
wikiPollTimerId = null;
}
};
const syncPendingUi = () => {
pendingFilesEl.innerHTML = "";
pendingFiles.forEach((f, i) => {
const row = el("div", { class: "chat-pending-row" }, [
el("span", { class: "muted", text: f.name }),
el("button", {
type: "button",
class: "chat-pending-remove",
text: "×",
title: currentLang === "zh" ? "移除" : "Remove",
onclick: () => {
pendingFiles.splice(i, 1);
syncPendingUi();
},
}),
]);
pendingFilesEl.appendChild(row);
});
syncSendEnabled();
};
attachBtn.addEventListener("click", () => fileInput.click());
const syncToolToggleBtn = () => {
toolToggleBtn.textContent = `${t("chat.tools")}: ${showToolOutput ? t("chat.tools.on") : t("chat.tools.off")}`;
toolToggleBtn.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
};
toolToggleBtn.addEventListener("click", () => {
showToolOutput = !showToolOutput;
adminChatShowToolOutput = showToolOutput;
syncToolToggleBtn();
statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
loadMessagesForActive().catch(() => {});
});
syncToolToggleBtn();
fileInput.addEventListener("change", () => {
const fs = fileInput.files;
if (!fs || !fs.length) return;
for (const f of fs) pendingFiles.push(f);
fileInput.value = "";
syncPendingUi();
});
const openSessionMenu = (sid, title) => {
const menu = document.createElement("div");
menu.className = "chat-sess-menu-pop";
const mk = (label, fn) =>
el("button", {
type: "button",
class: "chat-sess-menu-item",
text: label,
onclick: async (ev) => {
ev.stopPropagation();
menu.remove();
await fn();
},
});
menu.appendChild(
mk(t("chat.rename"), async () => {
const nv = window.prompt(t("chat.rename"), title);
if (nv == null) return;
try {
await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() });
await refreshSessions();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}),
);
menu.appendChild(
mk(t("chat.delete"), async () => {
if (!(await confirmChatAction(t("chat.deleteConfirm")))) return;
try {
const r = await apiDelete(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`);
const next = String(r.next_session_id || "");
if (idsMatch(activeId, sid)) {
activeId = next;
replaceSessionUrl(activeId);
}
await refreshSessions();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}),
);
menu.appendChild(
mk(t("chat.exportMd"), async () => {
try {
await downloadExport(sid, "md");
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}),
);
menu.appendChild(
mk(t("chat.exportJson"), async () => {
try {
await downloadExport(sid, "json");
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}),
);
menu.appendChild(
mk(t("chat.fork"), async () => {
try {
const r = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/fork`, {});
const ns = r.session || {};
const nid = String(ns.id || "");
if (!nid) return;
activeId = nid;
replaceSessionUrl(nid);
await refreshSessions();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}),
);
menu.appendChild(
mk(t("chat.audit"), () => {
openAdminFromChat("audit", sid);
}),
);
return menu;
};
const paintSessions = () => {
sessionsListEl.innerHTML = "";
loadMoreWrap.innerHTML = "";
if (!sessions.length) {
sessionsListEl.appendChild(
el("div", { class: "muted", style: "padding:10px 0 10px 8px", text: t("chat.noSessions") }),
);
return;
}
for (const s of sessions) {
const sid = String(s.id || "");
const title = String(s.title || sid || "");
const row = el("div", { class: "chat-sess-row" + (idsMatch(activeId, sid) ? " chat-sess-row--active" : "") });
const btn = el("button", {
class: "chat-sess-btn" + (idsMatch(activeId, sid) ? " chat-sess-btn--active" : ""),
text: title,
onclick: () => {
activeId = sid;
replaceSessionUrl(sid);
paintSessions();
Promise.all([loadMessagesForActive(), loadSessionModePreference()]).catch((e) => {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
});
startWikiPoller();
},
});
const more = el("button", {
type: "button",
class: "chat-sess-more" + (idsMatch(activeId, sid) ? " chat-sess-more--active" : ""),
text: "⋯",
title: t("chat.sessionMenu"),
onclick: (ev) => {
ev.stopPropagation();
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
const menu = openSessionMenu(sid, title);
const rect = more.getBoundingClientRect();
menu.style.position = "fixed";
document.body.appendChild(menu);
// Clamp into viewport; flip above if near bottom.
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 close = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
row.appendChild(btn);
row.appendChild(more);
sessionsListEl.appendChild(row);
}
if (sessions.length < sessionTotal) {
const lm = el("button", {
class: "btn",
style: "width:100%;margin-top:8px",
text: t("chat.loadMore"),
onclick: async () => {
try {
statusBar.textContent = t("chat.loading");
const off = sessions.length;
const resp = await apiGet(
`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=${off}`,
);
const next = Array.isArray(resp.sessions) ? resp.sessions : [];
sessionTotal = intOr(resp.total, sessionTotal);
sessions = sessions.concat(next);
statusBar.textContent = "";
paintSessions();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
},
});
loadMoreWrap.appendChild(lm);
}
};
function intOr(v, d) {
const n = parseInt(String(v), 10);
return Number.isFinite(n) ? n : d;
}
const loadMessagesForActive = async () => {
messagesEl.innerHTML = "";
if (!activeId) {
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
return;
}
statusBar.textContent = t("chat.loading");
try {
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`);
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
const renderRows = _buildRenderRows(msgs);
const total = intOr(resp.message_count, msgs.length);
if (total > msgs.length) {
messagesEl.appendChild(
el("div", {
class: "muted chat-msg-cap",
text: t("chat.historyTruncated", { shown: msgs.length, total }),
}),
);
}
if (!renderRows.length) {
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
} else {
for (const m of renderRows) {
await appendMessageRow(messagesEl, m);
}
}
statusBar.textContent = "";
messagesEl.scrollTop = messagesEl.scrollHeight;
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
};
const reloadSessionsOnly = async () => {
const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
sessions = Array.isArray(resp.sessions) ? resp.sessions : [];
sessionTotal = intOr(resp.total, sessions.length);
paintSessions();
};
const refreshSessions = async () => {
statusBar.textContent = t("chat.loading");
const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
sessions = Array.isArray(resp.sessions) ? resp.sessions : [];
sessionTotal = intOr(resp.total, sessions.length);
// URL 里的 session_id 可能属于其他账号或已删除;若不在当前用户列表中则丢弃,避免 GET …/messages 404。
const activeInList =
Boolean(activeId) &&
sessions.length > 0 &&
sessions.some((s) => idsMatch(s.id, activeId));
if (activeId && !activeInList) {
activeId = "";
replaceSessionUrl("");
}
if (!activeId && sessions.length) {
activeId = String(sessions[0].id || "");
replaceSessionUrl(activeId);
}
if (!activeId && sessions.length === 0) {
try {
const resp = await apiPost("/admin/api/chat/sessions", {});
adoptCreatedSession(resp);
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
}
paintSessions();
await loadMessagesForActive();
await loadSessionModePreference();
if (!activeId) {
stopWikiPoller();
} else {
startWikiPoller();
}
if (!String(statusBar.textContent || "").includes(t("chat.error"))) statusBar.textContent = "";
};
const btnNew = el("button", {
type: "button",
class: "chat-nav__new",
title: `${t("chat.newSession")}`,
onclick: async () => {
try {
statusBar.textContent = t("chat.loading");
const resp = await apiPost("/admin/api/chat/sessions", {});
adoptCreatedSession(resp);
paintSessions();
messagesEl.innerHTML = "";
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
statusBar.textContent = "";
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
},
});
btnNew.appendChild(el("span", { class: "chat-nav__newGlyph", "aria-hidden": "true" }));
btnNew.appendChild(
el("span", {
class: "chat-nav__newLabel",
"data-i18n": "chat.newSession",
text: t("chat.newSession"),
}),
);
class OclawWsChatTransport {
constructor({ tokenProvider }) {
this.tokenProvider = tokenProvider;
this.ws = null;
this.reqSeq = 0;
this.msgQueue = [];
this.waiters = [];
}
_isOpen() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
_wsUrl() {
const origin = String(window.location.origin || "").trim();
if (origin.startsWith("http://") || origin.startsWith("https://")) {
const wsOrigin = origin.replace(/^http/i, "ws");
return `${wsOrigin}/ws`;
}
const u = new URL(window.location.href);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
u.pathname = "/ws";
u.search = "";
u.hash = "";
return u.toString();
}
_nextReqId() {
this.reqSeq += 1;
return `req_${Date.now()}_${this.reqSeq}`;
}
async _openAndHandshake() {
if (this._isOpen()) return;
const token = String(this.tokenProvider() || "");
const wsUrl = this._wsUrl();
const ws = new WebSocket(wsUrl);
this.ws = ws;
this.msgQueue = [];
this.waiters = [];
ws.addEventListener("message", (ev) => {
let parsed = null;
try {
parsed = JSON.parse(String(ev.data || "{}"));
} catch (_) {
parsed = null;
}
if (!parsed) return;
const waiter = this.waiters.shift();
if (waiter) {
waiter.resolve(parsed);
} else {
this.msgQueue.push(parsed);
}
});
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`ws_open_timeout:${wsUrl}`)), 7000);
ws.onopen = () => {
clearTimeout(timer);
resolve();
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error(`ws_open_failed:${wsUrl}`));
};
});
// Receive optional connect.challenge first.
const challenge = await this._recv();
if (!(challenge && challenge.type === "event" && challenge.event === "connect.challenge")) {
throw new Error("ws_invalid_challenge");
}
const reqId = this._nextReqId();
ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: { id: "webchat-ui", version: "0.1", platform: navigator.platform || "web", mode: "webchat" },
role: "operator",
scopes: ["operator.read", "operator.write"],
auth: token ? { token } : {},
},
}),
);
const res = await this._recv();
if (!res || res.type !== "res" || res.id !== reqId || !res.ok) {
throw new Error(`ws_connect_failed:${JSON.stringify((res && res.error) || {})}`);
}
}
_recv() {
const ws = this.ws;
if (!ws) return Promise.reject(new Error("ws_not_connected"));
if (this.msgQueue.length) return Promise.resolve(this.msgQueue.shift());
return new Promise((resolve, reject) => {
const onErr = () => {
cleanup();
reject(new Error("ws_receive_failed"));
};
const onClose = () => {
cleanup();
reject(new Error("ws_closed"));
};
const cleanup = () => {
ws.removeEventListener("error", onErr);
ws.removeEventListener("close", onClose);
this.waiters = this.waiters.filter((w) => w.resolve !== resolve);
};
this.waiters.push({ resolve, reject });
ws.addEventListener("error", onErr);
ws.addEventListener("close", onClose);
});
}
close() {
if (this.ws) {
try {
this.ws.close(1000, "done");
} catch (_) {}
this.ws = null;
}
}
async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, signal, onEvent }) {
await this._openAndHandshake();
const reqId = this._nextReqId();
const req = {
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: String(sessionId || ""),
message: String(text || ""),
attachments: Array.isArray(attachments) ? attachments : [],
idempotencyKey: `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
thinking: "default",
interaction_mode: String(interactionMode || "comprehensive"),
specialist: String(specialist || "generalist"),
memory_mode: String(memoryMode || "default"),
},
};
this.ws.send(JSON.stringify(req));
let doneMeta = null;
while (true) {
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
const msg = await this._recv();
if (
msg &&
msg.type === "event" &&
(msg.event === "chat" ||
msg.event === "session.message" ||
msg.event === "agent.event" ||
msg.event === "session.tool" ||
msg.event === "session.marker") &&
typeof onEvent === "function"
) {
const payload = msg.payload || {};
onEvent({ event: String(msg.event || ""), payload });
const d = msg.event === "chat" ? { phase: String(payload.state || "") } : {};
if (msg.event === "session.message") {
const role = String(((payload.message || {}).role) || "").toLowerCase();
if (role === "assistant") return doneMeta;
}
const phase = String(d.phase || "");
if (phase === "final" || phase === "error" || phase === "aborted") {
return doneMeta;
}
continue;
}
if (msg && msg.type === "res" && msg.id === reqId) {
if (!msg.ok) throw new Error(`ws_req_failed:${JSON.stringify(msg.error || {})}`);
const p = msg.payload || {};
doneMeta = {
interaction_mode: String(p.interactionMode || interactionMode || ""),
selected_specialist: String(p.selectedSpecialist || specialist || ""),
dispatch_reason: String(p.dispatchReason || ""),
manager_selected_specialist: String(p.managerSelectedSpecialist || ""),
requested_specialist: String(p.requestedSpecialist || ""),
dynamic_agent_used: !!p.dynamicAgentUsed,
dynamic_agent_name: String(p.dynamicAgentName || ""),
relay_pointer_count: Number(p.relayPointerCount || 0) || 0,
relay_envelope_present: !!p.relayEnvelopePresent,
relay_envelope_pointer_count: Number(p.relayEnvelopePointerCount || 0) || 0,
relay_ttl_turn_count: Number(p.relayTtlTurnCount || 0) || 0,
relay_ttl_session_count: Number(p.relayTtlSessionCount || 0) || 0,
relay_ttl_keep_count: Number(p.relayTtlKeepCount || 0) || 0,
reply: String(p.reply || ""),
};
if (String(p.status || "") === "started") {
continue;
}
// Oclaw-style: response ack does not terminate the stream.
// Wait strictly for chat final/aborted/error or session.message assistant.
continue;
}
if (msg && msg.type === "event" && msg.event === "chat") {
const p = msg.payload || {};
const d = { phase: String(p.state || "") };
const phase = String(d.phase || "");
if (phase === "final" || phase === "error" || phase === "aborted") {
return doneMeta;
}
}
}
}
async sendChatAbort({ sessionId, runId }) {
// Abort should use the same WS connection when possible.
await this._openAndHandshake();
const reqId = this._nextReqId();
this.ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "chat.abort",
params: { sessionKey: String(sessionId || ""), runId: runId ? String(runId) : undefined },
}),
);
for (;;) {
const msg = await this._recv();
if (msg && msg.type === "res" && msg.id === reqId) {
if (!msg.ok) throw new Error(`ws_req_failed:${JSON.stringify(msg.error || {})}`);
return msg.payload || {};
}
}
}
}
let currentStreamAbortController = null;
let currentWsTransport = null;
let currentAbortMeta = { sessionId: "", runId: "" };
const isAbortError = (err) => {
const name = String(err && err.name ? err.name : "");
const msg = String(err && err.message ? err.message : err || "");
return name === "AbortError" || msg.toLowerCase().includes("aborted");
};
const _silentReplyPattern = /^\s*NO_REPLY\s*$/;
const _isSilentReplyStream = (text) => _silentReplyPattern.test(String(text || ""));
const _normalizeAssistantMessage = (message, { requireRole = false, requireContentArray = false } = {}) => {
if (!message || typeof message !== "object") return null;
const m = message;
const role = String(m.role || "").toLowerCase();
if (requireRole && role !== "assistant") return null;
if (requireContentArray && !Array.isArray(m.content)) return null;
if (!("content" in m) && !("text" in m)) return null;
return m;
};
const sendMessageStream = async (userText, attachmentPayload) => {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let streamBubble = null;
let streamRow = null;
let doneMeta = null;
let chatStream = "";
let chatRunId = null;
let chatStreamSegments = [];
let renderRafId = null;
let renderPending = false;
let typingRafId = null;
let typingTimerId = null;
let streamStatusTimerId = null;
let streamStatusBase = "oclaw";
let streamDisplayTarget = "";
let streamDisplayShown = "";
let toolSeq = 0;
let hasRealStreamText = false;
let markerState = { p: 0, e: false, ep: 0, t: 0, s: 0, k: 0, reclaimed: 0 };
const ensureStreamBubble = () => {
if (streamBubble) return streamBubble;
const inner = el("div", { class: "chat-msg chat-msg--assistant" });
inner.appendChild(el("div", { class: "chat-msg__stream-status", text: `${t("chat.status.oclaw")}...` }));
inner.appendChild(el("div", { class: "chat-msg__md", html: "" }));
streamRow = wrapAssistantMessage(inner, new Date().toISOString());
streamBubble = inner;
messagesEl.appendChild(streamRow);
return streamBubble;
};
const _labelPhase = (phase) => {
const p = String(phase || "").toLowerCase();
if (!p) return "oclaw";
if (p.includes("plan")) return "plan";
if (p.includes("tool")) return "tool";
if (p.includes("agent")) return "agent";
if (p.includes("core")) return "oclaw";
if (p.includes("manager")) return "oclaw";
if (p === "start") return "start";
if (p === "running") return "running";
if (p === "end") return "end";
if (p === "error") return "error";
if (p === "started") return "oclaw";
if (p === "delta") return "agent";
return p;
};
const _statusLabel = (key) => {
const k = String(key || "").trim();
if (!k) return t("chat.status.oclaw");
const dictKey = `chat.status.${k}`;
const localized = t(dictKey);
if (localized !== dictKey) return localized;
return k;
};
const _setStreamStatusBase = (phase) => {
streamStatusBase = _labelPhase(phase);
_setDynamicStreamStatus();
};
const _setDynamicStreamStatus = () => {
const bubble = ensureStreamBubble();
const statusEl = bubble.querySelector(".chat-msg__stream-status");
if (!statusEl) return;
const dots = ".".repeat((Math.floor(Date.now() / 400) % 3) + 1);
statusEl.textContent = `${_statusLabel(streamStatusBase)}${dots}`;
};
const _startDynamicStreamStatus = () => {
_setDynamicStreamStatus();
if (streamStatusTimerId != null) return;
streamStatusTimerId = setInterval(_setDynamicStreamStatus, 400);
};
const _stopDynamicStreamStatus = () => {
if (streamStatusTimerId != null) {
clearInterval(streamStatusTimerId);
streamStatusTimerId = null;
}
};
const _markStreamTerminal = (phase, noteText) => {
const bubble = ensureStreamBubble();
const statusEl = bubble.querySelector(".chat-msg__stream-status");
if (statusEl) statusEl.textContent = _statusLabel(_labelPhase(phase));
const md = bubble.querySelector(".chat-msg__md");
if (md && !String(md.textContent || "").trim() && String(noteText || "").trim()) {
md.innerHTML = `${escapeHtml(String(noteText || ""))}
`;
}
};
const _composeStreamPlainText = () => {
const chunks = [];
for (const seg of chatStreamSegments) {
if (!seg || seg.type !== "text") continue;
const txt = String(seg.text || "").trim();
if (txt) chunks.push(txt);
}
return decodeEscapedNewlines(chunks.join("\n"));
};
const _renderStreamCompositeNow = () => {
const bubble = ensureStreamBubble();
const md = bubble.querySelector(".chat-msg__md");
if (md) {
const blocks = [];
if (markerState.p > 0 || markerState.e || markerState.ep > 0) {
blocks.push(
`${escapeHtml(
t("chat.marker.summary", {
p: String(markerState.p || 0),
e: markerState.e ? "1" : "0",
ep: String(markerState.ep || 0),
}),
)}
`,
);
blocks.push(
`${escapeHtml(
t("chat.marker.ttl", {
t: String(markerState.t || 0),
s: String(markerState.s || 0),
k: String(markerState.k || 0),
}),
)}
`,
);
if ((Number(markerState.reclaimed || 0) || 0) > 0) {
blocks.push(
`${escapeHtml(
t("chat.marker.reclaimed", { n: String(markerState.reclaimed || 0) }),
)}
`,
);
}
}
let textIdx = 0;
for (const seg of chatStreamSegments) {
if (!seg) continue;
if (seg.type === "text") {
const full = String(seg.text || "");
if (!full) continue;
const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx));
const shown = full.slice(0, showLen);
textIdx += full.length;
if (!shown.trim()) continue;
blocks.push(`${escapeHtml(decodeEscapedNewlines(shown))}
`);
} else if (seg.type === "tool") {
if (!showToolOutput) continue;
const title = String(seg.title || "tool");
const body = String(seg.body || "");
const audit = seg.sqlAudit && typeof seg.sqlAudit === "object" ? seg.sqlAudit : null;
if (audit) {
const guard = audit.guard && typeof audit.guard === "object" ? audit.guard : {};
const autoLimit = _sqlLimitSuffix(audit.inputSql, audit.executedSql);
const executedDiffHtml = _renderExecutedSqlWithAddedHighlight(audit.inputSql, audit.executedSql);
blocks.push(
`${escapeHtml(title)} · SQL audit
input SQL
${escapeHtml(String(audit.inputSql || ""))}
executed SQL (added highlighted)
${executedDiffHtml}
${autoLimit ? `auto-added clause: ${escapeHtml(autoLimit)}
` : ""}
readonly_enforced=${escapeHtml(String(!!guard.readonly_enforced))} ·
multi_statement_forbidden=${escapeHtml(String(!!guard.multi_statement_forbidden))} ·
auto_limit_applied=${escapeHtml(String(!!guard.auto_limit_applied))} ·
result_row_cap=${escapeHtml(String(guard.result_row_cap != null ? guard.result_row_cap : ""))} ·
engine=${escapeHtml(String(audit.engine || ""))} ·
rows_returned=${escapeHtml(String(audit.rowsReturned != null ? audit.rowsReturned : ""))}
`,
);
} else {
blocks.push(
`${escapeHtml(title)}
${escapeHtml(body)} `,
);
}
}
}
const currentShown = streamDisplayShown.slice(textIdx);
if (String(currentShown || "").trim()) {
blocks.push(`${escapeHtml(decodeEscapedNewlines(currentShown))}
`);
}
md.innerHTML = blocks.join("");
}
messagesEl.scrollTop = messagesEl.scrollHeight;
};
const renderStreamComposite = () => {
if (renderPending) return;
renderPending = true;
renderRafId = requestAnimationFrame(() => {
renderPending = false;
renderRafId = null;
_renderStreamCompositeNow();
});
};
const _scheduleTypingTick = () => {
if (typingRafId != null) return;
typingRafId = requestAnimationFrame(() => {
typingRafId = null;
if (streamDisplayShown === streamDisplayTarget) {
renderStreamComposite();
return;
}
const remain = streamDisplayTarget.length - streamDisplayShown.length;
if (remain <= 0) {
streamDisplayShown = streamDisplayTarget;
renderStreamComposite();
return;
}
// Slightly faster typing while keeping typewriter feel for正文.
const step = 2;
streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step);
renderStreamComposite();
if (typingTimerId != null) {
clearTimeout(typingTimerId);
typingTimerId = null;
}
typingTimerId = setTimeout(() => {
typingTimerId = null;
_scheduleTypingTick();
}, 22);
});
};
const setStreamText = (text) => {
chatStream = String(text || "");
// Keep current text as a dedicated trailing segment for strict in-order rendering.
if (!chatStreamSegments.length || chatStreamSegments[chatStreamSegments.length - 1].type !== "text") {
chatStreamSegments.push({ type: "text", text: chatStream });
} else {
chatStreamSegments[chatStreamSegments.length - 1].text = chatStream;
}
streamDisplayTarget = _composeStreamPlainText();
_scheduleTypingTick();
};
const appendToolSegment = (payload) => {
const p = payload && typeof payload === "object" ? payload : {};
const name = String(p.name || "tool");
const key = `${name}:${++toolSeq}`;
const rawPayload = p.payload != null ? p.payload : p;
const body = formatToolPanelText(name, rawPayload);
const sqlAudit = extractSqlAuditPayload(rawPayload);
chatStreamSegments.push({ type: "tool", key, title: name, body, sqlAudit });
};
const appendFinalAssistant = async (message, fallbackText) => {
const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
if (normalized && !_isSilentReplyStream(extractWsAssistantText(normalized))) {
const norm = {
...normalized,
role: "assistant",
content: extractWsAssistantText(normalized),
timestamp: normalized.timestamp != null ? normalized.timestamp : new Date().toISOString(),
tool_calls: normalized.tool_calls != null ? normalized.tool_calls : normalized.toolCalls,
};
const rows = _buildRenderRows([norm]);
const last = rows && rows.length ? rows[rows.length - 1] : null;
if (last) {
if (streamRow && streamRow.parentNode) streamRow.remove();
await appendMessageRow(messagesEl, last);
messagesEl.scrollTop = messagesEl.scrollHeight;
return true;
}
}
const t0 = String(fallbackText || "").trim();
if (t0 && !_isSilentReplyStream(t0)) {
if (streamRow && streamRow.parentNode) streamRow.remove();
await appendMessageRow(messagesEl, {
role: "assistant",
content: decodeEscapedNewlines(t0),
timestamp: new Date().toISOString(),
});
messagesEl.scrollTop = messagesEl.scrollHeight;
return true;
}
return false;
};
btnStop.disabled = false;
btnSend.disabled = true;
composerShell.classList.add("chat-composer-shell--busy");
const abortController = new AbortController();
currentStreamAbortController = abortController;
currentAbortMeta = { sessionId: String(activeId || ""), runId: "" };
try {
const transport = new OclawWsChatTransport({
tokenProvider: () => token,
});
currentWsTransport = transport;
try {
// Immediate assistant placeholder bubble with dynamic status.
ensureStreamBubble();
_startDynamicStreamStatus();
doneMeta = await transport.sendSessionSend({
sessionId: activeId,
text: userText,
attachments: attachmentPayload,
interactionMode: String(modeSelect.value || "comprehensive"),
specialist:
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
: "generalist",
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
signal: abortController.signal,
onEvent: async (frame) => {
const eventName = String((frame && frame.event) || "");
const payload = (frame && frame.payload) || {};
if (eventName === "agent.event") {
_setStreamStatusBase((payload && (payload.stream || payload.event || payload.type)) || "agent");
return;
}
if (eventName === "session.marker") {
const action = String(payload.action || "").toLowerCase();
markerState = {
p: Number(payload.relayPointerCount || markerState.p || 0) || 0,
e: payload.relayEnvelopePresent != null ? !!payload.relayEnvelopePresent : !!markerState.e,
ep: Number(payload.relayEnvelopePointerCount || markerState.ep || 0) || 0,
t: Number(payload.relayTtlTurnCount || markerState.t || 0) || 0,
s: Number(payload.relayTtlSessionCount || markerState.s || 0) || 0,
k: Number(payload.relayTtlKeepCount || markerState.k || 0) || 0,
reclaimed:
action === "turn_reclaimed"
? Number(payload.reclaimedTurnPointers || payload.relayTtlTurnCount || 0) || 0
: Number(markerState.reclaimed || 0) || 0,
};
renderStreamComposite();
return;
}
if (eventName === "session.tool") {
_setStreamStatusBase("tool");
// Keep strict order: tool card appears exactly where tool event arrives.
appendToolSegment(payload);
streamDisplayTarget = _composeStreamPlainText();
_scheduleTypingTick();
return;
}
if (eventName !== "chat") return;
const state = String(payload.state || "");
if (state) _setStreamStatusBase(state);
if (payload.runId && !chatRunId) chatRunId = String(payload.runId);
if (chatRunId) currentAbortMeta = { sessionId: String(activeId || ""), runId: String(chatRunId || "") };
if (chatRunId && payload.runId && String(payload.runId) !== chatRunId && state !== "final") return;
if (state === "delta") {
const next = extractWsAssistantText(payload.message);
const d = String(payload.delta || "");
if (d && !_isSilentReplyStream(d)) {
const base = hasRealStreamText ? String(chatStream || "") : "";
chatStream = `${base}${decodeEscapedNewlines(d)}`;
hasRealStreamText = true;
setStreamText(chatStream);
} else if (typeof next === "string" && next && !_isSilentReplyStream(next)) {
// Fallback when gateway doesn't include delta.
chatStream = next;
hasRealStreamText = true;
setStreamText(chatStream);
}
return;
}
if (state === "final") {
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
chatStream = "";
chatRunId = null;
chatStreamSegments = [];
if (!ok) statusBar.textContent = "";
return;
}
if (state === "aborted") {
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream);
if (!ok) _markStreamTerminal("error", "aborted");
chatStream = "";
chatRunId = null;
chatStreamSegments = [];
return;
}
if (state === "error") {
_stopDynamicStreamStatus();
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
chatStream = "";
chatRunId = null;
chatStreamSegments = [];
statusBar.textContent = `${t("chat.error")}: ${String(payload.errorMessage || "chat error")}`;
}
},
});
} finally {
transport.close();
if (currentWsTransport === transport) currentWsTransport = null;
}
setTimeout(() => {
reloadSessionsOnly().catch(() => {});
}, 250);
return doneMeta;
} catch (e) {
if (isAbortError(e)) return doneMeta;
throw e;
} finally {
if (typingRafId != null) {
try {
cancelAnimationFrame(typingRafId);
} catch (_) {}
typingRafId = null;
}
if (typingTimerId != null) {
try {
clearTimeout(typingTimerId);
} catch (_) {}
typingTimerId = null;
}
_stopDynamicStreamStatus();
if (renderRafId != null) {
try {
cancelAnimationFrame(renderRafId);
} catch (_) {}
renderRafId = null;
renderPending = false;
}
btnStop.disabled = true;
btnSend.disabled = false;
syncSendEnabled();
composerShell.classList.remove("chat-composer-shell--busy");
if (currentStreamAbortController === abortController) currentStreamAbortController = null;
}
};
btnStop.addEventListener("click", async () => {
if (currentStreamAbortController) currentStreamAbortController.abort();
try {
const sid = String(currentAbortMeta.sessionId || activeId || "");
if (sid) {
const tpt = currentWsTransport;
if (tpt && typeof tpt.sendChatAbort === "function") {
await tpt.sendChatAbort({ sessionId: sid, runId: currentAbortMeta.runId || "" });
}
}
} catch (_) {
// ignore abort rpc errors; local abort still stops UI
}
statusBar.textContent = "";
});
btnSend.addEventListener("click", async () => {
if (btnSend.disabled) return;
const textRaw = String(textarea.value || "").trim();
const hasFiles = pendingFiles.length > 0;
if (!textRaw && !hasFiles) return;
if (!activeId) {
try {
statusBar.textContent = t("chat.loading");
const resp = await apiPost("/admin/api/chat/sessions", {});
adoptCreatedSession(resp);
paintSessions();
statusBar.textContent = "";
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
return;
}
}
if (!activeId) return;
const filesSnapshot = pendingFiles.slice();
btnSend.disabled = true;
statusBar.textContent = t("chat.sending");
let attPayload = null;
if (filesSnapshot.length) {
attPayload = [];
for (const f of filesSnapshot) {
attPayload.push(await fileToPayloadEntry(f));
}
pendingFiles = [];
syncPendingUi();
}
const userText =
textRaw ||
(hasFiles ? (currentLang === "zh" ? "(已上传附件)" : "(attachment uploaded)") : "");
/** 发送过程中用本地 blob 预览,避免只显示文件名直到回合结束再拉历史 */
const previewBlobUrls = [];
try {
textarea.value = "";
const innerUser = el("div", { class: "chat-msg chat-msg--user" });
innerUser.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(userText) }));
if (filesSnapshot.length) {
const wrap = el("div", { class: "chat-att-wrap" });
for (const f of filesSnapshot) {
const u = URL.createObjectURL(f);
previewBlobUrls.push(u);
wrap.appendChild(
el("img", {
class: "chat-att-img",
src: u,
alt: String(f.name || ""),
title: String(f.name || ""),
}),
);
}
innerUser.appendChild(wrap);
}
const userWrap = wrapUserMessage(innerUser, new Date().toISOString());
messagesEl.appendChild(userWrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
fitComposerTextarea();
const doneMeta = await sendMessageStream(userText, attPayload);
const mRaw = String((doneMeta && doneMeta.interaction_mode) || "").toLowerCase();
const m = interactionModeLabel(mRaw);
const s = specialistLabel(
mRaw === "expert"
? (doneMeta && doneMeta.selected_specialist)
: (doneMeta && doneMeta.requested_specialist),
);
const baseText = t("chat.execApplied", { mode: m, specialist: s });
const memoryModeNow = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
const memoryText = ` · ${t("chat.memoryApplied", { mode: memoryModeShortLabel(memoryModeNow) })}`;
const reason = reasonLabel(doneMeta && doneMeta.dispatch_reason);
const dyn = doneMeta && doneMeta.dynamic_agent_used ? ` · dynamic=${String(doneMeta.dynamic_agent_name || "1")}` : "";
const routeText = reason && reason !== "-" ? ` · ${reason}${dyn}` : dyn;
const markerText =
doneMeta && ((Number(doneMeta.relay_pointer_count || 0) || 0) > 0 || doneMeta.relay_envelope_present)
? ` · ${t("chat.marker.summary", {
p: String(Number(doneMeta.relay_pointer_count || 0) || 0),
e: doneMeta.relay_envelope_present ? "1" : "0",
ep: String(Number(doneMeta.relay_envelope_pointer_count || 0) || 0),
})} · ${t("chat.marker.ttl", {
t: String(Number(doneMeta.relay_ttl_turn_count || 0) || 0),
s: String(Number(doneMeta.relay_ttl_session_count || 0) || 0),
k: String(Number(doneMeta.relay_ttl_keep_count || 0) || 0),
})}`
: "";
const stats = await fetchDynamicExpertStats();
if (stats) {
let topReason = "-";
let topCount = -1;
const pairs = [];
for (const [k, v] of Object.entries(stats.reasons || {})) {
const n = parseInt(String(v || "0"), 10) || 0;
pairs.push([String(k || "-"), n]);
if (n > topCount) {
topCount = n;
topReason = String(k || "-");
}
}
pairs.sort((a, b) => b[1] - a[1]);
_statusReasonPairs = pairs.slice();
const top5 = pairs
.slice(0, 5)
.map(([k, n]) => `${String((stats.reasonLabels && stats.reasonLabels[k]) || reasonLabel(k))}:${n}`)
.join(", ");
const ratePct = Math.round((Number(stats.rate || 0) * 1000)) / 10;
const topReasonLabel = String((stats.reasonLabels && stats.reasonLabels[topReason]) || reasonLabel(topReason));
const topReasonText = t("chat.dynamicStatsDetail", { rate: ratePct, reason: topReasonLabel });
const topMixText = t("chat.dynamicTopReasons", { items: top5 || "-" });
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}`;
} else {
_statusReasonPairs = [];
statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}`;
}
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
} finally {
previewBlobUrls.forEach((u) => {
try {
URL.revokeObjectURL(u);
} catch (_) {}
});
btnSend.disabled = false;
}
});
textarea.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
btnSend.click();
}
});
try {
await refreshSessions();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
sessionsListEl.innerHTML = "";
messagesEl.innerHTML = "";
}
composerShell.appendChild(pendingFilesEl);
composerShell.appendChild(composerMetaBar);
composerShell.appendChild(
el("div", { class: "chat-composer-row" }, [
attachBtn,
textarea,
el("div", { class: "chat-composer-actions" }, [btnStop, btnSend]),
]),
);
const navFooter = el("div", { class: "chat-nav__footer" }, [
el("div", { id: "authUser", class: "chat-nav__user" }),
]);
return el("div", { class: "chat-layout chat-app" }, [
el("div", { class: "chat-nav" }, [
el("div", { class: "chat-nav__top" }, [
buildChatBrandLogoNode(),
]),
el("div", { class: "chat-nav__toolbar" }, [btnNew]),
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
navFooter,
]),
el("div", { class: "chat-main" }, [
el("div", { class: "muted", style: "font-size:12px;line-height:1.2;padding:10px 12px 0;" }, [activeModelText]),
messagesEl,
statusBar,
el("div", { class: "chat-composer" }, [fileInput, composerShell]),
]),
]);
}
document.body.addEventListener("click", (e) => {
const node = e.target;
if (!node || !node.closest) return;
const status = node.closest(".chat-status");
if (!status) return;
if (!_statusReasonPairs.length) return;
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
const items = _statusReasonPairs.map(([k, n]) => `${reasonLabel(k)}: ${n}`).join("\n");
const pop = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;max-width:420px;white-space:pre-wrap;line-height:1.4;" }, [
el("div", { class: "chat-sess-menu-item", text: t("chat.dynamicAllReasonsTitle") }),
el("div", { class: "chat-sess-menu-item", text: items || "-" }),
]);
const rect = status.getBoundingClientRect();
pop.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`;
pop.style.top = `${Math.max(8, rect.top - 180)}px`;
document.body.appendChild(pop);
const close = (ev) => {
if (!pop.contains(ev.target)) {
pop.remove();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
});
async function syncLangFromServer() {
try {
const r = await apiGet("/admin/api/chat/settings/ui-lang");
if (r && r.lang && (r.lang === "zh" || r.lang === "en")) {
currentLang = r.lang;
localStorage.setItem(LANG_KEY, currentLang);
}
} catch (_) {}
}
async function boot() {
applyI18nStatic();
if (forceReloginRequested()) {
clearAuthAndReloginFlagFromUrl();
}
try {
authSession = JSON.parse(localStorage.getItem(AUTH_SESSION_KEY) || "null");
} catch (_) {
authSession = null;
}
const tok = String(localStorage.getItem(AUTH_TOKEN_KEY) || "").trim();
if (!authSession || !tok) {
// Ensure stale overlays from previous chat UI never block login inputs.
closeChatImageLightbox();
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
document.body.style.overflow = "";
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
authSession = null;
try {
await apiPost("/admin/api/auth/bootstrap", {});
} catch (_) {}
mount(await renderLogin());
applyI18nStatic();
syncAuthUserLabel();
return;
}
await syncLangFromServer();
await loadMeProfile();
try {
mount(await renderChatUi());
} catch (err) {
mount(
el("div", { class: "chat-app--login" }, [
el("div", { class: "card", style: "max-width:520px;width:100%" }, [
el("div", { class: "card__title", text: t("common.error") }),
el("div", { class: "pre", text: String(err) }),
]),
]),
);
}
applyI18nStatic();
syncAuthUserLabel();
}
document.body.addEventListener("click", async (e) => {
const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]");
if (menuBtn) {
const action = String(menuBtn.getAttribute("data-menu-action") || "");
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
if (action === "profile") {
openAdminFromChat("profile");
return;
}
if (action === "logout") {
try {
await apiPost("/admin/api/auth/logout", {});
} catch (_) {}
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
try {
localStorage.removeItem(CHAT_URL_SCOPE_KEY);
} catch (_) {}
authSession = null;
await boot();
return;
}
if (action === "lang") {
currentLang = currentLang === "zh" ? "en" : "zh";
localStorage.setItem(LANG_KEY, currentLang);
try {
await apiPost("/admin/api/chat/settings/ui-lang", { lang: currentLang });
} catch (_) {}
await boot();
return;
}
if (action === "dispatchLabels") {
const status = document.querySelector(".chat-status");
await openDispatchLabelsEditor(status);
return;
}
}
});
window.addEventListener("popstate", () => {
if (authSession) boot().catch(() => {});
});
boot().catch((err) => {
mount(
el("div", { class: "chat-app--login" }, [
el("div", { class: "card", style: "max-width:520px;width:100%" }, [
el("div", { class: "card__title", text: t("common.error") }),
el("div", { class: "pre", text: String(err) }),
]),
]),
);
});