/* 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) }), ]), ]), ); });