oclaw/interfaces/admin/static/chat.js
oliver 0cf6aafa30 Add background jobs with Chat UI so long agent scripts can run for hours safely.
start_job/get_job/sleep replace blocked run_command for multi-hour work; Chat shows a live count badge and allows manual kill, with a reaper reclaiming timed-out or stale processes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:02:57 +08:00

5961 lines
234 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */
const PAGE_SIZE = 35;
const CHAT_MESSAGES_FETCH_LIMIT = 5000;
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.encodingAttachments": "编码附件中…",
"chat.error": "请求失败",
"chat.loadMore": "加载更多",
"chat.sessionMenu": "会话操作",
"chat.rename": "重命名",
"chat.delete": "删除",
"chat.copy": "复制",
"chat.deleteMessage": "删除消息",
"chat.deleteMessageConfirm": "删除这条消息?",
"chat.copyOk": "已复制",
"chat.copyFail": "复制失败",
"chat.deleteConfirm": "删除此会话?",
"chat.exportMd": "导出 Markdown",
"chat.exportJson": "导出 JSON",
"chat.fork": "复制会话",
"chat.audit": "审计与追踪",
"chat.myProfile": "设置",
"chat.jobs": "后台任务",
"chat.jobsTitle": "后台任务",
"chat.jobsEmpty": "暂无后台任务",
"chat.jobsRefresh": "刷新",
"chat.jobsKill": "终止",
"chat.jobsKillConfirm": "确认终止该任务?进程将被强制结束。",
"chat.jobsKillAll": "全部终止",
"chat.jobsKillAllConfirm": "确认终止全部运行中的任务?",
"chat.jobsPurge": "清除记录",
"chat.jobsPurgeConfirm": "清除该已结束任务的本地记录与日志?",
"chat.jobsClose": "关闭",
"chat.jobsRunning": "运行中 {n}",
"chat.jobsHint": "Agent 启动的长任务会列在这里;对话结束后进程仍可能继续。可手动终止以防风险。",
"chat.jobsStatus.running": "运行中",
"chat.jobsStatus.succeeded": "成功",
"chat.jobsStatus.failed": "失败",
"chat.jobsStatus.timeout": "超时",
"chat.jobsStatus.cancelled": "已取消",
"chat.attach": "附件",
"chat.tools": "推理",
"chat.tools.hidden": "推理已隐藏",
"chat.tools.visible": "推理已显示",
"chat.compressHistory": "压缩对话",
"chat.compressHistoryPrompt": "将本会话历史工具输出按回放策略写回压缩(不可逆)?建议仅在发现超大 tool_result/导出卡顿时使用。",
"chat.compressHistoryOk": "压缩完成:扫描 {scanned} 条 tool 消息,重写 {rewritten} 条,超限压缩 {compacted} 条(已跳过 {skipped} 条已压缩)。",
"chat.compressHistoryFail": "压缩失败:{error}",
"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": "模型推理片段",
"reasoning.processNotes": "主通道过程说明",
"tool.streamTitle": "工具与进度(本轮)",
"auth.login": "登录",
"auth.logout": "退出登录",
"auth.username": "用户名",
"auth.password": "密码",
"auth.invalid": "登录失败,请检查凭据",
"auth.consoleAdminOnly": "仅管理员(admin 角色)可登录管理台。",
"auth.chatLoginDenied": "当前账号无法使用对话(请联系管理员)。",
"auth.disabled": "账号已被禁用,请联系管理员",
"common.error": "错误",
"theme.label": "界面配色",
"theme.deepseek": "DeepSeek 默认",
"theme.github": "GitHub 暗色",
"theme.nord": "Nord",
"theme.dracula": "Dracula",
"theme.forest": "森绿",
"theme.catppuccin": "Catppuccin Mocha",
"theme.light": "浅色",
"lang.switch": "English",
"chat.langSwitchedWhileStreaming": "语言已切换。当前会话仍在输出,刷新页面可更新全部界面文案。",
"chat.imageViewerClose": "关闭",
"chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭",
"chat.imageViewerDownload": "下载图片",
"chat.specialistLabel": "专家",
"chat.modeLabel": "模式",
"chat.modeComprehensive": "综合",
"chat.modeExpert": "专家",
"chat.execModeLabel": "执行态",
"chat.execModeAgent": "Agent",
"chat.execModePlan": "Plan",
"chat.execModeApplied": "执行态:{mode}",
"chat.confirmStrategyLabel": "确认策略",
"chat.planAgentVersionLabel": "Plan / Agent 版本",
"chat.planAgentVersionV1": "v1(经典)",
"chat.planAgentVersionV2": "v2",
"chat.planAgentVersionV2Disabled": "v2(未启用,需 AIA_EXPERT_PLAN_AGENT_V2_ENABLED=1)",
"chat.confirmStrategyStrict": "Strict(需切换 Agent)",
"chat.confirmStrategyAuto": "Auto(自动确认执行)",
"chat.confirmStrategyOff": "Off(不拦截确认)",
"chat.specialistGeneralist": "通用",
"chat.specialistOps": "运维",
"chat.specialistImage": "图像",
"chat.specialistMemory": "记忆",
"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 专家?",
"chat.specialistManagePromptOff": "是否关闭 memory 专家?",
"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.specialistVideoShort": "视频",
"chat.specialistMemoryShort": "记忆",
"chat.specialistManagerSelfShort": "全能者",
"chat.attachment.download": "下载",
"chat.attachment.preview": "预览",
"chat.attachment.previewLoading": "加载中…",
"chat.attachment.previewError": "预览失败",
"chat.attachment.previewEmpty": "(空内容)",
"chat.attachmentAcl": "附件 ACL",
"chat.attachmentAclBackfill": "回填 ACL",
"chat.attachmentAclBackfillPrompt": "回填 attachment_acl(扫描历史消息 attachments)?建议先在低峰期执行。",
"chat.attachmentAclBackfillOk": "回填完成:插入 {inserted} 条(扫描 {scanned_messages} 条消息)",
"chat.attachmentAclBackfillFail": "回填失败:{error}",
},
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.encodingAttachments": "Encoding attachments…",
"chat.error": "Request failed",
"chat.loadMore": "Load more",
"chat.sessionMenu": "Session actions",
"chat.rename": "Rename",
"chat.delete": "Delete",
"chat.copy": "Copy",
"chat.deleteMessage": "Delete message",
"chat.deleteMessageConfirm": "Delete this message?",
"chat.copyOk": "Copied",
"chat.copyFail": "Copy failed",
"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.jobs": "Background jobs",
"chat.jobsTitle": "Background jobs",
"chat.jobsEmpty": "No background jobs",
"chat.jobsRefresh": "Refresh",
"chat.jobsKill": "Kill",
"chat.jobsKillConfirm": "Kill this job? The process will be force-terminated.",
"chat.jobsKillAll": "Kill all running",
"chat.jobsKillAllConfirm": "Kill all running jobs?",
"chat.jobsPurge": "Purge",
"chat.jobsPurgeConfirm": "Remove local records/logs for this finished job?",
"chat.jobsClose": "Close",
"chat.jobsRunning": "{n} running",
"chat.jobsHint": "Long agent jobs appear here and may keep running after the chat turn ends. Kill them manually if needed.",
"chat.jobsStatus.running": "running",
"chat.jobsStatus.succeeded": "succeeded",
"chat.jobsStatus.failed": "failed",
"chat.jobsStatus.timeout": "timeout",
"chat.jobsStatus.cancelled": "cancelled",
"chat.attach": "Attach",
"chat.tools": "Reasoning",
"chat.tools.hidden": "Reasoning hidden",
"chat.tools.visible": "Reasoning visible",
"chat.compressHistory": "Compress chat",
"chat.compressHistoryPrompt": "Rewrite this session's historical tool outputs using replay-guard compaction (irreversible). Use only when a session is polluted by huge tool_result.",
"chat.compressHistoryOk": "Compaction done: scanned {scanned} tool messages, rewritten {rewritten}, oversized compacted {compacted} (skipped {skipped} already compacted).",
"chat.compressHistoryFail": "Compaction failed: {error}",
"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",
"reasoning.processNotes": "Main-channel notes",
"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",
"theme.label": "Color theme",
"theme.deepseek": "DeepSeek (default)",
"theme.github": "GitHub Dark",
"theme.nord": "Nord",
"theme.dracula": "Dracula",
"theme.forest": "Forest",
"theme.catppuccin": "Catppuccin Mocha",
"theme.light": "Light",
"lang.switch": "中文",
"chat.langSwitchedWhileStreaming": "Language updated. Session is still streaming; refresh the page to reload all labels.",
"chat.imageViewerClose": "Close",
"chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close",
"chat.imageViewerDownload": "Download image",
"chat.specialistLabel": "Specialist",
"chat.modeLabel": "Mode",
"chat.modeComprehensive": "Comprehensive",
"chat.modeExpert": "Expert",
"chat.execModeLabel": "Execution",
"chat.execModeAgent": "Agent",
"chat.execModePlan": "Plan",
"chat.execModeApplied": "Execution: {mode}",
"chat.confirmStrategyLabel": "Confirm Strategy",
"chat.planAgentVersionLabel": "Plan / Agent version",
"chat.planAgentVersionV1": "v1 (classic)",
"chat.planAgentVersionV2": "v2",
"chat.confirmStrategyStrict": "Strict (switch to Agent first)",
"chat.confirmStrategyAuto": "Auto (confirm executes directly)",
"chat.confirmStrategyOff": "Off (no confirm-mode gate)",
"chat.specialistGeneralist": "Generalist",
"chat.specialistOps": "Ops",
"chat.specialistImage": "Image",
"chat.specialistMemory": "Memory",
"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 specialist?",
"chat.specialistManagePromptOff": "Disable memory 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.specialistVideoShort": "Video",
"chat.specialistMemoryShort": "Memory",
"chat.specialistManagerSelfShort": "Manager",
"chat.attachment.download": "Download",
"chat.attachment.preview": "Preview",
"chat.attachment.previewLoading": "Loading…",
"chat.attachment.previewError": "Preview failed",
"chat.attachment.previewEmpty": "(empty)",
"chat.attachmentAcl": "Attachment ACL",
"chat.attachmentAclBackfill": "Backfill ACL",
"chat.attachmentAclBackfillPrompt": "Backfill attachment_acl by scanning historical message attachments? Recommended during off-peak hours.",
"chat.attachmentAclBackfillOk": "Backfill done: inserted {inserted} rows (scanned {scanned_messages} messages)",
"chat.attachmentAclBackfillFail": "Backfill failed: {error}",
},
};
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 CHAT_EXECUTION_MODE_KEY = "ops_chat_execution_mode";
const CHAT_CONFIRM_STRATEGY_KEY = "ops_chat_confirm_strategy";
const CHAT_PLAN_AGENT_VERSION_KEY = "ops_chat_plan_agent_version";
const CHAT_USER_MENU_MODE_KEY = "ops_chat_user_menu_mode";
const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle";
const EXECUTION_MODE_AGENT = "agent";
const EXECUTION_MODE_PLAN = "plan";
const CONFIRM_STRATEGY_STRICT = "strict";
const CONFIRM_STRATEGY_AUTO = "auto";
const CONFIRM_STRATEGY_OFF = "off";
const PLAN_AGENT_V1 = "v1";
const PLAN_AGENT_V2 = "v2";
/** Default on: reasoning/tool fold matches streamed behavior; new browsers have no localStorage yet. */
const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = true;
const REASONING_BLOCK_MAX_CHARS = 12000;
const CHAT_ENABLE_WIKI_EVENT_POLLER = false;
const _rt = String(localStorage.getItem(CHAT_REASONING_TOGGLE_KEY) || "").trim().toLowerCase();
let adminChatShowToolOutput = _rt
? ["1", "true", "yes", "on"].includes(_rt)
: 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 _isAssistantBodyEventType(eventType) {
const et = _normalizeEventType(eventType);
return !et || et === "assistant_text" || et === "assistant" || et === "scheduled_reminder";
}
function _parseEventPayload(raw) {
let ep = raw;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
return ep && typeof ep === "object" && !Array.isArray(ep) ? ep : null;
}
function _isScheduledProactiveMessage(m) {
const eventType = _normalizeEventType(m && m.event_type);
if (eventType === "scheduled_reminder") return true;
const ep = _parseEventPayload(m && m.event_payload);
if (ep && ep.scheduled_proactive) return true;
const rc = String((ep && ep.reasoning_content) || "").trim();
if (!rc) return false;
if (!/定时主动提醒|定时任务模式|scheduled reminder|proactive reminder/i.test(rc)) return false;
return eventType === "assistant_text" || eventType === "assistant" || !eventType;
}
function _messageTurnUuid(m) {
return String((m && m.turn_uuid) || "").trim();
}
function _pushScheduledAssistantRow(rows, m, content, eventType) {
const body = String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (!body && !attsParsed.length) return;
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
rows.push({
role: "assistant",
content: body,
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: m && m.attachments ? m.attachments : null,
_items: [piece],
_message_ids: m && m.id != null ? [m.id] : [],
});
}
function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
const suffix =
raw.length > REASONING_BLOCK_MAX_CHARS
? `\n\n[truncated ${raw.length - REASONING_BLOCK_MAX_CHARS} chars to keep UI responsive]`
: "";
const box = document.createElement("div");
box.className = "chat-msg__reasoning-block";
box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") }));
const pre = el("pre", { class: "chat-msg__reasoning-pre", text: (clipped + suffix) || "—" });
box.appendChild(pre);
if (raw.length > REASONING_BLOCK_MAX_CHARS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.style.marginTop = "6px";
btn.textContent = currentLang === "zh" ? "展开全文(可能较慢)" : "Load full text (may be slow)";
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = currentLang === "zh" ? "加载中..." : "Loading...";
requestAnimationFrame(() => {
pre.textContent = raw || "—";
btn.remove();
});
});
box.appendChild(btn);
}
return box;
}
function _appendCollapsedBundle(inner, items) {
const list = Array.isArray(items) ? items : [];
if (!list.length) return;
const det = document.createElement("details");
det.className = "chat-msg__reasoning";
const sum = document.createElement("summary");
sum.textContent = t("reasoning.summary");
det.appendChild(sum);
for (const it of list) {
const title = String((it && it.title) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text));
}
inner.appendChild(det);
}
function _appendAssistantTextSegments(inner, rawText, collapsedItems) {
const sourceText = 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 _normFoldDedupText(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/** True when ``processText`` is already covered by text already queued for the reasoning fold. */
function _foldProcessTextRedundant(processText, collapsedItems) {
const p = _normFoldDedupText(processText);
if (!p) return true;
const hay = (Array.isArray(collapsedItems) ? collapsedItems : [])
.map((x) => _normFoldDedupText((x && x.text) || ""))
.join("\n\n");
if (!hay) return false;
if (hay.includes(p)) return true;
return false;
}
/**
* Prefer a single render of each attachment_id inside one aggregated bubble.
* When both generate + save_deliverable_attachment persist the same id, keep the
* deliverable row (or the first occurrence if none is marked deliverable).
*/
function _preferredAttachmentOwnerById(items) {
const preferred = new Map();
(Array.isArray(items) ? items : []).forEach((it, itemIdx) => {
const list = parseAttachments(it && it.attachments);
for (const att of list) {
if (!att || typeof att !== "object") continue;
const aid = String(att.attachment_id || att.attachmentId || "")
.trim()
.toLowerCase();
if (!aid) continue;
const prev = preferred.get(aid);
if (prev === undefined) {
preferred.set(aid, itemIdx);
continue;
}
if (att.deliverable === true) {
const prevAtts = parseAttachments((items[prev] && items[prev].attachments) || null);
const prevAtt = prevAtts.find((a) => {
const id = String((a && (a.attachment_id || a.attachmentId)) || "")
.trim()
.toLowerCase();
return id === aid;
});
if (!(prevAtt && prevAtt.deliverable === true)) preferred.set(aid, itemIdx);
}
}
});
return preferred;
}
function _attachmentsForBubbleItem(raw, itemIdx, preferredById) {
const list = parseAttachments(raw);
if (!list.length) return null;
const kept = [];
for (const att of list) {
if (!att || typeof att !== "object") continue;
const aid = String(att.attachment_id || att.attachmentId || "")
.trim()
.toLowerCase();
if (aid && preferredById instanceof Map) {
const owner = preferredById.get(aid);
if (owner != null && owner !== itemIdx) continue;
}
kept.push(att);
}
return kept.length ? kept : null;
}
async function _buildAggregatedAssistantBubble(tsIso, items) {
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
const preferredAttOwner = _preferredAttachmentOwnerById(items);
const itemList = Array.isArray(items) ? items : [];
for (let itemIdx = 0; itemIdx < itemList.length; itemIdx++) {
const it = itemList[itemIdx];
const kind = String((it && it.kind) || "");
const text = String((it && it.text) || "");
const filteredAtts = _attachmentsForBubbleItem(it && it.attachments, itemIdx, preferredAttOwner);
const hasInlineAtt = Array.isArray(filteredAtts) && filteredAtts.length > 0;
if (
!text.trim() &&
!(kind === "tool_result" && hasInlineAtt) &&
!(kind === "assistant_text" && hasInlineAtt)
) {
continue;
}
if (kind === "assistant_text") {
const aet = String((it && it.assistantEventType) || "assistant_text").toLowerCase();
if (aet === "tool_call") {
// Main-channel process notes belong in the reasoning fold only — never in visible body text.
if (adminChatShowToolOutput) {
const t0 = String(text || "").trim();
if (t0 && !_foldProcessTextRedundant(t0, collapsedItems)) {
collapsedItems.push({ title: t("reasoning.processNotes"), text: t0 });
}
}
const attsAssistant = await renderAttachmentsEl(filteredAtts);
if (attsAssistant) inner.appendChild(attsAssistant);
} else {
_appendAssistantTextSegments(inner, text, collapsedItems);
const attsAssistant = await renderAttachmentsEl(filteredAtts);
if (attsAssistant) inner.appendChild(attsAssistant);
}
} else if (kind === "reasoning") {
if (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 });
// 支持两类附件:base64(image/input_image)和引用型(image_ref)。
// image_ref 需要异步拉取 blob,因此改为走 renderAttachmentsEl()。
// Same attachment_id from generate + save_deliverable is shown once (prefer deliverable).
const attsEl = await renderAttachmentsEl(filteredAtts);
if (attsEl) inner.appendChild(attsEl);
} else {
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
}
}
if (collapsedItems.length) {
const det = document.createElement("details");
det.className = "chat-msg__reasoning";
const sum = document.createElement("summary");
sum.textContent = t("reasoning.summary");
det.appendChild(sum);
for (const it of collapsedItems) {
const title = String((it && it.title) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text));
}
inner.insertBefore(det, inner.firstChild);
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
!!inner.querySelector(".chat-att-wrap, img.chat-att-img, a.chat-att-ref__link, .chat-att-chip") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
function _buildRenderRows(msgs) {
const src = Array.isArray(msgs) ? msgs : [];
const ordered = src
.map((m, idx) => ({ m, idx }))
.sort((a, b) => {
const ta = Date.parse(String((a.m && a.m.timestamp) || "")) || 0;
const tb = Date.parse(String((b.m && b.m.timestamp) || "")) || 0;
if (ta !== tb) return ta - tb;
const ia = parseInt(String((a.m && a.m.id) || "0"), 10);
const ib = parseInt(String((b.m && b.m.id) || "0"), 10);
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
return a.idx - b.idx;
})
.map((x) => x.m);
const rows = [];
let agg = null;
const flush = () => {
if (!agg) return;
if (!Array.isArray(agg._items) || !agg._items.length) {
agg = null;
return;
}
rows.push(agg);
agg = null;
};
for (const m of ordered) {
const role = String((m && m.role) || "").toLowerCase();
const content = String((m && m.content) || "");
const eventType = _normalizeEventType(m && m.event_type);
if (role === "user") {
flush();
rows.push(m);
continue;
}
if (role === "assistant" || role === "tool" || role === "function") {
const turnUuid = _messageTurnUuid(m);
if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) {
flush();
}
if (role === "assistant" && _isScheduledProactiveMessage(m)) {
flush();
_pushScheduledAssistantRow(rows, m, content, eventType);
continue;
}
if (!agg) {
agg = {
role: "assistant",
content: "",
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: null,
_items: [],
_message_ids: [],
_turn_uuid: turnUuid,
};
} else if (turnUuid && !agg._turn_uuid) {
agg._turn_uuid = turnUuid;
}
if (m && m.id != null) agg._message_ids.push(m.id);
if (role === "assistant") {
// thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows).
const ep = _parseEventPayload(m && m.event_payload);
if (ep) {
const rc = String(ep.reasoning_content || "").trim();
if (rc) {
agg._items.push({ kind: "reasoning", text: rc });
}
}
if (eventType === "reasoning") {
if (String(content || "").trim()) {
agg._items.push({ kind: "reasoning", text: content });
}
} else if (eventType === "tool_call") {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = { kind: "assistant_text", text: content, assistantEventType: "tool_call" };
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
} else if (_isAssistantBodyEventType(eventType)) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
}
const tc = m.tool_calls;
if (tc != null && tc !== "") {
let line = "";
try {
line = typeof tc === "string" ? tc : JSON.stringify(tc, null, 0);
} catch (_) {
line = String(tc);
}
if (line) agg._items.push({ kind: "tool_call", text: line });
}
if (m.attachments) agg.attachments = m.attachments;
} else {
const hasToolText = !!String(content || "").trim();
const toolAtts = parseAttachments(m && m.attachments);
if (hasToolText || toolAtts.length) {
const piece = { kind: "tool_result", text: content };
if (toolAtts.length) piece.attachments = toolAtts;
else if (m.attachments) piece.attachments = m.attachments;
agg._items.push(piece);
if (m.attachments) agg.attachments = m.attachments;
}
}
continue;
}
flush();
rows.push(m);
}
flush();
return rows;
}
/** True when persisted history does not show a completed assistant reply for the latest turn (e.g. ends with user only). */
function _needsWsTextFallbackFromRenderRows(renderRows) {
const rows = Array.isArray(renderRows) ? renderRows : [];
if (!rows.length) return true;
const last = rows[rows.length - 1];
const lr = String((last && last.role) || "").toLowerCase();
if (lr === "user") return true;
if (lr !== "assistant") return true;
const items = last && Array.isArray(last._items) ? last._items : [];
for (const it of items) {
const k = String((it && it.kind) || "").toLowerCase();
if (k === "assistant_text" && String((it && it.text) || "").trim()) return false;
if (k === "reasoning" && String((it && it.text) || "").trim()) return false;
if (k === "tool_result") {
if (String((it && it.text) || "").trim()) return false;
if (Array.isArray(it.attachments) && it.attachments.length) return false;
}
}
if (String((last && last.content) || "").trim()) return false;
return true;
}
function 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 === "manager") return currentLang === "zh" ? "主控" : "Manager";
if (s === "manager_self") return t("chat.specialistManagerSelfShort");
if (s === "ops") return t("chat.specialistOpsShort");
if (s === "image") return t("chat.specialistImageShort");
if (s === "video") return t("chat.specialistVideoShort");
if (s === "memory") return t("chat.specialistMemoryShort");
return t("chat.specialistGeneralistShort");
}
function memoryModeShortLabel(memoryMode) {
const mm = String(memoryMode || "default").toLowerCase();
if (mm === "store_only") return t("chat.memoryModeStoreOnlyShort");
return t("chat.memoryModeDefaultShort");
}
function _ensureToastHost() {
let host = document.querySelector(".chat-toast-host");
if (host) return host;
host = el("div", { class: "chat-toast-host" });
document.body.appendChild(host);
return host;
}
function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
const host = _ensureToastHost();
const node = el("div", {
class: kind === "error" ? "chat-toast chat-toast--error" : "chat-toast",
text: String(text || ""),
});
host.appendChild(node);
const kill = () => {
try {
node.style.opacity = "0";
node.style.transform = "translateY(6px)";
} catch (_) {}
setTimeout(() => {
try {
node.remove();
} catch (_) {}
}, 220);
};
setTimeout(kill, Math.max(1200, Math.min(Number(ttlMs || 4200), 20000)));
return node;
}
let _jobsPanelPollTimer = null;
let _jobsBadgePollTimer = null;
let _jobsBadgeEl = null;
let _jobsBtnLabelEl = null;
function _jobStatusLabel(status) {
const s = String(status || "").trim().toLowerCase();
const key = `chat.jobsStatus.${s}`;
const labeled = t(key);
return labeled === key ? s || "-" : labeled;
}
function _fmtJobTs(ts) {
const n = Number(ts || 0);
if (!n) return "-";
try {
return new Date(n * 1000).toLocaleString(currentLang === "zh" ? "zh-CN" : "en-US");
} catch (_) {
return String(n);
}
}
function updateJobsBadge(runningCount) {
const n = Math.max(0, parseInt(String(runningCount || 0), 10) || 0);
if (_jobsBtnLabelEl) {
_jobsBtnLabelEl.textContent = t("chat.jobs");
}
if (!_jobsBadgeEl) return;
_jobsBadgeEl.textContent = String(n);
_jobsBadgeEl.hidden = false;
_jobsBadgeEl.setAttribute("aria-label", t("chat.jobsRunning", { n }));
if (n > 0) {
_jobsBadgeEl.classList.add("chat-jobs-badge--hot");
_jobsBadgeEl.classList.remove("chat-jobs-badge--idle");
} else {
_jobsBadgeEl.classList.remove("chat-jobs-badge--hot");
_jobsBadgeEl.classList.add("chat-jobs-badge--idle");
}
const btn = _jobsBadgeEl.closest && _jobsBadgeEl.closest(".chat-nav__jobs");
if (btn) {
if (n > 0) btn.classList.add("chat-nav__jobs--active");
else btn.classList.remove("chat-nav__jobs--active");
btn.title = t("chat.jobsRunning", { n });
}
}
async function refreshJobsBadge() {
try {
const r = await apiGet("/admin/api/chat/jobs?limit=50");
updateJobsBadge(r && r.running_count);
} catch (_) {}
}
function startJobsBadgePoller() {
stopJobsBadgePoller();
refreshJobsBadge();
// Keep sidebar count fresh without opening the panel.
_jobsBadgePollTimer = setInterval(refreshJobsBadge, 4000);
}
function stopJobsBadgePoller() {
if (_jobsBadgePollTimer) {
clearInterval(_jobsBadgePollTimer);
_jobsBadgePollTimer = null;
}
}
async function openBackgroundJobsPanel() {
document.querySelectorAll(".chat-jobs-backdrop").forEach((n) => n.remove());
if (_jobsPanelPollTimer) {
clearInterval(_jobsPanelPollTimer);
_jobsPanelPollTimer = null;
}
const backdrop = el("div", {
class: "chat-confirm-backdrop chat-jobs-backdrop",
style: "z-index:9998;",
});
const card = el("div", {
class: "chat-confirm-card chat-jobs-card",
style: "max-width:920px;width:94vw;max-height:82vh;display:flex;flex-direction:column;gap:10px;",
});
const title = el("div", { class: "card__title", text: t("chat.jobsTitle") });
const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsClose") });
const refreshBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsRefresh") });
const killAllBtn = el("button", {
type: "button",
class: "btn btn--danger",
text: t("chat.jobsKillAll"),
});
const head = el("div", { class: "row", style: "gap:8px;justify-content:space-between;align-items:center;flex-wrap:wrap;" }, [
title,
el("div", { class: "row", style: "gap:8px;" }, [refreshBtn, killAllBtn, closeBtn]),
]);
const hint = el("div", { class: "muted", style: "font-size:12px;line-height:1.45;", text: t("chat.jobsHint") });
const summary = el("div", { class: "muted", style: "font-size:12px;" });
const list = el("div", {
class: "chat-jobs-list",
style: "overflow:auto;flex:1;min-height:220px;max-height:58vh;display:flex;flex-direction:column;gap:8px;",
});
const close = () => {
if (_jobsPanelPollTimer) {
clearInterval(_jobsPanelPollTimer);
_jobsPanelPollTimer = null;
}
try {
backdrop.remove();
} catch (_) {}
refreshJobsBadge();
};
closeBtn.addEventListener("click", close);
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) close();
});
const paint = async () => {
list.innerHTML = "";
list.appendChild(el("div", { class: "muted", text: t("chat.loading") }));
try {
const r = await apiGet("/admin/api/chat/jobs?limit=50");
const jobs = Array.isArray(r && r.jobs) ? r.jobs : [];
const running = Number((r && r.running_count) || 0);
updateJobsBadge(running);
summary.textContent = t("chat.jobsRunning", { n: running }) + ` · total ${Number((r && r.total) || jobs.length)}`;
list.innerHTML = "";
if (!jobs.length) {
list.appendChild(el("div", { class: "muted", text: t("chat.jobsEmpty") }));
return;
}
for (const job of jobs) {
const status = String(job.status || "");
const runningJob = status === "running";
const row = el("div", { class: `chat-jobs-row chat-jobs-row--${status || "unknown"}` });
const top = el("div", { class: "chat-jobs-row__top" }, [
el("span", { class: "chat-jobs-row__name", text: String(job.name || job.job_id || "-") }),
el("span", { class: `chat-jobs-pill chat-jobs-pill--${status}`, text: _jobStatusLabel(status) }),
]);
const meta = el(
"div",
{ class: "chat-jobs-row__meta muted" },
[
el("div", { text: `id: ${job.job_id || "-"}` }),
el("div", { text: `pid: ${job.pid != null ? job.pid : "-"} · timeout: ${job.timeout_s || "-"}s` }),
el("div", { text: `created: ${_fmtJobTs(job.created_at)} · finished: ${_fmtJobTs(job.finished_at)}` }),
el("div", {
class: "chat-jobs-row__cmd",
text: String(job.command || ""),
title: String(job.command || ""),
}),
],
);
const actions = el("div", { class: "chat-jobs-row__actions row", style: "gap:8px;" });
if (runningJob) {
const killBtn = el("button", {
type: "button",
class: "btn btn--danger",
text: t("chat.jobsKill"),
onclick: async () => {
if (!window.confirm(t("chat.jobsKillConfirm"))) return;
killBtn.disabled = true;
try {
await apiPost(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}/cancel`, {});
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
killBtn.disabled = false;
}
},
});
actions.appendChild(killBtn);
} else {
const purgeBtn = el("button", {
type: "button",
class: "btn",
text: t("chat.jobsPurge"),
onclick: async () => {
if (!window.confirm(t("chat.jobsPurgeConfirm"))) return;
purgeBtn.disabled = true;
try {
await apiDelete(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}`);
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
purgeBtn.disabled = false;
}
},
});
actions.appendChild(purgeBtn);
}
const detailBtn = el("button", {
type: "button",
class: "btn",
text: currentLang === "zh" ? "日志" : "Logs",
onclick: async () => {
detailBtn.disabled = true;
try {
const d = await apiGet(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}?log_tail_chars=6000`);
const pre = el("pre", {
class: "chat-jobs-log",
text:
`status=${d.status} exit=${d.exit_code}\n\n--- stdout ---\n${d.stdout_tail || ""}\n\n--- stderr ---\n${d.stderr_tail || ""}`,
});
const existing = row.querySelector(".chat-jobs-log");
if (existing) existing.remove();
row.appendChild(pre);
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
detailBtn.disabled = false;
}
},
});
actions.appendChild(detailBtn);
row.appendChild(top);
row.appendChild(meta);
row.appendChild(actions);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = "";
list.appendChild(el("div", { class: "muted", text: `${t("chat.error")}: ${String(e)}` }));
}
};
refreshBtn.addEventListener("click", () => paint());
killAllBtn.addEventListener("click", async () => {
if (!window.confirm(t("chat.jobsKillAllConfirm"))) return;
killAllBtn.disabled = true;
try {
await apiPost("/admin/api/chat/jobs/cancel-running", {});
await paint();
} catch (e) {
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
} finally {
killAllBtn.disabled = false;
}
});
card.appendChild(head);
card.appendChild(hint);
card.appendChild(summary);
card.appendChild(list);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
await paint();
_jobsPanelPollTimer = setInterval(paint, 4000);
}
async function openWikiPreviewModal({ sessionId, path }) {
const sid = String(sessionId || "");
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
if (!sid || !p) return;
const backdrop = el("div", {
class: "chat-confirm-backdrop",
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: `<div class="muted">${escapeHtml(t("chat.loading"))}</div>` });
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 = `<div class="muted">${escapeHtml(`${t("chat.error")}: ${String(e)}`)}</div>`;
}
}
function reasonLabel(reason) {
const r = String(reason || "").trim();
if (!r) return "-";
const key = `chat.reason.${r}`;
const localized = t(key);
return localized === key ? r : localized;
}
async function fetchDynamicExpertStats() {
try {
const r = await apiGet("/admin/api/chat/admin/dynamic-expert-stats?limit=200");
if (!r || !r.ok) return null;
return {
dynamic: parseInt(String(r.dynamic_used_count || "0"), 10) || 0,
fallback: parseInt(String(r.fallback_generalist_count || "0"), 10) || 0,
rate: Number(r.dynamic_used_rate || 0),
reasons: r.dispatch_reasons && typeof r.dispatch_reasons === "object" ? r.dispatch_reasons : {},
reasonLabels:
r.dispatch_reason_labels && typeof r.dispatch_reason_labels === "object" ? r.dispatch_reason_labels : {},
};
} catch (_) {
return null;
}
}
async function getDispatchReasonLabelsConfig() {
try {
const r = await apiGet("/admin/api/chat/settings/dispatch-reason-labels");
if (!r || !r.ok) return { overrides: {}, effective: {} };
return {
overrides: r.overrides && typeof r.overrides === "object" ? r.overrides : {},
effective: r.effective && typeof r.effective === "object" ? r.effective : {},
};
} catch (_) {
return { overrides: {}, effective: {} };
}
}
async function setDispatchReasonLabelsConfig(overridesOrNull) {
return await apiPost("/admin/api/chat/settings/dispatch-reason-labels", {
overrides: overridesOrNull,
});
}
async function openDispatchLabelsEditor(statusEl) {
const cfg = await getDispatchReasonLabelsConfig();
const initText = Object.keys(cfg.overrides || {}).length
? JSON.stringify(cfg.overrides, null, 2)
: "";
const backdrop = el("div", {
class: "chat-confirm-backdrop",
style: "z-index:9999;",
});
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;",
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
let _chatLightboxKeyHandler = null;
let _chatLightboxPrevOverflow = "";
function closeChatImageLightbox() {
if (_chatLightboxKeyHandler) {
document.removeEventListener("keydown", _chatLightboxKeyHandler);
_chatLightboxKeyHandler = null;
}
document.querySelector(".chat-img-lightbox")?.remove();
document.body.style.overflow = _chatLightboxPrevOverflow || "";
}
function dismissChatMenus() {
document.querySelectorAll(".chat-sess-menu-pop, .chat-menu-scrim").forEach((n) => {
try {
n.remove();
} catch (_) {}
});
}
function isChatStreaming() {
return !!document.querySelector(".chat-composer-shell--busy");
}
/** Remove full-screen layers on ``document.body`` that survive ``#app`` remounts (boot/lang/popstate). */
function clearChatPageBlockers() {
closeChatImageLightbox();
dismissChatMenus();
document.body.style.overflow = "";
document.querySelectorAll(".chat-confirm-backdrop").forEach((n) => {
try {
n.remove();
} catch (_) {}
});
}
function attachChatMenuDismiss(menu) {
const scrim = el("div", {
class: "chat-menu-scrim",
style: "position:fixed;inset:0;z-index:250;background:transparent;",
});
const closeAll = () => {
dismissChatMenus();
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKey, true);
};
const onDocClick = (e) => {
if (menu.contains(e.target)) return;
closeAll();
};
const onKey = (e) => {
if (e.key === "Escape") closeAll();
};
scrim.addEventListener("click", closeAll);
document.body.appendChild(scrim);
document.body.appendChild(menu);
setTimeout(() => {
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKey, true);
}, 0);
return closeAll;
}
function openChatImageLightbox(src, alt) {
if (!src || !String(src).trim()) return;
closeChatImageLightbox();
_chatLightboxPrevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
_chatLightboxKeyHandler = (ev) => {
if (ev.key === "Escape") closeChatImageLightbox();
};
document.addEventListener("keydown", _chatLightboxKeyHandler);
const backdrop = el("div", {
class: "chat-img-lightbox",
role: "dialog",
"aria-modal": "true",
"aria-label": t("chat.imageViewerHint"),
});
const inner = el("div", { class: "chat-img-lightbox__inner" });
let scale = 1.0;
const clamp = (v) => Math.max(0.2, Math.min(5.0, Number(v || 1)));
const applyScale = () => {
scale = clamp(scale);
viewport.style.transform = `scale(${scale})`;
zoomText.textContent = `${Math.round(scale * 100)}%`;
};
const fileStem = (() => {
const raw = String(alt || "").trim() || "image";
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, "_").slice(0, 64);
return safe || "image";
})();
const ext = (() => {
const s = String(src || "");
if (/^data:image\/png/i.test(s)) return ".png";
if (/^data:image\/webp/i.test(s)) return ".webp";
if (/^data:image\/gif/i.test(s)) return ".gif";
if (/^data:image\/bmp/i.test(s)) return ".bmp";
if (/^data:image\/jpeg/i.test(s) || /^data:image\/jpg/i.test(s)) return ".jpg";
return ".png";
})();
const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" });
const btnMinus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "−",
title: currentLang === "zh" ? "缩小" : "Zoom out",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale - 0.1);
applyScale();
},
});
const btnReset = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "100%",
title: currentLang === "zh" ? "重置缩放" : "Reset zoom",
onclick: (e) => {
e.stopPropagation();
scale = 1.0;
applyScale();
wrap.scrollLeft = 0;
wrap.scrollTop = 0;
},
});
const btnPlus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "+",
title: currentLang === "zh" ? "放大" : "Zoom in",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale + 0.1);
applyScale();
},
});
const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" });
const saveBtn = el("a", {
class: "chat-mermaid-lightbox__btn chat-img-lightbox__btn--icon",
href: String(src),
download: `${fileStem}${ext}`,
text: "⤓",
title: t("chat.imageViewerDownload"),
"aria-label": t("chat.imageViewerDownload"),
onclick: (e) => e.stopPropagation(),
});
const wrap = el("div", { class: "chat-img-lightbox__viewportWrap" });
const viewport = el("div", { class: "chat-img-lightbox__viewport" });
const big = el("img", {
class: "chat-img-lightbox__img",
src: String(src),
alt: alt || "",
decoding: "async",
});
const closeBtn = el("button", {
type: "button",
class: "chat-img-lightbox__close",
text: "×",
"aria-label": t("chat.imageViewerClose"),
onclick: (e) => {
e.stopPropagation();
closeChatImageLightbox();
},
});
// Keep native image context menu so users can right-click save.
big.addEventListener("contextmenu", (e) => {
e.stopPropagation();
});
toolbar.appendChild(btnMinus);
toolbar.appendChild(btnReset);
toolbar.appendChild(btnPlus);
toolbar.appendChild(zoomText);
toolbar.appendChild(saveBtn);
viewport.appendChild(big);
wrap.appendChild(viewport);
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragScrollLeft = 0;
let dragScrollTop = 0;
const onDragStart = (e) => {
if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return;
dragging = true;
wrap.classList.add("chat-img-lightbox__viewportWrap--dragging");
dragStartX = Number(e.clientX || 0);
dragStartY = Number(e.clientY || 0);
dragScrollLeft = wrap.scrollLeft;
dragScrollTop = wrap.scrollTop;
};
const onDragMove = (e) => {
if (!dragging) return;
const x = Number(e.clientX || 0);
const y = Number(e.clientY || 0);
wrap.scrollLeft = dragScrollLeft - (x - dragStartX);
wrap.scrollTop = dragScrollTop - (y - dragStartY);
};
const onDragEnd = () => {
dragging = false;
wrap.classList.remove("chat-img-lightbox__viewportWrap--dragging");
};
wrap.addEventListener("mousedown", (e) => onDragStart(e));
window.addEventListener("mousemove", (e) => onDragMove(e));
window.addEventListener("mouseup", () => onDragEnd());
wrap.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const dy = Number(e.deltaY || 0);
const step = dy > 0 ? -0.08 : 0.08;
scale = clamp(scale + step);
applyScale();
},
{ passive: false },
);
inner.appendChild(toolbar);
inner.appendChild(closeBtn);
inner.appendChild(wrap);
backdrop.appendChild(inner);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeChatImageLightbox();
});
document.body.appendChild(backdrop);
applyScale();
}
function openChatMermaidLightbox(svg) {
const raw = String(svg || "").trim();
if (!raw) return;
closeChatImageLightbox();
_chatLightboxPrevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
_chatLightboxKeyHandler = (ev) => {
if (ev.key === "Escape") closeChatImageLightbox();
};
document.addEventListener("keydown", _chatLightboxKeyHandler);
const backdrop = el("div", {
class: "chat-img-lightbox",
role: "dialog",
"aria-modal": "true",
"aria-label": "Mermaid diagram viewer",
});
const inner = el("div", { class: "chat-img-lightbox__inner" });
let scale = 1.0;
const clamp = (v) => Math.max(0.2, Math.min(3.0, Number(v || 1)));
const applyScale = () => {
scale = clamp(scale);
viewport.style.transform = `scale(${scale})`;
zoomText.textContent = `${Math.round(scale * 100)}%`;
};
const closeBtn = el("button", {
type: "button",
class: "chat-img-lightbox__close",
text: "×",
"aria-label": t("chat.imageViewerClose"),
onclick: (e) => {
e.stopPropagation();
closeChatImageLightbox();
},
});
const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" });
const btnMinus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "−",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale - 0.1);
applyScale();
},
});
const btnPlus = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "+",
onclick: (e) => {
e.stopPropagation();
scale = clamp(scale + 0.1);
applyScale();
},
});
const btnReset = el("button", {
type: "button",
class: "chat-mermaid-lightbox__btn",
text: "100%",
onclick: (e) => {
e.stopPropagation();
scale = 1.0;
applyScale();
},
});
const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" });
toolbar.appendChild(btnMinus);
toolbar.appendChild(btnReset);
toolbar.appendChild(btnPlus);
toolbar.appendChild(zoomText);
const wrap = el("div", { class: "chat-mermaid-lightbox__svg" });
const viewport = el("div", { class: "chat-mermaid-lightbox__viewport" });
viewport.innerHTML = raw;
wrap.appendChild(viewport);
// Drag-to-pan (scroll) inside the zoomable viewport.
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragScrollLeft = 0;
let dragScrollTop = 0;
const onDragStart = (e) => {
// Ignore drags started on toolbar/buttons.
if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return;
dragging = true;
wrap.classList.add("chat-mermaid-lightbox__svg--dragging");
dragStartX = Number(e.clientX || 0);
dragStartY = Number(e.clientY || 0);
dragScrollLeft = wrap.scrollLeft;
dragScrollTop = wrap.scrollTop;
};
const onDragMove = (e) => {
if (!dragging) return;
const x = Number(e.clientX || 0);
const y = Number(e.clientY || 0);
wrap.scrollLeft = dragScrollLeft - (x - dragStartX);
wrap.scrollTop = dragScrollTop - (y - dragStartY);
};
const onDragEnd = () => {
dragging = false;
wrap.classList.remove("chat-mermaid-lightbox__svg--dragging");
};
wrap.addEventListener("mousedown", (e) => onDragStart(e));
window.addEventListener("mousemove", (e) => onDragMove(e));
window.addEventListener("mouseup", () => onDragEnd());
wrap.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const dy = Number(e.deltaY || 0);
const step = dy > 0 ? -0.08 : 0.08;
scale = clamp(scale + step);
applyScale();
},
{ passive: false },
);
inner.appendChild(closeBtn);
inner.appendChild(toolbar);
inner.appendChild(wrap);
backdrop.appendChild(inner);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeChatImageLightbox();
});
document.body.appendChild(backdrop);
applyScale();
}
function bindChatImageViewer(messagesEl) {
messagesEl.addEventListener("click", (ev) => {
const img = ev.target && ev.target.closest && ev.target.closest("img");
if (!img || !messagesEl.contains(img)) return;
const src = img.currentSrc || img.getAttribute("src") || "";
if (!src.trim()) return;
ev.preventDefault();
ev.stopPropagation();
openChatImageLightbox(src, img.getAttribute("alt") || "");
});
}
function bindChatMermaidViewer(messagesEl) {
messagesEl.addEventListener("click", (ev) => {
const svg = ev.target && ev.target.closest && ev.target.closest(".mermaid svg");
if (!svg || !messagesEl.contains(svg)) return;
ev.preventDefault();
ev.stopPropagation();
openChatMermaidLightbox(svg.outerHTML || "");
});
}
/** Keep markdown output safe but allow images (USE_PROFILES html strips img in many DOMPurify builds). */
function renderMarkdownHtml(src) {
const raw = String(src ?? "");
if (typeof marked !== "undefined" && typeof DOMPurify !== "undefined") {
const html =
typeof marked.parse === "function"
? marked.parse(raw, { breaks: true, mangle: false, headerIds: false })
: marked(raw, { breaks: true });
const opts = {
ADD_TAGS: ["img", "picture", "source"],
ADD_ATTR: ["src", "alt", "title", "loading", "class", "width", "height", "decoding", "referrerpolicy", "sizes", "srcset"],
};
try {
return DOMPurify.sanitize(html, opts);
} catch (_) {
return DOMPurify.sanitize(html);
}
}
return `<div class="chat-msg__plain">${escapeHtml(raw).replace(/\n/g, "<br/>")}</div>`;
}
let _mermaidBootstrapped = false;
let _mermaidRetryTimer = null;
window.__oclawHydrateMermaidAll = () => {
try {
const root = document.getElementById("app") || document.body;
hydrateMermaidIn(root);
} catch (_) {}
};
function hydrateMermaidIn(root) {
const host = root && root.querySelectorAll ? root : null;
if (!host) return;
const codeNodes = host.querySelectorAll("pre > code.language-mermaid, pre > code.lang-mermaid");
for (const code of codeNodes) {
const pre = code.parentElement;
if (!pre || !pre.parentElement) continue;
const txt = String(code.textContent || "").trim();
if (!txt) continue;
const box = document.createElement("div");
box.className = "mermaid";
box.setAttribute("data-mermaid-raw", txt);
box.textContent = txt;
pre.parentElement.replaceChild(box, pre);
}
const _attachFallback = (node, errText = "") => {
if (!node || node.querySelector(".mermaid-fallback")) return;
const raw = String(node.getAttribute("data-mermaid-raw") || "").trim() || String(node.textContent || "").trim();
const msg = String(errText || "").trim();
node.innerHTML = `<div class="mermaid-fallback">${msg ? `<div class="mermaid-fallback__err">${escapeHtml(msg)}</div>` : ""}<pre>${escapeHtml(raw)}</pre></div>`;
};
if (typeof mermaid === "undefined") {
if (_mermaidRetryTimer != null) return;
_mermaidRetryTimer = setTimeout(() => {
_mermaidRetryTimer = null;
try {
window.__oclawHydrateMermaidAll();
} catch (_) {}
}, 350);
return;
}
try {
if (!_mermaidBootstrapped && typeof mermaid.initialize === "function") {
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: "base",
themeVariables: {
background: "transparent",
primaryColor: "#1f2937",
primaryBorderColor: "#94a3b8",
primaryTextColor: "#e5e7eb",
lineColor: "#94a3b8",
textColor: "#e5e7eb",
fontFamily: "Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
},
});
_mermaidBootstrapped = true;
}
const nodes = Array.from(host.querySelectorAll(".mermaid")).filter((n) => n && n.isConnected);
if (!nodes.length) return;
// Mermaid skips already processed nodes. In streaming / rerender scenarios, DOM can be replaced.
// Remove the marker so Mermaid treats these nodes as fresh.
for (const n of nodes) {
try {
n.removeAttribute("data-processed");
} catch (_) {}
}
if (typeof mermaid.run === "function") {
Promise.resolve(mermaid.run({ nodes }))
.then(() => {
for (const n of nodes) {
if (!n || !n.isConnected) continue;
if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)");
}
})
.catch((e) => {
const msg = String((e && e.message) || e || "Mermaid render failed");
for (const n of nodes) _attachFallback(n, msg);
});
} else if (typeof mermaid.init === "function") {
try {
mermaid.init(undefined, nodes);
for (const n of nodes) {
if (!n || !n.isConnected) continue;
if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)");
}
} catch (e) {
const msg = String((e && e.message) || e || "Mermaid render failed");
for (const n of nodes) _attachFallback(n, msg);
}
}
} catch (e) {
const msg = String((e && e.message) || e || "Mermaid render failed");
const nodes = host.querySelectorAll(".mermaid");
for (const n of nodes) _attachFallback(n, msg);
}
}
const RE_REDACTED_THINKING = new RegExp("<redacted_thinking>\\s*([\\s\\S]*?)\\s*</redacted_thinking>", "i");
const RE_THINK_TAG = new RegExp("<think\\s*>\\s*([\\s\\S]*?)\\s*</think\\s*>", "i");
const RE_THINKING_TAG = new RegExp("<thinking\\s*>\\s*([\\s\\S]*?)\\s*</thinking\\s*>", "i");
const RE_THOUGHT_TAG = new RegExp("<thought\\s*>\\s*([\\s\\S]*?)\\s*</thought\\s*>", "i");
function _findEarliestReasoningBlock(remaining) {
let best = null;
for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) {
const m = re.exec(remaining);
re.lastIndex = 0;
if (!m) continue;
const start = m.index;
if (!best || start < best.start) {
best = { start, end: start + m[0].length, inner: (m[1] || "").trim() };
}
}
return best;
}
function parseReasoningSegments(raw) {
const parts = [];
let remaining = String(raw ?? "");
while (remaining.length) {
const hit = _findEarliestReasoningBlock(remaining);
if (!hit) {
parts.push({ type: "text", text: remaining });
break;
}
if (hit.start > 0) parts.push({ type: "text", text: remaining.slice(0, hit.start) });
parts.push({ type: "reasoning", text: hit.inner });
remaining = remaining.slice(hit.end);
}
return parts.length ? parts : [{ type: "text", text: "" }];
}
// Oclaw-style: strip <think>/<thinking>/<final> 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 <final> tags (but keep content).
let cleaned = raw;
if (_OC_FINAL_TAG_RE.test(cleaned)) {
_OC_FINAL_TAG_RE.lastIndex = 0;
const fences = _findCodeFenceRegions(cleaned);
const matches = [];
for (const m of cleaned.matchAll(_OC_FINAL_TAG_RE)) {
const idx = m.index ?? 0;
matches.push({ idx, len: m[0].length, inCode: _isInsideRegion(idx, fences) });
}
for (let i = matches.length - 1; i >= 0; i -= 1) {
const mm = matches[i];
if (mm.inCode) continue;
cleaned = cleaned.slice(0, mm.idx) + cleaned.slice(mm.idx + mm.len);
}
} else {
_OC_FINAL_TAG_RE.lastIndex = 0;
}
const fences = _findCodeFenceRegions(cleaned);
_OC_THINKING_TAG_RE.lastIndex = 0;
let out = "";
let last = 0;
let inThinking = false;
for (const m of cleaned.matchAll(_OC_THINKING_TAG_RE)) {
const idx = m.index ?? 0;
const isClose = m[1] === "/";
if (_isInsideRegion(idx, fences)) continue;
if (!inThinking) {
out += cleaned.slice(last, idx);
if (!isClose) inThinking = true;
} else if (isClose) {
inThinking = false;
}
last = idx + m[0].length;
}
if (!inThinking || mode === "preserve") out += cleaned.slice(last);
return out;
}
function extractThinkingFromText(text) {
const raw = String(text || "");
if (!raw) return "";
const blocks = [];
for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) {
const rr = new RegExp(re.source, "gi");
for (const m of raw.matchAll(rr)) {
const inner = String(m[1] || "").trim();
if (inner) blocks.push(inner);
}
}
return blocks.join("\n");
}
function extractWsAssistantText(message) {
if (!message || typeof message !== "object") return "";
const m = message;
if (typeof m.content === "string") return decodeEscapedNewlines(String(m.content || ""));
if (typeof m.text === "string") return decodeEscapedNewlines(String(m.text || ""));
const c = Array.isArray(m.content) ? m.content : [];
const parts = c
.map((x) => {
if (!x || typeof x !== "object") return "";
if (String(x.type || "") === "text") return decodeEscapedNewlines(String(x.text || ""));
return "";
})
.filter(Boolean);
return parts.join("\n");
}
function decodeEscapedNewlines(text) {
return String(text || "").replace(/\\n/g, "\n").replace(/\\N/g, "\n");
}
function normalizeStreamText(raw) {
return String(raw || "")
.replace(/^\s*\n+/, "")
.replace(/\n{3,}/g, "\n\n");
}
// Streaming surface should be smooth and avoid newline explosions.
function normalizeStreamDisplayText(raw) {
return String(raw || "")
.replace(/\r/g, "")
.replace(/^\s*\n+/, "")
.replace(/\n+/g, " ")
.replace(/[ \t]{2,}/g, " ")
.trimStart();
}
// Streaming-only: user prefers a more "natural" flow without many newlines.
function normalizeStreamBodyForUi(raw) {
return String(raw || "")
.replace(/^\s*\n+/, "")
.replace(/\r/g, "")
// Collapse pathological newline runs but keep paragraph breaks.
// 3+ newlines => 2 newlines; trim leading newlines.
.replace(/\n{3,}/g, "\n\n");
}
// Oclaw-style streaming stitcher: reduce boundary artifacts without destroying layout.
function createStreamStitcher() {
let prevTail = "";
return {
push(delta) {
let s = String(delta || "");
if (!s) return "";
s = s.replace(/\r/g, "");
// If previous ended with newline and delta starts with newline(s), drop the leading ones.
if (prevTail.endsWith("\n") && s.startsWith("\n")) {
s = s.replace(/^\n+/, "\n");
}
// If previous ended with a space and delta starts with space/newline, trim the head.
if (/[ \t]$/.test(prevTail) && /^[ \t\n]/.test(s)) {
s = s.replace(/^[ \t\n]+/, " ");
}
// Avoid 3+ newlines caused by chunk boundaries.
s = s.replace(/\n{3,}/g, "\n\n");
prevTail = (prevTail + s).slice(-12);
return s;
},
};
}
function formatToolPanelText(name, payload, options = {}) {
const streamMode = !!(options && options.streamMode);
const truncateToolPanel = (s) => {
const raw = String(s || "").trim();
if (streamMode) return raw;
const maxChars = 4000;
if (raw.length <= maxChars) return raw;
return `${raw.slice(0, maxChars)}\n\n…(为保证界面性能,已截断 ${raw.length - maxChars} 字符)`;
};
const n = String(name || "").trim() || "tool";
const p = payload && typeof payload === "object" ? payload : {};
if (String(p.phase || "") === "call" && (p.tool_name != null || p.arguments !== undefined)) {
const tn = String(p.tool_name || "").trim() || "tool";
let argsStr = "";
try {
argsStr =
p.arguments && typeof p.arguments === "object"
? JSON.stringify(p.arguments, null, 2)
: String(p.arguments ?? "");
} catch (_) {
argsStr = String(p.arguments ?? "");
}
const tid = String(p.tool_call_id || "").trim();
const head = currentLang === "zh" ? `[调用] ${tn}` : `[call] ${tn}`;
return normalizeStreamText([head, tid ? `tool_call_id: ${tid}` : "", "", argsStr].filter(Boolean).join("\n"));
}
const r = p.result && typeof p.result === "object" ? p.result : p;
// SQL audit payload: keep line breaks and key fields visible.
if (r && typeof r === "object" && (r.input_sql || r.executed_sql || r.sql_guard)) {
const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {};
const lines = [
`${n}`,
`[SQL] input`,
String(r.input_sql || ""),
"",
`[SQL] executed`,
String(r.executed_sql || ""),
"",
`[Guard]`,
`readonly_enforced=${String(!!guard.readonly_enforced)}`,
`multi_statement_forbidden=${String(!!guard.multi_statement_forbidden)}`,
`auto_limit_applied=${String(!!guard.auto_limit_applied)}`,
`result_row_cap=${String(guard.result_row_cap != null ? guard.result_row_cap : "")}`,
`engine=${String(r.engine || "")}`,
`rows_returned=${String(r.rows_returned != null ? r.rows_returned : "")}`,
];
return truncateToolPanel(lines.join("\n").trim());
}
let body = "";
// Prefer human-readable text from tool content blocks when available.
try {
const content = Array.isArray(r.content) ? r.content : [];
const textBlock = content.find((x) => x && typeof x === "object" && String(x.type || "") === "text");
const text = textBlock ? String(textBlock.text || "") : "";
if (text.trim()) body = text;
} catch (_) {}
if (!body) {
try {
body = JSON.stringify(r, null, 2);
} catch (_) {
body = String(r || "");
}
}
body = normalizeStreamText(body);
return `${n}\n${truncateToolPanel(body)}`.trim();
}
function extractSqlAuditPayload(payload) {
const p = payload && typeof payload === "object" ? payload : {};
const r = p.result && typeof p.result === "object" ? p.result : p;
if (!r || typeof r !== "object") return null;
const inputSql = String(r.input_sql || "");
const executedSql = String(r.executed_sql || "");
if (!inputSql && !executedSql && !(r.sql_guard && typeof r.sql_guard === "object")) return null;
const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {};
return {
inputSql,
executedSql,
guard,
engine: String(r.engine || ""),
rowsReturned: r.rows_returned != null ? Number(r.rows_returned) : null,
};
}
function extractToolImageItems(payload) {
const _toObj = (v) => {
if (v && typeof v === "object") return v;
if (typeof v !== "string") return null;
const s = String(v || "").trim();
if (!s) return null;
try {
const j = JSON.parse(s);
return j && typeof j === "object" ? j : null;
} catch (_) {
return null;
}
};
const p = _toObj(payload) || {};
const cands = [p, _toObj(p.payload), _toObj(p.result), _toObj(p.payload && p.payload.result), _toObj(p.result && p.result.result)].filter(Boolean);
const out = [];
for (const cand of cands) {
const content = Array.isArray(cand.content) ? cand.content : [];
for (const item of content) {
if (!item || typeof item !== "object") continue;
const typ = String(item.type || "").trim().toLowerCase();
if (typ === "image" || typ === "input_image") {
const srcObj = item.source && typeof item.source === "object" ? item.source : {};
const b64 = String(item.image_base64 || item.data || srcObj.data || "").trim();
if (!b64) continue;
const mime = String(item.mime_type || item.mime || srcObj.media_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
continue;
}
if (typ === "image_url") {
const urlObj = item.image_url && typeof item.image_url === "object" ? item.image_url : {};
const url = String(item.url || item.image_url || urlObj.url || "").trim();
if (!url) continue;
out.push({ type: "image_url", src: url });
}
}
}
// Fallback: direct attachment-like payload shape.
const direct = _toObj(p.attachments);
const atts = Array.isArray(direct) ? direct : [];
for (const a of atts) {
if (!a || typeof a !== "object") continue;
const t = String(a.type || "").toLowerCase();
if (t === "image" || t === "input_image") {
const b64 = String(a.image_base64 || a.data || "").trim();
if (!b64) continue;
const mime = String(a.mime || a.mime_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
} else if (t === "image_url") {
const src = String(a.url || a.image_url || "").trim();
if (src) out.push({ type: "image_url", src });
}
}
// De-dup by src.
const uniq = [];
const seen = new Set();
for (const it of out) {
const k = String((it && it.src) || "");
if (!k || seen.has(k)) continue;
seen.add(k);
uniq.push(it);
}
return uniq;
}
function _sqlLimitSuffix(inputSql, executedSql) {
const a = String(inputSql || "").trim();
const b = String(executedSql || "").trim();
if (!a || !b || b.length <= a.length) return "";
const lowerA = a.toLowerCase();
const lowerB = b.toLowerCase();
if (lowerB.startsWith(lowerA)) {
const suffix = b.slice(a.length);
if (/^\s*limit\s+\d+\s*$/i.test(suffix)) return suffix.trim();
}
return "";
}
function _tokenizeSqlForDiff(sqlText) {
const s = String(sqlText || "");
return s.match(/\s+|[^\s]+/g) || [];
}
function _renderExecutedSqlWithAddedHighlight(inputSql, executedSql) {
const a = _tokenizeSqlForDiff(inputSql);
const b = _tokenizeSqlForDiff(executedSql);
if (!a.length) return escapeHtml(String(executedSql || ""));
// LCS-based diff: mark tokens present only in executed SQL.
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i -= 1) {
for (let j = n - 1; j >= 0; j -= 1) {
if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
let i = 0;
let j = 0;
const out = [];
while (i < m && j < n) {
if (a[i] === b[j]) {
out.push(escapeHtml(b[j]));
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i += 1;
} else {
out.push(
`<span style="background:#ecfdf5;color:#065f46;border-radius:3px;padding:0 2px;">${escapeHtml(b[j])}</span>`,
);
j += 1;
}
}
while (j < n) {
out.push(
`<span style="background:#ecfdf5;color:#065f46;border-radius:3px;padding:0 2px;">${escapeHtml(b[j])}</span>`,
);
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/brand-assets/logo.svg";
const CHAT_BOT_LOGO_FALLBACK_SRC = "/admin/assets/oliver.svg";
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
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() {
const img = el("img", {
class: "chat-avatar chat-avatar--bot",
src: CHAT_BOT_LOGO_SRC,
alt: "",
loading: "lazy",
});
img.addEventListener(
"error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return img;
}
function buildUserAvatarSlot() {
const wrap = el("div", { class: "chat-avatar-slot" });
const cur = el("img", {
class: "chat-avatar chat-avatar--img chat-avatar--userBuiltin",
src: DEFAULT_USER_AVATAR_SRC,
alt: "",
loading: "lazy",
});
wrap.appendChild(cur);
const aid = 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);
}
}
const hasVisible =
!!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") ||
String(inner.textContent || "").trim().length > 0;
if (!hasVisible) {
inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") }));
}
return wrapAssistantMessage(inner, tsIso);
}
const _blobUrlCache = new Map();
const _attachmentTextPreviewCache = new Map();
async function fetchAttachmentBlobUrl(attachmentId) {
const aid = String(attachmentId || "").trim();
if (!aid) return null;
if (_blobUrlCache.has(aid)) return _blobUrlCache.get(aid);
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const res = await fetch(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_blobUrlCache.set(aid, url);
return url;
}
async function fetchAttachmentTextPreview(attachmentId, maxChars = 1800) {
const aid = String(attachmentId || "").trim();
if (!aid) return "";
if (_attachmentTextPreviewCache.has(aid)) return _attachmentTextPreviewCache.get(aid) || "";
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const res = await fetch(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`preview_http_${res.status}`);
const blob = await res.blob();
const txt = String(await blob.text());
const out = txt.length > maxChars ? `${txt.slice(0, maxChars)}\n…` : txt;
_attachmentTextPreviewCache.set(aid, out);
return out;
}
function parseAttachments(raw) {
if (raw == null || raw === "") return [];
if (Array.isArray(raw)) return raw.filter((x) => x && typeof x === "object");
if (typeof raw === "object" && !Array.isArray(raw)) {
return [raw];
}
if (typeof raw === "string") {
const s = raw.trim();
if (!s || s === "null") return [];
try {
const j = JSON.parse(s);
if (Array.isArray(j)) return j.filter((x) => x && typeof x === "object");
if (j && typeof j === "object") return [j];
} catch (_) {
return [];
}
}
return [];
}
async function renderAttachmentsEl(raw) {
const list = parseAttachments(raw);
if (!list.length) return null;
const wrap = el("div", { class: "chat-att-wrap" });
const isTextLikeMime = (mime) => {
const m = String(mime || "").trim().toLowerCase();
if (!m) return false;
if (m.startsWith("text/")) return true;
return (
m.includes("json") ||
m.includes("xml") ||
m.includes("yaml") ||
m.includes("yml") ||
m.includes("csv") ||
m.includes("javascript") ||
m.includes("typescript")
);
};
const buildRefCard = async (att, typ) => {
const aid = String(att.attachment_id || att.attachmentId || "").trim();
const mime = String(att.mime || att.mime_type || "application/octet-stream").trim();
const name = String(att.name || `${typ || "attachment"}`).trim();
const bytes = Number(att.bytes || 0);
const sizeLabel = bytes > 0 ? `${Math.round((bytes / 1024) * 10) / 10} KB` : "";
const card = el("div", { class: "chat-att-ref" });
card.appendChild(el("div", { class: "chat-att-ref__name", text: name }));
card.appendChild(el("div", { class: "chat-att-ref__meta", text: `${typ} · ${mime}${sizeLabel ? ` · ${sizeLabel}` : ""}` }));
if (aid) card.appendChild(el("div", { class: "chat-att-ref__meta", text: `id: ${aid.slice(0, 16)}...` }));
if (aid) {
const url = await fetchAttachmentBlobUrl(aid);
if (url) {
card.appendChild(
el("a", {
class: "chat-att-ref__link",
href: url,
target: "_blank",
rel: "noopener noreferrer",
download: name || undefined,
text: t("chat.attachment.download"),
}),
);
if (String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video",
controls: true,
preload: "metadata",
style: "max-width:100%;max-height:420px;margin-top:8px;border-radius:8px;",
src: url,
}),
);
}
}
} else {
const remote = String(att.url || "").trim();
if (remote && String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video",
controls: true,
preload: "metadata",
style: "max-width:100%;max-height:420px;margin-top:8px;border-radius:8px;",
src: remote,
}),
);
card.appendChild(
el("a", {
class: "chat-att-ref__link",
href: remote,
target: "_blank",
rel: "noopener noreferrer",
text: t("chat.attachment.download"),
}),
);
}
}
const canPreviewText = !!aid && (String(typ || "") === "text_ref" || isTextLikeMime(mime));
if (canPreviewText) {
const preview = el("button", {
type: "button",
class: "chat-att-ref__btn",
text: t("chat.attachment.preview"),
});
const pre = el("pre", { class: "chat-att-ref__preview" });
preview.addEventListener("click", async () => {
if (!pre.hidden) {
pre.hidden = true;
preview.textContent = t("chat.attachment.preview");
return;
}
preview.disabled = true;
preview.textContent = t("chat.attachment.previewLoading");
try {
const txt = await fetchAttachmentTextPreview(aid);
pre.textContent = txt || t("chat.attachment.previewEmpty");
} catch (_) {
pre.textContent = t("chat.attachment.previewError");
} finally {
pre.hidden = false;
preview.disabled = false;
preview.textContent = t("chat.attachment.preview");
}
});
card.appendChild(preview);
card.appendChild(pre);
}
return card;
};
for (const att of list) {
if (!att || typeof att !== "object") continue;
const typ = String(att.type || "");
if (typ === "image_ref") {
const aid = String(att.attachment_id || att.attachmentId || "").trim();
const url = aid ? await fetchAttachmentBlobUrl(aid) : null;
if (url) {
const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || "") });
wrap.appendChild(img);
} else {
wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") }));
}
} else if (typ === "relay_pointer") {
let aid = String(att.attachment_id || att.attachmentId || "").trim();
if (!aid) {
const p = String(att.pointer_uri || "").trim();
const m = p.match(/^relay:\/\/attachments\/[^/]+\/([a-f0-9]{8,64})$/i);
if (m && m[1]) aid = String(m[1]).toLowerCase();
}
const mime = String(att.mime || att.mime_type || "").toLowerCase();
const url = aid ? await fetchAttachmentBlobUrl(aid) : null;
if (url && (!mime || mime.startsWith("image/"))) {
const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || att.rel_path || "relay image") });
wrap.appendChild(img);
} else {
const title = String(att.name || att.rel_path || att.pointer_uri || "relay pointer");
wrap.appendChild(el("span", { class: "chat-att-chip", text: `📎 ${title}` }));
}
} else if (typ === "image_url") {
const src = String(att.url || att.image_url || "").trim();
if (!src) continue;
const img = el("img", {
class: "chat-att-img",
src,
alt: String(att.name || "generated image"),
decoding: "async",
referrerPolicy: "no-referrer",
});
img.addEventListener(
"error",
() => {
try {
const parent = img.parentNode;
if (!parent) return;
parent.replaceChild(
el("a", {
class: "chat-att-ref__link",
href: src,
target: "_blank",
rel: "noopener noreferrer",
text:
currentLang === "zh"
? "图片无法嵌入显示(可能被防盗链),点击打开原始链接"
: "Image blocked from inline display; open original URL",
}),
img,
);
} catch (_) {}
},
{ once: true },
);
wrap.appendChild(img);
} else if (typ === "video_ref" || typ === "text_ref" || typ === "binary_ref") {
wrap.appendChild(await buildRefCard(att, typ));
} else if (typ === "image" || typ === "input_image") {
const b64 = att.image_base64 || att.data;
const mime = String(att.mime || "image/jpeg");
if (b64) {
const src = `data:${mime};base64,${String(b64).replace(/\s/g, "")}`;
wrap.appendChild(el("img", { class: "chat-att-img", src, alt: String(att.name || "") }));
} else {
wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") }));
}
} else {
const maybeAid = String(att.attachment_id || att.attachmentId || "").trim();
if (maybeAid) wrap.appendChild(await buildRefCard(att, typ || "attachment_ref"));
else wrap.appendChild(el("span", { class: "chat-att-chip", text: `📄 ${String(att.name || "file")}` }));
}
}
return wrap.children.length ? wrap : null;
}
async function appendMessageRow(messagesEl, m, options = {}) {
const role = String(m.role || "");
const content = String(m.content || "");
const ts = m.timestamp != null ? m.timestamp : "";
const bubble = Array.isArray(m._items)
? await _buildAggregatedAssistantBubble(ts, m._items)
: await buildMessageBubble(role, content, ts);
// For aggregated assistant bubbles, attachments should be rendered inline
// at tool_result positions, not appended at bubble tail.
const att = Array.isArray(m._items) ? null : await renderAttachmentsEl(m.attachments);
if (att) {
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) innerBubble.appendChild(att);
else bubble.appendChild(att);
}
const colNode = bubble.querySelector(".chat-msg-col");
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) {
const copyText = (() => {
const v = String(innerBubble.innerText || "").trim();
return v;
})();
const ids = Array.isArray(m._message_ids) ? m._message_ids.filter((x) => x != null) : (m.id != null ? [m.id] : []);
const canDelete = ids.length === 1 && typeof options.onDeleteMessage === "function";
if (copyText || canDelete) {
const bar = el("div", { class: "chat-msg__actions" });
if (copyText) {
bar.appendChild(
el("button", {
type: "button",
class: "chat-msg__action-btn",
text: "⧉",
title: t("chat.copy"),
"aria-label": t("chat.copy"),
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(copyText);
if (typeof options.onActionStatus === "function") options.onActionStatus(t("chat.copyOk"));
} catch (_) {
if (typeof options.onActionStatus === "function") options.onActionStatus(t("chat.copyFail"));
}
},
}),
);
}
if (canDelete) {
bar.appendChild(
el("button", {
type: "button",
class: "chat-msg__action-btn chat-msg__action-btn--danger",
text: "🗑",
title: t("chat.deleteMessage"),
"aria-label": t("chat.deleteMessage"),
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
let ok = false;
if (typeof options.onConfirm === "function") {
ok = await options.onConfirm(t("chat.deleteMessageConfirm"));
} else {
ok = window.confirm(t("chat.deleteMessageConfirm"));
}
if (!ok) return;
await options.onDeleteMessage(ids[0]);
},
}),
);
}
if (colNode) colNode.appendChild(bar);
else innerBubble.appendChild(bar);
}
}
messagesEl.appendChild(bubble);
hydrateMermaidIn(bubble);
}
function mount(node) {
clearChatPageBlockers();
const c = document.getElementById("app");
c.innerHTML = "";
c.appendChild(node);
}
function buildChatBrandLogoNode() {
const img = el("img", {
class: "chat-nav__brandLogo",
src: CHAT_BOT_LOGO_SRC,
alt: "site logo",
});
img.addEventListener(
"error",
() => {
if (img.dataset.logoFallbackApplied === "1") return;
img.dataset.logoFallbackApplied = "1";
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
},
{ once: true },
);
return el("div", { class: "chat-nav__brandWrap" }, [img]);
}
function resolveAdminHashUrl(hashPath, sessionId) {
const path = (location.pathname || "").replace(/\/+$/, "") || "/";
const cleanHash = String(hashPath || "").replace(/^#?\/?/, "");
const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
if (path.endsWith("/chat")) {
const base = path.slice(0, -5);
return base.endsWith("/admin") ? `${base}#/${cleanHash}${q}` : `${base}/admin#/${cleanHash}${q}`;
}
return `/admin#/${cleanHash}${q}`;
}
function openAdminFromChat(hashPath, sessionId) {
const target = resolveAdminHashUrl(hashPath, sessionId);
// Keep navigation in the same window so desktop shell feels native.
window.location.assign(target);
}
function syncAuthUserLabel() {
const user = document.getElementById("authUser");
if (!user) return;
user.innerHTML = "";
const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || "");
const isAdminViewer = String((authSession && authSession.username) || "")
.trim()
.toLowerCase() === "administrator";
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();
clearChatPageBlockers();
const items = [
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "profile",
text: t("chat.myProfile"),
}),
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "jobs",
text: t("chat.jobs"),
}),
];
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("div", { class: "muted", style: "padding:6px 10px 2px;font-size:12px;", text: t("theme.label") }),
);
const themeSelMenu = el("select", {
class: "input",
style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);",
});
try {
(window.OclawAdminTheme && window.OclawAdminTheme.THEMES ? window.OclawAdminTheme.THEMES : ["deepseek"]).forEach((tid) => {
themeSelMenu.appendChild(el("option", { value: tid, text: t(`theme.${tid}`) }));
});
themeSelMenu.value = window.OclawAdminTheme ? window.OclawAdminTheme.currentAdminTheme() : "deepseek";
} catch (_) {}
themeSelMenu.addEventListener("change", () => {
try {
if (window.OclawAdminTheme) window.OclawAdminTheme.persistAdminTheme(themeSelMenu.value);
} catch (_) {}
});
items.push(themeSelMenu);
const bridge = window.__chatUserMenuPrefs;
if (bridge && typeof bridge === "object") {
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(el("div", { class: "muted", style: "padding:6px 10px 2px;font-size:12px;", text: t("chat.modeLabel") }));
const modeSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" });
try {
const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : [];
rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") })));
modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || "");
} catch (_) {}
modeSel.addEventListener("change", async () => {
const v = modeSel.value;
try {
modeSel.disabled = true;
if (bridge.setModeValue) await bridge.setModeValue(v);
} catch (_) {
// errors are surfaced by saveUserGlobalModePreference()
} finally {
modeSel.disabled = false;
}
});
items.push(modeSel);
items.push(el("div", { class: "muted", style: "padding:6px 10px 2px;font-size:12px;", text: t("chat.planAgentVersionLabel") }));
const pavSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" });
try {
const prow = Array.isArray(bridge.getPlanAgentVersionOptions && bridge.getPlanAgentVersionOptions())
? bridge.getPlanAgentVersionOptions()
: [];
prow.forEach((r) =>
pavSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") })),
);
pavSel.value = String((bridge.getPlanAgentVersionValue && bridge.getPlanAgentVersionValue()) || "");
} catch (_) {}
pavSel.addEventListener("change", async () => {
const v = pavSel.value;
try {
pavSel.disabled = true;
if (bridge.setPlanAgentVersionValue) await bridge.setPlanAgentVersionValue(v);
} catch (_) {
// errors are surfaced by saveUserGlobalModePreference()
} finally {
pavSel.disabled = false;
}
});
items.push(pavSel);
items.push(el("div", { class: "muted", style: "padding:2px 10px 2px;font-size:12px;", text: t("chat.confirmStrategyLabel") }));
const csSel = el("select", { class: "input", style: "width:100%;margin:4px 8px 8px;max-width:calc(100% - 16px);" });
try {
const rows = Array.isArray(bridge.getConfirmStrategyOptions && bridge.getConfirmStrategyOptions())
? bridge.getConfirmStrategyOptions()
: [];
rows.forEach((r) => csSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") })));
csSel.value = String((bridge.getConfirmStrategyValue && bridge.getConfirmStrategyValue()) || "");
} catch (_) {}
csSel.addEventListener("change", async () => {
const v = csSel.value;
try {
csSel.disabled = true;
if (bridge.setConfirmStrategyValue) await bridge.setConfirmStrategyValue(v);
} catch (_) {
// errors are surfaced by saveUserGlobalModePreference()
} finally {
csSel.disabled = false;
}
});
items.push(csSel);
const reasonWrap = el("label", { class: "switch-wrap", style: "margin:2px 8px 8px;" }, [
el("input", { type: "checkbox", class: "switch-input" }),
el("span", { class: "switch-slider" }),
el("span", { class: "muted", text: t("chat.tools") }),
]);
const reasonCb = reasonWrap.querySelector("input.switch-input");
try {
reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible());
} catch (_) {}
reasonCb.addEventListener("change", () => {
try {
Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {});
} catch (_) {}
});
items.push(reasonWrap);
}
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "lang",
text: t("lang.switch"),
}),
);
items.push(el("div", { class: "chat-sess-menu-sep" }));
items.push(
el("button", {
type: "button",
class: "chat-sess-menu-item",
"data-menu-action": "logout",
text: t("auth.logout"),
}),
);
const menu = el("div", {
class: "chat-sess-menu-pop",
style: "position:fixed;min-width:220px;z-index:300;",
}, items);
const rect = moreBtn.getBoundingClientRect();
attachChatMenuDismiss(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
},
});
const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]);
user.appendChild(row);
}
async function fileToPayloadEntry(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const dataUrl = String(r.result || "");
const idx = dataUrl.indexOf(",");
const data_base64 = idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl;
resolve({ name: file.name || "file", data_base64 });
};
r.onerror = () => reject(r.error || new Error("file_read_failed"));
r.readAsDataURL(file);
});
}
async function renderLogin() {
applyI18nStatic();
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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
const CHAT_ICON_SEND =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 5.5 19 17.5h-5.25V19H10v-1.5H5L12 5.5z"/></svg>';
const CHAT_ICON_STOP =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>';
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);
bindChatMermaidViewer(messagesEl);
const AUTO_SCROLL_BOTTOM_GAP_PX = 56;
let shouldFollowMessages = true;
let autoScrollRaf = 0;
let autoScrollTimer = 0;
const isNearBottom = () => {
const remaining = messagesEl.scrollHeight - (messagesEl.scrollTop + messagesEl.clientHeight);
return remaining <= AUTO_SCROLL_BOTTOM_GAP_PX;
};
const scheduleFollowScroll = (force = false) => {
if (!force && !shouldFollowMessages) return;
if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf);
if (autoScrollTimer) clearTimeout(autoScrollTimer);
autoScrollRaf = requestAnimationFrame(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
autoScrollRaf = requestAnimationFrame(() => {
if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight;
});
});
// Some async markdown/image/UI post-processing happens after RAF.
autoScrollTimer = setTimeout(() => {
if (force || shouldFollowMessages) messagesEl.scrollTop = messagesEl.scrollHeight;
autoScrollTimer = 0;
}, 60);
};
const scrollMessagesToBottom = (force = false) => {
scheduleFollowScroll(force);
};
messagesEl.addEventListener("scroll", () => {
shouldFollowMessages = isNearBottom();
});
messagesEl.addEventListener(
"load",
(ev) => {
const target = ev && ev.target;
if (!target || target.tagName !== "IMG") return;
scrollMessagesToBottom();
},
true,
);
const messagesMutationObserver = new MutationObserver(() => {
scheduleFollowScroll(false);
});
messagesMutationObserver.observe(messagesEl, {
childList: true,
subtree: true,
characterData: true,
});
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 MAIN_MODE_VALUE = "comprehensive";
const EXCLUDED_SPECIALISTS = new Set(["main", "memory", "manager_self", "pycache", "__pycache__"]);
let specialistCatalog = [];
/** Hidden: mode is global-only (⋯ menu). Kept for specialist option list in `publishUserMenuPrefsBridge`. */
const modeSelect = el("select", {
class: "input",
style: "display:none;",
"aria-hidden": "true",
tabIndex: -1,
});
let globalMenuModeValue = String(localStorage.getItem(CHAT_USER_MENU_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase();
const modelSelect = el("select", {
class: "input",
style: "min-width:150px;max-width:240px;padding:6px 8px;",
title: t("chat.activeModelLabel"),
});
let modelSelectNameToId = new Map();
const normalizeExecutionMode = (v) => {
const raw = String(v || "").trim().toLowerCase();
return raw === EXECUTION_MODE_PLAN ? EXECUTION_MODE_PLAN : EXECUTION_MODE_AGENT;
};
const normalizeConfirmStrategy = (v) => {
const raw = String(v || "").trim().toLowerCase();
if (raw === CONFIRM_STRATEGY_AUTO) return CONFIRM_STRATEGY_AUTO;
if (raw === CONFIRM_STRATEGY_OFF) return CONFIRM_STRATEGY_OFF;
return CONFIRM_STRATEGY_STRICT;
};
const executionModeLabel = (v) =>
normalizeExecutionMode(v) === EXECUTION_MODE_PLAN ? t("chat.execModePlan") : t("chat.execModeAgent");
let currentExecutionMode = normalizeExecutionMode(localStorage.getItem(CHAT_EXECUTION_MODE_KEY) || EXECUTION_MODE_AGENT);
let currentConfirmStrategy = normalizeConfirmStrategy(localStorage.getItem(CHAT_CONFIRM_STRATEGY_KEY) || CONFIRM_STRATEGY_STRICT);
const normalizePlanAgentVersion = (v) => {
const raw = String(v || "").trim().toLowerCase();
return raw === PLAN_AGENT_V2 ? PLAN_AGENT_V2 : PLAN_AGENT_V1;
};
let planAgentV2GloballyEnabled = false;
let currentPlanAgentVersion = normalizePlanAgentVersion(localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY) || PLAN_AGENT_V1);
const execSelect = el("select", {
class: "input",
style: "min-width:96px;max-width:140px;padding:6px 8px;",
});
const refreshExecutionSelect = () => {
const prev = normalizeExecutionMode(execSelect.value || currentExecutionMode);
execSelect.innerHTML = "";
execSelect.appendChild(el("option", { value: EXECUTION_MODE_AGENT, text: t("chat.execModeAgent") }));
execSelect.appendChild(el("option", { value: EXECUTION_MODE_PLAN, text: t("chat.execModePlan") }));
execSelect.value = prev;
};
const setExecutionMode = (v, { persistLocal = true, saveSession = true } = {}) => {
currentExecutionMode = normalizeExecutionMode(v);
if (persistLocal) localStorage.setItem(CHAT_EXECUTION_MODE_KEY, currentExecutionMode);
refreshExecutionSelect();
execSelect.value = currentExecutionMode;
if (saveSession && activeId) saveSessionModePreference();
};
execSelect.addEventListener("change", () => {
setExecutionMode(execSelect.value, { persistLocal: true, saveSession: true });
});
refreshExecutionSelect();
execSelect.value = currentExecutionMode;
const execSelectWrap = el("span", { class: "chat-exec-mode-wrap", style: "display:inline-flex;align-items:center;" }, [
execSelect,
]);
const outboundPlanAgentVersion = () => {
const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
if (modeVal === MAIN_MODE_VALUE) return PLAN_AGENT_V1;
return normalizePlanAgentVersion(currentPlanAgentVersion);
};
const refreshExecUi = () => {
const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
const expert = modeVal !== MAIN_MODE_VALUE;
const show =
expert &&
planAgentV2GloballyEnabled &&
normalizePlanAgentVersion(currentPlanAgentVersion) === PLAN_AGENT_V2;
execSelectWrap.style.display = show ? "inline-flex" : "none";
};
const modeOptionLabel = (v) => {
const key = String(v || "").trim().toLowerCase();
if (key === MAIN_MODE_VALUE) return t("chat.modeComprehensive");
if (key === "generalist") return t("chat.specialistGeneralist");
const row = specialistCatalog.find((x) => String(x.id || "").toLowerCase() === key);
if (!row) return key || t("chat.modeComprehensive");
const zh = String(row.display_name_zh || "").trim();
const en = String(row.display_name_en || "").trim();
return currentLang === "zh" ? (zh || en || key) : (en || zh || key);
};
const isSelectableSpecialist = (v) => {
const key = String(v || "").trim().toLowerCase();
if (!key || key === MAIN_MODE_VALUE) return false;
if (key === "generalist") return true;
return specialistCatalog.some((x) => String(x.id || "").toLowerCase() === key);
};
const persistModeSelection = () => {
const v = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, v);
if (v !== MAIN_MODE_VALUE) localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, v);
};
const syncHiddenModeSelectFromGlobal = () => {
const g = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
if (Array.from(modeSelect.options || []).some((o) => String(o.value || "") === g)) modeSelect.value = g;
else modeSelect.value = MAIN_MODE_VALUE;
};
const applyModeOptions = () => {
const prev = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
modeSelect.innerHTML = "";
modeSelect.appendChild(el("option", { value: MAIN_MODE_VALUE, text: modeOptionLabel(MAIN_MODE_VALUE) }));
modeSelect.appendChild(el("option", { value: "generalist", text: modeOptionLabel("generalist") }));
specialistCatalog.forEach((x) => {
const sid = String(x.id || "").toLowerCase();
if (!sid || sid === "generalist") return;
modeSelect.appendChild(el("option", { value: sid, text: modeOptionLabel(sid) }));
});
if (Array.from(modeSelect.options).some((o) => String(o.value || "") === prev)) {
modeSelect.value = prev;
} else {
modeSelect.value = MAIN_MODE_VALUE;
globalMenuModeValue = MAIN_MODE_VALUE;
}
};
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
const loadSpecialistCatalog = async () => {
try {
const r = await apiGet("/admin/api/experts");
const items = Array.isArray(r && r.items) ? r.items : [];
specialistCatalog = items
.filter((x) => {
const id = String((x && x.id) || "").trim().toLowerCase();
if (!id || EXCLUDED_SPECIALISTS.has(id)) return false;
const role = String((x && x.role) || "").trim().toLowerCase();
return role === "expert" || id === "generalist";
})
.map((x) => ({
id: String(x.id || "").trim().toLowerCase(),
display_name_en: String(x.display_name_en || "").trim(),
display_name_zh: String(x.display_name_zh || "").trim(),
}));
} catch (_) {
specialistCatalog = [];
}
applyModeOptions();
};
await loadSpecialistCatalog();
const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` });
const applyModelSelectorVisibility = (modelsState) => {
const show = !(modelsState && modelsState.chat_model_selector_visible === false);
modelSelect.style.display = show ? "" : "none";
modelSelect.disabled = !show;
};
const refreshActiveModelText = async () => {
try {
const ms = await apiGet("/admin/api/models");
applyModelSelectorVisibility(ms);
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}`;
modelSelect.innerHTML = "";
modelSelectNameToId = new Map();
const usedKeys = new Set();
profiles.forEach((row) => {
const pid = String(row && row.id ? row.id : "");
if (!pid) return;
const rawName = String(row.name || "").trim();
const keyBase = rawName || pid;
let key = keyBase;
let n = 2;
while (usedKeys.has(key)) {
key = `${keyBase}#${n}`;
n += 1;
}
usedKeys.add(key);
modelSelectNameToId.set(key, pid);
const display = String(row.model || row.name || pid);
modelSelect.appendChild(el("option", { value: key, text: display }));
});
if (p) {
const rawName = String(p.name || "").trim();
const firstKey = Array.from(modelSelectNameToId.keys()).find((k) => modelSelectNameToId.get(k) === aid) || "";
const key = firstKey || rawName;
if (key && Array.from(modelSelect.options).some((o) => String(o.value || "") === key)) {
modelSelect.value = key;
}
} else if (modelSelect.options.length > 0) {
modelSelect.value = String(modelSelect.options[0].value || "");
}
} catch (_) {
applyModelSelectorVisibility(null);
activeModelText.textContent = `${t("chat.activeModelLabel")}: -`;
modelSelect.innerHTML = "";
modelSelect.appendChild(el("option", { value: "", text: "-" }));
modelSelect.value = "";
}
};
modelSelect.addEventListener("change", async () => {
const selectedKey = String(modelSelect.value || "").trim();
const pid = String(modelSelectNameToId.get(selectedKey) || "").trim();
if (!pid) return;
try {
await apiPost("/admin/api/models/active", { profile_id: pid });
await refreshActiveModelText();
} catch (e) {
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
}
});
const loadSessionModePreference = async () => {
if (!activeId) {
setExecutionMode(currentExecutionMode, { persistLocal: true, saveSession: false });
refreshExecUi();
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();
const em = String((resp && resp.execution_mode) || "").toLowerCase();
const cs = String((resp && resp.confirm_strategy) || "").toLowerCase();
planAgentV2GloballyEnabled = !!(resp && resp.plan_agent_v2_globally_enabled);
const pavRaw = String((resp && resp.plan_agent_version) || "").trim().toLowerCase();
currentPlanAgentVersion = normalizePlanAgentVersion(pavRaw || localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY));
localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion);
const gm = resp && resp.global_menu && typeof resp.global_menu === "object" ? resp.global_menu : null;
if (gm) {
const gIm = String(gm.interaction_mode || "").toLowerCase();
const gSp = String(gm.specialist || "").toLowerCase();
if (gIm === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE;
else if (gIm === "expert") {
if (isSelectableSpecialist(gSp)) globalMenuModeValue = gSp;
else globalMenuModeValue = "generalist";
} else globalMenuModeValue = MAIN_MODE_VALUE;
} else {
try {
const ur = await apiGet("/admin/api/chat/user-mode");
if (ur && ur.ok) {
planAgentV2GloballyEnabled = !!(ur.plan_agent_v2_globally_enabled != null ? ur.plan_agent_v2_globally_enabled : planAgentV2GloballyEnabled);
const gum = String((ur.interaction_mode || "").toLowerCase());
const gus = String((ur.specialist || "").toLowerCase());
currentConfirmStrategy = normalizeConfirmStrategy(ur.confirm_strategy || currentConfirmStrategy);
localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy);
currentPlanAgentVersion = normalizePlanAgentVersion(
ur.plan_agent_version || localStorage.getItem(CHAT_PLAN_AGENT_VERSION_KEY),
);
localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion);
if (gum === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE;
else if (gum === "expert") {
if (isSelectableSpecialist(gus)) globalMenuModeValue = gus;
else globalMenuModeValue = "generalist";
} else globalMenuModeValue = MAIN_MODE_VALUE;
}
} catch (_) {
if (m === "comprehensive") globalMenuModeValue = MAIN_MODE_VALUE;
else if (m === "expert" && isSelectableSpecialist(s)) globalMenuModeValue = s;
else globalMenuModeValue = MAIN_MODE_VALUE;
}
}
localStorage.setItem(CHAT_USER_MENU_MODE_KEY, String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase());
syncHiddenModeSelectFromGlobal();
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
setExecutionMode(em, { persistLocal: true, saveSession: false });
currentConfirmStrategy = normalizeConfirmStrategy(cs || currentConfirmStrategy);
localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy);
persistModeSelection();
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
refreshExecUi();
publishUserMenuPrefsBridge();
} catch (_) {
setExecutionMode(currentExecutionMode, { persistLocal: true, saveSession: false });
refreshExecUi();
}
};
const saveUserGlobalModePreference = async () => {
try {
const modeVal = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase();
const isMain = modeVal === MAIN_MODE_VALUE;
const resp = await apiPost("/admin/api/chat/user-mode", {
interaction_mode: isMain ? "comprehensive" : "expert",
specialist: isMain ? "generalist" : modeVal,
confirm_strategy: String(currentConfirmStrategy || CONFIRM_STRATEGY_STRICT),
plan_agent_version: String(currentPlanAgentVersion || PLAN_AGENT_V1),
});
if (resp && typeof resp.plan_agent_v2_globally_enabled === "boolean") {
planAgentV2GloballyEnabled = !!resp.plan_agent_v2_globally_enabled;
}
localStorage.setItem(CHAT_USER_MENU_MODE_KEY, modeVal);
if (!resp || resp.ok === false) {
const detail = String((resp && (resp.error || resp.detail)) || "unknown_error");
throw new Error(detail);
}
// Hard-verify persistence: session switches reload mode from server.
// If server didn't persist, the UI will "snap back" to defaults.
try {
const ur = await apiGet("/admin/api/chat/user-mode");
if (ur && ur.ok) {
const gum = String((ur.interaction_mode || "").toLowerCase());
const gus = String((ur.specialist || "").toLowerCase());
const expectedIm = isMain ? "comprehensive" : "expert";
const expectedSp = isMain ? "generalist" : modeVal;
if (gum !== expectedIm || gus !== expectedSp) {
const msg =
currentLang === "zh"
? `模式未固化:后端返回 interaction_mode=${gum || "-"} specialist=${gus || "-"}(期望 ${expectedIm}/${expectedSp})`
: `Mode not persisted: server returned interaction_mode=${gum || "-"} specialist=${gus || "-"} (expected ${expectedIm}/${expectedSp})`;
showToast(msg, { kind: "error", ttlMs: 8000 });
}
}
} catch (_) {}
} catch (e) {
// If persistence fails, user will observe "mode resets after reload/restart".
// Surface this instead of silently swallowing.
try {
const msg =
currentLang === "zh"
? `保存“模式”失败:${String(e || "").slice(0, 180) || "unknown"}`
: `Failed to save mode: ${String(e || "").slice(0, 180) || "unknown"}`;
showToast(msg, { kind: "error", ttlMs: 6500 });
} catch (_) {}
}
};
const saveSessionModePreference = async () => {
if (!activeId) return;
try {
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
execution_mode: String(currentExecutionMode || EXECUTION_MODE_AGENT),
});
} catch (_) {}
};
await refreshActiveModelText();
refreshExecUi();
const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [
execSelectWrap,
modelSelect,
el("button", {
type: "button",
class: "btn",
style: "padding:4px 8px;min-height:auto;font-size:12px;",
text: t("chat.compressHistory"),
onclick: async () => {
const sid = String(activeId || "");
if (!sid) return;
if (!(await confirmChatAction(t("chat.compressHistoryPrompt")))) return;
try {
const resp = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/compress-history`, {});
const r = resp && resp.result ? resp.result : {};
if (!resp || !resp.ok) {
const err = String((resp && (resp.error || resp.detail)) || "failed");
showToast(t("chat.compressHistoryFail", { error: err.slice(0, 180) }), { kind: "error", ttlMs: 6500 });
return;
}
showToast(
t("chat.compressHistoryOk", {
scanned: String(Number(r.scanned_tool_messages || 0)),
rewritten: String(Number(r.rewritten_all_tool_messages || 0)),
compacted: String(Number(r.compacted_tool_messages || 0)),
skipped: String(Number(r.skipped_already_guarded || 0)),
}),
{ kind: "info", ttlMs: 6500 },
);
// Reload messages so UI reflects compacted history.
loadMessagesForActive().catch(() => {});
} catch (e) {
showToast(t("chat.compressHistoryFail", { error: String(e || "failed").slice(0, 180) }), { kind: "error", ttlMs: 6500 });
}
},
}),
]);
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 promptChatText = (message, initialValue = "") =>
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 input = el("input", {
class: "input",
type: "text",
value: String(initialValue || ""),
style: "width:100%;margin-top:10px;",
});
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.dispatchLabelsSave") });
const close = (ok) => {
if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
resolve(ok ? String(input.value || "") : null);
};
btnCancel.addEventListener("click", () => close(false));
btnOk.addEventListener("click", () => close(true));
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
close(true);
} else if (ev.key === "Escape") {
ev.preventDefault();
close(false);
}
});
backdrop.addEventListener("click", (ev) => {
if (ev.target === backdrop) close(false);
});
card.appendChild(text);
card.appendChild(input);
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);
setTimeout(() => {
try {
input.focus();
input.select();
} catch (_) {}
}, 0);
});
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 = [];
return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions };
};
const pollWikiEvents = async () => {
if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
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 (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
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 setReasoningVisible = (next, { showStatus = true } = {}) => {
showToolOutput = !!next;
adminChatShowToolOutput = showToolOutput;
localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
if (showStatus && isStreaming) {
// Avoid clearing active stream bubble mid-turn (loadMessagesForActive() resets messagesEl).
statusBar.textContent = `${showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden")} · ${
currentLang === "zh" ? "本轮结束后应用到历史消息" : "applies to history after current turn"
}`;
return;
}
if (showStatus) statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
loadMessagesForActive().catch(() => {});
};
const publishUserMenuPrefsBridge = () => {
try {
window.__chatUserMenuPrefs = {
getModeOptions: () =>
Array.from(modeSelect.options || []).map((o) => ({
value: String(o.value || ""),
label: String(o.text || o.label || o.value || ""),
})),
getModeValue: () => String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(),
setModeValue: async (v) => {
const next = String(v || "").toLowerCase();
if (!Array.from(modeSelect.options || []).some((o) => String(o.value || "") === next)) return;
globalMenuModeValue = next;
localStorage.setItem(CHAT_USER_MENU_MODE_KEY, globalMenuModeValue);
syncHiddenModeSelectFromGlobal();
await saveUserGlobalModePreference();
refreshExecUi();
publishUserMenuPrefsBridge();
},
getPlanAgentVersionOptions: () => [
{ value: PLAN_AGENT_V1, label: t("chat.planAgentVersionV1") },
{ value: PLAN_AGENT_V2, label: t("chat.planAgentVersionV2") },
],
getPlanAgentVersionValue: () => String(currentPlanAgentVersion || PLAN_AGENT_V1),
setPlanAgentVersionValue: async (v) => {
const next = normalizePlanAgentVersion(v);
currentPlanAgentVersion = next;
localStorage.setItem(CHAT_PLAN_AGENT_VERSION_KEY, currentPlanAgentVersion);
await saveUserGlobalModePreference();
refreshExecUi();
publishUserMenuPrefsBridge();
},
getConfirmStrategyOptions: () => [
{ value: CONFIRM_STRATEGY_STRICT, label: t("chat.confirmStrategyStrict") },
{ value: CONFIRM_STRATEGY_AUTO, label: t("chat.confirmStrategyAuto") },
{ value: CONFIRM_STRATEGY_OFF, label: t("chat.confirmStrategyOff") },
],
getConfirmStrategyValue: () => String(currentConfirmStrategy || CONFIRM_STRATEGY_STRICT),
setConfirmStrategyValue: async (v) => {
currentConfirmStrategy = normalizeConfirmStrategy(v);
localStorage.setItem(CHAT_CONFIRM_STRATEGY_KEY, currentConfirmStrategy);
await saveUserGlobalModePreference();
publishUserMenuPrefsBridge();
},
getReasoningVisible: () => !!showToolOutput,
setReasoningVisible: async (v) => {
setReasoningVisible(!!v, { showStatus: true });
},
};
} catch (_) {}
};
publishUserMenuPrefsBridge();
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 = await promptChatText(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();
clearChatPageBlockers();
const menu = openSessionMenu(sid, title);
menu.style.position = "fixed";
menu.style.zIndex = "300";
const rect = more.getBoundingClientRect();
attachChatMenuDismiss(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
},
});
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 handleDeleteMessage = async (messageId) => {
const sid = String(activeId || "").trim();
const mid = parseInt(String(messageId || 0), 10);
if (!sid || !Number.isFinite(mid) || mid <= 0) return;
await apiDelete(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/messages/${mid}`);
await loadMessagesForActive();
};
const rowRenderOptions = {
onDeleteMessage: handleDeleteMessage,
onConfirm: confirmChatAction,
onActionStatus: (msg) => {
statusBar.textContent = String(msg || "");
},
};
const loadMessagesForActive = async (opts = {}) => {
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
const rid = loadMessagesForActive._rid;
shouldFollowMessages = true;
if (!activeId) {
loadMessagesForActive._needsWsTextFallback = true;
messagesEl.innerHTML = "";
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
return 0;
}
statusBar.textContent = t("chat.loading");
loadMessagesForActive._needsWsTextFallback = true;
try {
const resp = await apiGet(
`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}`,
);
if (rid !== loadMessagesForActive._rid) return 0;
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
const renderRows = _buildRenderRows(msgs);
loadMessagesForActive._needsWsTextFallback = _needsWsTextFallbackFromRenderRows(renderRows);
const total = intOr(resp.message_count, msgs.length);
// End-of-turn hydrate: if the server returns nothing renderable yet (PG commit lag, transient API
// glitch) but the caller still has a live stream worth keeping, do not wipe messagesEl — that
// caused "stream flashes then entire dialog is empty" when reasoning/tool-output mode reloads.
if (!renderRows.length && opts.keepDomIfNoHistoryRows) {
statusBar.textContent = "";
return 0;
}
messagesEl.innerHTML = "";
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, rowRenderOptions);
}
}
statusBar.textContent = "";
scrollMessagesToBottom(true);
return renderRows.length;
} catch (e) {
// Keep existing message list on reload failure (e.g. toggle reload races),
// so users don't perceive "messages disappeared".
loadMessagesForActive._needsWsTextFallback = true;
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
return -1;
}
};
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);
await loadSessionModePreference();
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"),
}),
);
const jobsBadge = el("span", { class: "chat-jobs-badge chat-jobs-badge--idle", text: "0" });
_jobsBadgeEl = jobsBadge;
const jobsLabel = el("span", { class: "chat-nav__jobsLabel", text: t("chat.jobs") });
_jobsBtnLabelEl = jobsLabel;
const btnJobs = el("button", {
type: "button",
class: "chat-nav__jobs",
title: t("chat.jobsRunning", { n: 0 }),
onclick: () => {
openBackgroundJobsPanel().catch((e) => showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" }));
},
});
btnJobs.appendChild(jobsLabel);
btnJobs.appendChild(jobsBadge);
startJobsBadgePoller();
// Immediate paint so count is visible before first interval tick.
updateJobsBadge(0);
refreshJobsBadge();
class OclawWsChatTransport {
constructor({ tokenProvider }) {
this.tokenProvider = tokenProvider;
this.ws = null;
this.reqSeq = 0;
this.msgQueue = [];
this.waiters = [];
this.lastSeq = 0;
this._sessionSubscriptions = new Set();
this._reconnectBaseMs = 500;
this._reconnectCapMs = 5000;
this._maxReconnectAttempts = 4;
}
_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;
let lastErr = null;
for (let i = 0; i <= this._maxReconnectAttempts; i += 1) {
try {
await this._openAndHandshakeOnce();
await this._restoreSubscriptions();
return;
} catch (err) {
lastErr = err;
this.close();
if (i >= this._maxReconnectAttempts) break;
const delay = Math.min(this._reconnectCapMs, this._reconnectBaseMs * Math.pow(2, i));
const jitter = Math.floor(Math.random() * 150);
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
}
}
throw lastErr || new Error("ws_open_failed");
}
async _openAndHandshakeOnce() {
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;
if (parsed && parsed.type === "event" && Number.isFinite(Number(parsed.seq))) {
this.lastSeq = Math.max(this.lastSeq, Number(parsed.seq) || 0);
}
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}`));
};
});
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,
lastSeq: Number(this.lastSeq || 0),
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) || {})}`);
}
}
async _sendReqAndAwait(method, params) {
await this._openAndHandshake();
const reqId = this._nextReqId();
this.ws.send(JSON.stringify({ type: "req", id: reqId, method: String(method || ""), params: params || {} }));
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 || {};
}
}
}
async _restoreSubscriptions() {
const items = Array.from(this._sessionSubscriptions);
for (let i = 0; i < items.length; i += 1) {
const key = String(items[i] || "");
if (!key) continue;
await this._sendReqAndAwait("sessions.messages.subscribe", { sessionKey: key });
}
}
_trackSessionSubscription(sessionId) {
const key = String(sessionId || "").trim();
if (!key) return;
this._sessionSubscriptions.add(key);
}
_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,
executionMode,
idempotencyKey,
signal,
onEvent,
}) {
await this._openAndHandshake();
this._trackSessionSubscription(sessionId);
await this._sendReqAndAwait("sessions.messages.subscribe", { sessionKey: String(sessionId || "") });
const stableIdempotencyKey = String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
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: stableIdempotencyKey,
thinking: "default",
interaction_mode: String(interactionMode || "expert"),
specialist: String(specialist || "generalist"),
memory_mode: String(memoryMode || "default"),
execution_mode: String(executionMode || "agent"),
plan_agent_version: outboundPlanAgentVersion(),
lang: currentLang === "en" ? "en" : "zh",
},
};
this.ws.send(JSON.stringify(req));
let doneMeta = null;
let retriedAfterReconnect = false;
while (true) {
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
let msg = null;
try {
msg = await this._recv();
} catch (err) {
const em = String((err && err.message) || err || "").toLowerCase();
if (!retriedAfterReconnect && (em.includes("ws_closed") || em.includes("ws_receive_failed"))) {
retriedAfterReconnect = true;
await this._openAndHandshake();
this.ws.send(JSON.stringify(req));
continue;
}
throw err;
}
if (
msg &&
msg.type === "event" &&
(msg.event === "chat" ||
msg.event === "session.message" ||
msg.event === "agent.event" ||
msg.event === "session.tool" ||
msg.event === "session.marker" ||
msg.event === "session.turn_started") &&
typeof onEvent === "function"
) {
const payload = msg.payload || {};
onEvent({ event: String(msg.event || ""), payload });
const d = msg.event === "chat" ? { phase: String(payload.state || "") } : {};
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 || ""),
execution_mode: String(p.executionMode || executionMode || EXECUTION_MODE_AGENT || ""),
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,
ttft: p.ttft && typeof p.ttft === "object" ? p.ttft : null,
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: "" };
// Disable WS send inactivity timeout by default (0 = disabled).
// Some gateways can legitimately stream slower than 3 minutes.
const WS_CHAT_SEND_TIMEOUT_MS = 0;
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;
const hasRootAttachments = m.attachments != null && m.attachments !== "";
if (!("content" in m) && !("text" in m) && !hasRootAttachments) return null;
return m;
};
const _expandAssistantMessageForRender = (message) => {
if (!message || typeof message !== "object") return [];
const mesAttParsed = parseAttachments(message.attachments);
const mesAtt = mesAttParsed.length ? mesAttParsed : null;
const srcEventType = _normalizeEventType(message.event_type || message.eventType);
const fallbackEventType = srcEventType === "tool_call" ? "tool_call" : "assistant_text";
const base = {
role: "assistant",
id: message.id,
timestamp: message.timestamp != null ? message.timestamp : new Date().toISOString(),
};
const out = [];
let ep = message.event_payload;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
if (ep && typeof ep === "object" && !Array.isArray(ep)) {
const rc = decodeEscapedNewlines(String(ep.reasoning_content || "")).trim();
if (rc) out.push({ ...base, content: rc, event_type: "reasoning" });
}
const contentItems = Array.isArray(message.content) ? message.content : [];
for (const item of contentItems) {
if (!item || typeof item !== "object") continue;
const typ = String(item.type || "").toLowerCase();
if (typ === "reasoning" || typ === "reasoning_text" || typ === "thinking" || typ === "thought") {
const reasoningText = decodeEscapedNewlines(String(item.text || item.content || item.summary || "")).trim();
if (reasoningText) out.push({ ...base, content: reasoningText, event_type: "reasoning" });
continue;
}
const textBody = decodeEscapedNewlines(
String(item.text || item.output_text || item.content || item.value || ""),
).trim();
if (!textBody) continue;
if (typ === "text" || typ === "output_text" || typ === "assistant_text" || !typ) {
out.push({ ...base, content: textBody, event_type: "assistant_text" });
}
}
const textFallback = decodeEscapedNewlines(
typeof message.content === "string" ? message.content : typeof message.text === "string" ? message.text : "",
).trim();
const hasBodyText = out.some((row) => {
const et = _normalizeEventType(row.event_type);
return _isAssistantBodyEventType(et) || et === "tool_call";
});
if (textFallback && !hasBodyText) {
out.push({ ...base, content: textFallback, event_type: fallbackEventType });
}
if (!out.length && (textFallback || (mesAtt && mesAtt.length))) {
return [
{
...base,
content: textFallback,
event_type: fallbackEventType,
tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls,
...(mesAtt && mesAtt.length ? { attachments: mesAtt } : {}),
},
];
}
if (out.length) {
const _isBodyRow = (row) => {
const et = _normalizeEventType(row && row.event_type);
return _isAssistantBodyEventType(et) || et === "tool_call";
};
let attachIdx = out.length - 1;
for (let i = out.length - 1; i >= 0; i -= 1) {
if (_isBodyRow(out[i])) {
attachIdx = i;
break;
}
}
out[attachIdx] = {
...out[attachIdx],
tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls,
};
// Avoid duplicating blobs: attach WS/API root attachments to the latest body row only.
if (mesAtt && mesAtt.length) {
for (let i = out.length - 1; i >= 0; i -= 1) {
if (_isBodyRow(out[i])) {
out[i] = { ...out[i], attachments: mesAtt };
break;
}
}
}
return out;
}
return [];
};
const sendMessageStream = async (userText, attachmentPayload, turnId) => {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const turnStartedAtMs = Date.now();
let streamBubble = null;
let streamRow = null;
let doneMeta = null;
let chatStream = "";
let chatRunId = null;
let chatStreamSegments = [];
let renderRafId = null;
let renderPending = false;
let transportUsed = "ws";
let typingRafId = null;
let typingTimerId = null;
let streamStatusTimerId = null;
let streamStatusBase = "oclaw";
let streamDisplayTarget = "";
let streamDisplayShown = "";
let streamTextBuffer = "";
const streamStitcher = createStreamStitcher();
let streamMermaidTimerId = null;
let perCharNewlineMode = false;
let perCharNewlineScore = 0;
let toolSeq = 0;
let hasRealStreamText = false;
let sawWsChatEvent = false;
let sawWsTerminalEvent = false;
let sawStreamToolRefAttachments = false;
let markerState = { p: 0, e: false, ep: 0, t: 0, s: 0, k: 0, reclaimed: 0 };
let turnAcceptedAtMs = null;
let phaseRunningAtMs = null;
let firstDeltaAtMs = null;
const _numOrNull = (v) => {
if (v === null || v === undefined) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const _sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, Math.max(0, Number(ms) || 0));
});
const _recoverLatestAssistantFromHistory = async () => {
const resp = await apiGet(
`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}`,
);
const msgs = Array.isArray(resp && resp.messages) ? resp.messages : [];
if (!msgs.length) return false;
let last = null;
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i];
if (String((m && m.role) || "").toLowerCase() !== "assistant") continue;
// Recovery should only accept visible assistant body, not intermediate
// reasoning/tool-call events; otherwise we may terminate on a partial line.
const et = String((m && m.event_type) || "").trim().toLowerCase();
if (et && !_isAssistantBodyEventType(et)) continue;
if (!String((m && m.content) || "").trim()) continue;
{
last = m;
break;
}
}
if (!last) return false;
const tsRaw = String((last && last.timestamp) || "");
const tsMs = tsRaw ? Date.parse(tsRaw) : NaN;
const fresh = Number.isFinite(tsMs) ? tsMs >= turnStartedAtMs - 1500 : true;
if (!fresh) return false;
await loadMessagesForActive();
return true;
};
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);
const baseLabel = _statusLabel(streamStatusBase);
const elapsedMs = turnAcceptedAtMs ? Math.max(0, Date.now() - Number(turnAcceptedAtMs || 0)) : 0;
const elapsedText = elapsedMs > 0 ? ` ${Math.round(elapsedMs / 100) / 10}s` : "";
statusEl.textContent = `${baseLabel}${elapsedText}${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 = `<div class="chat-msg__plain">${escapeHtml(String(noteText || ""))}</div>`;
}
};
const _composeStreamPlainText = () => {
const chunks = [];
for (const seg of chatStreamSegments) {
if (!seg || seg.type !== "text") continue;
const txt = String(seg.text || "");
if (txt) chunks.push(txt);
}
// Keep 1:1 character mapping with original text segments.
// Never inject separators here; otherwise typewriter offsets drift.
return decodeEscapedNewlines(chunks.join(""));
};
const _normalizeStreamTargetForRender = (raw) => {
let s = String(raw || "").replace(/\r/g, "");
if (!s) return "";
// Streaming-only guard: some gateways emit per-char newlines in delta chunks,
// producing vertical "one character per line" layout.
const lines = s.split("\n");
if (lines.length >= 4) {
let short = 0;
let nonEmpty = 0;
let totalLen = 0;
for (const ln of lines) {
const t = String(ln || "");
if (!t) continue;
nonEmpty += 1;
totalLen += t.length;
if (t.length <= 2) short += 1;
}
const avgLen = nonEmpty ? totalLen / nonEmpty : 0;
const shortRatio = nonEmpty ? short / nonEmpty : 0;
if (shortRatio >= 0.75 && avgLen <= 2.0) {
// Join characters back for live display.
s = lines.join("");
} else {
// Fallback: if newline density is abnormally high, collapse line breaks for live view.
const nl = lines.length - 1;
if (nl >= 8 && nl >= Math.floor(s.length * 0.2)) {
s = s.replace(/\n+/g, "");
}
}
}
return s;
};
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(
`<div class="chat-msg__stream-status">${escapeHtml(
t("chat.marker.summary", {
p: String(markerState.p || 0),
e: markerState.e ? "1" : "0",
ep: String(markerState.ep || 0),
}),
)}</div>`,
);
blocks.push(
`<div class="chat-msg__stream-status">${escapeHtml(
t("chat.marker.ttl", {
t: String(markerState.t || 0),
s: String(markerState.s || 0),
k: String(markerState.k || 0),
}),
)}</div>`,
);
if ((Number(markerState.reclaimed || 0) || 0) > 0) {
blocks.push(
`<div class="chat-msg__stream-status">${escapeHtml(
t("chat.marker.reclaimed", { n: String(markerState.reclaimed || 0) }),
)}</div>`,
);
}
}
let textIdx = 0;
let textBuf = "";
const flushTextBuf = () => {
if (!String(textBuf || "").trim()) {
textBuf = "";
return;
}
blocks.push(
`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(textBuf)).replace(/\\n/g, "<br/>")}</div>`,
);
textBuf = "";
};
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) continue;
textBuf += shown;
continue;
}
if (seg.type === "tool") {
const title = String(seg.title || "tool");
const body = String(seg.body || "");
const images = Array.isArray(seg.images) ? seg.images : [];
// Live stream: always render tool cards from session.tool so过程态完整;showToolOutput 仍用于历史聚合气泡。
flushTextBuf();
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(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)} · SQL audit</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<div><div class="muted" style="margin-bottom:4px;">input SQL</div><pre class="chat-msg__reasoning-pre">${escapeHtml(String(audit.inputSql || ""))}</pre></div>
<div><div class="muted" style="margin-bottom:4px;">executed SQL (added highlighted)</div><pre class="chat-msg__reasoning-pre">${executedDiffHtml}</pre></div>
</div>
${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added clause:</span> <code style="background:#fff7ed;color:#9a3412;padding:2px 6px;border-radius:4px;">${escapeHtml(autoLimit)}</code></div>` : ""}
<div class="muted" style="margin-top:8px;line-height:1.5;">
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 : ""))}
</div>
</details>`,
);
} else {
blocks.push(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)}</summary><pre class="chat-msg__reasoning-pre">${escapeHtml(body)}</pre></details>`,
);
}
for (const im of images) {
const src = String((im && im.src) || "").trim();
if (!src) continue;
blocks.push(`<div class="chat-att-wrap"><img class="chat-att-img" src="${escapeHtml(src)}" alt="tool image" /></div>`);
}
}
}
const currentShown = streamDisplayShown.slice(textIdx);
if (String(currentShown || "").trim()) textBuf += currentShown;
flushTextBuf();
md.innerHTML = blocks.join("");
}
scrollMessagesToBottom();
};
const _maybeScheduleStreamMermaidHydrate = () => {
// Mermaid render is expensive and can crash if DOM is re-written mid-run.
// For streaming, only hydrate when we see a complete mermaid fence block.
const bubble = streamBubble;
if (!bubble) return;
const md = bubble.querySelector(".chat-msg__md");
if (!md) return;
const raw = String(streamDisplayShown || "");
if (!raw.includes("```mermaid")) return;
// Require a closed fence to avoid parsing partial streams.
if (!/```mermaid[\s\S]*?\n```/m.test(raw)) return;
if (streamMermaidTimerId != null) clearTimeout(streamMermaidTimerId);
streamMermaidTimerId = setTimeout(() => {
streamMermaidTimerId = null;
try {
hydrateMermaidIn(md);
} catch (_) {}
}, 180);
};
const renderStreamComposite = () => {
if (renderPending) return;
renderPending = true;
renderRafId = requestAnimationFrame(() => {
renderPending = false;
renderRafId = null;
_renderStreamCompositeNow();
_maybeScheduleStreamMermaidHydrate();
});
};
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;
}
// Adaptive typing speed: when backlog grows, catch up quickly
// to avoid fragmented/laggy streaming and end-of-turn text bursts.
const step = Math.max(4, Math.min(180, Math.ceil(remain / 10)));
streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step);
renderStreamComposite();
if (typingTimerId != null) {
clearTimeout(typingTimerId);
typingTimerId = null;
}
typingTimerId = setTimeout(() => {
typingTimerId = null;
_scheduleTypingTick();
}, 14);
});
};
const _sanitizeStreamDelta = (delta) => {
let s = streamStitcher.push(delta);
if (!s) return "";
// Detect and suppress "one char per line" noise early in the stream.
// Typical pattern: "好\n问\n题\n" or chunks like "好\n".
const nl = (s.match(/\n/g) || []).length;
const nonNlLen = s.replace(/\n/g, "").length;
if (nl > 0 && nonNlLen > 0 && nonNlLen <= 2 && nl >= nonNlLen) {
perCharNewlineScore += 1;
} else if (nonNlLen >= 4 && nl === 0) {
perCharNewlineScore = Math.max(0, perCharNewlineScore - 1);
}
if (!perCharNewlineMode && perCharNewlineScore >= 3) perCharNewlineMode = true;
if (perCharNewlineMode) {
// Drop all newlines in this mode; rely on natural wrapping.
s = s.replace(/\n+/g, "");
}
return s;
};
const appendStreamTextChunk = (chunk) => {
const piece = _sanitizeStreamDelta(chunk);
if (!piece) return;
streamTextBuffer = `${streamTextBuffer}${piece}`;
chatStream = streamTextBuffer;
// Keep strict chronological order: append text deltas as independent segments.
chatStreamSegments.push({ type: "text", text: piece });
streamDisplayTarget = _normalizeStreamTargetForRender(_composeStreamPlainText());
_scheduleTypingTick();
};
const appendToolSegment = (payload) => {
const p = payload && typeof payload === "object" ? payload : {};
const name = String(p.name || "tool");
const liveTag = currentLang === "zh" ? "[实时全量]" : "[LIVE FULL]";
const key = `${name}:${++toolSeq}`;
const rawPayload = p.payload != null ? p.payload : p;
const titleTool =
rawPayload && typeof rawPayload === "object" && String(rawPayload.tool_name || "").trim()
? String(rawPayload.tool_name || "").trim()
: "";
const displayName =
name === "tool_use_call" && titleTool
? `${currentLang === "zh" ? "调用" : "call"} · ${titleTool}`
: name;
const body = formatToolPanelText(name, rawPayload, { streamMode: true });
const sqlAudit = extractSqlAuditPayload(rawPayload);
const images = extractToolImageItems(rawPayload);
const _containsRefAttachments = (obj, depth = 0) => {
if (!obj || typeof obj !== "object" || depth > 4) return false;
// Common case: { attachments: [...] }
if (Array.isArray(obj.attachments)) {
for (const att of obj.attachments) {
if (!att || typeof att !== "object") continue;
const typ = String(att.type || "").toLowerCase();
if (!typ) continue;
if (typ.endsWith("_ref") || typ === "relay_pointer") return true;
}
}
// Recurse a few common container keys first.
const keys = ["result", "payload", "message", "data", "output"];
for (const k of keys) {
if (k in obj && _containsRefAttachments(obj[k], depth + 1)) return true;
}
// Shallow scan other object values (bounded).
let scanned = 0;
for (const v of Object.values(obj)) {
if (scanned++ > 12) break;
if (_containsRefAttachments(v, depth + 1)) return true;
}
return false;
};
if (_containsRefAttachments(rawPayload)) sawStreamToolRefAttachments = true;
chatStreamSegments.push({
type: "tool",
key,
title: `${displayName} ${liveTag}`.trim(),
body,
sqlAudit,
images,
});
};
const appendFinalAssistant = async (message, fallbackText) => {
const wsAttachments =
message && typeof message === "object" && message.attachments != null ? message.attachments : null;
const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
if (normalized) {
const rows = _buildRenderRows(_expandAssistantMessageForRender(normalized));
const last = rows && rows.length ? rows[rows.length - 1] : null;
if (last) {
if (wsAttachments != null && (last.attachments == null || last.attachments === "")) {
last.attachments = wsAttachments;
}
if (streamRow && streamRow.parentNode) streamRow.remove();
await appendMessageRow(messagesEl, last, rowRenderOptions);
scrollMessagesToBottom(true);
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(),
...(wsAttachments != null ? { attachments: wsAttachments } : {}),
},
rowRenderOptions,
);
scrollMessagesToBottom(true);
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: "" };
// End-of-turn reload gating: avoid clearing/repainting messagesEl after we already
// finalized the stream bubble in-place (prevents end "flash").
let turnFinalized = false;
let turnStreamedEnough = false;
try {
const transport = new OclawWsChatTransport({
tokenProvider: () => token,
});
currentWsTransport = transport;
try {
// Immediate assistant placeholder bubble with dynamic status.
ensureStreamBubble();
_startDynamicStreamStatus();
let wsLastActivityAt = Date.now();
const wsSendPromise = transport.sendSessionSend({
sessionId: activeId,
text: userText,
attachments: attachmentPayload,
interactionMode:
String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE ? "comprehensive" : "expert",
idempotencyKey: String(turnId || ""),
specialist:
String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE
? "generalist"
: isSelectableSpecialist(String(globalMenuModeValue || "").toLowerCase())
? String(globalMenuModeValue || "generalist").toLowerCase()
: "generalist",
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
executionMode: String(currentExecutionMode || EXECUTION_MODE_AGENT),
signal: abortController.signal,
onEvent: async (frame) => {
wsLastActivityAt = Date.now();
const eventName = String((frame && frame.event) || "");
const payload = (frame && frame.payload) || {};
if (eventName === "agent.event") {
try {
const stream = String((payload && payload.stream) || "");
const data = (payload && payload.data) || {};
const phase = String((data && data.phase) || "").toLowerCase();
if (stream === "lifecycle") {
if (phase === "start" && !turnAcceptedAtMs) {
turnAcceptedAtMs = Date.now();
}
if (phase === "running" && !phaseRunningAtMs) {
phaseRunningAtMs = Date.now();
}
}
} catch (_) {}
_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.turn_started") {
turnAcceptedAtMs = Number(payload.acceptedAt || Date.now()) || Date.now();
_setStreamStatusBase("start");
return;
}
if (eventName === "session.tool") {
_setStreamStatusBase("tool");
// Keep strict order: tool card appears exactly where tool event arrives.
appendToolSegment(payload);
streamDisplayTarget = _normalizeStreamTargetForRender(_composeStreamPlainText());
_scheduleTypingTick();
return;
}
if (eventName !== "chat") return;
sawWsChatEvent = true;
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") {
if (!firstDeltaAtMs) firstDeltaAtMs = Date.now();
const next = extractWsAssistantText(payload.message);
const d = String(payload.delta || "");
if (d && !_isSilentReplyStream(d)) {
appendStreamTextChunk(decodeEscapedNewlines(d));
hasRealStreamText = true;
} else if (typeof next === "string" && next && !_isSilentReplyStream(next)) {
// Fallback when gateway doesn't include delta.
// Prefer monotonic append from snapshot deltas.
const snapshot = decodeEscapedNewlines(next);
if (snapshot.startsWith(streamTextBuffer)) {
appendStreamTextChunk(snapshot.slice(streamTextBuffer.length));
} else if (!streamTextBuffer.startsWith(snapshot)) {
appendStreamTextChunk(snapshot);
}
hasRealStreamText = true;
}
return;
}
if (state === "final") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const hasStreamToolImages = chatStreamSegments.some(
(seg) => seg && seg.type === "tool" && Array.isArray(seg.images) && seg.images.length > 0,
);
let ok = false;
if (hasStreamToolImages) {
// Keep stream bubble as final UI when it already contains image blocks.
renderStreamComposite();
_markStreamTerminal("end", t("chat.status.end"));
ok = true;
} else if (adminChatShowToolOutput || sawStreamToolRefAttachments) {
// Hydrate from history for reasoning / tool panels / ref attachments. If the DB has not
// yet persisted the assistant reply (or only has the user row), n>0 used to skip
// appendFinalAssistant and removed the stream bubble — leaving an empty pane like the
// user screenshot. Use _needsWsTextFallback when WS still holds usable text.
const hadStream =
hasRealStreamText ||
(Array.isArray(chatStreamSegments) && chatStreamSegments.length > 0) ||
!!String(chatStream || "").trim();
const fbLine = extractWsAssistantText(payload.message || {}) || chatStream;
const fbTrim = String(fbLine || "").trim();
const fbOk = !!fbTrim && !_isSilentReplyStream(fbTrim);
const n = await loadMessagesForActive({ keepDomIfNoHistoryRows: hadStream });
const needWsFallback =
hadStream && fbOk && (n < 0 || !!loadMessagesForActive._needsWsTextFallback);
if (n >= 0) {
try {
if (streamRow && streamRow.parentNode) streamRow.remove();
} catch (_) {}
}
if (needWsFallback) {
if (n < 0) {
try {
if (streamRow && streamRow.parentNode) streamRow.remove();
} catch (_) {}
}
ok = await appendFinalAssistant(payload.message, fbLine);
} else if (n > 0) {
ok = true;
} else {
ok = n === 0;
}
scrollMessagesToBottom(true);
} else {
ok = await appendFinalAssistant(
payload.message,
extractWsAssistantText(payload.message || {}) || chatStream,
);
}
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
sawStreamToolRefAttachments = false;
const streamedEnough = hasRealStreamText || chatStreamSegments.length > 0 || sawWsChatEvent;
turnFinalized = true;
turnStreamedEnough = streamedEnough;
chatStreamSegments = [];
if (streamMermaidTimerId != null) {
clearTimeout(streamMermaidTimerId);
streamMermaidTimerId = null;
}
// Avoid end-of-turn flash: only reload history when stream had no usable content.
if (!streamedEnough) {
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
}
if (!ok) statusBar.textContent = "";
return;
}
if (state === "aborted") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream);
if (!ok) _markStreamTerminal("error", "aborted");
turnFinalized = true;
turnStreamedEnough = hasRealStreamText || chatStreamSegments.length > 0 || sawWsChatEvent;
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
chatStreamSegments = [];
if (streamMermaidTimerId != null) {
clearTimeout(streamMermaidTimerId);
streamMermaidTimerId = null;
}
return;
}
if (state === "error") {
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
turnFinalized = true;
turnStreamedEnough = hasRealStreamText || chatStreamSegments.length > 0 || sawWsChatEvent;
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
chatStreamSegments = [];
if (streamMermaidTimerId != null) {
clearTimeout(streamMermaidTimerId);
streamMermaidTimerId = null;
}
statusBar.textContent = `${t("chat.error")}: ${String(payload.errorMessage || "chat error")}`;
}
},
});
let wsWatchdog = 0;
const wsSendObserved = wsSendPromise.finally(() => {
if (wsWatchdog) {
clearInterval(wsWatchdog);
wsWatchdog = 0;
}
});
if (Number(WS_CHAT_SEND_TIMEOUT_MS || 0) > 0) {
const wsInactivityTimeoutPromise = new Promise((_, reject) => {
wsWatchdog = setInterval(() => {
if (Date.now() - wsLastActivityAt > WS_CHAT_SEND_TIMEOUT_MS) {
if (wsWatchdog) {
clearInterval(wsWatchdog);
wsWatchdog = 0;
}
reject(new Error(`ws_send_timeout:${WS_CHAT_SEND_TIMEOUT_MS}`));
}
}, 1000);
});
doneMeta = await Promise.race([wsSendObserved, wsInactivityTimeoutPromise]);
} else {
doneMeta = await wsSendObserved;
}
if (doneMeta && typeof doneMeta === "object") doneMeta.__transport = "ws";
if (doneMeta && typeof doneMeta === "object") {
const startToRunning =
turnAcceptedAtMs && phaseRunningAtMs ? Math.max(0, Number(phaseRunningAtMs) - Number(turnAcceptedAtMs)) : null;
const runningToFirst =
phaseRunningAtMs && firstDeltaAtMs ? Math.max(0, Number(firstDeltaAtMs) - Number(phaseRunningAtMs)) : null;
doneMeta.__phase_start_to_running_ms = Number.isFinite(Number(startToRunning)) ? Number(startToRunning) : null;
doneMeta.__phase_running_to_first_ms = Number.isFinite(Number(runningToFirst)) ? Number(runningToFirst) : null;
}
} finally {
transport.close();
if (currentWsTransport === transport) currentWsTransport = null;
}
setTimeout(() => {
reloadSessionsOnly().catch(() => {});
}, 250);
return doneMeta;
} catch (e) {
if (isAbortError(e)) return doneMeta;
const emsg = String(e && e.message ? e.message : e || "").toLowerCase();
const wsLikeFailure =
emsg.includes("ws_") ||
emsg.includes("websocket") ||
emsg.includes("receive_failed") ||
emsg.includes("closed") ||
emsg.includes("timeout");
if (wsLikeFailure) {
// Only short-circuit when a terminal event was already received.
// If we only saw partial deltas and WS drops, we must attempt recovery.
if (sawWsTerminalEvent) {
// If the stream bubble was already finalized with usable content, do NOT
// repaint messagesEl (prevents end-of-turn flash). Only reload when we
// have nothing usable and need to recover from persisted history.
if (!turnFinalized || !turnStreamedEnough) {
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
}
return doneMeta;
}
// WS timeout may happen while backend still computes and persists final reply.
// Try recovering from persisted history before surfacing a hard error.
if (emsg.includes("ws_send_timeout")) {
try {
for (let i = 0; i < 12; i++) {
if (await _recoverLatestAssistantFromHistory()) {
statusBar.textContent =
currentLang === "zh"
? "请求超时,已从历史补齐本轮结果。"
: "Request timed out; recovered this turn from history.";
return doneMeta || { __transport: "ws_timeout_history_recovered" };
}
await _sleep(600 + i * 400);
}
} catch (_) {
// fall through to generic ws-like failure recovery
}
}
// WS may time out/close while backend persists final message slightly later.
// Poll history briefly before surfacing hard failure.
try {
for (let i = 0; i < 6; i++) {
if (await _recoverLatestAssistantFromHistory()) {
statusBar.textContent =
currentLang === "zh"
? "WS流式中断,已从历史补齐本轮结果。"
: "WS stream interrupted; recovered this turn from history.";
return doneMeta || { __transport: "ws_history_recovered" };
}
await _sleep(500 + i * 350);
}
} catch (_) {
// fall through to original error
}
}
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;
const userText =
textRaw ||
(hasFiles ? (currentLang === "zh" ? "(已上传附件)" : "(attachment uploaded)") : "");
/** 发送过程中用本地 blob 预览;先插入气泡再编码,避免大图 base64 阻塞主线程时长时间只有「准备附件」无预览 */
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);
scrollMessagesToBottom(true);
fitComposerTextarea();
if (filesSnapshot.length) {
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
statusBar.textContent = t("chat.encodingAttachments");
attPayload = await Promise.all(filesSnapshot.map((f) => fileToPayloadEntry(f)));
pendingFiles = [];
syncPendingUi();
}
const turnId = `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
statusBar.textContent = currentLang === "zh" ? "连接中…" : "Connecting…";
const doneMeta = await sendMessageStream(userText, attPayload, turnId);
const transportTag = (doneMeta && doneMeta.__transport) || "";
const mRaw = String((doneMeta && doneMeta.interaction_mode) || "").toLowerCase();
const m = interactionModeLabel(mRaw);
const s = specialistLabel(
mRaw === "expert"
? (doneMeta && doneMeta.selected_specialist)
: (doneMeta && (doneMeta.manager_selected_specialist || doneMeta.selected_specialist || 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 execModeFromServer = String((doneMeta && doneMeta.execution_mode) || "").trim().toLowerCase();
const effectiveExecMode = execModeFromServer === EXECUTION_MODE_PLAN ? EXECUTION_MODE_PLAN : currentExecutionMode;
const execText = ` · ${t("chat.execModeApplied", { mode: executionModeLabel(effectiveExecMode) })}`;
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 ttftObj = doneMeta && doneMeta.ttft && typeof doneMeta.ttft === "object" ? doneMeta.ttft : null;
const localStartToRunningRaw = doneMeta && doneMeta.__phase_start_to_running_ms;
const localRunningToFirstRaw = doneMeta && doneMeta.__phase_running_to_first_ms;
const localStartToRunning =
typeof localStartToRunningRaw === "number"
? localStartToRunningRaw
: (() => {
const n = Number(localStartToRunningRaw);
return Number.isFinite(n) ? n : null;
})();
const localRunningToFirst =
typeof localRunningToFirstRaw === "number"
? localRunningToFirstRaw
: (() => {
const n = Number(localRunningToFirstRaw);
return Number.isFinite(n) ? n : null;
})();
const segA2G = ttftObj && ttftObj.accepted_to_gateway_ms != null ? Number(ttftObj.accepted_to_gateway_ms) : null;
const segG2M =
ttftObj && ttftObj.gateway_to_model_start_ms != null
? Number(ttftObj.gateway_to_model_start_ms)
: (Number.isFinite(localStartToRunning) ? localStartToRunning : null);
const segM2F =
ttftObj && ttftObj.model_start_to_first_token_ms != null
? Number(ttftObj.model_start_to_first_token_ms)
: (Number.isFinite(localRunningToFirst) ? localRunningToFirst : null);
const ttftTotal =
ttftObj && ttftObj.accepted_to_first_token_ms != null
? Number(ttftObj.accepted_to_first_token_ms)
: (Number.isFinite(segG2M) && Number.isFinite(segM2F) ? Number(segG2M) + Number(segM2F) : null);
const ttftText =
Number.isFinite(ttftTotal) || Number.isFinite(segA2G) || Number.isFinite(segG2M) || Number.isFinite(segM2F)
? ` · 阶段(A→G:${Number.isFinite(segA2G) ? `${segA2G}ms` : "-"} / G→M:${Number.isFinite(segG2M) ? `${segG2M}ms` : "-"} / M→F:${Number.isFinite(segM2F) ? `${segM2F}ms` : "-"})` +
`${Number.isFinite(ttftTotal) ? ` · TTFT=${ttftTotal}ms` : ""}`
: "";
const phaseText =
Number.isFinite(localStartToRunning)
? (() => {
return Number.isFinite(localRunningToFirst)
? ` · 阶段耗时(启动→执行)=${localStartToRunning}ms (执行→首字)=${localRunningToFirst}ms`
: ` · 阶段耗时(启动→执行)=${localStartToRunning}ms`;
})()
: "";
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}${execText}${routeText}${markerText}${ttftText}${phaseText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}${transportTag ? ` · transport=${transportTag}` : ""}`;
} else {
_statusReasonPairs = [];
statusBar.textContent = `${baseText}${memoryText}${execText}${routeText}${markerText}${ttftText}${phaseText}${transportTag ? ` · transport=${transportTag}` : ""}`;
}
} 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, btnJobs]),
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
navFooter,
]),
el("div", { class: "chat-main" }, [
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() {
clearChatPageBlockers();
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) {
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 === "jobs") {
openBackgroundJobsPanel().catch((err) => showToast(`${t("chat.error")}: ${String(err)}`, { kind: "error" }));
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 (_) {}
clearChatPageBlockers();
if (isChatStreaming()) {
applyI18nStatic();
syncAuthUserLabel();
showToast(t("chat.langSwitchedWhileStreaming"), { kind: "info", ttlMs: 6500 });
} else {
await boot();
}
return;
}
}
});
window.addEventListener("popstate", () => {
if (authSession) boot().catch(() => {});
});
document.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape" || ev.defaultPrevented) return;
if (!document.querySelector(".chat-confirm-backdrop, .chat-sess-menu-pop, .chat-img-lightbox, .chat-menu-scrim")) {
return;
}
clearChatPageBlockers();
});
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) }),
]),
]),
);
});