diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 217045ce..3f2a6a9e 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -14,7 +14,7 @@ |------|------| | `gateway.out.log` / `gateway.err.log` | `start_service` 重定向子进程的 stdout/stderr;裸 `print`、未走 `logging` 的输出等。**追加写,无应用内轮转**;生产可配合系统 logrotate。 | | `channel_*.out.log` / `.err.log` | 同上,渠道名中的 `:` 会替换为 `_`。 | -| `app/oclaw.log` | **轮转**:应用与 uvicorn 主日志(`RotatingFileHandler`)。 | +| `app/oclaw.log` | **轮转**:应用与 uvicorn 主日志(`SafeRotatingFileHandler`,容忍 Windows 文件锁)。 | | `app/uvicorn-access.log` | **轮转**:HTTP access(仅网关 uvicorn 模式)。 | | `weixin_sidecar.log` / `weixin_sidecar.err.log` | `weixin_start.ps1` 重定向 sidecar 进程输出(原在 `data/channel_sidecar/.../logs`,已统一到运行日志根)。 | | `whatsapp_sidecar.log` / `whatsapp_sidecar.err.log` | `whatsapp_start.ps1` 同上。 | diff --git a/docs/MCP_LOCAL_SERVER.md b/docs/MCP_LOCAL_SERVER.md index d034ec2e..94d89097 100644 --- a/docs/MCP_LOCAL_SERVER.md +++ b/docs/MCP_LOCAL_SERVER.md @@ -145,47 +145,33 @@ for raw in sys.stdin: 工具数量 > 0 即接通成功。 -### 3.2 JSON 安装 +### 3.2 Cursor JSON 安装 -**单条**(在 Plugins **「3」MCP 安装** 的 **Install from JSON** 中粘贴,或作 array 的其中一个元素): +在 Plugins **【2】从 Cursor JSON 安装** 粘贴与 Cursor `mcp.json` 相同的文档(仅支持 `mcpServers`,不再兼容旧的 `servers[]` / 表单字段): ```json { - "source_type": "pypi", - "source_ref": "local-echo", - "server_id": "local-echo", - "version": "", - "entry_command": "python", - "entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"], - "required_permissions": [], - "risk_level": "low", - "enabled": true, - "timeout_s": 30 + "mcpServers": { + "local-echo": { + "command": "python", + "args": ["__REPO_ROOT__/examples/mcp_echo_server.py"] + }, + "remote-demo": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${API_KEY}" } + } + } } ``` -**批量**:以下三种写法 **`Install from JSON`** 都支持,会**逐条** preflight + install(任一条失败会记结果并继续下一条,最后看结果 JSON): - -1. **数组**:`[ { 上面一条的字段… }, { … } ]` -2. **对象包一层 `servers`**(与 `data/mcp_registry.seed.json`、导出文件一致): - ```json - { - "servers": [ - { "source_type": "npm", "source_ref": "@upstash/context7-mcp", "server_id": "mcp-context7", "version": "", "entry_command": "npx", "entry_args": ["-y", "@upstash/context7-mcp"], "env_schema": {}, "required_permissions": [], "risk_level": "medium", "enabled": true, "timeout_s": 60, "dry_run": false } - ] - } - ``` -3. **带 `payload` 的单条**(如 `examples/mcp_install_context7.json`): - ```json - { "payload": { "source_type": "npm", "source_ref": "…", "server_id": "…" } } - ``` - -路径占位符 `__REPO_ROOT__/…` 在**管理台安装 / preflight** 中会与 `scripts/seed_mcp_registry.py` 一样展开为仓库根下的绝对路径(如 `mcp-echo` 的脚本路径、filesystem 的目录根、sqlite 的库文件路径)。若不用占位符,可直接写本机绝对路径。 +- stdio:`command` / `args` / `env` +- 远程:仅 `url`(可带 `headers`)时由服务端归一成 `npx -y mcp-remote …` 桥接 +- `__REPO_ROOT__/…` 在安装时展开为仓库根路径 **导出与自动备份** -- 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。 -- 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。 +- **Export JSON** 下载当前全部已安装 MCP 的 Cursor `mcpServers` 快照。 +- 安装 / 改配置 / 卸载删记录后刷新 **`oclaw/_local/mcp_registry_migrated.json`**(同结构)。可用 `python -m runtime.operations.scripts.seed_mcp_registry path/to/file.json` 灌库。密钥仍放 `oclaw/_local/mcp_local.env`。 ### 3.3 MCP 工具可见性(仅绑定) @@ -265,7 +251,7 @@ MCP 工具是增量能力,不会替代原有内置工具体系。 ## Goal -Write local tools as a **standard MCP server over stdio (JSON-RPC)**, then connect them from Admin MCP Market. +Write local tools as a **standard MCP server over stdio (JSON-RPC)**, then connect them from Admin → Plugins → Install from Cursor JSON. This project now uses MCP standard flow in runtime: @@ -369,29 +355,20 @@ Then send one JSON-RPC request line from stdin to verify. --- -## Install in Admin MCP Market +## Install in Admin (Cursor JSON) -For local Python script: +Paste Cursor `mcpServers` JSON in **Plugins → Install from Cursor JSON**. There is no MCP Market UI; edit installed servers via the Edit action (same Cursor entry shape). -- `source_type`: `pypi` (or any source type you use for bookkeeping) -- `source_ref`: custom label (for example `local-echo`) -- `entry_command`: `python` -- `entry_args`: `` - -Example JSON install payload: +For a local Python script: ```json { - "source_type": "pypi", - "source_ref": "local-echo", - "server_id": "local-echo", - "version": "", - "entry_command": "python", - "entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"], - "required_permissions": [], - "risk_level": "low", - "enabled": true, - "timeout_s": 30 + "mcpServers": { + "local-echo": { + "command": "python", + "args": ["D:/project/chatgpt/examples/mcp_echo_server.py"] + } + } } ``` diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index d9ec5a55..5d8a1743 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -37,14 +37,17 @@ from svc.persistence.sqlite_store import SqliteStore from svc.persistence.assistant_store import get_assistant_store from runtime.agents.specialists import discover_specialist_ids, parse_agent_profile_bindings from runtime.tools.mcp.env_config import mcp_runtime_for_row +from runtime.tools.mcp.cursor_config import ( + config_payload_to_upsert_fields, + parse_cursor_mcp_document, + registry_row_to_cursor_server, +) from runtime.tools.mcp.installer import ( _safe_server_id, - detect_local_dependencies, install_mcp_server, preflight_mcp_server, uninstall_mcp_server, ) -from runtime.tools.mcp.market import search_mcp_market, trending_mcp_market from runtime.tools.mcp.manifest import McpServerManifest from runtime.operations.mcp_registry_export import ( build_mcp_install_export_document, @@ -2386,6 +2389,21 @@ def build_admin_router() -> APIRouter: }, } + @router.get("/admin/api/scheduled-jobs/stats") + def api_scheduled_jobs_stats( + recent_limit: int = Query(default=80), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = get_assistant_store() + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + tenant_id = str(ctx.get("tenant_id") or "") + summary = store.scheduled_job_failure_summary( + tenant_id=tenant_id, + recent_limit=max(10, min(int(recent_limit or 80), 300)), + ) + return {"ok": True, **summary} + @router.get("/admin/api/scheduled-jobs/meta/recipe-templates") def api_scheduled_jobs_meta_recipe_templates( authorization: str | None = Header(default=None), @@ -3051,6 +3069,7 @@ def build_admin_router() -> APIRouter: sid = str(r.get("server_id") or "") r["health"] = health.get(sid) or {} r["tools"] = store.list_mcp_server_tools(server_id=sid) + r["cursor"] = registry_row_to_cursor_server(r) return {"ok": True, "servers": rows} @router.get("/admin/api/mcp/export") @@ -3089,40 +3108,6 @@ def build_admin_router() -> APIRouter: "calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit), } - @router.get("/admin/api/mcp/market/search") - def api_mcp_market_search( - q: str = Query(default=""), - per_source_limit: int = Query(default=6), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - query = str(q or "").strip() - if not query: - return {"ok": True, "items": []} - items = search_mcp_market(query, per_source_limit=per_source_limit) - return {"ok": True, "items": items} - - @router.get("/admin/api/mcp/market/trending") - def api_mcp_market_trending( - per_source_limit: int = Query(default=5), - refresh: int = Query(default=0), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - items = trending_mcp_market(force_refresh=bool(int(refresh or 0)), per_source_limit=per_source_limit) - return {"ok": True, "items": items} - - @router.get("/admin/api/mcp/dependencies") - def api_mcp_dependencies(authorization: str | None = Header(default=None)) -> dict[str, Any]: - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - return {"ok": True, "items": detect_local_dependencies()} - @router.get("/admin/api/mcp/binding") def api_mcp_binding(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() @@ -3342,26 +3327,147 @@ def build_admin_router() -> APIRouter: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") - source_type = str(payload.get("source_type") or "").strip().lower() - source_ref = str(payload.get("source_ref") or "").strip() - if source_type not in {"github", "npm", "pypi", "local"} or not source_ref: - return {"ok": False, "error": "invalid_source"} - server_id = _safe_server_id(str(payload.get("server_id") or source_ref)) - manifest = McpServerManifest( - server_id=server_id, - source_type=source_type, - source_ref=source_ref, - version=str(payload.get("version") or "").strip(), - entry_command=str(payload.get("entry_command") or "").strip(), - entry_args=_expand_mcp_entry_args(payload.get("entry_args") if isinstance(payload.get("entry_args"), list) else []), - env_schema=payload.get("env_schema") if isinstance(payload.get("env_schema"), dict) else {}, - permissions=[str(x) for x in (payload.get("required_permissions") or [])], - risk_level=str(payload.get("risk_level") or "high"), - enabled=bool(payload.get("enabled", True)), - timeout_s=float(payload.get("timeout_s") or 30.0), - ) dry_run = bool(payload.get("dry_run", False)) - install = install_mcp_server(manifest, dry_run=dry_run) + try: + items = parse_cursor_mcp_document(payload) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + results: list[dict[str, Any]] = [] + all_ok = True + for item in items: + entry_args = _expand_mcp_entry_args( + item.get("entry_args") if isinstance(item.get("entry_args"), list) else [] + ) + manifest = McpServerManifest( + server_id=str(item.get("server_id") or ""), + source_type=str(item.get("source_type") or "local"), + source_ref=str(item.get("source_ref") or ""), + version=str(item.get("version") or "").strip(), + entry_command=str(item.get("entry_command") or "").strip(), + entry_args=entry_args, + env_schema=item.get("env_schema") if isinstance(item.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (item.get("required_permissions") or [])], + risk_level=str(item.get("risk_level") or "high"), + enabled=bool(item.get("enabled", True)), + timeout_s=float(item.get("timeout_s") or 30.0), + ) + install = install_mcp_server(manifest, dry_run=dry_run) + store.upsert_mcp_server( + server_id=manifest.server_id, + source_type=manifest.source_type, + source_ref=manifest.source_ref, + version=manifest.version, + entry_command=manifest.entry_command, + entry_args=manifest.entry_args, + env_schema=manifest.env_schema, + required_permissions=manifest.permissions, + risk_level=manifest.risk_level, + timeout_s=manifest.timeout_s, + enabled=manifest.enabled if install.ok else False, + ) + store.add_mcp_installation_log( + server_id=manifest.server_id, + status="ok" if install.ok else "error", + error_code=install.error_code, + detail={"error": install.error, **(install.details or {})}, + install_command=install.install_command, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_install", + target_type="mcp_server", + target_id=manifest.server_id, + status="ok" if install.ok else "error", + detail={ + "source_type": manifest.source_type, + "source_ref": manifest.source_ref, + "error_code": install.error_code, + "cursor": True, + }, + ) + row_out = { + "ok": bool(install.ok), + "server_id": manifest.server_id, + "install_command": install.install_command, + "error_code": install.error_code, + "error": install.error, + "cursor": registry_row_to_cursor_server( + { + "server_id": manifest.server_id, + "entry_command": manifest.entry_command, + "entry_args": manifest.entry_args, + "env_schema": manifest.env_schema, + } + ), + } + if not install.ok: + all_ok = False + results.append(row_out) + if all_ok: + persist_mcp_migrated_file(store) + return { + "ok": all_ok, + "results": results, + "installed": [x.get("server_id") for x in results if x.get("ok")], + "mcp_migrated_saved": str(mcp_migrated_json_path()) if all_ok else "", + } + + @router.post("/admin/api/mcp/config") + def api_mcp_config( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = get_assistant_store() + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + server_id = str(payload.get("server_id") or "").strip() + if not server_id: + return {"ok": False, "error": "server_id_required"} + rows = store.list_mcp_servers(enabled_only=False) + existing = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) + if not existing: + return {"ok": False, "error": "server_not_found"} + try: + fields = config_payload_to_upsert_fields(payload, existing=existing) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + entry_args = _expand_mcp_entry_args( + fields.get("entry_args") if isinstance(fields.get("entry_args"), list) else [] + ) + reinstall = bool(payload.get("reinstall", False)) + dry_run = bool(payload.get("dry_run", False)) + manifest = McpServerManifest( + server_id=str(fields.get("server_id") or server_id), + source_type=str(fields.get("source_type") or "local"), + source_ref=str(fields.get("source_ref") or server_id), + version=str(fields.get("version") or "").strip(), + entry_command=str(fields.get("entry_command") or "").strip(), + entry_args=entry_args, + env_schema=fields.get("env_schema") if isinstance(fields.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (fields.get("required_permissions") or [])], + risk_level=str(fields.get("risk_level") or "high"), + enabled=bool(fields.get("enabled", True)), + timeout_s=float(fields.get("timeout_s") or 30.0), + ) + install_ok = True + install_command = "" + error_code = "" + error = "" + if reinstall: + install = install_mcp_server(manifest, dry_run=dry_run) + install_ok = bool(install.ok) + install_command = install.install_command + error_code = install.error_code + error = install.error + store.add_mcp_installation_log( + server_id=manifest.server_id, + status="ok" if install.ok else "error", + error_code=install.error_code, + detail={"error": install.error, **(install.details or {}), "config_reinstall": True}, + install_command=install.install_command, + ) store.upsert_mcp_server( server_id=manifest.server_id, source_type=manifest.source_type, @@ -3373,31 +3479,38 @@ def build_admin_router() -> APIRouter: required_permissions=manifest.permissions, risk_level=manifest.risk_level, timeout_s=manifest.timeout_s, - enabled=manifest.enabled if install.ok else False, - ) - store.add_mcp_installation_log( - server_id=manifest.server_id, - status="ok" if install.ok else "error", - error_code=install.error_code, - detail={"error": install.error, **(install.details or {})}, - install_command=install.install_command, + enabled=manifest.enabled if install_ok else False, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], - action="mcp_install", + action="mcp_config_update", target_type="mcp_server", target_id=manifest.server_id, - status="ok" if install.ok else "error", - detail={"source_type": source_type, "source_ref": source_ref, "error_code": install.error_code}, + status="ok" if install_ok else "error", + detail={"reinstall": reinstall, "error_code": error_code}, ) - if not install.ok: - return {"ok": False, "server_id": manifest.server_id, "error_code": install.error_code, "error": install.error} + if not install_ok: + return { + "ok": False, + "server_id": manifest.server_id, + "error_code": error_code, + "error": error, + } persist_mcp_migrated_file(store) + cursor = registry_row_to_cursor_server( + { + "server_id": manifest.server_id, + "entry_command": manifest.entry_command, + "entry_args": manifest.entry_args, + "env_schema": manifest.env_schema, + } + ) return { "ok": True, "server_id": manifest.server_id, - "install_command": install.install_command, + "install_command": install_command, + "cursor": cursor, "mcp_migrated_saved": str(mcp_migrated_json_path()), } diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 9f92506e..ff9cc67b 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -1,9700 +1 @@ -const I18N = { - zh: { - "brand.title": "oliver", - "nav.chat": "对话", - "nav.models": "模型管理", - "nav.apiGrants": "API 使用授权", - "nav.stack": "运行时", - "nav.scheduledJobs": "定时任务", - "nav.users": "用户管理", - "nav.workspacePaths": "工作区路径", - "nav.memory": "记忆", - "nav.audit": "审计与追踪", - "nav.sessionMonitor": "会话监控", - "nav.adminAudit": "管理审计", - "nav.plugins": "插件", - "nav.skills": "技能", - "nav.attachments": "附件", - "nav.profile": "用户信息", - "notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问", - "action.refresh": "刷新", - "title.stack": "运行时", - "title.scheduledJobs": "定时任务", - "scheduledJobs.all": "全部", - "scheduledJobs.statusActive": "运行中", - "scheduledJobs.statusPaused": "已暂停", - "scheduledJobs.loading": "Loading…", - "scheduledJobs.count": "{count} 个任务", - "scheduledJobs.colName": "名称", - "scheduledJobs.colSchedule": "计划", - "scheduledJobs.colPlaybook": "剧本", - "scheduledJobs.colStatus": "状态", - "scheduledJobs.colNextRun": "下次运行", - "scheduledJobs.colLastRun": "上次运行", - "scheduledJobs.colSpecialist": "专家", - "scheduledJobs.colDelivery": "投递", - "scheduledJobs.colActions": "操作", - "scheduledJobs.playbookYes": "playbook · {n} 步", - "scheduledJobs.playbookPartial": "有 recipe · {n} 步", - "scheduledJobs.playbookNo": "提醒", - "scheduledJobs.skippedOverlap": "已跳过(叠跑)", - "scheduledJobs.menuTitle": "任务操作", - "scheduledJobs.viewRuns": "查看运行记录", - "scheduledJobs.pause": "暂停", - "scheduledJobs.resume": "恢复", - "scheduledJobs.runNow": "立即运行", - "scheduledJobs.delete": "删除", - "scheduledJobs.deleteConfirm": "确定删除该定时任务?", - "scheduledJobs.triggered": "已触发", - "scheduledJobs.createTitle": "新建任务", - "scheduledJobs.create": "创建", - "scheduledJobs.edit": "编辑", - "scheduledJobs.editTitle": "编辑任务", - "scheduledJobs.updated": "已保存", - "scheduledJobs.cancel": "取消", - "scheduledJobs.fieldName": "名称", - "scheduledJobs.fieldSchedule": "计划", - "scheduledJobs.fieldKind": "类型", - "scheduledJobs.fieldExpr": "表达式", - "scheduledJobs.fieldPrompt": "提示词", - "scheduledJobs.fieldSpecialist": "专家", - "scheduledJobs.fieldWhatsapp": "WhatsApp chat_id", - "scheduledJobs.phName": "任务名称", - "scheduledJobs.phExpr": "cron / ISO 时间 / 秒数", - "scheduledJobs.phPrompt": "到期时要执行的提示词", - "scheduledJobs.phSpecialist": "例如 generalist", - "scheduledJobs.phWhatsapp": "可选,群或私聊 chat_id", - "scheduledJobs.runHistory": "运行记录", - "scheduledJobs.runHistoryHint": "最近运行(自动刷新)", - "scheduledJobs.noRuns": "暂无运行记录", - "scheduledJobs.weixinFixed": "微信固定投递:{id}", - "scheduledJobs.weixinMissing": "未找到微信绑定(需管理员渠道身份)", - "scheduledJobs.deliveryWeixin": "微信", - "scheduledJobs.deliveryWhatsapp": "WhatsApp", - "title.users": "用户管理", - "title.workspacePaths": "工作区路径(按用户)", - "title.memory": "记忆", - "title.models": "模型管理", - "title.apiGrants": "API 使用授权", - "title.audit": "审计与追踪", - "title.sessionMonitor": "会话监控", - "title.adminAudit": "管理审计", - "title.plugins": "插件", - "title.skills": "技能", - "title.attachments": "附件", - "skills.docsTitle": "oclaw 文档快捷入口:", - "skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md(安装与运行排障)", - "skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md(trace 字段与阶段)", - "title.profile": "用户信息", - "attachments.title": "附件", - "attachments.excelPolicy": "Excel 策略", - "attachments.excelPolicyHint": "针对 Excel/表格附件的解析与 SQL 查询保护策略。该配置写入 oclaw.json(优先级低于环境变量)。", - "attachments.maxRowsRead": "最大读取行数", - "attachments.maxColumns": "最大保留列数", - "attachments.maxCellChars": "单元格最大字符数", - "attachments.maxExcelSheets": "Excel 最大 Sheet 数", - "attachments.largePreviewRows": "大表摘要预览行数", - "attachments.toolModeEnabled": "启用大表工具模式", - "attachments.toolModeMinRows": "触发工具模式最小行数", - "attachments.toolModeMaxBytes": "工具模式最大文件字节数", - "attachments.sqlTimeoutMs": "SQL 超时(ms)", - "attachments.imageReplayCapChars": "图片结果回放上限字符数", - "attachments.videoReplayCapChars": "视频转写结果回放上限字符数", - "attachments.videoTranscriptChunkSize": "视频转写分块大小", - "attachments.videoTranscriptChunkOverlap": "视频转写分块重叠", - "attachments.archiveMaxDepth": "压缩包最大嵌套深度", - "attachments.archiveMaxFileCount": "压缩包最大文件数", - "attachments.archiveMaxEntryBytes": "压缩包单文件最大解压字节", - "attachments.archiveMaxTotalBytes": "压缩包总解压字节上限", - "attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?", - "attachments.invalidNumber": "请输入有效正整数", - "attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。", - "attachments.imageReplayCapHint": "历史轮次中,query_image_attachment 的 OCR/描述结果回放上限。范围 600..30000,默认 4000。", - "attachments.videoReplayCapHint": "历史轮次中,query_video_attachment 的 transcript 结果回放上限。范围 600..30000,默认 4000。", - "attachments.videoTranscriptChunkHint": "query_video_attachment(task=transcript) 默认使用该分块参数落库,便于后续 query_text_attachment 检索。", - "attachments.archivePolicyHint": "archive_processor 的统一预算:支持 zip/tar/tgz/gz,限制嵌套层级、文件数与解压体积。", - "attachments.loadError": "加载失败", - "attachments.saved": "已保存", - "attachments.save": "保存", - "attachments.resetDefaults": "恢复默认", - "profile.help": "在此调整显示名称、头像及账号标识;对话页中头像为圆形,助手使用站点 Logo。", - "profile.displayName": "显示名称", - "profile.username": "用户名", - "profile.userId": "用户 ID", - "profile.tenantId": "团队 ID", - "profile.role": "角色", - "profile.createdAt": "注册时间", - "profile.avatar": "头像", - "profile.avatarHint": "支持 PNG、JPEG、WebP、GIF,最大 2MB。", - "profile.chooseImage": "选择图片", - "profile.removeAvatar": "移除头像", - "profile.save": "保存设置", - "profile.saved": "已保存", - "profile.openChat": "打开对话", - "profile.loadError": "加载失败", - "profile.uploadError": "上传失败", - "profile.chatPrefs": "对话偏好", - "profile.memoryMode": "记忆模式", - "profile.memoryModeDefault": "默认(可注入)", - "profile.memoryModeStoreOnly": "仅记录不注入", - "profile.memoryCurator": "记忆整理专家", - "profile.memoryCuratorEnabled": "启用", - "profile.memoryCuratorDisabled": "停用", - "table.name": "名称", - "table.provider": "提供方", - "stack.title": "服务栈", - "stack.alerts": "异常告警", - "stack.mustCleanup": "检测到重复 worker,必须先清理再继续操作。", - "stack.missingRequired": "关键服务未运行,请先启动栈。", - "stack.missingServices": "缺失服务:{services}", - "stack.noAlerts": "未发现异常。", - "stack.cleanup": "清理异常进程", - "stack.cleanupDone": "清理完成,已终止异常进程数:{count}", - "stack.cleanupNone": "未发现可清理的异常进程。", - "stack.up": "启动栈", - "stack.down": "停止栈", - "table.service": "服务", - "table.pid": "进程号", - "table.status": "状态", - "status.running": "运行中", - "status.stopped": "已停止", - "tenants.title": "团队", - "tenants.select": "选择团队", - "tenants.create": "新建团队", - "tenants.createPlaceholder": "团队名称(默认 Team)", - "tenants.users": "用户", - "tenants.bindings": "渠道绑定", - "tenants.bindCodes": "绑定码", - "tenants.createCode": "生成绑定码", - "tenants.deleteCode": "删除", - "tenants.codeStatus": "状态", - "tenants.codeUsed": "已使用", - "tenants.codeUnused": "未使用", - "tenants.codeUsedBy": "绑定人", - "tenants.codeDeleteConfirm": "确认删除该绑定码?", - "tenants.codeDeleted": "已删除绑定码:{code}", - "tenants.role": "角色", - "tenants.codeOptional": "Code(可留空自动生成)", - "tenants.deleteUnbound": "删除未绑定用户", - "tenants.deleted": "已删除未绑定用户数:{count}", - "tenants.deleteUnboundResult": "未绑定用户清理:deleted={deleted}, orphan={orphan}, bound={bound}, total={total}", - "tenants.saved": "配置已保存", - "tenants.noTenants": "暂无团队数据。请先通过绑定流程或初始化脚本创建团队。", - "tenants.noUsers": "该团队暂无用户。", - "tenants.noBindings": "该团队暂无渠道绑定。", - "table.id": "ID", - "table.createdAt": "创建时间", - "table.userId": "用户ID", - "table.username": "用户名", - "table.externalUserId": "外部用户ID", - "table.channel": "渠道", - "tenants.userSource": "来源", - "tenants.sourceWecom": "企业微信", - "tenants.sourceChannel": "其他渠道", - "tenants.sourceConsole": "控制台", - "tenants.chatLogin": "Web 登录", - "tenants.chatLoginYes": "可(已设密码)", - "tenants.chatLoginNo": "否", - "tenants.noSessionTenant": "缺少会话团队信息,请重新登录。", - "tenants.sessionTenantMissing": "当前登录会话对应的团队不在列表中,请重新登录或联系管理员。", - "tenants.scopeHint": "「选择团队」决定下方用户/渠道绑定/绑定码所针对的团队。administrator 账号可切换并管理任意团队。上表最后一列标出登录会话最初绑定的团队。", - "tenants.colScope": "登录标识", - "tenants.scopeLoginHome": "登录所属", - "tenants.allTenantsHint": "多团队时:「登录所属」仅作提示;实际操作范围以「选择团队」为准。", - "tenants.rowActions": "操作", - "tenants.colTenantName": "团队名", - "tenants.colTenantId": "团队 ID", - "tenants.deleteTenant": "删除团队", - "tenants.deleteTenantConfirm": "确定删除团队「{name}」?将级联删除该团队下用户、绑定与数据,且不可恢复。", - "tenants.errLastTenant": "至少保留一个团队,无法删除。", - "tenants.errCannotDeleteCurrentTenant": "不能删除当前登录会话所属团队,请先切换到其他团队再删,或使用其他账号。", - "tenants.errTenantDeleteMiss": "删除未生效(可能数据库约束失败),请查看服务端日志。", - "tenants.deleteDisabledLast": "至少需保留一个团队。", - "tenants.deleteDisabledCurrent": "不能删除当前登录会话所属团队;请先在登录页使用另一团队的 UUID 登录后再删。", - "audit.title": "审计与追踪", - "audit.sessionIdPlaceholder": "session_id(可选)", - "audit.query": "查询", - "audit.result": "结果", - "audit.note": "支持表格 + JSON 双视图。", - "audit.auditTable": "审计表", - "audit.traceTable": "追踪表", - "audit.rawJson": "原始 JSON", - "audit.empty": "暂无数据", - "audit.columns": "显示列", - "table.timestamp": "时间", - "table.specialist": "专家", - "table.action": "动作", - "table.statusText": "状态", - "table.reason": "原因", - "table.durationMs": "耗时(ms)", - "table.routeAttachmentIds": "路由附件IDs", - "table.inputAttachmentIds": "输入附件IDs", - "table.outputAttachmentIds": "输出附件IDs", - "table.outputAttachmentUrls": "输出附件URLs", - "table.noOutputAttachment": "无输出附件", - "table.eventType": "事件类型", - "table.traceId": "trace_id", - "table.spanId": "span_id", - "table.parentSpanId": "parent_span_id", - "table.payload": "payload", - "table.user": "用户", - "table.sessionsCount": "会话数", - "table.activeSessions30m": "活跃会话(30m)", - "table.activeLogins30m": "活跃登录(30m)", - "table.totalTokensEst": "总 Token(估算)", - "wecom.title": "企业微信配置", - "wecom.botId": "Bot ID", - "wecom.botSecret": "Bot Secret", - "wecom.autoBind": "首条消息自动绑定", - "wecom.autoBindTenant": "自动绑定团队", - "wecom.autoBindRole": "自动绑定角色", - "wecom.unbind": "解绑 WeCom", - "wecom.unbindConfirm": "将清空本地 WeCom 配置和全部 WeCom 绑定/会话,确认继续?", - "wecom.unbindDone": "已解绑 WeCom。identity={ident},session={sess}", - "action.save": "保存", - "plugins.title": "工具插件", - "workspacePaths.help": - "为每个用户配置除主工作区(AIA_WORKSPACE_ROOT / 项目根)外可访问的绝对路径,用竖线 | 分隔,例如 D:\\\\|E:\\\\repos\\\\other。与网关环境变量 AIA_WORKSPACE_EXTRA_ROOTS 合并生效。下方「额外根路径」会参与内置工具校验,并在用户聊天时合并进官方 MCP @modelcontextprotocol/server-filesystem 的启动参数(仅该用户)。", - "workspacePaths.tenant": "团队", - "workspacePaths.user": "用户", - "workspacePaths.extraRoots": "额外根路径(| 分隔)", - "workspacePaths.allowAny": "允许任意路径(高风险)", - "workspacePaths.allowAnyHint": - "仅作用于内置工作区工具(read_file / glob 等)的路径校验;不会放开 MCP filesystem,也不会自动把整盘写进 MCP。需要 MCP 列目录的盘符/目录请填在「额外根路径」或环境变量 AIA_WORKSPACE_EXTRA_ROOTS。", - "workspacePaths.allowHighTools": "允许高风险 Public 工具(全局)", - "workspacePaths.allowHighToolsHint": - "开启后将放开 run_command / write_file / edit_file 等高风险 public 工具的模型可见性(等效 AIA_PUBLIC_TOOLS_ALLOW_HIGH=1)。", - "workspacePaths.load": "加载", - "workspacePaths.save": "保存", - "workspacePaths.status": "状态", - "workspacePaths.fromDb": "已保存到数据库", - "workspacePaths.selfOnly": "当前账号仅能查看并编辑自己的工作区路径策略(不能代他人配置)。", - "chat.newSession": "新会话", - "chat.send": "发送", - "chat.sessions": "会话", - "chat.placeholder": "输入消息…", - "chat.empty": "暂无消息", - "chat.noSessions": "暂无会话,点击新建开始。", - "chat.loading": "加载中…", - "chat.sending": "发送中…", - "chat.error": "请求失败", - "table.version": "版本", - "table.entryPoint": "入口点", - "table.enabled": "启用", - "common.notFound": "未找到页面", - "common.forbidden": "无权访问该页面。", - "common.error": "错误", - "theme.label": "界面配色", - "theme.netx": "NetX 暗色", - "theme.deepseek": "DeepSeek", - "theme.github": "GitHub 暗色", - "theme.nord": "Nord", - "theme.dracula": "Dracula", - "theme.forest": "森绿", - "theme.catppuccin": "Catppuccin Mocha", - "theme.light": "浅色", - "lang.switch": "English", - "memory.title": "记忆治理", - "memory.hits": "最近命中", - "memory.items": "记忆条目", - "memory.config": "向量配置", - "memory.enabled": "开启向量检索", - "memory.backend": "后端", - "memory.topk": "TopK", - "memory.writerEnabled": "开启自动写入", - "memory.minConfidence": "最小写入置信度", - "memory.save": "保存配置", - "memory.reindex": "重建向量索引", - "memory.cleanup": "清理低置信记忆", - "memory.delete": "删除", - "memory.noData": "暂无记忆数据", - "memory.filters": "筛选", - "memory.tenantId": "团队 ID / tenant_id(可选)", - "memory.userId": "user_id(可选)", - "memory.applyFilters": "应用筛选", - "memory.clearFilters": "清空筛选", - "memory.stats": "统计", - "memory.hitCount": "命中总数", - "memory.itemCount": "记忆条目数", - "memory.avgScore": "平均得分", - "memory.topSources": "最近来源", - "memory.wikiCard": "Wiki", - "memory.wikiStatus": "插件状态", - "memory.wikiPluginFound": "已安装:{name} v{version}", - "memory.wikiPluginMissing": "未检测到 memory-wiki 插件", - "memory.openPlugins": "打开插件页", - "auth.login": "登录", - "auth.logout": "退出登录", - "auth.username": "用户名", - "auth.consoleUsernameHint": "填写用户名与密码;各菜单仍按账号权限显示。", - "auth.password": "密码", - "auth.invalid": "登录失败,请检查凭据", - "auth.disabled": "账号已被禁用,请联系管理员", - "users.title": "用户管理", - "users.search": "搜索用户", - "users.colUser": "用户", - "users.createNamePlaceholder": "名称(登录名,唯一)", - "users.createNameRequired": "请填写名称。", - "users.deleteConfirm": "确定删除该用户?不可恢复。", - "users.create": "创建用户", - "users.update": "更新", - "users.delete": "删除", - "users.disable": "禁用", - "users.enable": "启用", - "users.passwordOptional": "新密码(可选)", - "users.wecomAccounts": "企微实例(每用户多个,具名)", - "users.wecomInstanceName": "实例名", - "users.wecomBotSecret": "Bot Secret", - "users.hasBotSecret": "已配Bot密钥", - "users.clearBotSecret": "清除Bot密钥", - "users.loadForm": "填入表单", - "users.noBindingsForUser": "暂无企微绑定或已保存实例;用户在微信侧绑定后会出现行,填齐参数后保存。", - "users.accountId": "账号ID", - "users.wecomBotId": "Bot ID", - "users.botIdRequired": "请先填写 Bot ID", - "users.accountName": "名称", - "users.accountActive": "启用", - "users.accountActions": "账号操作", - "users.accountSave": "保存账号", - "users.accountDelete": "删除账号", - "users.wecomUserLabel": "用户", - "users.wecomUserEmpty": "请在上表点击一行选中用户", - "users.accountLoadHint": "选中用户后,表格列出绑定与实例;需编辑时点「填入表单」,再填写 Bot Secret 等并保存(不会自动填入表单)。", - "users.noTenant": "请先登录并选择团队", - "users.needUserRead": "需要 admin:user:read 权限以查看与编辑用户列表。", - "users.colActive": "启用", - "users.colSetRole": "调整角色", - "adminAudit.title": "管理操作审计", - "adminAudit.action": "动作", - "adminAudit.actor": "操作者", - "adminAudit.status": "状态", - "adminAudit.total": "共 {total} 条", - "adminAudit.jumpPlaceholder": "页码", - "adminAudit.jump": "跳转", - "models.sectionActive": "当前模型配置", - "models.sectionBindings": "智能体与模型绑定", - "models.sectionNew": "新建模型配置", - "models.sectionApi": "API 与密钥", - "models.chatModelSelector": "聊天页模型选择器", - "models.chatModelSelectorHint": "控制 Chat 页底部的模型下拉是否显示。", - "models.sectionEval": "Agent Evaluation", - "models.pickModel": "选用配置", - "models.agentBindingHelp": "可为不同角色指定模型配置;留空则跟随上方全局选用。", - "models.bindingsExtraHint": "被定向授权或团队共享的 API 会出现在上方「选用配置」列表中;成员需在此为各角色自行选择要绑定的配置。", - "models.bindingsScopeHint": "选用与绑定下拉仅含:内置、您自建的配置,以及管理员授权给团队或您个人的配置(与当前账号可见列表一致)。", - "models.linkApiGrants": "打开 API 使用授权", - "models.grantsNavHint": "管理谁能看到并选用可分享的模型配置(不含在此页改密钥或端点)。", - "apiGrants.intro": "选择下方列表中的模型配置(profile),将「使用权」授予整个团队或指定用户。密钥与 Base URL 仍在「模型管理」中配置;成员获得可见性后,在「模型管理」里为各角色绑定 profile。", - "apiGrants.pickApi": "选择要授权的 API", - "apiGrants.teamSection": "授权给整个团队", - "apiGrants.teamHint": "授予后,租户内所有成员在其「选用配置」列表中可见该 API;端点与密钥仍在「模型管理」中维护。", - "apiGrants.userSection": "授权给指定用户", - "apiGrants.userHint": "仅该用户在其「选用配置」列表中可见;对方在「模型管理」里自行绑定各角色。", - "apiGrants.noShareable": "(暂无可分享的模型配置)", - "apiGrants.forbidden": "仅 administrator 账号且具备团队写权限(或 owner 角色)可管理 API 使用权。", - "apiGrants.openModels": "前往模型管理", - "models.useGlobal": "(跟随全局)", - "models.role.ops": "网络运维", - "models.role.generalist": "通用", - "models.role.image": "图像", - "models.role.memory": "记忆整理", - "models.profileName": "配置名称", - "models.mode": "模式", - "models.mode.openai": "OpenAI 兼容", - "models.mode.openai_responses": "OpenAI Responses", - "models.mode.anthropic": "Anthropic", - "models.mode.google": "Google (Gemini)", - "models.mode.ollama": "Ollama", - "models.mode.rule": "规则(无 LLM)", - "models.model": "模型名", - "models.baseUrl": "Base URL", - "models.baseUrlPlaceholder": "可选,留空用环境默认", - "models.apiKey": "API Key", - "models.thinkMode": "Think 模式(仅当前 API)", - "models.thinkModeHint": "开启后仅对此配置回放完整 reasoning_content(默认关闭)", - "models.reasoningEffort": "Reasoning 强度", - "models.reasoningEffortDefault": "默认", - "models.reasoningEffortLow": "低", - "models.reasoningEffortMedium": "中", - "models.reasoningEffortHigh": "高", - "models.rememberKey": "记住密钥(存库加密)", - "models.save": "保存", - "models.delete": "删除此配置", - "models.deleteConfirm": "确定删除该模型配置?此操作不可撤销。", - "models.cannotDeleteBuiltin": "无法删除内置 Ollama 配置。", - "models.createNamePlaceholder": "配置显示名", - "models.newProfileDefault": "新配置", - "models.createBtn": "创建", - "models.builtinLocked": "内置 Ollama 配置:模式固定为 Ollama,可改地址与模型名。", - "models.warnKeyInDb": "数据库中已有密钥:若输入框为空,保存时不会覆盖(请先填入新密钥或取消勾选「记住密钥」以清空)。", - "models.openaiKeyHint": "未配置密钥时,将尝试使用环境变量 OPENAI_API_KEY。", - "models.ollamaHint": "Ollama 本地默认 http://127.0.0.1:11434;一般无需 API Key。", - "models.evalTotal": "样本数", - "models.evalSuccess": "成功率", - "models.evalP95": "P95 延迟", - "models.evalLogs": "最近评估日志", - "models.evalToggle": "Agent Evaluation · 点击展开(页内预览最多 100 条)", - "models.evalLogsPreview": "页内预览", - "models.evalDownloadCsv": "下载全量 CSV", - "models.evalDownloadJson": "下载全量 JSON", - "models.noEvalLogs": "暂无评估记录", - "models.noProfiles": "暂无模型配置数据。", - "models.readonly": "只读:当前账号无团队数据写权限(admin:tenant:write)。", - "models.profileReadonlyHint": "当前配置为管理员授权,仅可选用与对话,不能改名称、端点或密钥。", - "models.grantsTitle": "授权其他用户使用当前配置", - "models.grantsHint": "仅 administrator 可管理。被授权用户可在其模型列表中看到并选用此配置(共用密钥)。", - "models.grantsGrantBtn": "授权", - "models.grantsRevoke": "撤销", - "models.grantsEmpty": "尚无定向授权用户。", - "models.grantsUserHint": "定向授权给单个用户:", - "models.grantTenantBtn": "全团队可用", - "models.revokeTenantGrant": "撤销全团队", - "models.tenantGrantOn": "全团队:已授权", - "models.tenantGrantOff": "全团队:未授权", - "models.membersTitle": "各成员可见的模型配置", - "models.vis.builtin": "内置", - "models.vis.owned": "我的", - "models.vis.grantUser": "定向授权", - "models.vis.grantTenant": "团队共享", - "models.vis.global": "全局", - "models.vis.otherUser": "他人配置", - "models.loadFailed": "加载失败,请展开下方错误信息。", - "models.dbPath": "当前使用的数据库文件", - "secrets.migrateTitle": "密钥加密迁移", - "secrets.migrateHint": "检测到旧版 b64 编码的密钥(弱保护)。建议设置 AIA_ASSISTANT_MASTER_KEY 后一键迁移到更安全的加密格式。", - "secrets.migrateBtn": "立即迁移", - "secrets.migrateDone": "迁移完成:settings={s},profiles={p}", - "secrets.migrateNoop": "未发现需要迁移的旧密钥。", - "secrets.migrateNeedKey": "未配置 AIA_ASSISTANT_MASTER_KEY,无法迁移。", - "secrets.migrateForbidden": "需要 admin:tenant:write 权限。", - "sessionMonitor.totals": "总览统计", - "sessionMonitor.totalTokensEst": "总 Token(估算)", - "sessionMonitor.activeSessions30m": "活跃会话(30m)", - "sessionMonitor.activeLogins30m": "活跃登录(30m)", - "sessionMonitor.usersCount": "用户数", - "sessionMonitor.userStats": "用户统计", - "sessionMonitor.sessions": "会话明细", - "sessionMonitor.filterUser": "筛选用户", - "sessionMonitor.filterSession": "筛选会话", - "sessionMonitor.selectUserFirst": "请先选择一个用户查看会话。", - "sessionMonitor.viewAudit": "查看审计", - "sessionMonitor.viewDetail": "查看详情", - "sessionMonitor.messages": "消息明细", - "sessionMonitor.exportMd": "导出 MD", - "sessionMonitor.exportJson": "导出 JSON", - "sessionMonitor.pagePrev": "上一页", - "sessionMonitor.pageNext": "下一页", - "sessionMonitor.pageInfo": "第 {page} 页 / 共 {totalPages} 页", - "sessionMonitor.noMessages": "该会话暂无消息。", - "sessionMonitor.closeDetail": "关闭详情", - "sessionMonitor.roleFilter": "角色过滤", - "sessionMonitor.roleAll": "全部", - "sessionMonitor.roleUser": "用户", - "sessionMonitor.roleAssistant": "助手", - "sessionMonitor.roleTool": "工具", - "sessionMonitor.onlyAdministrator": "仅 administrator 账号可访问该页面。", - }, - en: { - "brand.title": "oliver", - "nav.chat": "Chat", - "nav.models": "Models", - "nav.apiGrants": "API access", - "nav.stack": "Runtime", - "nav.scheduledJobs": "Scheduled Jobs", - "nav.users": "Users", - "nav.workspacePaths": "Workspace paths", - "nav.memory": "Memory", - "nav.audit": "Audit & Trace", - "nav.sessionMonitor": "Session Monitor", - "nav.adminAudit": "Admin Audit", - "nav.plugins": "Plugins", - "nav.skills": "Skills", - "nav.attachments": "Attachments", - "nav.profile": "User Info", - "notice.noLogin": "v2 login enabled: internal network only", - "action.refresh": "Refresh", - "title.stack": "Runtime", - "title.scheduledJobs": "Scheduled Jobs", - "scheduledJobs.all": "All", - "scheduledJobs.statusActive": "active", - "scheduledJobs.statusPaused": "paused", - "scheduledJobs.loading": "Loading…", - "scheduledJobs.count": "{count} job(s)", - "scheduledJobs.colName": "name", - "scheduledJobs.colSchedule": "schedule", - "scheduledJobs.colPlaybook": "playbook", - "scheduledJobs.colStatus": "status", - "scheduledJobs.colNextRun": "next_run", - "scheduledJobs.colLastRun": "last_run", - "scheduledJobs.colSpecialist": "specialist", - "scheduledJobs.colDelivery": "delivery", - "scheduledJobs.colActions": "actions", - "scheduledJobs.playbookYes": "playbook · {n} steps", - "scheduledJobs.playbookPartial": "recipe · {n} steps", - "scheduledJobs.playbookNo": "reminder", - "scheduledJobs.skippedOverlap": "Skipped (overlapping)", - "scheduledJobs.menuTitle": "Job actions", - "scheduledJobs.viewRuns": "View runs", - "scheduledJobs.pause": "Pause", - "scheduledJobs.resume": "Resume", - "scheduledJobs.runNow": "Run now", - "scheduledJobs.delete": "Delete", - "scheduledJobs.deleteConfirm": "Delete this scheduled job?", - "scheduledJobs.triggered": "Triggered.", - "scheduledJobs.createTitle": "Create job", - "scheduledJobs.create": "Create", - "scheduledJobs.edit": "Edit", - "scheduledJobs.editTitle": "Edit job", - "scheduledJobs.updated": "Saved.", - "scheduledJobs.cancel": "Cancel", - "scheduledJobs.fieldName": "Name", - "scheduledJobs.fieldSchedule": "Schedule", - "scheduledJobs.fieldKind": "Kind", - "scheduledJobs.fieldExpr": "Expression", - "scheduledJobs.fieldPrompt": "Prompt", - "scheduledJobs.fieldSpecialist": "Specialist", - "scheduledJobs.fieldWhatsapp": "WhatsApp chat_id", - "scheduledJobs.phName": "Job name", - "scheduledJobs.phExpr": "cron / ISO time / seconds", - "scheduledJobs.phPrompt": "Prompt to run when due", - "scheduledJobs.phSpecialist": "e.g. generalist", - "scheduledJobs.phWhatsapp": "Optional group or DM chat_id", - "scheduledJobs.runHistory": "Run history", - "scheduledJobs.runHistoryHint": "Recent runs (auto refresh)", - "scheduledJobs.noRuns": "No runs yet", - "scheduledJobs.weixinFixed": "Weixin fixed target: {id}", - "scheduledJobs.weixinMissing": "Weixin binding not found (administrator channel identity required)", - "scheduledJobs.deliveryWeixin": "WeChat", - "scheduledJobs.deliveryWhatsapp": "WhatsApp", - "title.users": "Users", - "title.workspacePaths": "Workspace paths (per user)", - "title.memory": "Memory", - "title.models": "Model Management", - "title.apiGrants": "API access grants", - "title.audit": "Audit & Trace", - "title.sessionMonitor": "Session Monitor", - "title.adminAudit": "Admin Audit", - "title.plugins": "Plugins", - "title.skills": "Skills", - "title.attachments": "Attachments", - "skills.docsTitle": "oclaw docs quick links:", - "skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md (install/runtime troubleshooting)", - "skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md (trace fields and stages)", - "title.profile": "User Info", - "attachments.title": "Attachments", - "attachments.excelPolicy": "Excel policy", - "attachments.excelPolicyHint": "Policy for Excel/tabular attachments parsing and SQL query safeguards. Saved into oclaw.json (lower priority than env vars).", - "attachments.maxRowsRead": "Max rows read", - "attachments.maxColumns": "Max columns kept", - "attachments.maxCellChars": "Max chars per cell", - "attachments.maxExcelSheets": "Max Excel sheets", - "attachments.largePreviewRows": "Large-table preview rows", - "attachments.toolModeEnabled": "Enable large-table tool mode", - "attachments.toolModeMinRows": "Min rows to trigger tool mode", - "attachments.toolModeMaxBytes": "Max file bytes for tool mode", - "attachments.sqlTimeoutMs": "SQL timeout (ms)", - "attachments.imageReplayCapChars": "Image replay cap chars", - "attachments.videoReplayCapChars": "Video replay cap chars", - "attachments.videoTranscriptChunkSize": "Video transcript chunk size", - "attachments.videoTranscriptChunkOverlap": "Video transcript chunk overlap", - "attachments.archiveMaxDepth": "Archive max depth", - "attachments.archiveMaxFileCount": "Archive max file count", - "attachments.archiveMaxEntryBytes": "Archive max entry uncompressed bytes", - "attachments.archiveMaxTotalBytes": "Archive max total uncompressed bytes", - "attachments.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?", - "attachments.invalidNumber": "Please enter valid positive integers", - "attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.", - "attachments.imageReplayCapHint": "Replay cap for query_image_attachment OCR/description results in historical context. Range 600..30000, default 4000.", - "attachments.videoReplayCapHint": "Replay cap for query_video_attachment transcript results in historical context. Range 600..30000, default 4000.", - "attachments.videoTranscriptChunkHint": "Default chunk parameters used by query_video_attachment(task=transcript) when persisting transcript chunks for query_text_attachment retrieval.", - "attachments.archivePolicyHint": "Unified archive_processor budget for zip/tar/tgz/gz: limits depth, file count and uncompressed size.", - "attachments.loadError": "Load failed", - "attachments.saved": "Saved", - "attachments.save": "Save", - "attachments.resetDefaults": "Reset defaults", - "profile.help": "Adjust display name, avatar, and account identifiers here. In chat, avatars are circular; the assistant uses the site logo.", - "profile.displayName": "Display name", - "profile.username": "Username", - "profile.userId": "User ID", - "profile.tenantId": "Team ID", - "profile.role": "Role", - "profile.createdAt": "Created at", - "profile.avatar": "Avatar", - "profile.avatarHint": "PNG, JPEG, WebP, or GIF, up to 2 MB.", - "profile.chooseImage": "Choose image", - "profile.removeAvatar": "Remove avatar", - "profile.save": "Save settings", - "profile.saved": "Saved", - "profile.openChat": "Open chat", - "profile.loadError": "Load failed", - "profile.uploadError": "Upload failed", - "profile.chatPrefs": "Chat Preferences", - "profile.memoryMode": "Memory mode", - "profile.memoryModeDefault": "Default (inject allowed)", - "profile.memoryModeStoreOnly": "Store only (no inject)", - "profile.memoryCurator": "Memory curator specialist", - "profile.memoryCuratorEnabled": "Enabled", - "profile.memoryCuratorDisabled": "Disabled", - "table.name": "name", - "table.provider": "provider", - "stack.title": "Stack", - "stack.alerts": "Alerts", - "stack.mustCleanup": "Duplicate workers detected. Cleanup is required before continuing.", - "stack.missingRequired": "Required services are not running. Start the stack first.", - "stack.missingServices": "Missing services: {services}", - "stack.noAlerts": "No anomalies detected.", - "stack.cleanup": "Cleanup orphan workers", - "stack.cleanupDone": "Cleanup completed. Killed orphan processes: {count}", - "stack.cleanupNone": "No orphan processes found.", - "stack.up": "stack up", - "stack.down": "stack down", - "table.service": "service", - "table.pid": "pid", - "table.status": "status", - "status.running": "running", - "status.stopped": "stopped", - "tenants.title": "Teams", - "tenants.select": "Select Team", - "tenants.create": "Create Team", - "tenants.createPlaceholder": "Team name (default Team)", - "tenants.users": "Users", - "tenants.bindings": "Channel Bindings", - "tenants.bindCodes": "Bind Codes", - "tenants.createCode": "Create Bind Code", - "tenants.deleteCode": "Delete", - "tenants.codeStatus": "Status", - "tenants.codeUsed": "Used", - "tenants.codeUnused": "Unused", - "tenants.codeUsedBy": "Used By", - "tenants.codeDeleteConfirm": "Delete this bind code?", - "tenants.codeDeleted": "Bind code deleted: {code}", - "tenants.role": "Role", - "tenants.codeOptional": "Code (optional, auto-generated if blank)", - "tenants.deleteUnbound": "Delete Unbound Users", - "tenants.deleted": "Deleted unbound users: {count}", - "tenants.deleteUnboundResult": "Unbound cleanup: deleted={deleted}, orphan={orphan}, bound={bound}, total={total}", - "tenants.saved": "Configuration saved", - "tenants.noTenants": "No teams found. Create one via bind flow or bootstrap script first.", - "tenants.noUsers": "No users under this team.", - "tenants.noBindings": "No channel bindings under this team.", - "table.id": "id", - "table.createdAt": "created_at", - "table.userId": "user_id", - "table.username": "username", - "table.externalUserId": "external_user_id", - "table.channel": "channel", - "tenants.userSource": "Source", - "tenants.sourceWecom": "WeCom", - "tenants.sourceChannel": "Other channel", - "tenants.sourceConsole": "Console", - "tenants.chatLogin": "Web login", - "tenants.chatLoginYes": "Yes (password set)", - "tenants.chatLoginNo": "No", - "tenants.noSessionTenant": "Missing team in session. Please sign in again.", - "tenants.sessionTenantMissing": "The team for this session is not in the list. Sign in again or contact an admin.", - "tenants.scopeHint": "The team selector controls which team’s users, bindings, and bind codes are shown below. Accounts named administrator may switch and manage any team. The last column marks the team your login session was issued for.", - "tenants.colScope": "Session marker", - "tenants.scopeLoginHome": "Logged-in team", - "tenants.allTenantsHint": "With multiple teams: “Logged-in team” is informational; the selected team in the dropdown is what you edit.", - "tenants.rowActions": "Actions", - "tenants.colTenantName": "Team name", - "tenants.colTenantId": "Team ID", - "tenants.deleteTenant": "Delete team", - "tenants.deleteTenantConfirm": "Delete team “{name}”? All users, bindings, and data under it will be removed. This cannot be undone.", - "tenants.errLastTenant": "At least one team must remain.", - "tenants.errCannotDeleteCurrentTenant": "You cannot delete the team of your current session. Switch to another team first or use another account.", - "tenants.errTenantDeleteMiss": "Delete had no effect (constraints may have blocked it). Check server logs.", - "tenants.deleteDisabledLast": "At least one team must remain.", - "tenants.deleteDisabledCurrent": "You cannot delete your current session team; log in with another team UUID on the login page first.", - "audit.title": "Audit & Trace", - "audit.sessionIdPlaceholder": "session_id (optional)", - "audit.query": "Query", - "audit.result": "Result", - "audit.note": "Supports dual view: table + raw JSON.", - "audit.auditTable": "Audit Table", - "audit.traceTable": "Trace Table", - "audit.rawJson": "Raw JSON", - "audit.empty": "No data", - "audit.columns": "Columns", - "table.timestamp": "timestamp", - "table.specialist": "specialist", - "table.action": "action", - "table.statusText": "status", - "table.reason": "reason", - "table.durationMs": "duration(ms)", - "table.routeAttachmentIds": "route_attachment_ids", - "table.inputAttachmentIds": "input_attachment_ids", - "table.outputAttachmentIds": "output_attachment_ids", - "table.outputAttachmentUrls": "output_attachment_urls", - "table.noOutputAttachment": "no_output_attachment", - "table.eventType": "event_type", - "table.traceId": "trace_id", - "table.spanId": "span_id", - "table.parentSpanId": "parent_span_id", - "table.payload": "payload", - "table.user": "user", - "table.sessionsCount": "sessions", - "table.activeSessions30m": "active_sessions(30m)", - "table.activeLogins30m": "active_logins(30m)", - "table.totalTokensEst": "total_tokens(est)", - "plugins.title": "Tool Plugins", - "workspacePaths.help": - "Per-user extra absolute roots (besides AIA_WORKSPACE_ROOT / project root). Use | as separator, e.g. D:\\\\|E:\\\\repos\\\\other. Merged with gateway env AIA_WORKSPACE_EXTRA_ROOTS. These roots are enforced for built-in workspace tools and, for this user’s chat sessions, appended to the official @modelcontextprotocol/server-filesystem process argv.", - "workspacePaths.tenant": "Team", - "workspacePaths.user": "User", - "workspacePaths.extraRoots": "Extra roots (| separated)", - "workspacePaths.allowAny": "Allow any path (high risk)", - "workspacePaths.allowAnyHint": - "Applies only to built-in workspace tools (read_file / glob, etc.); it does not unlock MCP filesystem or auto-mount whole disks for MCP. List directories you need in “Extra roots” or AIA_WORKSPACE_EXTRA_ROOTS.", - "workspacePaths.allowHighTools": "Allow high-risk public tools (global)", - "workspacePaths.allowHighToolsHint": - "When enabled, high-risk public tools (run_command / write_file / edit_file, etc.) become model-visible (equivalent to AIA_PUBLIC_TOOLS_ALLOW_HIGH=1).", - "workspacePaths.load": "Load", - "workspacePaths.save": "Save", - "workspacePaths.status": "Status", - "workspacePaths.fromDb": "Saved in database", - "workspacePaths.selfOnly": "You can only view and edit your own workspace path policy (not other users).", - "chat.newSession": "New chat", - "chat.send": "Send", - "chat.sessions": "Sessions", - "chat.placeholder": "Message…", - "chat.empty": "No messages yet", - "chat.noSessions": "No sessions yet. Create one to start.", - "chat.loading": "Loading…", - "chat.sending": "Sending…", - "chat.error": "Request failed", - "wecom.title": "WeCom Config", - "wecom.botId": "Bot ID", - "wecom.botSecret": "Bot Secret", - "wecom.autoBind": "Auto bind on first message", - "wecom.autoBindTenant": "Auto bind team", - "wecom.autoBindRole": "Auto bind role", - "wecom.unbind": "Unbind WeCom", - "wecom.unbindConfirm": "Clear local WeCom config and all WeCom bindings/sessions?", - "wecom.unbindDone": "WeCom unbound. identities={ident}, sessions={sess}", - "action.save": "Save", - "table.version": "version", - "table.entryPoint": "entry_point", - "table.enabled": "enabled", - "common.notFound": "Not found", - "common.forbidden": "You do not have access to this page.", - "common.error": "Error", - "theme.label": "Color theme", - "theme.netx": "NetX Dark", - "theme.deepseek": "DeepSeek", - "theme.github": "GitHub Dark", - "theme.nord": "Nord", - "theme.dracula": "Dracula", - "theme.forest": "Forest", - "theme.catppuccin": "Catppuccin Mocha", - "theme.light": "Light", - "lang.switch": "中文", - "memory.title": "Memory Governance", - "memory.hits": "Recent Hits", - "memory.items": "Memory Items", - "memory.config": "Vector Config", - "memory.enabled": "Enable vector retrieval", - "memory.backend": "Backend", - "memory.topk": "TopK", - "memory.writerEnabled": "Enable auto write", - "memory.minConfidence": "Min write confidence", - "memory.save": "Save Config", - "memory.reindex": "Reindex vectors", - "memory.cleanup": "Cleanup low-confidence", - "memory.delete": "Delete", - "memory.noData": "No memory data", - "memory.filters": "Filters", - "memory.tenantId": "team ID / tenant_id (optional)", - "memory.userId": "user_id (optional)", - "memory.applyFilters": "Apply Filters", - "memory.clearFilters": "Clear Filters", - "memory.stats": "Stats", - "memory.hitCount": "Hit Count", - "memory.itemCount": "Memory Items", - "memory.avgScore": "Average Score", - "memory.topSources": "Recent Sources", - "memory.wikiCard": "Wiki", - "memory.wikiStatus": "Plugin status", - "memory.wikiPluginFound": "Installed: {name} v{version}", - "memory.wikiPluginMissing": "memory-wiki plugin not found", - "memory.openPlugins": "Open plugins page", - "auth.login": "Login", - "auth.logout": "Logout", - "auth.username": "Username", - "auth.consoleUsernameHint": "Username and password; menus still follow your permissions.", - "auth.password": "Password", - "auth.invalid": "Login failed", - "auth.disabled": "Account is disabled", - "users.title": "User Management", - "users.search": "Search users", - "users.colUser": "User", - "users.createNamePlaceholder": "Name (login id, unique)", - "users.createNameRequired": "Enter a name.", - "users.deleteConfirm": "Delete this user? This cannot be undone.", - "users.create": "Create User", - "users.update": "Update", - "users.delete": "Delete", - "users.disable": "Disable", - "users.enable": "Enable", - "users.passwordOptional": "New password (optional)", - "users.wecomAccounts": "WeCom instances (named, multiple per user)", - "users.wecomInstanceName": "Instance name", - "users.wecomBotSecret": "Bot Secret", - "users.hasBotSecret": "Bot secret set", - "users.clearBotSecret": "Clear bot secret", - "users.loadForm": "Load into form", - "users.noBindingsForUser": "No WeCom binding or saved instance yet; after the user binds on WeChat, a row appears—fill secrets and save.", - "users.accountId": "Account ID", - "users.wecomBotId": "Bot ID", - "users.botIdRequired": "Bot ID is required", - "users.accountName": "Name", - "users.accountActive": "Active", - "users.accountActions": "Account Actions", - "users.accountSave": "Save Account", - "users.accountDelete": "Delete Account", - "users.wecomUserLabel": "User", - "users.wecomUserEmpty": "Click a user row in the table above", - "users.accountLoadHint": "After selecting a user, the table lists bindings and instances. Click “Load into form” to edit, then fill Bot Secret and save (no auto-fill).", - "users.noTenant": "Sign in first and choose a team scope", - "users.needUserRead": "admin:user:read is required to view and edit the user list.", - "users.colActive": "Active", - "users.colSetRole": "Set role", - "adminAudit.title": "Admin Operation Audit", - "adminAudit.action": "Action", - "adminAudit.actor": "Actor", - "adminAudit.status": "Status", - "adminAudit.total": "Total {total}", - "adminAudit.jumpPlaceholder": "Page", - "adminAudit.jump": "Go", - "models.sectionActive": "Active profile", - "models.sectionBindings": "Agent ↔ profile bindings", - "models.sectionNew": "New profile", - "models.sectionApi": "API & secret", - "models.chatModelSelector": "Chat model selector", - "models.chatModelSelectorHint": "Control whether the model dropdown is visible on the Chat page.", - "models.sectionEval": "Agent Evaluation", - "models.pickModel": "Profile", - "models.agentBindingHelp": "Optional per-role profile; leave empty to use the global selection above.", - "models.bindingsExtraHint": "Profiles shared via user or team grants appear in the picker above; each member binds roles here themselves.", - "models.bindingsScopeHint": "Pickers only include built-in profiles, ones you created, and those granted by an admin (team-wide or to you)—the same set your account can see.", - "models.linkApiGrants": "Open API access grants", - "models.grantsNavHint": "Control who can see and select shareable profiles (keys and endpoints are edited on Model Management).", - "apiGrants.intro": "Pick a profile below, then grant visibility to the whole team or specific users. Keys and URLs are still edited under Model Management; members bind roles there after they can see the profile.", - "apiGrants.pickApi": "Profile to grant", - "apiGrants.teamSection": "Grant to entire team", - "apiGrants.teamHint": "Everyone in the tenant can then see this profile in their picker; endpoints and secrets stay under Model Management.", - "apiGrants.userSection": "Grant to one user", - "apiGrants.userHint": "Only that user sees the profile in their picker; they bind roles under Model Management.", - "apiGrants.noShareable": "(No shareable profiles)", - "apiGrants.forbidden": "Only the administrator console user with team write permission (or owner role) can manage API access grants.", - "apiGrants.openModels": "Go to Model Management", - "models.useGlobal": "(use global)", - "models.role.ops": "Network ops", - "models.role.generalist": "Generalist", - "models.role.image": "Image", - "models.role.memory": "Memory Curator", - "models.profileName": "Name", - "models.mode": "Mode", - "models.mode.openai": "OpenAI-compatible", - "models.mode.openai_responses": "OpenAI Responses", - "models.mode.anthropic": "Anthropic", - "models.mode.google": "Google (Gemini)", - "models.mode.ollama": "Ollama", - "models.mode.rule": "Rule (no LLM)", - "models.model": "Model", - "models.baseUrl": "Base URL", - "models.baseUrlPlaceholder": "Optional", - "models.apiKey": "API key", - "models.thinkMode": "Think mode (this API profile only)", - "models.thinkModeHint": "When on, replay full assistant reasoning_content for this profile only (default off).", - "models.reasoningEffort": "Reasoning effort", - "models.reasoningEffortDefault": "Default", - "models.reasoningEffortLow": "Low", - "models.reasoningEffortMedium": "Medium", - "models.reasoningEffortHigh": "High", - "models.rememberKey": "Remember key (encrypted in DB)", - "models.save": "Save", - "models.delete": "Delete profile", - "models.deleteConfirm": "Delete this profile? This cannot be undone.", - "models.cannotDeleteBuiltin": "Cannot delete the built-in Ollama profile.", - "models.createNamePlaceholder": "Display name", - "models.newProfileDefault": "New profile", - "models.createBtn": "Create", - "models.builtinLocked": "Built-in Ollama profile: mode is fixed; you can edit URL/model.", - "models.warnKeyInDb": "A key exists in DB: empty field won’t overwrite until you enter a new key or uncheck remember.", - "models.openaiKeyHint": "Without a key, OPENAI_API_KEY from the environment may be used.", - "models.ollamaHint": "Local Ollama defaults to http://127.0.0.1:11434; API key is usually empty.", - "models.evalTotal": "Samples", - "models.evalSuccess": "Success rate", - "models.evalP95": "P95 latency", - "models.evalLogs": "Recent eval logs", - "models.evalToggle": "Agent Evaluation · click to expand (preview up to 100 rows)", - "models.evalLogsPreview": "Preview", - "models.evalDownloadCsv": "Download full CSV", - "models.evalDownloadJson": "Download full JSON", - "models.noEvalLogs": "No evaluation logs yet", - "models.noProfiles": "No LLM profiles.", - "models.readonly": "Read-only: missing admin:tenant:write.", - "models.profileReadonlyHint": "This profile is shared by an administrator: you may select it for chat but cannot edit name, endpoint, or secret.", - "models.grantsTitle": "Grant other users access to the selected profile", - "models.grantsHint": "Only the administrator account can manage grants. Granted users see and can select this profile (shared API key).", - "models.grantsGrantBtn": "Grant", - "models.grantsRevoke": "Revoke", - "models.grantsEmpty": "No per-user grants yet.", - "models.grantsUserHint": "Grant to one user:", - "models.grantTenantBtn": "Entire team", - "models.revokeTenantGrant": "Revoke team", - "models.tenantGrantOn": "Team: granted", - "models.tenantGrantOff": "Team: not granted", - "models.membersTitle": "Model profiles visible to each member", - "models.vis.builtin": "Built-in", - "models.vis.owned": "Mine", - "models.vis.grantUser": "User grant", - "models.vis.grantTenant": "Team grant", - "models.vis.global": "Global", - "models.vis.otherUser": "Other user", - "models.loadFailed": "Load failed; see error below.", - "models.dbPath": "SQLite file in use", - "secrets.migrateTitle": "Secret migration", - "secrets.migrateHint": "Legacy b64-encoded secrets detected (weak protection). Set AIA_ASSISTANT_MASTER_KEY and migrate to a stronger encrypted format.", - "secrets.migrateBtn": "Migrate now", - "secrets.migrateDone": "Migration done: settings={s}, profiles={p}", - "secrets.migrateNoop": "No legacy secrets found.", - "secrets.migrateNeedKey": "AIA_ASSISTANT_MASTER_KEY is not set; cannot migrate.", - "secrets.migrateForbidden": "Requires admin:tenant:write permission.", - "sessionMonitor.totals": "Overview", - "sessionMonitor.totalTokensEst": "Total tokens (est)", - "sessionMonitor.activeSessions30m": "Active sessions (30m)", - "sessionMonitor.activeLogins30m": "Active logins (30m)", - "sessionMonitor.usersCount": "Users", - "sessionMonitor.userStats": "User stats", - "sessionMonitor.sessions": "Sessions", - "sessionMonitor.filterUser": "Filter users", - "sessionMonitor.filterSession": "Filter sessions", - "sessionMonitor.selectUserFirst": "Select a user to view sessions.", - "sessionMonitor.viewAudit": "Open audit", - "sessionMonitor.viewDetail": "View details", - "sessionMonitor.messages": "Messages", - "sessionMonitor.exportMd": "Export MD", - "sessionMonitor.exportJson": "Export JSON", - "sessionMonitor.pagePrev": "Prev", - "sessionMonitor.pageNext": "Next", - "sessionMonitor.pageInfo": "Page {page} / {totalPages}", - "sessionMonitor.noMessages": "No messages in this session.", - "sessionMonitor.closeDetail": "Close details", - "sessionMonitor.roleFilter": "Role filter", - "sessionMonitor.roleAll": "All", - "sessionMonitor.roleUser": "User", - "sessionMonitor.roleAssistant": "Assistant", - "sessionMonitor.roleTool": "Tool", - "sessionMonitor.onlyAdministrator": "This page is available to administrator only.", - }, -}; - -const LANG_KEY = "ops_admin_lang"; -const SESSION_MONITOR_ROLE_FILTER_KEY = "ops_session_monitor_role_filter"; -let currentLang = (localStorage.getItem(LANG_KEY) || "zh").toLowerCase(); -if (!I18N[currentLang]) currentLang = "zh"; -const AUTH_TOKEN_KEY = "ops_admin_token"; -const AUTH_SESSION_KEY = "ops_admin_session"; -const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode"; -/** 与 localStorage 不同:sessionStorage 按标签页隔离,避免「管理员页 + 普通用户控制台」互相覆盖 token。 */ -function authStoreGet(key) { - try { - return sessionStorage.getItem(key); - } catch (_) { - return null; - } -} -function authStoreSet(key, value) { - try { - sessionStorage.setItem(key, value); - } catch (_) {} -} -function authStoreRemove(key) { - try { - sessionStorage.removeItem(key); - } catch (_) {} -} -/** 历史版本把 token 放在 localStorage,多标签会串号;启动时清掉以免误读。 */ -try { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_SESSION_KEY); -} catch (_) {} -/** - * /chat 与 /admin 历史上使用不同 key;当用户在同一窗口从 /chat 跳到 /admin 时, - * 没有 opener 可用,需要从 chat 存储主动迁移一次以复用登录态。 - */ -(function seedAdminAuthFromChatStorageIfNeeded() { - try { - if (authStoreGet(AUTH_TOKEN_KEY)) return; - const pairs = [ - [sessionStorage, "ops_chat_token", "ops_chat_session"], - [localStorage, "ops_chat_token", "ops_chat_session"], - ]; - for (const [store, kTok, kSess] of pairs) { - let tok = null; - let sess = null; - try { - tok = store.getItem(kTok); - sess = store.getItem(kSess); - } catch (_) { - continue; - } - const t = String(tok || "").trim(); - const s = String(sess || "").trim(); - if (t && s) { - authStoreSet(AUTH_TOKEN_KEY, t); - authStoreSet(AUTH_SESSION_KEY, s); - break; - } - } - } catch (_) {} -})(); -/** 从聊天页新开 /admin(审计、设置等)时,复用 opener 同一登录态(chat 或 admin token)。勿对 window.open 使用 noopener,否则读不到 opener。 */ -(function seedAdminAuthFromOpenerIfNeeded() { - try { - if (authStoreGet(AUTH_TOKEN_KEY)) return; - const op = window.opener; - if (!op || op === window) return; - if (String(location.origin) !== String(op.location.origin)) return; - const pairs = [ - ["ops_admin_token", "ops_admin_session"], - ["ops_chat_token", "ops_chat_session"], - ]; - for (const [kTok, kSess] of pairs) { - let tok = null; - let sess = null; - try { - tok = op.sessionStorage.getItem(kTok) || op.localStorage.getItem(kTok); - sess = op.sessionStorage.getItem(kSess) || op.localStorage.getItem(kSess); - } catch (_) { - continue; - } - const t = String(tok || "").trim(); - const s = String(sess || "").trim(); - if (t && s) { - authStoreSet(AUTH_TOKEN_KEY, t); - authStoreSet(AUTH_SESSION_KEY, s); - break; - } - } - } catch (_) {} -})(); -let authSession = null; - -function t(key) { - return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en && I18N.en[key]) || key; -} - -function tf(key, vars = {}) { - const template = t(key); - return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? "")); -} - -/** 合并展示名与登录名(列表/团队页共用) */ -function formatUserLabel(u) { - const un = String(u.username ?? "").trim(); - const dn = String(u.display_name ?? "").trim(); - if (dn && un && dn.toLowerCase() !== un.toLowerCase()) { - return `${dn} (${un})`; - } - return dn || un || "—"; -} - -function formatAuditActor(r) { - const un = String(r.actor_username ?? "").trim(); - const dn = String(r.actor_display_name ?? "").trim(); - const id = String(r.actor_user_id ?? "").trim(); - if (dn && un && dn.toLowerCase() !== un.toLowerCase()) { - return `${dn} (${un})`; - } - if (dn || un) return dn || un; - return id ? id.slice(0, 8) + (id.length > 8 ? "…" : "") : "—"; -} - -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); - }); - const langBtn = document.getElementById("btnLang"); - if (langBtn) langBtn.textContent = t("lang.switch"); - const themeSel = document.getElementById("adminThemeSelect"); - if (themeSel && window.OclawAdminTheme) { - try { - themeSel.value = OclawAdminTheme.currentAdminTheme(); - } catch (_) {} - } -} - -function toggleLang() { - currentLang = currentLang === "zh" ? "en" : "zh"; - localStorage.setItem(LANG_KEY, currentLang); -} - -function getRoute() { - const raw = (location.hash || "#/stack").replace(/^#\//, ""); - const [pageRaw, queryRaw] = raw.split("?", 2); - let page = (pageRaw || "stack").trim(); - if (page === "channels") page = "stack"; - if (page === "tenants") page = "users"; - const params = new URLSearchParams(queryRaw || ""); - return { page, params }; -} - -/** 当控制台挂在子路径(如 /gw/admin)时,把 /admin/api/... 解析为 /gw/admin/api/... */ -function resolveAdminApiUrl(path) { - const p = String(path || ""); - if (!p.startsWith("/admin/")) return p; - const pathname = (location.pathname || "").replace(/\/+$/, "") || "/"; - const marker = "/admin"; - const pos = pathname.lastIndexOf(marker); - if (pos <= 0) return p; - return pathname.slice(0, pos) + p; -} - -function resolveChatUrl() { - const pathname = (location.pathname || "").replace(/\/+$/, "") || "/"; - const marker = "/admin"; - if (pathname.endsWith(marker)) { - return `${pathname.slice(0, -marker.length) || ""}/chat`; - } - return "/chat"; -} - -function getStoredAuthToken() { - return String(authStoreGet(AUTH_TOKEN_KEY) || "").trim(); -} - -/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */ -function scheduleReauthAfter401(requestUrl) { - const u = String(requestUrl || ""); - if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return; - authStoreRemove(AUTH_TOKEN_KEY); - authStoreRemove(AUTH_SESSION_KEY); - authSession = null; - setTimeout(() => { - router().catch(() => {}); - }, 0); -} - -function _haltAfter401() { - // 401 means "login required". We re-route to login; callers should not show a "request failed" popup. - // Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert. - return new Promise(() => {}); -} - -async function apiGet(path) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = { "accept": "application/json" }; - if (token) headers["authorization"] = `Bearer ${token}`; - const res = await fetch(url, { headers }); - if (res.status === 401) { - scheduleReauthAfter401(url); - return await _haltAfter401(); - } - if (!res.ok) throw new Error(`GET ${url} ${res.status}`); - return await res.json(); -} - -async function apiGetOptional(path) { - try { - return await apiGet(path); - } catch (err) { - console.warn(`apiGetOptional failed path=${String(path || "")} err=${String(err)}`); - return null; - } -} - -async function apiGetNoHang(path) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = { "accept": "application/json" }; - if (token) headers["authorization"] = `Bearer ${token}`; - const res = await fetch(url, { headers }); - if (res.status === 401) { - scheduleReauthAfter401(url); - // Returning null avoids Promise.all / await from hanging forever. - return null; - } - if (!res.ok) throw new Error(`GET ${url} ${res.status}`); - return await res.json(); -} - -async function apiPost(path, body) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = { "content-type": "application/json", "accept": "application/json" }; - if (token) headers["authorization"] = `Bearer ${token}`; - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body ?? {}), - }); - const text = await res.text(); - let data = null; - try { - data = text ? JSON.parse(text) : null; - } catch (_) { - data = null; - } - if (res.status === 401) { - scheduleReauthAfter401(url); - return await _haltAfter401(); - } - if (!res.ok) { - const d = data && typeof data === "object" ? data : {}; - const detail = (d).detail != null ? String((d).detail) : ""; - const errKey = (d).error != null ? String((d).error) : ""; - const msg = detail || errKey || `POST ${url} ${res.status}`; - throw new Error(msg); - } - return data ?? {}; -} - -/** Skills endpoints often return HTTP 200 with `{ ok: false, result: {...} }` on failure — treat as error for UX. */ -function assertSkillMutationOk(r, fallbackMessage) { - if (!r || r.ok !== false) return; - const res = r.result && typeof r.result === "object" ? r.result : {}; - const code = res.error_code != null ? String(res.error_code).trim() : ""; - const detail = res.detail != null ? String(res.detail).trim() : ""; - const msg = [code, detail].filter(Boolean).join(": ") || String(fallbackMessage || "skill operation failed"); - throw new Error(msg); -} - -async function apiRequest(method, path, body) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = { "accept": "application/json" }; - if (token) headers["authorization"] = `Bearer ${token}`; - if (body !== undefined && method !== "GET" && method !== "HEAD") { - headers["content-type"] = "application/json"; - } - const res = await fetch(url, { - method, - headers, - body: body !== undefined && method !== "GET" && method !== "HEAD" ? JSON.stringify(body ?? {}) : undefined, - }); - if (res.status === 401) { - scheduleReauthAfter401(url); - return await _haltAfter401(); - } - if (!res.ok) { - let msg = `${method} ${url} ${res.status}`; - try { - const j = await res.json(); - if (j && j.detail !== undefined) { - msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); - } - } catch (_) {} - throw new Error(msg); - } - const ct = res.headers.get("content-type") || ""; - if (!ct.includes("application/json")) return {}; - return await res.json(); -} - -async function apiPostFormData(path, formData) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = {}; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(url, { method: "POST", headers, body: formData }); - const text = await res.text(); - let data = null; - try { - data = text ? JSON.parse(text) : null; - } catch (_) { - data = null; - } - if (res.status === 401) { - scheduleReauthAfter401(url); - return await _haltAfter401(); - } - if (!res.ok) { - const d = data && typeof data === "object" ? data : {}; - const detail = d.detail != null ? String(d.detail) : ""; - const errKey = d.error != null ? String(d.error) : ""; - throw new Error(detail || errKey || `POST ${url} ${res.status}`); - } - return data ?? {}; -} - -async function apiDeleteJson(path) { - const url = resolveAdminApiUrl(path); - const token = getStoredAuthToken(); - const headers = { accept: "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(url, { method: "DELETE", headers }); - if (res.status === 401) { - scheduleReauthAfter401(url); - return await _haltAfter401(); - } - if (!res.ok) { - let msg = `DELETE ${url} ${res.status}`; - try { - const j = await res.json(); - if (j && j.detail !== undefined) msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); - } catch (_) {} - throw new Error(msg); - } - try { - return await res.json(); - } catch (_) { - return {}; - } -} - -function _activeCopyCell() { - return document.querySelector(".cell-copyable.cell-selected"); -} - -function _selectCopyCell(cell) { - const prev = _activeCopyCell(); - if (prev && prev !== cell) prev.classList.remove("cell-selected"); - if (cell) cell.classList.add("cell-selected"); -} - -function _shouldIgnoreCopyShortcut(ev) { - const t = ev && ev.target ? ev.target : null; - if (!t || !(t instanceof HTMLElement)) return false; - const tag = String(t.tagName || "").toLowerCase(); - if (t.isContentEditable) return true; - return tag === "input" || tag === "textarea" || tag === "select"; -} - -function _installCellCopyKeyboardShortcut() { - if (window.__adminCellCopyShortcutInstalled) return; - window.__adminCellCopyShortcutInstalled = true; - document.addEventListener("keydown", async (ev) => { - if (_shouldIgnoreCopyShortcut(ev)) return; - if (!(ev.ctrlKey || ev.metaKey) || String(ev.key || "").toLowerCase() !== "c") return; - const active = _activeCopyCell(); - if (!active) return; - try { - const txt = String(active.getAttribute("data-copy-text") || active.getAttribute("title") || active.textContent || ""); - if (!txt.trim()) return; - ev.preventDefault(); - await navigator.clipboard.writeText(txt); - active.classList.add("cell-copied"); - setTimeout(() => active.classList.remove("cell-copied"), 2600); - } catch (_) { - // no-op - } - }); -} - -function attachCellCopyBehavior(cell, getCopyText) { - if (!cell || cell.dataset.copyBound === "1") return; - _installCellCopyKeyboardShortcut(); - cell.dataset.copyBound = "1"; - cell.classList.add("cell-copyable"); - cell.addEventListener("click", () => { - _selectCopyCell(cell); - }); - cell.addEventListener("dblclick", async () => { - try { - const txt = String(typeof getCopyText === "function" ? getCopyText() : (cell.textContent || "")); - if (!txt) return; - cell.setAttribute("data-copy-text", txt); - await navigator.clipboard.writeText(txt); - cell.classList.add("cell-copied"); - setTimeout(() => cell.classList.remove("cell-copied"), 2600); - } catch (_) { - // keep tooltip fallback when clipboard API unavailable - } - }); -} - -function el(tag, attrs = {}, children = []) { - const e = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (v === undefined || v === null) continue; - if (k === "class") e.className = v; - else if (k === "text") e.textContent = v; - else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v); - else if (k === "disabled" && v === false) { - e.removeAttribute("disabled"); - } else e.setAttribute(k, v); - } - for (const c of children) e.appendChild(c); - if (String(tag || "").toLowerCase() === "td" && String(e.getAttribute("data-copy-disabled") || "") !== "1") { - const txt = String(e.textContent || "").trim(); - if (txt) { - if (!e.getAttribute("title")) e.setAttribute("title", txt); - attachCellCopyBehavior(e, () => String(e.getAttribute("title") || e.textContent || "")); - } - } - return e; -} - -function tdCell(value, maxLen = 120) { - const full = String(value ?? ""); - const shown = formatSystemLocalDateTime(full); - const title = shown === full ? full : `${shown}\n${full}`; - const td = el("td", { text: shortText(shown, maxLen), title }); - td.setAttribute("data-copy-text", shown); - attachCellCopyBehavior(td, () => shown); - return td; -} - -function enableTableColumnResize(tableEl, columnIndexes = []) { - if (!tableEl) return; - const heads = Array.from(tableEl.querySelectorAll("thead th")); - if (!heads.length) return; - tableEl.classList.add("table--resizable"); - heads.forEach((th) => th.querySelectorAll(".table-col-resizer").forEach((x) => x.remove())); - const enabled = new Set(Array.isArray(columnIndexes) ? columnIndexes : []); - heads.forEach((th, idx) => { - if (enabled.size && !enabled.has(idx)) return; - const handle = document.createElement("span"); - handle.className = "table-col-resizer"; - handle.addEventListener("mousedown", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const startX = ev.clientX; - const startW = th.getBoundingClientRect().width; - document.body.classList.add("col-resize-active"); - const onMove = (mv) => { - const next = Math.max(72, Math.round(startW + (mv.clientX - startX))); - th.style.width = `${next}px`; - }; - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.body.classList.remove("col-resize-active"); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }); - th.appendChild(handle); - }); -} - -function setActive(page) { - document.querySelectorAll(".nav__item").forEach((a) => { - a.classList.toggle("nav__item--active", a.dataset.page === page); - }); - document.getElementById("topTitle").textContent = ({ - stack: t("title.stack"), - users: t("title.users"), - memory: t("title.memory"), - models: t("title.models"), - "api-grants": t("title.apiGrants"), - audit: t("title.audit"), - "session-monitor": t("title.sessionMonitor"), - "admin-audit": t("title.adminAudit"), - plugins: t("title.plugins"), - skills: t("title.skills"), - attachments: t("title.attachments"), - "workspace-paths": t("title.workspacePaths"), - profile: t("title.profile"), - }[page] || page); -} - -function mount(node) { - const c = document.getElementById("content"); - c.innerHTML = ""; - c.appendChild(node); -} - -function renderPageShell(opts = {}, children = []) { - const title = String(opts.title || "").trim(); - const subtitle = String(opts.subtitle || "").trim(); - const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean) : []; - const sections = Array.isArray(opts.sections) ? opts.sections.filter((x) => x && x.id && x.label) : []; - const head = []; - if (title || subtitle || actions.length) { - const left = []; - if (title) left.push(el("h1", { class: "page-shell__title", text: title })); - if (subtitle) left.push(el("div", { class: "page-shell__subtitle muted", text: subtitle })); - head.push( - el("div", { class: "page-shell__header" }, [ - el("div", { class: "page-shell__headline" }, left), - el("div", { class: "page-shell__actions row" }, actions), - ]), - ); - if (sections.length > 1) { - head.push( - el( - "div", - { class: "page-shell__toc", role: "navigation", "aria-label": "page sections" }, - sections.map((s) => - el("button", { - class: "page-shell__tocItem", - type: "button", - text: String(s.label), - onclick: () => { - const target = document.getElementById(String(s.id)); - if (!target) return; - target.scrollIntoView({ behavior: "smooth", block: "start" }); - }, - }), - ), - ), - ); - } - } - return el("section", { class: "page-shell" }, [...head, ...children.filter(Boolean)]); -} - -function renderSectionCard(title, subtitle, bodyNodes = [], opts = {}) { - const headlineKids = [el("div", { class: "card__title", text: title })]; - if (subtitle) headlineKids.push(el("div", { class: "card__subtitle muted", text: subtitle })); - const headKids = [el("div", { class: "card__headline" }, headlineKids)]; - if (Array.isArray(opts.actions) && opts.actions.length) { - headKids.push(el("div", { class: "card__actions row" }, opts.actions)); - } - const nodes = [el("div", { class: "card__head" }, headKids)]; - bodyNodes.filter(Boolean).forEach((node) => nodes.push(node)); - const attrs = { class: "card section-card" }; - if (opts.id) attrs.id = String(opts.id); - return el("div", attrs, nodes); -} - -function renderMetaChips(entries) { - const items = []; - const src = entries && typeof entries === "object" ? entries : {}; - Object.keys(src).forEach((k) => { - const v = src[k]; - if (v == null || v === "") return; - items.push(el("span", { class: "meta-chip", text: `${k}=${v}` })); - }); - return el("div", { class: "meta-line" }, items.length ? items : [el("span", { class: "muted", text: "—" })]); -} - -function shortText(v, maxLen = 200) { - const s = String(v ?? ""); - if (s.length <= maxLen) return s; - return s.slice(0, Math.max(0, maxLen - 3)) + "..."; -} - -function formatSystemLocalDateTime(value) { - const raw = String(value ?? "").trim(); - if (!raw) return ""; - // Keep non-datetime strings unchanged. - if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) return raw; - const d = new Date(raw); - if (Number.isNaN(d.getTime())) return raw; - const locale = currentLang === "zh" ? "zh-CN" : "en-US"; - try { - return d.toLocaleString(locale, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - } catch (_) { - return raw; - } -} - -function formatIds(v) { - if (!Array.isArray(v)) return ""; - const vals = v.map((x) => String(x ?? "").trim()).filter(Boolean); - if (!vals.length) return ""; - if (vals.length > 4) return vals.slice(0, 4).join(", ") + ` (+${vals.length - 4})`; - return vals.join(", "); -} - -function yesNo(v) { - return v ? "yes" : "no"; -} - -let runtimePrewarmReminder = ""; -function markPrewarmReminder(reason) { - const why = String(reason || "").trim(); - const base = currentLang === "zh" ? "配置已变更,请立即预热(或重启)" : "Config changed. Please run prewarm now (or restart)."; - runtimePrewarmReminder = why ? `${base} [${why}]` : base; -} - -async function renderStack() { - const results = await Promise.allSettled([ - apiGetNoHang("/admin/api/runtime/scan-artifacts"), - apiGetNoHang("/admin/api/runtime/prewarm/status"), - apiGetNoHang("/admin/api/runtime/prewarm/prompts?role=generalist"), - apiGetNoHang("/admin/api/chat/settings/specialist-flags"), - apiGetNoHang("/admin/api/chat/settings/channel-dispatch/weixin"), - apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"), - apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"), - apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"), - ]); - const scanResp = results[0].status === "fulfilled" ? results[0].value : null; - const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null; - const prewarmPromptsResp = results[2].status === "fulfilled" ? results[2].value : null; - const channelSpecResp = results[3].status === "fulfilled" ? results[3].value : null; - const weixinDispatchResp = results[4].status === "fulfilled" ? results[4].value : null; - const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null; - const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null; - const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null; - - // If auth failed (401), show a gentle message instead of an infinite spinner. - if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.stack") || "Stack" }), - el("div", { class: "muted", text: currentLang === "zh" ? "未登录或会话已过期,请重新登录。" : "Not logged in or session expired. Please log in again." }), - ]); - } - const availableDispatchSpecialists = Array.isArray(channelSpecResp && channelSpecResp.available_specialists) && channelSpecResp.available_specialists.length - ? channelSpecResp.available_specialists.map((x) => String(x || "").trim()).filter(Boolean) - : ["generalist"]; - const createChannelDispatchCard = (channel, title, initial) => { - const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert"; - const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist"; - const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto"; - const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) => - el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }), - )); - const langSel = el("select", { class: "input" }, [ - el("option", { value: "auto", text: currentLang === "zh" ? "自动" : "Auto", selected: curLang === "auto" ? "selected" : undefined }), - el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: curLang === "zh" ? "selected" : undefined }), - el("option", { value: "en", text: currentLang === "zh" ? "英文" : "English", selected: curLang === "en" ? "selected" : undefined }), - ]); - const status = renderMetaChips({ mode: curMode, specialist: curSpecialist, lang: curLang }); - const paintStatus = (obj) => { - const fresh = renderMetaChips(obj); - status.replaceChildren(...Array.from(fresh.childNodes)); - }; - const saveExpertBtn = el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "绑定专家" : "Bind specialist", - onclick: async () => { - const specialist = String(specialistSel.value || "generalist").trim() || "generalist"; - const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto"; - const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, { - interaction_mode: "expert", - specialist, - lang, - }); - paintStatus({ - mode: String(resp.interaction_mode || "expert"), - specialist: String(resp.specialist || specialist), - lang: String(resp.lang || lang), - }); - }, - }); - return el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: title }), - el("div", { - class: "card__subtitle", - text: currentLang === "zh" ? "通道统一走专家模式;选择默认专家即可。" : "Channels are expert-only; pick the default specialist.", - }), - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }), - specialistSel, - ]), - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "语言" : "Lang" }), - langSel, - ]), - el("div", { class: "form-inline__actions" }, [saveExpertBtn]), - ]), - status, - ]); - }; - const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {}); - const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {}); - const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : []; - const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {}; - const waGroupSel = el( - "select", - { class: "input" }, - [ - el("option", { value: "", text: currentLang === "zh" ? "选择群…" : "Select group…" }), - ...waGroups.map((g) => { - const jid = String((g && g.group_jid) || "").trim(); - const name = String((g && g.group_name) || "").trim(); - const label = name ? `${name} (${jid})` : jid; - return el("option", { - value: jid, - text: label || jid, - selected: jid && jid === String(waBinding.group_jid || "") ? "selected" : undefined, - }); - }), - ], - ); - const waBindingStatus = el("div", { - class: "muted", - text: waBinding.group_jid - ? `binding=${waBinding.group_jid} enabled=${Boolean(waBinding.enabled)}` - : (currentLang === "zh" ? "尚未绑定告警群" : "No alert group bound"), - }); - const waAccessCfg = (whatsappAccessResp && whatsappAccessResp.config) || {}; - const waContacts = Array.isArray(whatsappAccessResp && whatsappAccessResp.contacts) ? whatsappAccessResp.contacts : []; - const waPending = Array.isArray(whatsappAccessResp && whatsappAccessResp.pending) ? whatsappAccessResp.pending : []; - const waDenied = Array.isArray(whatsappAccessResp && whatsappAccessResp.denied) ? whatsappAccessResp.denied : []; - const waPhoneDisplay = (row) => { - const phone = String((row && row.phone) || "").trim(); - if (phone) return phone; - const jid = String((row && row.external_user_id) || "").trim(); - const base = jid.split("@")[0] || ""; - const digits = base.replace(/\D/g, ""); - return digits || base || "-"; - }; - const waAccessModeSel = el("select", { class: "input" }, [ - el("option", { - value: "blacklist", - text: currentLang === "zh" ? "黑名单模式(默认拒绝,白名单放行)" : "Blacklist mode (deny by default)", - selected: String(waAccessCfg.access_mode || "blacklist") === "blacklist" ? "selected" : undefined, - }), - el("option", { - value: "whitelist", - text: currentLang === "zh" ? "白名单模式(默认放行,黑名单拒绝)" : "Whitelist mode (allow by default)", - selected: String(waAccessCfg.access_mode || "") === "whitelist" ? "selected" : undefined, - }), - ]); - const waAccessLangSel = el("select", { class: "input" }, [ - el("option", { value: "en", text: "English", selected: String(waAccessCfg.lang || "en") === "en" ? "selected" : undefined }), - el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: String(waAccessCfg.lang || "") === "zh" ? "selected" : undefined }), - ]); - const waCounts = { admin: 0, whitelist: 0, blacklist: 0 }; - waContacts.forEach((c) => { - const lt = String((c && c.list_type) || "").trim().toLowerCase(); - if (lt === "admin") waCounts.admin += 1; - else if (lt === "whitelist") waCounts.whitelist += 1; - else if (lt === "blacklist") waCounts.blacklist += 1; - }); - const waAccessStatus = renderMetaChips({ - mode: String(waAccessCfg.access_mode || "blacklist"), - lang: String(waAccessCfg.lang || "en"), - admin: waCounts.admin, - whitelist: waCounts.whitelist, - blacklist: waCounts.blacklist, - pending: waPending.length, - denied: waDenied.length, - }); - const waContactFilterSel = el("select", { class: "input" }, [ - el("option", { value: "all", text: currentLang === "zh" ? "全部" : "All" }), - el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }), - el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }), - el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }), - ]); - const waContactsTbody = el("tbody", {}); - const waContactPhone = (row) => { - const phone = String((row && row.phone) || "").trim(); - if (phone) return phone; - return waPhoneDisplay(row); - }; - const waSaveContact = async (phone, pushName, listType) => { - const phoneVal = String(phone || "").trim(); - const list_type = String(listType || "").trim().toLowerCase(); - if (!phoneVal) return; - if (!list_type) { - window.alert(currentLang === "zh" ? "请选择类型" : "Please select a type"); - return; - } - try { - const resp = await apiPost("/admin/api/whatsapp/access/contacts", { - phone: phoneVal, - push_name: String(pushName || "").trim(), - list_type, - }); - if (!resp || resp.ok === false) { - window.alert(String((resp && resp.error) || (currentLang === "zh" ? "修改失败" : "Update failed"))); - return; - } - router(); - } catch (err) { - window.alert(String(err)); - } - }; - const renderWaContactRows = (filterValue) => { - const filter = String(filterValue || "all").trim().toLowerCase() || "all"; - const rows = waContacts - .filter((c) => { - const lt = String((c && c.list_type) || "").trim().toLowerCase(); - if (filter === "all") return true; - return lt === filter; - }) - .map((c) => { - const name = String((c && c.push_name) || ""); - const lt = String((c && c.list_type) || "").trim().toLowerCase(); - const phone = waContactPhone(c); - const typeSel = el("select", { class: "input" }, [ - el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }), - el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }), - el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }), - ]); - typeSel.value = lt || "whitelist"; - const actionBtns = [ - el("button", { - class: "btn", - text: currentLang === "zh" ? "修改" : "Update", - onclick: async () => { - await waSaveContact(phone, name, String(typeSel.value || "").trim()); - }, - }), - ]; - if (lt === "blacklist") { - actionBtns.push(el("span", { text: " " })); - actionBtns.push(el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "加白名单" : "Whitelist", - onclick: async () => { - await waSaveContact(phone, name, "whitelist"); - }, - })); - } - actionBtns.push(el("span", { text: " " })); - actionBtns.push(el("button", { - class: "btn btn--danger", - text: currentLang === "zh" ? "删除" : "Remove", - onclick: async () => { - try { - const resp = await apiDeleteJson( - `/admin/api/whatsapp/access/contacts?phone=${encodeURIComponent(phone)}`, - ); - if (resp && resp.deleted === false) { - window.alert(currentLang === "zh" ? "未找到可删除的联系人" : "Contact not found"); - return; - } - router(); - } catch (err) { - window.alert(String(err)); - } - }, - })); - return el("tr", {}, [ - el("td", { "data-copy-disabled": "1" }, [typeSel]), - el("td", { text: name || "-" }), - el("td", { text: phone || "-" }), - el("td", { "data-copy-disabled": "1" }, actionBtns), - ]); - }); - waContactsTbody.replaceChildren( - ...(rows.length - ? rows - : [el("tr", {}, [el("td", { colspan: "4", text: currentLang === "zh" ? "暂无联系人" : "No contacts" })])]), - ); - }; - waContactFilterSel.addEventListener("change", () => { - renderWaContactRows(String(waContactFilterSel.value || "all")); - }); - renderWaContactRows("all"); - const waContactPhoneInput = el("input", { class: "input", placeholder: currentLang === "zh" ? "电话,如 +8615601877957" : "Phone, e.g. +8615601877957" }); - const waContactNameInput = el("input", { class: "input", placeholder: currentLang === "zh" ? "显示名(可选)" : "Display name (optional)" }); - const waContactTypeSel = el("select", { class: "input" }, [ - el("option", { value: "admin", text: currentLang === "zh" ? "管理员" : "Admin" }), - el("option", { value: "whitelist", text: currentLang === "zh" ? "白名单" : "Whitelist" }), - el("option", { value: "blacklist", text: currentLang === "zh" ? "黑名单" : "Blacklist" }), - ]); - const waPendingPickSel = el( - "select", - { class: "input" }, - [ - el("option", { value: "", text: currentLang === "zh" ? "从待审批选择联系人…" : "Pick from pending requests…" }), - ...waPending.map((p, idx) => { - const phone = waPhoneDisplay(p); - const name = String((p && p.push_name) || "").trim(); - const label = name ? `${name} (${phone})` : phone; - return el("option", { value: String(idx), text: label || phone }); - }), - ], - ); - waPendingPickSel.addEventListener("change", () => { - const idx = Number(String(waPendingPickSel.value || "").trim()); - if (!Number.isFinite(idx) || idx < 0 || idx >= waPending.length) return; - const picked = waPending[idx] || {}; - const phone = waPhoneDisplay(picked); - const name = String((picked && picked.push_name) || "").trim(); - if (phone && phone !== "-") waContactPhoneInput.value = phone; - waContactNameInput.value = name || ""; - }); - const waResolvePending = async (pendingId, action) => { - if (!pendingId) return; - try { - const resp = await apiPost("/admin/api/whatsapp/access/pending/resolve", { - pending_id: pendingId, - action, - }); - if (!resp || resp.ok === false) { - window.alert(String((resp && resp.error) || (currentLang === "zh" ? "操作失败" : "Request failed"))); - return; - } - router(); - } catch (err) { - window.alert(String(err)); - } - }; - const waDeletePending = async (pendingId) => { - if (!pendingId) return; - try { - const resp = await apiDeleteJson( - `/admin/api/whatsapp/access/pending?pending_id=${encodeURIComponent(pendingId)}`, - ); - if (!resp || resp.ok === false) { - window.alert(String((resp && resp.error) || (currentLang === "zh" ? "删除失败" : "Delete failed"))); - return; - } - router(); - } catch (err) { - window.alert(String(err)); - } - }; - const waDeniedRows = waDenied.map((p) => { - const pendingId = String((p && p.id) || "").trim(); - const phone = waPhoneDisplay(p); - const name = String((p && p.push_name) || "").trim(); - return el("tr", {}, [ - el("td", { text: name || "-" }), - el("td", { text: phone }), - el("td", { text: String((p && p.request_text) || "").slice(0, 80) }), - el("td", { text: String((p && p.resolved_at) || p.created_at || "") }), - el("td", {}, [ - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "加白名单" : "Whitelist", - onclick: async () => { await waResolvePending(pendingId, "approve"); }, - }), - el("span", { text: " " }), - el("button", { - class: "btn btn--danger", - text: currentLang === "zh" ? "删除" : "Delete", - onclick: async () => { await waDeletePending(pendingId); }, - }), - ]), - ]); - }); - const waPendingRows = waPending.map((p) => { - const pendingId = String((p && p.id) || "").trim(); - return el("tr", {}, [ - el("td", { text: String((p && p.push_name) || "-") }), - el("td", { text: waPhoneDisplay(p) }), - el("td", { text: String((p && p.external_user_id) || "") }), - el("td", { text: String((p && p.request_text) || "").slice(0, 80) }), - el("td", { text: String((p && p.created_at) || "") }), - el("td", {}, [ - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "通过" : "Approve", - onclick: async () => { await waResolvePending(pendingId, "approve"); }, - }), - el("span", { text: " " }), - el("button", { - class: "btn", - text: currentLang === "zh" ? "拒绝" : "Deny", - onclick: async () => { await waResolvePending(pendingId, "deny"); }, - }), - el("span", { text: " " }), - el("button", { - class: "btn btn--danger", - text: currentLang === "zh" ? "删除" : "Delete", - onclick: async () => { await waDeletePending(pendingId); }, - }), - ]), - ]); - }); - const whatsappAccessCard = el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 访问控制" : "WhatsApp access control" }), - el("div", { - class: "card__subtitle", - text: currentLang === "zh" ? "默认黑名单模式:未授权用户进入待审批;拒绝后进入黑名单/已拒绝列表,可一键加白名单。" : "Default blacklist mode: unauthorized users go to Pending; denied users go to blacklist/denied list and can be whitelisted.", - }), - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "模式" : "Mode" }), - waAccessModeSel, - ]), - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "提示语言" : "Message lang" }), - waAccessLangSel, - ]), - el("div", { class: "form-inline__actions" }, [ - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "保存配置" : "Save config", - onclick: async () => { - const resp = await apiPost("/admin/api/whatsapp/access/config", { - tenant_id: "default", - access_mode: String(waAccessModeSel.value || "blacklist"), - lang: String(waAccessLangSel.value || "en"), - }); - const cfg = (resp && resp.config) || {}; - waAccessStatus.replaceChildren( - ...Array.from( - renderMetaChips({ - mode: String(cfg.access_mode || "blacklist"), - lang: String(cfg.lang || "en"), - admin: waCounts.admin, - whitelist: waCounts.whitelist, - blacklist: waCounts.blacklist, - pending: waPending.length, - denied: waDenied.length, - }).childNodes, - ), - ); - }, - }), - ]), - ]), - waAccessStatus, - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "筛选" : "Filter" }), - waContactFilterSel, - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field form-inline__field--wide" }, [waPendingPickSel]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [waContactPhoneInput]), - el("div", { class: "form-inline__field" }, [waContactNameInput]), - el("div", { class: "form-inline__field" }, [waContactTypeSel]), - el("div", { class: "form-inline__actions" }, [ - el("button", { - class: "btn", - text: currentLang === "zh" ? "添加联系人" : "Add contact", - onclick: async () => { - const phone = String(waContactPhoneInput.value || "").trim(); - if (!phone) return; - try { - const resp = await apiPost("/admin/api/whatsapp/access/contacts", { - phone, - push_name: String(waContactNameInput.value || "").trim(), - list_type: String(waContactTypeSel.value || "whitelist"), - }); - if (!resp || resp.ok === false) { - window.alert(String((resp && resp.error) || (currentLang === "zh" ? "添加失败" : "Add failed"))); - return; - } - router(); - } catch (err) { - window.alert(String(err)); - } - }, - }), - ]), - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "类型" : "Type" }), - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - waContactsTbody, - ]), - ]), - el("div", { class: "card__title", text: currentLang === "zh" ? "待审批请求" : "Pending requests" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: "JID" }), - el("th", { text: currentLang === "zh" ? "消息" : "Message" }), - el("th", { text: currentLang === "zh" ? "时间" : "Time" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: currentLang === "zh" ? "无待审批" : "None" })])]), - ]), - ]), - el("div", { class: "card__title", text: currentLang === "zh" ? "已拒绝 / 黑名单记录" : "Denied / blacklist records" }), - el("div", { class: "card__hint", text: currentLang === "zh" ? "点「拒绝」后用户会进入黑名单联系人,并保留在此列表;可一键加白名单恢复访问。" : "Denied users are blacklisted and listed here; use Whitelist to restore access." }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: currentLang === "zh" ? "名称" : "Name" }), - el("th", { text: currentLang === "zh" ? "电话" : "Phone" }), - el("th", { text: currentLang === "zh" ? "消息" : "Message" }), - el("th", { text: currentLang === "zh" ? "拒绝时间" : "Denied at" }), - el("th", { text: currentLang === "zh" ? "操作" : "Action" }), - ])]), - el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: currentLang === "zh" ? "无已拒绝记录" : "None" })])]), - ]), - ]), - ]); - const whatsappAlertBindingCard = el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "WhatsApp 告警群绑定" : "WhatsApp alert group" }), - el("div", { - class: "card__subtitle", - text: currentLang === "zh" ? "NetX 关键告警将推送到此群;与 Chat 会话删除无关。" : "NetX key alerts go to this group; independent of chat sessions.", - }), - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field form-inline__field--wide" }, [waGroupSel]), - el("div", { class: "form-inline__actions" }, [ - el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "保存绑定" : "Save binding", - onclick: async () => { - const group_jid = String(waGroupSel.value || "").trim(); - if (!group_jid) return; - const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {}; - const resp = await apiPost("/admin/api/whatsapp/alert-binding", { - tenant_id: "default", - group_jid, - group_name: String((picked && picked.group_name) || ""), - enabled: true, - }); - const b = (resp && resp.binding) || {}; - waBindingStatus.textContent = b.group_jid - ? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}` - : (currentLang === "zh" ? "绑定失败" : "Bind failed"); - }, - }), - el("button", { - class: "btn", - text: currentLang === "zh" ? "测试推送" : "Test push", - onclick: async () => { - const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" }); - waBindingStatus.textContent = resp && resp.ok - ? `test ok outbound_id=${String(resp.outbound_id || "")}` - : `test failed: ${String((resp && resp.error) || "unknown")}`; - }, - }), - ]), - ]), - waBindingStatus, - ]); - const scanItems = Array.isArray(scanResp && scanResp.items) ? scanResp.items : []; - const scanDir = String((scanResp && scanResp.dir) || ""); - const scanStatus = el("div", { class: "muted", text: "" }); - const keepLatestInput = el("input", { class: "input", type: "number", value: "20", min: "0", max: "500" }); - const maxAgeDaysInput = el("input", { class: "input", type: "number", value: "7", min: "0", max: "3650" }); - const btnCleanScan = el("button", { - class: "btn btn--danger", - text: "清理扫描缓存", - onclick: async () => { - const resp = await apiPost("/admin/api/runtime/scan-artifacts/cleanup", {}); - scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)}`; - router(); - }, - }); - const btnPruneScan = el("button", { - class: "btn", - text: "按策略清理", - onclick: async () => { - const keepLatest = Number(keepLatestInput.value || 20); - const maxAgeDays = Number(maxAgeDaysInput.value || 7); - const resp = await apiPost("/admin/api/runtime/scan-artifacts/prune", { - keep_latest: Number.isFinite(keepLatest) ? keepLatest : 20, - max_age_days: Number.isFinite(maxAgeDays) ? maxAgeDays : 7, - }); - scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)} keep_latest=${Number((resp && resp.keep_latest) || 0)} max_age_days=${Number((resp && resp.max_age_days) || 0)}`; - router(); - }, - }); - const scanRows = scanItems.length - ? scanItems.map((x) => el("tr", {}, [ - tdCell(String(x.name || ""), 26), - tdCell(String(x.bytes || 0), 12), - tdCell(String(x.modified_at || ""), 28), - ])) - : [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })])]; - const prewarmInfo = prewarmStatusResp && typeof prewarmStatusResp === "object" ? prewarmStatusResp : {}; - const prewarmLast = prewarmInfo.last && typeof prewarmInfo.last === "object" ? prewarmInfo.last : {}; - const prewarmHistory = Array.isArray(prewarmInfo.history) ? prewarmInfo.history : []; - const freeze = prewarmInfo.freeze && typeof prewarmInfo.freeze === "object" ? prewarmInfo.freeze : {}; - const prewarmStatus = el("div", { class: "muted", text: "" }); - const btnPrewarm = el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "立即预热" : "Run prewarm now", - onclick: async () => { - prewarmStatus.textContent = currentLang === "zh" ? "[预热] 提交中…" : "[prewarm] submitting…"; - const resp = await apiPost("/admin/api/runtime/prewarm", { mode: "async", reason: "admin_manual" }); - if (!(resp && resp.accepted)) { - prewarmStatus.textContent = `[prewarm] accepted=${Boolean(resp && resp.accepted)}`; - return; - } - prewarmStatus.textContent = - currentLang === "zh" - ? "[预热] 后台执行中,完成后自动刷新本页数据(含提示词)…" - : "[prewarm] running in background; refreshing this page when finished…"; - runtimePrewarmReminder = ""; - const maxWaitMs = 120000; - const stepMs = 400; - let waited = 0; - while (waited < maxWaitMs) { - await new Promise((r) => setTimeout(r, stepMs)); - waited += stepMs; - const st = await apiGet("/admin/api/runtime/prewarm/status"); - if (!(st && st.running)) break; - } - await router(); - }, - }); - const prewarmSummary = [ - `running=${Boolean(prewarmInfo.running)}`, - `last_ok=${Boolean(prewarmLast.ok)}`, - `elapsed_ms=${Number(prewarmLast.elapsed_ms || 0)}`, - `freeze_enabled=${Boolean(freeze.enabled)}`, - `frozen=${Boolean(freeze.frozen)}`, - `last_warm=${ - Number(freeze.last_warm_ts_ms || 0) > 0 - ? formatSystemLocalDateTime(new Date(Number(freeze.last_warm_ts_ms || 0)).toISOString()) - : "-" - }`, - ].join(" | "); - const historyRows = prewarmHistory.length - ? prewarmHistory.slice(0, 20).map((x) => - el("tr", {}, [ - tdCell(formatSystemLocalDateTime(new Date(Number(x.finished_at_ms || 0)).toISOString()), 24), - tdCell(String(x.reason || "-"), 20), - tdCell(String(Boolean(x.ok)), 8), - tdCell(String(Number(x.elapsed_ms || 0)), 10), - tdCell(String(x.error || "-"), 38), - ])) - : [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "5" })])]; - const promptsSectionBody = el("div"); - const promptsRoleSelect = el("select", { class: "input" }, [ - el("option", { value: "generalist", text: "generalist (default)" }), - el("option", { value: "", text: currentLang === "zh" ? "全部角色" : "all roles" }), - ]); - const renderPromptCards = (resp) => { - const promptsObj = resp && typeof resp === "object" ? resp : {}; - const promptsMap = promptsObj.prompts && typeof promptsObj.prompts === "object" ? promptsObj.prompts : {}; - const promptRoleCards = Object.keys(promptsMap) - .sort((a, b) => String(a).localeCompare(String(b))) - .map((rid) => { - const item = promptsMap[rid] && typeof promptsMap[rid] === "object" ? promptsMap[rid] : {}; - const finalSystem = String(item.system_prompt || ""); - return el("div", { class: "card u-mt-10" }, [ - el("div", { class: "card__title", text: `role: ${rid}` }), - el("div", {}, [ - el("div", { class: "muted", text: "system_prompt" }), - el("textarea", { - class: "input", - rows: "12", - readonly: "readonly", - text: finalSystem, - style: "width:100%;box-sizing:border-box;", - }), - ]), - ]); - }); - promptsSectionBody.innerHTML = ""; - if (promptRoleCards.length) { - promptRoleCards.forEach((n) => promptsSectionBody.appendChild(n)); - } else { - promptsSectionBody.appendChild(el("div", { class: "muted", text: "-" })); - } - }; - const btnReloadPrompts = el("button", { - class: "btn", - text: currentLang === "zh" ? "刷新提示词" : "Reload prompts", - onclick: async () => { - const role = String(promptsRoleSelect.value || "").trim(); - const q = role ? `?role=${encodeURIComponent(role)}` : ""; - const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`); - renderPromptCards(resp); - }, - }); - promptsRoleSelect.addEventListener("change", async () => { - const role = String(promptsRoleSelect.value || "").trim(); - const q = role ? `?role=${encodeURIComponent(role)}` : ""; - const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`); - renderPromptCards(resp); - }); - renderPromptCards(prewarmPromptsResp); - return el("div", { class: "page-shell" }, [ - el("div", { class: "page-grid page-grid--two" }, [ - weixinDispatchCard, - whatsappDispatchCard, - ]), - whatsappAccessCard, - whatsappAlertBindingCard, - el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "提示词/工具预热" : "Prompt/Tool Prewarm" }), - el("div", { class: "card__subtitle", text: prewarmSummary }), - ]), - el("div", { class: "card__actions" }, [btnPrewarm]), - ]), - runtimePrewarmReminder ? el("div", { class: "alert alert--warning", text: runtimePrewarmReminder }) : null, - el( - "div", - { - class: "card__hint", - text: - currentLang === "zh" - ? "任何 skill/tool/角色/提示词变更后,请立即预热;复杂变更可直接重启。系统每10分钟自动异步预热一次。" - : "After any skill/tool/role/prompt change, run prewarm immediately; restart for complex changes. System also auto-prewarms every 10 minutes.", - }, - ), - prewarmStatus, - ].filter(Boolean)), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "预热历史(最近20次)" : "Prewarm History (latest 20)" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "finished_at" }), - el("th", { text: "reason" }), - el("th", { text: "ok" }), - el("th", { text: "elapsed_ms" }), - el("th", { text: "error" }), - ])]), - el("tbody", {}, historyRows), - ]), - ]), - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: currentLang === "zh" ? "预热后提示词(按专家)" : "Prewarmed Prompts by Role" }), - el("div", { - class: "card__subtitle", - text: currentLang === "zh" ? "展示各专家预热后的提示词内容。" : "Shows prewarmed prompt content for specialists.", - }), - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [ - el("label", { text: currentLang === "zh" ? "角色筛选" : "Role filter" }), - promptsRoleSelect, - ]), - el("div", { class: "form-inline__actions" }, [btnReloadPrompts]), - ]), - promptsSectionBody, - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__head" }, [ - el("div", { class: "card__headline" }, [ - el("div", { class: "card__title", text: "扫描缓存文件" }), - el("div", { class: "card__subtitle", text: `${scanDir || "-"} · 仅清理 history_entries_*.json 与 state_scan_*.json` }), - ]), - ]), - el("div", { class: "form-inline" }, [ - el("div", { class: "form-inline__field" }, [ - el("label", { text: "keep_latest" }), - keepLatestInput, - ]), - el("div", { class: "form-inline__field" }, [ - el("label", { text: "max_age_days" }), - maxAgeDaysInput, - ]), - el("div", { class: "form-inline__actions" }, [btnPruneScan, btnCleanScan]), - ]), - scanStatus, - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "bytes" }), el("th", { text: "modified_at" })])]), - el("tbody", {}, scanRows), - ]), - ]), - ]), - ]); -} - -async function renderUserManagement() { - const sessionTid = String((authSession && authSession.tenant_id) || "").trim(); - if (!sessionTid) { - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("users.title") }), - el("div", { class: "muted", text: t("tenants.noSessionTenant") }), - ]), - ]); - } - - let allTenants = []; - try { - const allTenantsResp = await apiGet("/admin/api/tenants"); - allTenants = allTenantsResp.tenants || []; - } catch (err) { - if (!hasPermission("admin:user:read")) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("common.error") }), - el("div", { class: "pre", text: String(err) }), - ]); - } - allTenants = [{ id: sessionTid, name: "", created_at: "" }]; - } - - if (!allTenants.length) { - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("users.title") }), - el("div", { class: "muted", text: t("tenants.noTenants") }), - ]), - ]); - } - - const status = el("div", { class: "muted", text: "" }); - - const tenantRows = allTenants.map((x) => { - const tid = String(x.id || ""); - const isLoginHome = tid === sessionTid; - const tname = String(x.name || "").trim() || tid.slice(0, 8); - const canDelete = allTenants.length > 1 && tid !== sessionTid; - const btnDel = el("button", { - type: "button", - class: "btn btn--danger", - text: t("tenants.deleteTenant"), - disabled: canDelete ? undefined : "disabled", - title: canDelete - ? undefined - : allTenants.length <= 1 - ? t("tenants.deleteDisabledLast") - : t("tenants.deleteDisabledCurrent"), - onclick: async (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!canDelete) return; - if (!window.confirm(tf("tenants.deleteTenantConfirm", { name: tname }))) return; - status.textContent = ""; - try { - const resp = await apiPost("/admin/api/tenants/delete", { tenant_id: tid }); - if (!resp.ok) { - const err = String(resp.error || "error"); - status.textContent = - err === "last_tenant_cannot_delete" - ? t("tenants.errLastTenant") - : err === "cannot_delete_current_tenant" - ? t("tenants.errCannotDeleteCurrentTenant") - : err; - return; - } - if (!Number(resp.deleted || 0)) { - status.textContent = t("tenants.errTenantDeleteMiss"); - return; - } - await router(); - } catch (err) { - status.textContent = String(err && err.message ? err.message : err); - } - }, - }); - return el("tr", {}, [ - el("td", { text: String(x.name || "") }), - el("td", { text: formatSystemLocalDateTime(String(x.created_at || "")) }), - el("td", { - class: isLoginHome ? "" : "muted", - text: isLoginHome ? t("tenants.scopeLoginHome") : "—", - }), - el("td", {}, [btnDel]), - ]); - }); - - const selector = el("select", { class: "input" }); - for (const item of allTenants) { - const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8); - selector.appendChild(el("option", { value: String(item.id || ""), text: optLabel })); - } - const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid); - selector.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0; - - const bindingsBody = el("tbody"); - const codesBody = el("tbody"); - - const searchInput = el("input", { class: "input", placeholder: t("users.search") }); - const userStatus = el("div", { class: "muted", text: "" }); - const userBody = el("tbody"); - const accountStatus = el("div", { class: "muted", text: "" }); - const selectedUserField = el("input", { - class: "input input--readonly", - readonly: "readonly", - placeholder: t("users.wecomUserEmpty"), - }); - const accountBody = el("tbody"); - const accountChannelInput = el("select", { class: "input" }, [ - el("option", { value: "wecom", text: "wecom" }), - el("option", { value: "weixin", text: "weixin" }), - el("option", { value: "whatsapp", text: "whatsapp" }), - ]); - const accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") }); - const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") }); - const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") }); - const botSecretField = el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]); - const clearBotChk = el("input", { type: "checkbox" }); - const clearBotField = el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]); - const accountActiveInput = el("input", { type: "checkbox" }); - const accountSpecialistInput = el("select", { class: "input" }, [el("option", { value: "generalist", text: "generalist" })]); - accountActiveInput.checked = true; - let selectedUserId = ""; - let selectedUsername = ""; - let selectedDisplayName = ""; - let availableAccountSpecialists = ["generalist"]; - - const refreshSelectedUserField = () => { - if (!selectedUserId) { - selectedUserField.value = ""; - return; - } - const who = formatUserLabel({ username: selectedUsername, display_name: selectedDisplayName }); - selectedUserField.value = who; - }; - - const clearWecomForm = () => { - accountIdInput.value = ""; - accountNameInput.value = ""; - botSecretInput.value = ""; - clearBotChk.checked = false; - accountActiveInput.checked = true; - accountSpecialistInput.value = "generalist"; - }; - const currentAccountChannel = () => String(accountChannelInput.value || "wecom").trim().toLowerCase() || "wecom"; - const refreshAccountFormByChannel = () => { - const isWecom = currentAccountChannel() === "wecom"; - botSecretField.style.display = isWecom ? "" : "none"; - clearBotField.style.display = isWecom ? "" : "none"; - }; - const setAccountSpecialistOptions = (specialists) => { - const opts = Array.isArray(specialists) && specialists.length - ? specialists.map((x) => String(x || "").trim()).filter(Boolean) - : ["generalist"]; - const uniq = Array.from(new Set(["generalist", ...opts])); - availableAccountSpecialists = uniq; - const old = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; - accountSpecialistInput.innerHTML = ""; - uniq.forEach((sid) => { - accountSpecialistInput.appendChild(el("option", { value: sid, text: sid, selected: sid === old ? "selected" : undefined })); - }); - if (!uniq.includes(old)) accountSpecialistInput.value = "generalist"; - }; - - const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") }); - const newUserRole = el("select", { class: "input" }, [ - el("option", { value: "member", text: "member" }), - el("option", { value: "admin", text: "admin" }), - el("option", { value: "guest", text: "guest" }), - el("option", { value: "owner", text: "owner" }), - ]); - const newUserPassword = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); - - const getTenantId = () => String(selector.value || ""); - - const mergeWecomBindingAndInstances = (bindings, items) => { - const byAid = new Map(); - for (const it of items) { - const aid = String(it.account_id || "").trim(); - if (aid) byAid.set(aid, it); - } - const seen = new Set(); - const out = []; - for (const b of bindings) { - const aid = String(b.account_id || "").trim(); - if (!aid || seen.has(aid)) continue; - seen.add(aid); - out.push({ binding: b, instance: byAid.get(aid) || null }); - } - for (const it of items) { - const aid = String(it.account_id || "").trim(); - if (!aid || seen.has(aid)) continue; - out.push({ binding: null, instance: it }); - } - return out; - }; - - const prefillFromMerged = (m) => { - const inst = m.instance; - const bind = m.binding; - const aid = inst ? String(inst.account_id || "").trim() : String(bind?.account_id || "").trim(); - accountIdInput.value = aid; - const nameFromInst = inst ? String(inst.name || "").trim() : ""; - const nameFromBind = bind ? String(bind.account_name || "").trim() : ""; - accountNameInput.value = nameFromInst || nameFromBind; - if (inst) { - accountActiveInput.checked = !!inst.is_active; - } else { - accountActiveInput.checked = true; - } - const specialist = String(((inst && inst.config) || {}).specialist || "generalist").trim().toLowerCase() || "generalist"; - accountSpecialistInput.value = availableAccountSpecialists.includes(specialist) ? specialist : "generalist"; - accountStatus.textContent = - currentLang === "zh" ? `当前账号模式:专家(${specialist})` : `Current account mode: expert (${specialist})`; - botSecretInput.value = ""; - clearBotChk.checked = false; - }; - - const loadAccounts = async () => { - accountBody.innerHTML = ""; - clearWecomForm(); - const tenantId = getTenantId(); - if (!selectedUserId) { - accountStatus.textContent = ""; - accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "10" })])); - return; - } - const ch = currentAccountChannel(); - const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=${encodeURIComponent(ch)}&user_id=${encodeURIComponent(selectedUserId)}`; - const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=${encodeURIComponent(ch)}&include_inactive=1`; - const [bindResp, accResp] = await Promise.all([ch === "wecom" ? apiGet(bindUrl) : Promise.resolve({ bindings: [] }), apiGet(accUrl)]); - const bindings = Array.isArray(bindResp && bindResp.bindings) ? bindResp.bindings : []; - const items = Array.isArray(accResp.items) ? accResp.items : []; - const merged = ch === "wecom" ? mergeWecomBindingAndInstances(bindings, items) : items.map((it) => ({ binding: null, instance: it })); - accountStatus.textContent = ""; - if (!merged.length) { - accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "10" })])); - return; - } - merged.forEach((m) => { - const inst = m.instance; - const bind = m.binding; - const accountId = inst ? String(inst.account_id || "") : String(bind?.account_id || ""); - const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || ""; - const extUid = bind ? String(bind.external_user_id || "") : ""; - const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || ""; - const cfg = (inst && inst.config && typeof inst.config === "object") ? inst.config : {}; - const modeText = String(cfg.interaction_mode || "expert"); - const specialistText = String(cfg.specialist || "generalist"); - const btnFill = el("button", { - class: "btn", - text: t("users.loadForm"), - onclick: (e) => { - e.stopPropagation(); - prefillFromMerged(m); - }, - }); - const deleteCell = el("td", {}); - if (inst) { - const btnBindExpert = el("button", { - class: "btn btn--primary", - text: currentLang === "zh" ? "绑定专家" : "Bind specialist", - onclick: async (e) => { - e.stopPropagation(); - const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; - await apiPost("/admin/api/user-channel-accounts/upsert", { - tenant_id: tenantId, - user_id: selectedUserId, - channel: ch, - account_id: accountId, - name: displayName, - is_active: !!inst.is_active, - config: { interaction_mode: "expert", specialist }, - }); - await loadAccounts(); - }, - }); - const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => { - e.stopPropagation(); - await apiPost("/admin/api/user-channel-accounts/delete", { - tenant_id: tenantId, - user_id: selectedUserId, - channel: ch, - account_id: accountId, - }); - await loadAccounts(); - }}); - deleteCell.appendChild(btnBindExpert); - deleteCell.appendChild(btnDeleteAccount); - } else { - deleteCell.appendChild(document.createTextNode("—")); - } - accountBody.appendChild(el("tr", {}, [ - tdCell(accountId, 22), - tdCell(displayName, 16), - tdCell(extUid || "—", 18), - tdCell(inst ? (ch === "wecom" ? (inst.has_bot_secret ? "Y" : "-") : "-") : "—", 4), - tdCell(inst ? (inst.is_active ? "1" : "0") : "—", 4), - tdCell(modeText || "expert", 8), - tdCell(specialistText || "generalist", 10), - tdCell(ts, 20), - el("td", {}, [btnFill]), - deleteCell, - ])); - }); - }; - - const btnSaveAccount = el("button", { class: "btn", text: t("users.accountSave"), onclick: async () => { - if (!selectedUserId) { - accountStatus.textContent = "select user first"; - return; - } - const aid = accountIdInput.value.trim(); - if (!aid) { - accountStatus.textContent = t("users.botIdRequired"); - return; - } - const tenantId = getTenantId(); - const ch = currentAccountChannel(); - const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist"; - const resp = await apiPost("/admin/api/user-channel-accounts/upsert", { - tenant_id: tenantId, - user_id: selectedUserId, - channel: ch, - account_id: aid, - name: accountNameInput.value.trim(), - wecom_mode: ch === "wecom" ? "bot_api" : undefined, - bot_secret: botSecretInput.value.trim(), - clear_bot_secret: !!clearBotChk.checked, - is_active: !!accountActiveInput.checked, - config: { - interaction_mode: "expert", - specialist, - }, - }); - accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error"); - if (resp.ok) { - botSecretInput.value = ""; - clearBotChk.checked = false; - await loadAccounts(); - await loadList(); - } - }}); - accountChannelInput.addEventListener("change", async () => { - clearWecomForm(); - refreshAccountFormByChannel(); - await loadAccounts(); - }); - - const loadList = async () => { - if (!hasPermission("admin:user:read")) { - userBody.innerHTML = ""; - userBody.appendChild(el("tr", {}, [el("td", { text: t("users.needUserRead"), colspan: "7" })])); - return; - } - const tenantId = getTenantId(); - const tMeta = allTenants.find((r) => String(r.id || "") === tenantId) || {}; - const sessionTenantName = String(tMeta.name || "").trim(); - const q = encodeURIComponent(searchInput.value.trim()); - const resp = await apiGet(`/admin/api/users?tenant_id=${encodeURIComponent(tenantId)}&include_inactive=1&q=${q}&limit=300`); - const users = Array.isArray(resp.users) ? resp.users : []; - userBody.innerHTML = ""; - if (!users.length) { - userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "7" })])); - return; - } - users.forEach((u) => { - const newRole = el("select", { class: "input" }, [ - el("option", { value: "member", text: "member" }), - el("option", { value: "admin", text: "admin" }), - el("option", { value: "guest", text: "guest" }), - el("option", { value: "owner", text: "owner" }), - ]); - newRole.value = String(u.role || "member"); - const pwd = el("input", { class: "input", type: "password", placeholder: t("users.passwordOptional") }); - const btnToggle = el("button", { class: "btn", text: u.is_active ? t("users.disable") : t("users.enable"), onclick: async () => { - await apiPost("/admin/api/users/update", { - tenant_id: tenantId, - user_id: u.id, - is_active: !u.is_active, - }); - await loadList(); - }}); - const btnUpdate = el("button", { class: "btn", text: t("users.update"), onclick: async () => { - await apiPost("/admin/api/users/update", { - tenant_id: tenantId, - user_id: u.id, - role: newRole.value, - password: pwd.value.trim() || undefined, - }); - await loadList(); - }}); - const btnDelete = el("button", { class: "btn btn--danger", text: t("users.delete"), onclick: async (e) => { - e.stopPropagation(); - if (!window.confirm(t("users.deleteConfirm"))) return; - await apiPost("/admin/api/users/delete", { tenant_id: tenantId, user_id: u.id }); - await loadList(); - }}); - const actionWrap = el("div", { class: "table__cell-actions" }, [btnToggle, btnUpdate, btnDelete]); - const tr = el("tr", {}, [ - tdCell(sessionTenantName || "—", 14), - tdCell(formatUserLabel(u), 28), - tdCell(u.role || "", 10), - tdCell(u.is_active ? "1" : "0", 4), - el("td", { class: "table__cell--form" }, [newRole]), - el("td", { class: "table__cell--form" }, [pwd]), - el("td", { class: "table__cell--actions" }, [actionWrap]), - ]); - tr.style.cursor = "pointer"; - tr.addEventListener("click", async (e) => { - if (e.target && e.target.closest && e.target.closest("button,select,input")) return; - selectedUserId = String(u.id || ""); - selectedUsername = String(u.username || ""); - selectedDisplayName = String(u.display_name || ""); - refreshSelectedUserField(); - await loadAccounts(); - }); - userBody.appendChild(tr); - }); - }; - - const btnCreateUser = el("button", { class: "btn btn--primary", text: t("users.create"), onclick: async () => { - if (!hasPermission("admin:user:write")) { - userStatus.textContent = "forbidden"; - return; - } - try { - const raw = newUserName.value.trim(); - if (!raw) { - userStatus.textContent = t("users.createNameRequired"); - return; - } - const resp = await apiPost("/admin/api/users/create", { - tenant_id: getTenantId(), - username: raw, - display_name: raw, - role: newUserRole.value, - password: newUserPassword.value.trim(), - }); - userStatus.textContent = resp.ok ? "ok" : String(resp.error || "error"); - if (resp.ok) { - newUserName.value = ""; - newUserPassword.value = ""; - await loadList(); - await loadBindingsAndCodes(); - } - } catch (e) { - userStatus.textContent = String(e && e.message ? e.message : e); - } - }}); - - const btnSearchUsers = el("button", { class: "btn", text: t("audit.query"), onclick: loadList }); - - const createName = el("input", { class: "input", placeholder: t("tenants.createPlaceholder") }); - const btnCreateTenant = el("button", { class: "btn", text: t("tenants.create"), onclick: async () => { - if (!hasPermission("admin:tenant:write")) { - status.textContent = "forbidden"; - return; - } - await apiPost("/admin/api/tenants/create", { name: createName.value.trim() || "Team" }); - await router(); - }}); - - const roleInput = el("select", { class: "input" }, [ - el("option", { value: "member", text: "member" }), - el("option", { value: "admin", text: "admin" }), - el("option", { value: "guest", text: "guest" }), - ]); - const codeInput = el("input", { class: "input", placeholder: t("tenants.codeOptional") }); - const btnCreateCode = el("button", { class: "btn", text: t("tenants.createCode"), onclick: async () => { - const tid = String(selector.value || ""); - await apiPost("/admin/api/bind-codes/create", { - tenant_id: tid, - role: roleInput.value || "member", - code: codeInput.value.trim(), - }); - codeInput.value = ""; - await loadBindingsAndCodes(); - }}); - const btnDeleteUnbound = el("button", { class: "btn btn--danger", text: t("tenants.deleteUnbound"), onclick: async () => { - const tid = String(selector.value || ""); - const resp = await apiPost("/admin/api/users/delete-unbound", { tenant_id: tid, channel: "wecom" }); - status.textContent = tf("tenants.deleteUnboundResult", { - deleted: Number(resp.deleted || 0), - orphan: Number(resp.orphan_users || 0), - bound: Number(resp.bound_users || 0), - total: Number(resp.users_total || 0), - }); - await loadBindingsAndCodes(); - await loadList(); - }}); - - const loadBindingsAndCodes = async () => { - const tid = String(selector.value || ""); - const [bindingsResp, codesResp] = await Promise.all([ - apiGet("/admin/api/bindings?tenant_id=" + encodeURIComponent(tid) + "&channel=wecom"), - apiGet("/admin/api/bind-codes?tenant_id=" + encodeURIComponent(tid)), - ]); - const bindings = bindingsResp.bindings || []; - const codes = codesResp.codes || []; - - const bindingTenantLabel = (tenantId) => { - const id = String(tenantId || ""); - const meta = allTenants.find((x) => String(x.id || "") === id); - const name = String(meta && meta.name ? meta.name : "").trim(); - return name || id.slice(0, 8) || "—"; - }; - const bindingUserLabel = (b) => - formatUserLabel({ - username: String(b.username != null && b.username !== "" ? b.username : b.user_id || ""), - display_name: String(b.display_name || ""), - }); - - bindingsBody.innerHTML = ""; - if (!bindings.length) { - bindingsBody.appendChild(el("tr", {}, [el("td", { text: t("tenants.noBindings"), colspan: "6" })])); - } else { - bindings.forEach((b) => { - bindingsBody.appendChild(el("tr", {}, [ - el("td", { text: bindingTenantLabel(b.tenant_id) }), - el("td", { text: bindingUserLabel(b) }), - el("td", { text: String(b.channel || "") }), - el("td", { text: String(b.account_id || "") }), - el("td", { text: String(b.account_name || "") }), - el("td", { text: String(b.external_user_id || "") }), - ])); - }); - } - - codesBody.innerHTML = ""; - if (!codes.length) { - codesBody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "6" })])); - } else { - codes.forEach((c) => { - const codeText = String(c.code || ""); - const isUsed = Boolean(c.used_at); - const btnDeleteCode = el("button", { class: "btn btn--danger", text: t("tenants.deleteCode"), onclick: async () => { - if (!window.confirm(t("tenants.codeDeleteConfirm"))) return; - await apiPost("/admin/api/bind-codes/delete", { tenant_id: tid, code: codeText }); - status.textContent = tf("tenants.codeDeleted", { code: codeText }); - await loadBindingsAndCodes(); - }}); - codesBody.appendChild(el("tr", {}, [ - el("td", { text: codeText }), - el("td", { text: String(c.role || "") }), - el("td", { text: isUsed ? t("tenants.codeUsed") : t("tenants.codeUnused") }), - el("td", { text: String(c.used_by_external_user_id || "") }), - el("td", { text: formatSystemLocalDateTime(String(c.created_at || "")) }), - el("td", {}, [btnDeleteCode]), - ])); - }); - } - }; - selector.addEventListener("change", () => { - selectedUserId = ""; - selectedUsername = ""; - selectedDisplayName = ""; - refreshSelectedUserField(); - Promise.all([loadBindingsAndCodes(), loadList(), loadAccounts()]).catch((err) => { - mount(el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.error") }), el("div", { class: "pre", text: String(err) })])); - }); - }); - await loadBindingsAndCodes(); - try { - const sf = await apiGet("/admin/api/chat/settings/specialist-flags"); - setAccountSpecialistOptions(sf.available_specialists || ["generalist"]); - } catch (_) { - setAccountSpecialistOptions(["generalist"]); - } - await loadList(); - refreshSelectedUserField(); - refreshAccountFormByChannel(); - await loadAccounts(); - - const tenantsListHint = allTenants.length > 1 - ? el("div", { class: "muted u-mt-8", text: t("tenants.allTenantsHint") }) - : el("div", {}); - - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("tenants.title") }), - el("div", { class: "row" }, [createName, btnCreateTenant]), - el("div", { class: "row" }, [el("label", { text: t("tenants.select") }), selector]), - el("div", { class: "muted u-mt-6", text: t("tenants.scopeHint") }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("tenants.colTenantName") }), - el("th", { text: t("table.createdAt") }), - el("th", { text: t("tenants.colScope") }), - el("th", { text: t("tenants.rowActions") }), - ])]), - el("tbody", {}, tenantRows), - ]), - status, - tenantsListHint, - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("users.title") }), - el("div", { class: "row" }, [searchInput, btnSearchUsers]), - el("div", { class: "row" }, [newUserName, newUserRole, newUserPassword, btnCreateUser]), - userStatus, - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact table--users-mgmt" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("tenants.colTenantName") }), - el("th", { text: t("users.colUser") }), - el("th", { text: t("tenants.role") }), - el("th", { text: t("users.colActive") }), - el("th", { text: t("users.colSetRole") }), - el("th", { text: t("users.passwordOptional") }), - el("th", { text: t("tenants.rowActions") }), - ])]), - userBody, - ])]), - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("users.wecomAccounts") }), - el("div", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]), - el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }), - el("div", { class: "row row--wecom-form" }, [ - el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: "channel" }), accountChannelInput]), - el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotId") }), accountIdInput]), - el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomInstanceName") }), accountNameInput]), - el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: currentLang === "zh" ? "专家" : "Specialist" }), accountSpecialistInput]), - botSecretField, - el("label", { class: "kv row--wecom-form__chk" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]), - clearBotField, - btnSaveAccount, - ]), - accountStatus, - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("users.wecomBotId") }), - el("th", { text: t("users.wecomInstanceName") }), - el("th", { text: t("table.externalUserId") }), - el("th", { text: t("users.hasBotSecret") }), - el("th", { text: t("users.accountActive") }), - el("th", { text: currentLang === "zh" ? "模式" : "Mode" }), - el("th", { text: t("table.specialist") }), - el("th", { text: t("table.timestamp") }), - el("th", { text: t("users.loadForm") }), - el("th", { text: t("users.accountActions") }), - ])]), - accountBody, - ])]), - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("tenants.bindings") }), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("tenants.title") }), - el("th", { text: t("tenants.users") }), - el("th", { text: t("table.channel") }), - el("th", { text: "account_id" }), - el("th", { text: "account_name" }), - el("th", { text: t("table.externalUserId") }), - ])]), - bindingsBody, - ]), - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("tenants.bindCodes") }), - el("div", { class: "row" }, [ - el("label", { text: t("tenants.role") }), - roleInput, - codeInput, - btnCreateCode, - btnDeleteUnbound, - ]), - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "code" }), - el("th", { text: t("tenants.role") }), - el("th", { text: t("tenants.codeStatus") }), - el("th", { text: t("tenants.codeUsedBy") }), - el("th", { text: t("table.createdAt") }), - el("th", { text: t("tenants.deleteCode") }), - ])]), - codesBody, - ]), - ]), - ]); -} - -function hasPermission(permission) { - const role = String((authSession && authSession.role) || ""); - const perms = new Set(Array.isArray(authSession && authSession.permissions) ? authSession.permissions : []); - if (role === "owner") return true; - return perms.has(permission); -} - -/** 与 models_api._can_manage_llm_grants 一致:仅控制台 administrator 用户可管理授权 */ -function canManageApiGrants() { - const un = String((authSession && authSession.username) || "").trim().toLowerCase(); - if (un !== "administrator") return false; - const role = String((authSession && authSession.role) || "").trim(); - if (role === "owner") return true; - return hasPermission("admin:tenant:write"); -} - -function isAdministratorUsername() { - return String((authSession && authSession.username) || "").trim().toLowerCase() === "administrator"; -} - -/** 与 models_api._profile_shareable_for_admin_grant 一致 */ -function profileShareableForGrant(p, sessionUserId) { - if (!p || p.is_builtin) return false; - const own = String(p.owner_user_id || "").trim(); - if (!own) return true; - return own === String(sessionUserId || "").trim(); -} - -async function renderAudit(initialSessionId = "") { - const input = el("input", { class: "input", placeholder: t("audit.sessionIdPlaceholder") }); - const resultWrap = el("div"); - let selectedTraceId = ""; - const onlyFailures = el("input", { type: "checkbox" }); - const formatTracePayload = (row) => { - const et = String((row && row.event_type) || ""); - const p = (row && typeof row.payload === "object" && row.payload) ? row.payload : {}; - if (et === "tool_wire_filter") { - const before = Number(p.tools_before || 0); - const after = Number(p.tools_after || 0); - const hidden = Number(p.hidden_total || 0); - const hiddenMcp = Number(p.hidden_mcp_total || 0); - const preview = Array.isArray(p.hidden_mcp_preview) ? p.hidden_mcp_preview.slice(0, 8).join(", ") : ""; - const head = `wire_filter before=${before} after=${after} hidden=${hidden} hidden_mcp=${hiddenMcp}`; - return preview ? `${head} | mcp: ${preview}` : head; - } - if (et === "llm_first_token") { - const ms = Number(p.first_token_ms || 0); - return `ttft_ms=${ms}`; - } - if (et === "memory_retrieval_finished") { - return `memory short=${Number(p.short_term_count || 0)} semantic=${Number(p.semantic_hit_count || 0)} enabled=${Boolean(p.enabled)}`; - } - if (et === "router_decision") { - return `route mode=${String(p.mode || "")} reason=${String(p.reason || "")}`; - } - if (et === "run_started") { - return `run_started run_id=${String(p.run_id || "")} max_attempts=${Number(p.max_attempts || 0)}`; - } - if (et === "attempt_started") { - return `attempt_started run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)}`; - } - if (et === "attempt_finished") { - return `attempt_finished run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)} status=${String(p.status || "")}`; - } - if (et === "run_retry") { - return `run_retry run_id=${String(p.run_id || "")} next_attempt=${Number(p.next_attempt_no || 0)}`; - } - if (et === "run_compact") { - return `run_compact run_id=${String(p.run_id || "")} count=${Number(p.compact_count || 0)}`; - } - if (et === "run_finished") { - return `run_finished run_id=${String(p.run_id || "")} status=${String(p.status || "")} attempts=${Number(p.attempts || 0)}`; - } - if (et === "task_enqueued" || et === "task_claimed" || et === "task_finished" || et === "task_failed") { - return `${et} task_id=${String(p.task_id || "")} ok=${p.ok === undefined ? "-" : Boolean(p.ok)}`; - } - if (et === "turn_stage" && String(p.stage || "") === "finish") { - const ttft = p.first_token_ms; - const elapsed = Number(p.turn_elapsed_ms || 0); - const tools = Number(p.tool_trace_count || 0); - return `finish ttft_ms=${ttft === null || ttft === undefined ? "-" : Number(ttft)} elapsed_ms=${elapsed} tools=${tools}`; - } - return JSON.stringify(p || {}); - }; - const auditColumnDefs = [ - { key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 }, - { key: "specialist", label: t("table.specialist"), getter: (r) => r.specialist || "", maxLen: 24 }, - { key: "action", label: t("table.action"), getter: (r) => r.action || "", maxLen: 24 }, - { key: "status", label: t("table.statusText"), getter: (r) => r.status || "", maxLen: 16 }, - { key: "reason", label: t("table.reason"), getter: (r) => r.reason || "", maxLen: 40 }, - { key: "duration", label: t("table.durationMs"), getter: (r) => String(r.duration_ms || ""), maxLen: 20 }, - { - key: "noOutput", - label: t("table.noOutputAttachment"), - getter: (r) => yesNo(((r.payload && typeof r.payload === "object") ? r.payload.no_output_attachment : false)), - maxLen: 8, - }, - { - key: "attachments", - label: "attachments", - getter: (r) => { - const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {}; - const routeIds = formatIds(payload.attachment_ids); - const inIds = formatIds(payload.input_attachment_ids); - const outIds = formatIds(payload.output_attachment_ids); - const outUrls = formatIds(payload.output_attachment_urls); - return [routeIds ? `route:${routeIds}` : "", inIds ? `in:${inIds}` : "", outIds ? `out:${outIds}` : "", outUrls ? `url:${outUrls}` : ""] - .filter(Boolean) - .join(" | ") || "-"; - }, - maxLen: 120, - }, - { - key: "payload", - label: t("table.payload"), - getter: (r) => JSON.stringify(r.payload || {}), - maxLen: 120, - }, - ]; - const traceColumnDefs = [ - { key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 }, - { key: "eventType", label: t("table.eventType"), getter: (r) => r.event_type || "", maxLen: 28 }, - { key: "traceId", label: t("table.traceId"), getter: (r) => r.trace_id || "", maxLen: 20 }, - { key: "spanId", label: t("table.spanId"), getter: (r) => r.span_id || "", maxLen: 20 }, - { key: "parentSpanId", label: t("table.parentSpanId"), getter: (r) => r.parent_span_id || "", maxLen: 20 }, - { - key: "errorCode", - label: "error_code", - getter: (r) => String((r.payload && r.payload.error_code) || ""), - maxLen: 32, - }, - { key: "payload", label: t("table.payload"), getter: (r) => formatTracePayload(r), maxLen: 120 }, - ]; - const auditVisible = new Set(auditColumnDefs.map((x) => x.key)); - const traceVisible = new Set(traceColumnDefs.map((x) => x.key)); - - const buildColumnToggleRow = (defs, visibleSet) => { - const wrap = el("div", { class: "row" }); - defs.forEach((d) => { - const cb = el("input", { type: "checkbox" }); - cb.checked = visibleSet.has(d.key); - cb.addEventListener("change", () => { - if (cb.checked) visibleSet.add(d.key); - else visibleSet.delete(d.key); - }); - wrap.appendChild(el("label", { class: "kv" }, [cb, document.createTextNode(" " + d.label)])); - }); - return wrap; - }; - - const auditColsToggle = buildColumnToggleRow(auditColumnDefs, auditVisible); - const traceColsToggle = buildColumnToggleRow(traceColumnDefs, traceVisible); - const runQuery = async () => { - const sid = input.value.trim(); - const a = await apiGet("/admin/api/audit" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : "")); - const tr = await apiGet("/admin/api/trace" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : "")); - const tasks = await apiGet("/admin/api/oclaw/tasks?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=120"); - const runs = await apiGet("/admin/api/oclaw/runs?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=80&include_attempts=1"); - const health = await apiGet( - "/admin/api/audit/session-health?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&limit=20") : "limit=40"), - ); - const auditRows = Array.isArray(a.audit) ? a.audit : []; - const traceRows = Array.isArray(tr.trace) ? tr.trace : []; - const traceIds = Array.from(new Set(traceRows.map((r) => String(r.trace_id || "")).filter(Boolean))); - if (selectedTraceId && !traceIds.includes(selectedTraceId)) selectedTraceId = ""; - const filteredByTrace = selectedTraceId ? traceRows.filter((r) => String(r.trace_id || "") === selectedTraceId) : traceRows; - const filteredTraceRows = onlyFailures.checked - ? filteredByTrace.filter((r) => { - const p = (r && typeof r.payload === "object") ? r.payload : {}; - return p.ok === false || String(p.error_code || "").trim() !== ""; - }) - : filteredByTrace; - const auditBody = el("tbody"); - const visibleAuditCols = auditColumnDefs.filter((c) => auditVisible.has(c.key)); - if (!auditRows.length) { - auditBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleAuditCols.length)) })])); - } else { - auditRows.forEach((r) => { - const row = el("tr"); - visibleAuditCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120))); - auditBody.appendChild(row); - }); - } - const traceBody = el("tbody"); - const visibleTraceCols = traceColumnDefs.filter((c) => traceVisible.has(c.key)); - if (!filteredTraceRows.length) { - traceBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleTraceCols.length)) })])); - } else { - filteredTraceRows.forEach((r) => { - const row = el("tr"); - visibleTraceCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120))); - traceBody.appendChild(row); - }); - } - const pre = el("div", { class: "pre", text: JSON.stringify({ audit: a, trace: tr, health }, null, 2) }); - resultWrap.innerHTML = ""; - const healthItems = Array.isArray(health.items) ? health.items : []; - const healthRows = healthItems.map((x) => el("tr", {}, [ - tdCell(String(x.session_id || ""), 26), - tdCell(String(x.title || ""), 26), - tdCell(String(x.assistant_count || 0), 8), - tdCell(String(x.tool_count || 0), 8), - tdCell(String(x.mcp_tool_count || 0), 8), - tdCell(String(x.last_tool_at || "-"), 20), - tdCell(String(x.status || ""), 20), - ])); - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "Session Tool Health" }), - el("div", { class: "muted", text: `warn=${Number(health.warn_count || 0)} (assistant replied but no tool uses)` }), - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "session_id" }), - el("th", { text: "title" }), - el("th", { text: "assistant_msgs" }), - el("th", { text: "tool_uses" }), - el("th", { text: "mcp_calls" }), - el("th", { text: "last_tool_at" }), - el("th", { text: "status" }), - ])]), - el("tbody", {}, healthRows.length ? healthRows : [el("tr", {}, [el("td", { text: "-", colspan: "7" })])]), - ])]), - ])); - const taskRows = Array.isArray(tasks.tasks) ? tasks.tasks : []; - const queued = Number((tasks.counts || {}).queued || 0); - const claimed = Number((tasks.counts || {}).claimed || 0); - const done = Number((tasks.counts || {}).done || 0); - const failed = Number((tasks.counts || {}).failed || 0); - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "oclaw Tasks" }), - el("div", { class: "muted", text: `queued=${queued} claimed=${claimed} done=${done} failed=${failed}` }), - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "task_id" }), - el("th", { text: "status" }), - el("th", { text: "attempt_count" }), - el("th", { text: "session_id" }), - el("th", { text: "updated_at" }), - el("th", { text: "last_error" }), - ])]), - el( - "tbody", - {}, - taskRows.length - ? taskRows.slice(0, 80).map((x) => el("tr", {}, [ - tdCell(String(x.id || ""), 24), - tdCell(String(x.status || ""), 10), - tdCell(String(x.attempt_count || 0), 10), - tdCell(String(x.session_id || ""), 20), - tdCell(String(x.updated_at || ""), 22), - tdCell(String(x.last_error || ""), 40), - ])) - : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])], - ), - ])]), - ])); - const runRows = Array.isArray(runs.runs) ? runs.runs : []; - const runRunning = Number((runs.counts || {}).running || 0); - const runSuccess = Number((runs.counts || {}).success || 0); - const runFailed = Number((runs.counts || {}).failed || 0); - const rp = (runs && typeof runs.retry_policy === "object" && runs.retry_policy) ? runs.retry_policy : {}; - const effRetryCodes = Array.isArray(rp.effective_retryable_error_codes) ? rp.effective_retryable_error_codes.join(", ") : ""; - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "oclaw Runs" }), - el("div", { class: "muted", text: `running=${runRunning} success=${runSuccess} failed=${runFailed}` }), - el("div", { class: "muted", text: effRetryCodes ? `retryable_error_codes=${effRetryCodes}` : "" }), - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "run_id" }), - el("th", { text: "status" }), - el("th", { text: "session_id" }), - el("th", { text: "attempts" }), - el("th", { text: "updated_at" }), - ])]), - el( - "tbody", - {}, - runRows.length - ? runRows.slice(0, 60).map((x) => { - const attempts = Array.isArray(x.attempts) ? x.attempts : []; - const attemptSummary = attempts.length - ? attempts.map((a) => `${Number(a.attempt_no || 0)}:${String(a.status || "")}`).join(" | ") - : "-"; - return el("tr", {}, [ - tdCell(String(x.run_id || ""), 24), - tdCell(String(x.status || ""), 10), - tdCell(String(x.session_id || ""), 20), - tdCell(attemptSummary, 60), - tdCell(String(x.updated_at || ""), 22), - ]); - }) - : [el("tr", {}, [el("td", { text: "-", colspan: "5" })])], - ), - ])]), - ])); - - const deliveryRows = auditRows - .filter((r) => r && typeof r === "object" && r.action === "specialist_step") - .map((r) => { - const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {}; - const d = (payload && typeof payload.specialist_delivery === "object" && payload.specialist_delivery) - ? payload.specialist_delivery - : null; - if (!d) return null; - const traces = Array.isArray(d.tool_traces) ? d.tool_traces : []; - const toolsText = traces.length - ? traces - .map((x) => `${String(x.name || "")}(ok=${Boolean(x.ok)}, ${Number(x.latency_ms || 0)}ms)`) - .join(" | ") - : "-"; - return { - timestamp: String(r.timestamp || ""), - specialist: String(r.specialist || d.specialist || ""), - stepId: String(d.step_id || payload.step_id || ""), - answer: String(d.answer_text || ""), - tools: toolsText, - notes: String(d.notes || ""), - }; - }) - .filter(Boolean); - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "Specialist Delivery Timeline" }), - el("div", { class: "muted", text: "专家侧工具执行与交付摘要(specialist -> core)" }), - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "timestamp" }), - el("th", { text: "specialist" }), - el("th", { text: "step_id" }), - el("th", { text: "answer_for_user" }), - el("th", { text: "tools_executed_inside_specialist" }), - el("th", { text: "notes" }), - ])]), - el( - "tbody", - {}, - deliveryRows.length - ? deliveryRows.map((x) => - el("tr", {}, [ - tdCell(x.timestamp, 24), - tdCell(x.specialist, 14), - tdCell(x.stepId, 20), - tdCell(x.answer, 120), - tdCell(x.tools, 120), - tdCell(x.notes, 24), - ]), - ) - : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])], - ), - ])]), - ])); - - // Turn summary (group by trace_id) for quick navigation. - if (traceIds.length) { - const sel = el("select", { class: "input" }); - sel.appendChild(el("option", { value: "", text: `trace_id (${traceIds.length})` })); - traceIds.forEach((tid) => sel.appendChild(el("option", { value: tid, text: tid }))); - sel.value = selectedTraceId; - const summary = el("div", { class: "muted", text: "" }); - const failSummary = el("div", { class: "muted", text: "" }); - const recomputeSummary = () => { - const rows = filteredTraceRows; - const counts = {}; - rows.forEach((r) => { - const et = String(r.event_type || ""); - counts[et] = (counts[et] || 0) + 1; - }); - const get = (k) => Number(counts[k] || 0); - summary.textContent = [ - `route_decided=${get("route_decided")}`, - `plan_created=${get("plan_created")}`, - `tool_wire_filter=${get("tool_wire_filter")}`, - `llm_first_token=${get("llm_first_token")}`, - `llm_called=${get("llm_called")}`, - `llm_result=${get("llm_result")}`, - `tool_use_called=${get("tool_called")}`, - `tool_result=${get("tool_result")}`, - ].join(" | "); - const agg = {}; - rows.forEach((r) => { - const p = (r && typeof r.payload === "object") ? r.payload : {}; - const ec = String(p.error_code || "").trim(); - if (!ec) return; - agg[ec] = (agg[ec] || 0) + 1; - }); - const parts = Object.entries(agg).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k}:${v}`); - failSummary.textContent = parts.length ? `Failure summary: ${parts.join(" | ")}` : "Failure summary: -"; - }; - sel.addEventListener("change", () => { - selectedTraceId = String(sel.value || ""); - runQuery(); - }); - recomputeSummary(); - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: "Turn summary" }), - el("div", { class: "row" }, [sel, el("label", { class: "kv" }, [onlyFailures, document.createTextNode(" only failures")])]), - summary, - failSummary, - ])); - } - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("audit.auditTable") }), - el("div", { class: "muted", text: t("audit.columns") }), - auditColsToggle, - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, auditColumnDefs.filter((c) => auditVisible.has(c.key)).map((c) => el("th", { text: c.label })))]), - auditBody, - ])]), - ])); - resultWrap.appendChild(el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("audit.traceTable") }), - el("div", { class: "muted", text: t("audit.columns") }), - traceColsToggle, - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, traceColumnDefs.filter((c) => traceVisible.has(c.key)).map((c) => el("th", { text: c.label })))]), - traceBody, - ])]), - ])); - const details = el("details", { class: "details" }, [ - el("summary", { text: t("audit.rawJson") }), - pre, - ]); - resultWrap.appendChild(el("div", { class: "card" }, [details])); - }; - const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: async () => { - await runQuery(); - }}); - onlyFailures.addEventListener("change", runQuery); - auditColsToggle.addEventListener("change", runQuery); - traceColsToggle.addEventListener("change", runQuery); - const card = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("audit.title") }), - el("div", { class: "row" }, [input, btn]), - el("div", { class: "muted", text: t("audit.note") }), - ]); - if (initialSessionId) { - input.value = initialSessionId; - await runQuery(); - } - return el("div", {}, [card, resultWrap]); -} - -async function renderMemory() { - const route = getRoute(); - const tenantQ = route.params.get("tenant_id") || ""; - const userQ = route.params.get("user_id") || ""; - const buildQS = (limit = 50) => { - const p = new URLSearchParams(); - p.set("limit", String(limit)); - if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim()); - if (userInput.value.trim()) p.set("user_id", userInput.value.trim()); - return p.toString(); - }; - - const tenantInput = el("input", { class: "input", placeholder: t("memory.tenantId"), value: tenantQ }); - const userInput = el("input", { class: "input", placeholder: t("memory.userId"), value: userQ }); - const status = el("div", { class: "muted", text: "" }); - const statsWrap = el("div", { class: "muted", text: "" }); - const hitsWrap = el("div"); - const itemsWrap = el("div"); - const wikiStatus = el("div", { class: "muted", text: "" }); - const memoryModeSelect = el("select", { class: "input", style: "max-width:320px;" }); - memoryModeSelect.appendChild(el("option", { value: "default", text: t("profile.memoryModeDefault") })); - memoryModeSelect.appendChild(el("option", { value: "store_only", text: t("profile.memoryModeStoreOnly") })); - const memoryCuratorSelect = el("select", { class: "input", style: "max-width:220px;" }); - memoryCuratorSelect.appendChild(el("option", { value: "1", text: t("profile.memoryCuratorEnabled") })); - memoryCuratorSelect.appendChild(el("option", { value: "0", text: t("profile.memoryCuratorDisabled") })); - - const normalizeMemoryMode = (raw) => { - const mm = String(raw || "").trim().toLowerCase(); - return mm === "store_only" ? "store_only" : "default"; - }; - - const enabled = el("input", { type: "checkbox" }); - const backend = el("select", { class: "input" }, [ - el("option", { value: "sqlite", text: "sqlite" }), - ]); - const topk = el("input", { class: "input", type: "number", value: "5", min: "1", max: "20" }); - const writerEnabled = el("input", { type: "checkbox" }); - const ragMode = el("select", { class: "input" }, [ - el("option", { value: "keyword", text: "keyword" }), - el("option", { value: "vector", text: "vector" }), - ]); - const embeddingMode = el("select", { class: "input" }, [ - el("option", { value: "", text: "openai(default/fallback)" }), - el("option", { value: "openai", text: "openai" }), - el("option", { value: "hash", text: "hash/offline" }), - ]); - const episodicTtlDays = el("input", { class: "input", type: "number", value: "90", min: "1", max: "3650" }); - const minConf = el("input", { - class: "input", - type: "number", - value: "0.75", - min: "0", - max: "1", - step: "0.05", - }); - const btnSave = el("button", { class: "btn btn--primary", text: t("memory.save"), onclick: async () => { - const saved = await apiPost("/admin/api/memory/config", { - enabled: enabled.checked, - backend: backend.value, - top_k: Number(topk.value || 5), - writer_enabled: writerEnabled.checked, - write_min_confidence: Number(minConf.value || 0.75), - rag_mode: String(ragMode.value || "keyword"), - rag_embedding_mode: String(embeddingMode.value || ""), - memory_episodic_ttl_days: Number(episodicTtlDays.value || 90), - }); - localStorage.setItem(CHAT_MEMORY_MODE_KEY, normalizeMemoryMode(memoryModeSelect.value)); - await apiPost("/admin/api/chat/settings/specialist-flags", { - flags: { - generalist: true, - memory: String(memoryCuratorSelect.value || "1") !== "0", - }, - }); - status.textContent = JSON.stringify(saved.config || {}); - }}); - const btnReindex = el("button", { class: "btn", text: t("memory.reindex"), onclick: async () => { - const resp = await apiPost("/admin/api/memory/reindex", {}); - status.textContent = `reindexed=${Number(resp.reindexed || 0)}`; - }}); - const btnCleanup = el("button", { class: "btn btn--danger", text: t("memory.cleanup"), onclick: async () => { - const resp = await apiPost("/admin/api/memory/cleanup-low-confidence", { max_confidence: 0.35 }); - status.textContent = `deleted=${Number(resp.deleted || 0)}`; - router(); - }}); - - const loadData = async () => { - const qs = buildQS(50); - const [cfgResp, hitsResp, itemsResp, statsResp] = await Promise.all([ - apiGet("/admin/api/memory/config"), - apiGet("/admin/api/memory/hits?" + qs), - apiGet("/admin/api/memory/items?" + qs), - apiGet("/admin/api/memory/stats?" + qs), - ]); - const cfg = cfgResp.config || {}; - enabled.checked = !!cfg.enabled; - backend.value = String(cfg.backend || "sqlite"); - topk.value = String(cfg.top_k || 5); - writerEnabled.checked = !!cfg.writer_enabled; - minConf.value = String(cfg.write_min_confidence ?? 0.75); - ragMode.value = String(cfg.rag_mode || "keyword"); - embeddingMode.value = String(cfg.rag_embedding_mode || ""); - episodicTtlDays.value = String(cfg.memory_episodic_ttl_days || 90); - memoryModeSelect.value = normalizeMemoryMode(localStorage.getItem(CHAT_MEMORY_MODE_KEY)); - try { - const sf = await apiGet("/admin/api/chat/settings/specialist-flags"); - const flags = sf && sf.flags && typeof sf.flags === "object" ? sf.flags : {}; - memoryCuratorSelect.value = flags.memory === false ? "0" : "1"; - } catch (_) { - memoryCuratorSelect.value = "1"; - } - const hits = Array.isArray(hitsResp.hits) ? hitsResp.hits : []; - const items = Array.isArray(itemsResp.items) ? itemsResp.items : []; - const stats = (statsResp && statsResp.stats) || {}; - const srcText = Array.isArray(stats.top_sources) ? stats.top_sources.map((x) => `${x.source}:${x.count}`).join(", ") : ""; - statsWrap.textContent = `${t("memory.hitCount")}: ${Number(stats.hit_count || 0)} | ${t("memory.itemCount")}: ${Number(stats.item_count || 0)} | ${t("memory.avgScore")}: ${Number(stats.avg_score || 0).toFixed(3)} | ${t("memory.topSources")}: ${srcText || "-"}`; - - const hitBody = el("tbody"); - if (!hits.length) { - hitBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "6" })])); - } else { - hits.forEach((h) => { - hitBody.appendChild(el("tr", {}, [ - tdCell(h.timestamp || "", 24), - tdCell(h.tenant_id || "", 18), - tdCell(h.user_id || "", 18), - tdCell(h.query_text || "", 40), - tdCell(h.memory_id || "", 18), - tdCell(String(h.score || ""), 10), - ])); - }); - } - hitsWrap.innerHTML = ""; - hitsWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("table.timestamp") }), - el("th", { text: "tenant_id" }), - el("th", { text: "user_id" }), - el("th", { text: "query" }), - el("th", { text: "memory_id" }), - el("th", { text: "score" }), - ])]), - hitBody, - ])])); - - const itemBody = el("tbody"); - if (!items.length) { - itemBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "8" })])); - } else { - items.forEach((it) => { - const btnDelete = el("button", { class: "btn btn--danger", text: t("memory.delete"), onclick: async () => { - await apiPost("/admin/api/memory/delete", { memory_id: it.memory_id }); - await loadData(); - }}); - itemBody.appendChild(el("tr", {}, [ - tdCell(it.memory_id || "", 18), - tdCell(it.tenant_id || "", 18), - tdCell(it.user_id || "", 18), - tdCell(it.memory_type || "", 14), - tdCell(String(it.confidence ?? ""), 8), - tdCell(it.content || "", 80), - tdCell(it.updated_at || "", 24), - el("td", {}, [btnDelete]), - ])); - }); - } - itemsWrap.innerHTML = ""; - itemsWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "memory_id" }), - el("th", { text: "tenant_id" }), - el("th", { text: "user_id" }), - el("th", { text: "type" }), - el("th", { text: "confidence" }), - el("th", { text: "content" }), - el("th", { text: t("table.timestamp") }), - el("th", { text: t("memory.delete") }), - ])]), - itemBody, - ])])); - - try { - const pluginsResp = await apiGet("/admin/api/plugins"); - const rows = Array.isArray(pluginsResp.plugins) ? pluginsResp.plugins : []; - const wikiPlugin = rows.find((x) => String((x && x.plugin_name) || "").toLowerCase() === "memory-wiki"); - if (wikiPlugin) { - wikiStatus.textContent = t("memory.wikiPluginFound", { - name: String(wikiPlugin.plugin_name || "memory-wiki"), - version: String(wikiPlugin.plugin_version || "-"), - }); - } else { - wikiStatus.textContent = t("memory.wikiPluginMissing"); - } - } catch (_) { - wikiStatus.textContent = t("memory.wikiPluginMissing"); - } - }; - - const btnApplyFilters = el("button", { class: "btn", text: t("memory.applyFilters"), onclick: async () => { - const p = new URLSearchParams(); - if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim()); - if (userInput.value.trim()) p.set("user_id", userInput.value.trim()); - location.hash = "#/memory" + (p.toString() ? ("?" + p.toString()) : ""); - await loadData(); - }}); - const btnClearFilters = el("button", { class: "btn", text: t("memory.clearFilters"), onclick: async () => { - tenantInput.value = ""; - userInput.value = ""; - location.hash = "#/memory"; - await loadData(); - }}); - await loadData(); - - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("memory.config") }), - el("div", { class: "row" }, [el("label", { text: t("memory.enabled") }), enabled]), - el("div", { class: "row" }, [el("label", { text: t("memory.backend") }), backend]), - el("div", { class: "row" }, [el("label", { text: t("memory.topk") }), topk]), - el("div", { class: "row" }, [el("label", { text: t("memory.writerEnabled") }), writerEnabled]), - el("div", { class: "row" }, [el("label", { text: "RAG mode" }), ragMode]), - el("div", { class: "row" }, [el("label", { text: "Embedding mode" }), embeddingMode]), - el("div", { class: "row" }, [el("label", { text: "Episodic TTL days" }), episodicTtlDays]), - el("div", { class: "row" }, [el("label", { text: t("memory.minConfidence") }), minConf]), - el("div", { class: "row" }, [btnSave, btnReindex, btnCleanup]), - status, - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("memory.wikiCard") }), - el("div", { class: "row" }, [el("label", { text: t("profile.memoryMode") }), memoryModeSelect]), - el("div", { class: "row" }, [el("label", { text: t("profile.memoryCurator") }), memoryCuratorSelect]), - el("div", { class: "row" }, [el("label", { text: t("memory.wikiStatus") }), wikiStatus]), - el("div", { class: "row" }, [ - el("button", { - class: "btn", - text: t("memory.openPlugins"), - onclick: () => { - location.hash = "#/plugins"; - }, - }), - ]), - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("memory.filters") }), - el("div", { class: "row" }, [tenantInput, userInput, btnApplyFilters, btnClearFilters]), - statsWrap, - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("memory.hits") }), - hitsWrap, - ]), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("memory.items") }), - itemsWrap, - ]), - ]); -} - -async function renderApiGrants() { - const status = el("div", { class: "muted", text: "" }); - const profileSel = el("select", { class: "input" }); - const grantListEl = el("div", {}); - const grantUserSel = el("select", { class: "input" }); - const grantMsg = el("div", { class: "muted", text: "" }); - const grantBtn = el("button", { class: "btn btn--primary", text: t("models.grantsGrantBtn") }); - const tenantGrantStatus = el("span", { class: "muted", text: "" }); - - let state = null; - - const VIS_KEYS = { - builtin: "models.vis.builtin", - owned: "models.vis.owned", - grant_user: "models.vis.grantUser", - grant_tenant: "models.vis.grantTenant", - global: "models.vis.global", - other_user: "models.vis.otherUser", - }; - - function visibilityTag(vr) { - const k = VIS_KEYS[String(vr || "")]; - return k ? t(k) : ""; - } - - async function paintGrants() { - grantMsg.textContent = ""; - const pid = String(profileSel.value || "").trim(); - if (!pid) { - grantListEl.innerHTML = ""; - tenantGrantStatus.textContent = ""; - return; - } - try { - const tg = await apiGet("/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid)); - tenantGrantStatus.textContent = tg.granted ? t("models.tenantGrantOn") : t("models.tenantGrantOff"); - const g = await apiGet("/admin/api/models/grants?profile_id=" + encodeURIComponent(pid)); - const rows = Array.isArray(g.grants) ? g.grants : []; - grantListEl.innerHTML = ""; - if (!rows.length) { - grantListEl.appendChild(el("div", { class: "muted", text: t("models.grantsEmpty") })); - return; - } - rows.forEach((r) => { - const disp = String(r.display_name || r.username || r.user_id || "").trim(); - const rev = el("button", { class: "btn", text: t("models.grantsRevoke"), onclick: async () => { - try { - await apiRequest( - "DELETE", - "/admin/api/models/grants?profile_id=" + encodeURIComponent(pid) + "&user_id=" + encodeURIComponent(String(r.user_id || "")), - undefined, - ); - await paintGrants(); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - } }); - grantListEl.appendChild(el("div", { class: "row" }, [ - el("span", { text: disp || r.user_id }), - rev, - ])); - }); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - } - - const btnTenantGrant = el("button", { - class: "btn btn--primary", - text: t("models.grantTenantBtn"), - onclick: async () => { - const pid = String(profileSel.value || "").trim(); - if (!pid) return; - try { - await apiPost("/admin/api/models/grants/tenant", { profile_id: pid }); - await paintGrants(); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - }, - }); - const btnTenantRevoke = el("button", { - class: "btn", - text: t("models.revokeTenantGrant"), - onclick: async () => { - const pid = String(profileSel.value || "").trim(); - if (!pid) return; - try { - await apiRequest( - "DELETE", - "/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid), - undefined, - ); - await paintGrants(); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - }, - }); - const grantTenantRow = el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ - btnTenantGrant, - btnTenantRevoke, - tenantGrantStatus, - ]); - - const membersBody = el("div", {}); - let membersLoaded = false; - const membersDetails = el("details", { class: "details" }); - membersDetails.appendChild(el("summary", { text: t("models.membersTitle") })); - membersDetails.appendChild(membersBody); - const membersWrap = el("div", { class: "card" }, [membersDetails]); - - membersDetails.addEventListener("toggle", async () => { - if (!membersDetails.open || membersLoaded) return; - try { - const r = await apiGet("/admin/api/models/members"); - membersLoaded = true; - membersBody.innerHTML = ""; - const list = Array.isArray(r.members) ? r.members : []; - if (!list.length) { - membersBody.appendChild(el("div", { class: "muted", text: "—" })); - return; - } - list.forEach((m) => { - const names = (m.profiles || []).map((p) => { - const nm = String(p.name || p.id || "").trim(); - const vr = p.visibility_reason ? ` (${visibilityTag(p.visibility_reason)})` : ""; - return nm + vr; - }); - membersBody.appendChild(el("div", { class: "card", style: "margin-bottom:10px" }, [ - el("div", { class: "card__title", text: formatUserLabel(m) }), - el("div", { class: "muted", text: names.filter(Boolean).length ? names.join(";") : "—" }), - ])); - }); - } catch (e) { - membersLoaded = false; - membersBody.appendChild(el("div", { class: "muted", text: String(e.message || e) })); - } - }); - - async function ensureGrantUsers() { - grantUserSel.innerHTML = ""; - const tid = String((authSession && authSession.tenant_id) || "").trim(); - if (!tid) return; - try { - const u = await apiGet("/admin/api/users?tenant_id=" + encodeURIComponent(tid) + "&limit=500"); - grantUserSel.appendChild(el("option", { value: "", text: "—" })); - (u.users || []).forEach((x) => { - const uid = String(x.id || "").trim(); - if (!uid) return; - grantUserSel.appendChild(el("option", { value: uid, text: formatUserLabel(x) || uid })); - }); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - } - - function fillProfileSelect() { - profileSel.innerHTML = ""; - const uid = String((authSession && authSession.user_id) || "").trim(); - const all = (state && Array.isArray(state.profiles)) ? state.profiles : []; - const list = all.filter((p) => profileShareableForGrant(p, uid)); - if (!list.length) { - profileSel.appendChild(el("option", { value: "", text: t("apiGrants.noShareable") })); - profileSel.disabled = true; - return; - } - profileSel.disabled = false; - list.forEach((p) => { - const own = String(p.owner_user_id || "").trim(); - const tag = own ? t("models.vis.owned") : t("models.vis.global"); - const name = String(p.name || p.id || "").trim(); - profileSel.appendChild(el("option", { value: String(p.id), text: `${name} (${tag})` })); - }); - } - - profileSel.addEventListener("change", async () => { - await paintGrants(); - }); - - grantBtn.addEventListener("click", async () => { - const pid = String(profileSel.value || "").trim(); - const uid = String(grantUserSel.value || "").trim(); - if (!pid || !uid) return; - try { - await apiPost("/admin/api/models/grants", { profile_id: pid, user_id: uid }); - await paintGrants(); - } catch (e) { - grantMsg.textContent = String(e.message || e); - } - }); - - async function refresh() { - try { - const data = await apiGet("/admin/api/models"); - state = data; - status.textContent = ""; - if (!data || data.can_manage_llm_grants !== true) return; - fillProfileSelect(); - await ensureGrantUsers(); - await paintGrants(); - } catch (e) { - state = null; - status.textContent = String(e.message || e); - } - } - - await refresh(); - - const forbiddenCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.apiGrants") }), - el("div", { class: "muted", text: t("apiGrants.forbidden") }), - el("div", { class: "row" }, [ - el("a", { href: "#/models", text: t("apiGrants.openModels") }), - ]), - ]); - - if (!state || state.can_manage_llm_grants !== true) { - const bits = [forbiddenCard]; - if (status.textContent) bits.push(el("div", { class: "card" }, [el("pre", { class: "pre", text: status.textContent })])); - return el("div", {}, bits); - } - - const introCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.apiGrants") }), - el("div", { class: "muted", text: t("apiGrants.intro") }), - status, - ]); - const pickCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("apiGrants.pickApi") }), - el("div", { class: "row" }, [profileSel]), - ]); - const teamCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("apiGrants.teamSection") }), - el("div", { class: "muted", text: t("apiGrants.teamHint") }), - grantTenantRow, - ]); - const userCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("apiGrants.userSection") }), - el("div", { class: "muted", text: t("apiGrants.userHint") }), - el("div", { class: "row" }, [grantUserSel, grantBtn]), - grantListEl, - grantMsg, - ]); - - return el("div", {}, [introCard, pickCard, teamCard, userCard, membersWrap]); -} - -async function renderModels() { - const canPickActive = hasPermission("admin:read"); - const canConfigureBindings = canPickActive; - const canConfigureChatUi = hasPermission("admin:tenant:write"); - const status = el("div", { class: "muted", text: "" }); - const isAdminPool = String((authSession && authSession.username) || "").trim().toLowerCase() === "administrator"; - // 与后端 _require_models_mutate 对齐:administrator 写全局池需 tenant:write;普通用户写自己的配置只需 admin:read - const canMutateProfiles = isAdminPool ? hasPermission("admin:tenant:write") : canPickActive; - const readonlyHint = el("div", { - class: "muted", - text: isAdminPool && !canMutateProfiles ? t("models.readonly") : "", - }); - const activeSelect = el("select", { class: "input", disabled: !canPickActive }); - const chatModelSelectorVisibleCb = el("input", { type: "checkbox", disabled: !canConfigureChatUi }); - const bindingWrap = el("div", {}); - const opsAiSpecialistSelect = el("select", { class: "input", disabled: !canConfigureBindings }); - const opsAiProfileSelect = el("select", { class: "input", disabled: !canConfigureBindings }); - const modelsGrantsLinkRow = el("div", { class: "muted u-hidden" }); - const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") }); - const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [ - el("option", { value: "openai", text: t("models.mode.openai") }), - el("option", { value: "anthropic", text: t("models.mode.anthropic") }), - el("option", { value: "google", text: t("models.mode.google") }), - el("option", { value: "ollama", text: t("models.mode.ollama") }), - el("option", { value: "rule", text: t("models.mode.rule") }), - ]); - const profName = el("input", { class: "input" }); - const modeSel = el("select", { class: "input" }); - const modelInp = el("input", { class: "input" }); - const baseInp = el("input", { class: "input", placeholder: t("models.baseUrlPlaceholder") }); - const thinkingModeCb = el("input", { type: "checkbox" }); - const reasoningEffortSel = el("select", { class: "input", style: "max-width:180px" }, [ - el("option", { value: "", text: t("models.reasoningEffortDefault") }), - el("option", { value: "low", text: t("models.reasoningEffortLow") }), - el("option", { value: "medium", text: t("models.reasoningEffortMedium") }), - el("option", { value: "high", text: t("models.reasoningEffortHigh") }), - ]); - const keyInp = el("input", { class: "input", type: "password", autocomplete: "off" }); - const rememberCb = el("input", { type: "checkbox" }); - const readonlyProfileHint = el("div", { class: "muted", text: "" }); - const builtinCap = el("div", { class: "muted", text: "" }); - const warnKey = el("div", { class: "muted", text: "" }); - const openaiHint = el("div", { class: "muted", text: "" }); - const ollamaHint = el("div", { class: "muted", text: "" }); - const evalMetrics = el("div", { class: "row" }); - const evalTableWrap = el("div"); - const evalDetails = el("details", { class: "details" }); - const evalSummary = el("summary", { text: t("models.evalToggle") }); - const evalInner = el("div", {}); - const expertsStatus = el("div", { class: "muted", text: "" }); - const expertsSelect = el("select", { class: "input" }); - const expertNewId = el("input", { class: "input", placeholder: "new expert id, e.g. qa" }); - const expertNewNameEn = el("input", { class: "input", placeholder: "English name (required)" }); - const expertNewNameZh = el("input", { class: "input", placeholder: "中文名(可选)" }); - const expertRoleSel = el("select", { class: "input" }, [ - el("option", { value: "expert", text: "expert" }), - el("option", { value: "system", text: "system" }), - ]); - const expertNameEn = el("input", { class: "input", placeholder: "English name (required)" }); - const expertNameZh = el("input", { class: "input", placeholder: "中文名(可选)" }); - const expertSoul = el("textarea", { class: "input", rows: "5", placeholder: "SOUL.md (optional)" }); - const expertRoleSystem = el("textarea", { class: "input", rows: "5", placeholder: "ROLE_SYSTEM.md (optional)" }); - - async function downloadEvalExport(format) { - const fmt = format === "json" ? "json" : "csv"; - const url = resolveAdminApiUrl(`/admin/api/models/eval/export?format=${encodeURIComponent(fmt)}`); - const token = getStoredAuthToken(); - const res = await fetch(url, { - headers: { - accept: fmt === "json" ? "application/json" : "text/csv", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - }); - if (!res.ok) throw new Error(`export ${res.status}`); - const blob = await res.blob(); - const a = document.createElement("a"); - const name = fmt === "json" ? "agent_eval_logs.json" : "agent_eval_logs.csv"; - a.href = URL.createObjectURL(blob); - a.download = name; - a.click(); - URL.revokeObjectURL(a.href); - } - - const btnEvalCsv = el("button", { - class: "btn", - text: t("models.evalDownloadCsv"), - onclick: async () => { - try { - await downloadEvalExport("csv"); - } catch (e) { - status.textContent = String(e.message || e); - } - }, - }); - const btnEvalJson = el("button", { - class: "btn", - text: t("models.evalDownloadJson"), - onclick: async () => { - try { - await downloadEvalExport("json"); - } catch (e) { - status.textContent = String(e.message || e); - } - }, - }); - const evalActionRow = el("div", { class: "row" }, [ - el("span", { class: "muted", text: t("models.evalLogsPreview") }), - btnEvalCsv, - btnEvalJson, - ]); - evalInner.appendChild(evalMetrics); - evalInner.appendChild(evalActionRow); - evalInner.appendChild(evalTableWrap); - evalDetails.appendChild(evalSummary); - evalDetails.appendChild(evalInner); - - let state = null; - let evalPack = null; - let secretsStatus = null; - let expertsState = { items: [] }; - - const secretsStatusMsg = el("div", { class: "muted", text: "" }); - const secretsMigrateBtn = el("button", { class: "btn", text: t("secrets.migrateBtn") }); - const secretsCard = el("div", { class: "card u-hidden" }, [ - el("div", { class: "card__title", text: t("secrets.migrateTitle") }), - el("div", { class: "muted", text: t("secrets.migrateHint") }), - el("div", { class: "row" }, [secretsMigrateBtn]), - secretsStatusMsg, - ]); - - function fmtSecretsDone(s, p) { - return t("secrets.migrateDone").replace("{s}", String(s)).replace("{p}", String(p)); - } - - function paintSecretsCard() { - secretsStatusMsg.textContent = ""; - if (!secretsStatus || secretsStatus.ok !== true) { - secretsCard.style.display = "none"; - return; - } - const n1 = intOr0(secretsStatus.legacy_b64_app_settings); - const n2 = intOr0(secretsStatus.legacy_b64_llm_profiles); - const total = n1 + n2; - const canMigrate = hasPermission("admin:tenant:write"); - if (total <= 0) { - secretsCard.style.display = "none"; - return; - } - secretsCard.style.display = "block"; - if (!canMigrate) { - secretsMigrateBtn.disabled = true; - secretsStatusMsg.textContent = t("secrets.migrateForbidden"); - return; - } - if (!secretsStatus.has_master_key) { - secretsMigrateBtn.disabled = true; - secretsStatusMsg.textContent = t("secrets.migrateNeedKey"); - return; - } - secretsMigrateBtn.disabled = false; - secretsStatusMsg.textContent = `legacy: settings=${n1}, profiles=${n2}`; - } - - const VIS_KEYS = { - builtin: "models.vis.builtin", - owned: "models.vis.owned", - grant_user: "models.vis.grantUser", - grant_tenant: "models.vis.grantTenant", - global: "models.vis.global", - other_user: "models.vis.otherUser", - }; - - function visibilityTag(vr) { - const k = VIS_KEYS[String(vr || "")]; - return k ? t(k) : ""; - } - - function labelFor(pid) { - const profiles = (state && state.profiles) || []; - const p = profiles.find((x) => String(x.id) === String(pid)); - const base = (p && String(p.name || "").trim()) || pid; - const vr = p && p.visibility_reason; - if (!vr) return base; - const tag = visibilityTag(vr); - return tag ? `${base} (${tag})` : base; - } - - function fillModeSelect(sel, modes, current) { - sel.innerHTML = ""; - modes.forEach((m) => { - const label = - m === "openai" - ? t("models.mode.openai") - : m === "openai_responses" - ? t("models.mode.openai_responses") - : m === "anthropic" - ? t("models.mode.anthropic") - : m === "google" - ? t("models.mode.google") - : m === "ollama" - ? t("models.mode.ollama") - : t("models.mode.rule"); - sel.appendChild(el("option", { - value: m, - text: label, - })); - }); - if (modes.includes(current)) sel.value = current; - else sel.value = modes[0]; - } - - async function refresh() { - try { - const data = await apiGet("/admin/api/models"); - state = data; - status.textContent = ""; - paint(); - try { - secretsStatus = await apiGet("/admin/api/secrets/status"); - } catch (_) { - secretsStatus = null; - } - paintSecretsCard(); - try { - evalPack = await apiGet("/admin/api/models/eval?limit_logs=100"); - paintEval(); - } catch (ee) { - evalPack = { ok: false, summary: {}, logs: [] }; - paintEval(); - status.textContent = [status.textContent, `${t("models.sectionEval")}: ${String(ee.message || ee)}`].filter(Boolean).join(" | "); - } - try { - expertsState = await apiGet("/admin/api/experts"); - } catch (_) { - expertsState = { ok: false, items: [] }; - } - paintExperts(); - } catch (e) { - state = null; - evalPack = null; - secretsStatus = null; - expertsState = { ok: false, items: [] }; - status.textContent = String(e.message || e); - } - } - - function currentExpertItem() { - const items = Array.isArray(expertsState && expertsState.items) ? expertsState.items : []; - const id = String(expertsSelect.value || ""); - return items.find((x) => String(x.id || "") === id) || null; - } - - function fillExpertFields(item) { - const files = (item && item.files && typeof item.files === "object") ? item.files : {}; - expertNameEn.value = String((item && item.display_name_en) || ""); - expertNameZh.value = String((item && item.display_name_zh) || ""); - expertRoleSel.value = String((item && item.role) || "expert"); - expertSoul.value = String(files["SOUL.md"] || ""); - expertRoleSystem.value = String(files["ROLE_SYSTEM.md"] || ""); - const isSystem = !!(item && (item.builtin || String(item.role || "") === "system")); - expertRoleSel.disabled = isSystem; - btnExpertDelete.disabled = isSystem; - } - - function paintExperts() { - const items = Array.isArray(expertsState && expertsState.items) ? expertsState.items : []; - const prev = String(expertsSelect.value || ""); - expertsSelect.innerHTML = ""; - items.forEach((x) => { - const id = String(x.id || ""); - if (!id) return; - const isSystem = !!(x && (x.builtin || String(x.role || "") === "system")); - const tail = isSystem ? " (system)" : ""; - const name = String(x.display_name_en || "").trim() || id; - expertsSelect.appendChild(el("option", { value: id, text: `${name} [${id}]${tail}` })); - }); - if (items.some((x) => String(x.id || "") === prev)) expertsSelect.value = prev; - else if (items.length) expertsSelect.value = String(items[0].id || ""); - fillExpertFields(currentExpertItem()); - } - - secretsMigrateBtn.addEventListener("click", async () => { - if (!hasPermission("admin:tenant:write")) { - secretsStatusMsg.textContent = t("secrets.migrateForbidden"); - return; - } - secretsMigrateBtn.disabled = true; - secretsStatusMsg.textContent = ""; - try { - const res = await apiPost("/admin/api/secrets/migrate", {}); - if (res && res.ok) { - const s = intOr0(res.migrated_app_settings); - const p = intOr0(res.migrated_llm_profiles); - secretsStatusMsg.textContent = (s + p) > 0 ? fmtSecretsDone(s, p) : t("secrets.migrateNoop"); - } else { - secretsStatusMsg.textContent = String((res && (res.error || res.detail)) || "migrate_failed"); - } - try { - secretsStatus = await apiGet("/admin/api/secrets/status"); - } catch (_) { - secretsStatus = null; - } - paintSecretsCard(); - } catch (e) { - secretsStatusMsg.textContent = String(e.message || e); - paintSecretsCard(); - } finally { - secretsMigrateBtn.disabled = false; - } - }); - - function paintBindings() { - bindingWrap.innerHTML = ""; - if (!state || !state.bindings) return; - const profiles = state.profiles || []; - const profileIds = profiles.map((p) => String(p.id)); - const roleIds = Array.isArray(state.role_ids) ? state.role_ids : []; - roleIds.forEach((rid) => { - const sel = el("select", { class: "input", disabled: !canConfigureBindings }); - sel.appendChild(el("option", { value: "", text: t("models.useGlobal") })); - profiles.forEach((p) => { - sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); - }); - const v = String(state.bindings[rid] || "").trim(); - sel.value = profileIds.includes(v) ? v : ""; - sel.addEventListener("change", async () => { - if (!canConfigureBindings) return; - const next = Object.assign({}, state.bindings); - next[rid] = String(sel.value || ""); - try { - await apiPost("/admin/api/models/bindings", { bindings: next }); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }); - bindingWrap.appendChild(el("div", { class: "row" }, [ - el("label", { text: t("models.role." + rid) + " " }), - sel, - ])); - }); - } - - function paintOpsAiBindings() { - if (!state || !state.ops_ai_bindings) return; - const profiles = state.profiles || []; - const profileIds = profiles.map((p) => String(p.id)); - const roleIds = (Array.isArray(state.role_ids) ? state.role_ids : []).filter((rid) => String(rid || "") !== "manager"); - - const curSpecialist = String(opsAiSpecialistSelect.value || "").trim(); - opsAiSpecialistSelect.innerHTML = ""; - roleIds.forEach((rid) => { - opsAiSpecialistSelect.appendChild(el("option", { value: String(rid), text: t("models.role." + rid) })); - }); - if (roleIds.includes(curSpecialist)) opsAiSpecialistSelect.value = curSpecialist; - else if (roleIds.length) opsAiSpecialistSelect.value = roleIds[0]; - - const rid = String(opsAiSpecialistSelect.value || "").trim(); - opsAiProfileSelect.innerHTML = ""; - profiles.forEach((p) => { - opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); - }); - const v = String((state.ops_ai_bindings && state.ops_ai_bindings[rid]) || "").trim(); - opsAiProfileSelect.value = profileIds.includes(v) ? v : ""; - } - - function paintEval() { - evalMetrics.innerHTML = ""; - evalTableWrap.innerHTML = ""; - const summary = (evalPack && evalPack.summary) || {}; - const total = intOr0(summary.total); - const sr = Number(summary.success_rate || 0); - const p95 = intOr0(summary.p95_latency_ms); - evalMetrics.appendChild(el("span", { text: `${t("models.evalTotal")}: ${total} ` })); - evalMetrics.appendChild(el("span", { text: `${t("models.evalSuccess")}: ${(sr * 100).toFixed(1)}% ` })); - evalMetrics.appendChild(el("span", { text: `${t("models.evalP95")}: ${p95} ms` })); - const logs = Array.isArray(evalPack && evalPack.logs) ? evalPack.logs : []; - if (!logs.length) { - evalTableWrap.appendChild(el("div", { class: "muted", text: t("models.noEvalLogs") })); - return; - } - const tbody = el("tbody"); - logs.forEach((r) => { - tbody.appendChild(el("tr", {}, [ - tdCell(r.timestamp || "", 24), - tdCell(r.session_id || "", 20), - tdCell(r.specialist || "", 12), - tdCell(r.task_kind || "", 14), - tdCell(r.success ? "1" : "0", 4), - tdCell(String(r.latency_ms ?? ""), 8), - tdCell(String(r.notes || ""), 60), - ])); - }); - evalTableWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("table.timestamp") }), - el("th", { text: "session_id" }), - el("th", { text: t("table.specialist") }), - el("th", { text: "task_kind" }), - el("th", { text: t("table.status") }), - el("th", { text: t("table.durationMs") }), - el("th", { text: "notes" }), - ])]), - tbody, - ])])); - } - - function intOr0(x) { - const n = parseInt(String(x), 10); - return Number.isFinite(n) ? n : 0; - } - - function paint() { - if (!state) return; - if (state.ok !== true) { - status.textContent = status.textContent || t("models.loadFailed"); - return; - } - const profiles = Array.isArray(state.profiles) ? state.profiles : []; - const builtin = String(state.builtin_ollama_profile_id || ""); - activeSelect.innerHTML = ""; - profiles.forEach((p) => { - activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); - }); - const aid = String(state.active_llm_profile_id || ""); - if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid; - activeSelect.disabled = !canPickActive; - chatModelSelectorVisibleCb.disabled = !canConfigureChatUi; - chatModelSelectorVisibleCb.checked = state.chat_model_selector_visible !== false; - opsAiSpecialistSelect.disabled = !canConfigureBindings; - opsAiProfileSelect.disabled = !canConfigureBindings; - - paintBindings(); - paintOpsAiBindings(); - - const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {}; - const pid = String(selProf.id || ""); - const rowMutable = selProf.mutable !== false; - const canEditFields = canMutateProfiles && rowMutable; - readonlyProfileHint.textContent = rowMutable ? "" : t("models.profileReadonlyHint"); - const isBuiltin = pid === builtin; - profName.value = String(selProf.name || ""); - const rawMode = String(selProf.mode || "openai").toLowerCase(); - const baseAllowedModes = ["openai", "anthropic", "google", "ollama", "rule"]; - const allowedModes = rawMode === "openai_responses" ? ["openai_responses", ...baseAllowedModes] : baseAllowedModes; - const modeVal = allowedModes.includes(rawMode) ? rawMode : "openai"; - if (isBuiltin) { - fillModeSelect(modeSel, ["ollama"], "ollama"); - modeSel.disabled = true; - builtinCap.textContent = t("models.builtinLocked"); - } else { - modeSel.disabled = !canEditFields; - builtinCap.textContent = ""; - fillModeSelect(modeSel, allowedModes, modeVal); - } - modelInp.value = String(selProf.model || ""); - baseInp.value = String(selProf.base_url || ""); - thinkingModeCb.checked = !!selProf.thinking_mode_enabled; - reasoningEffortSel.value = String(selProf.reasoning_effort || ""); - keyInp.value = String(state.profile_secret || ""); - rememberCb.checked = !!selProf.has_key; - - profName.disabled = !canEditFields; - modelInp.disabled = !canEditFields; - baseInp.disabled = !canEditFields; - thinkingModeCb.disabled = !canEditFields; - reasoningEffortSel.disabled = !canEditFields; - keyInp.disabled = !canEditFields; - rememberCb.disabled = !canEditFields; - - const m = isBuiltin ? "ollama" : modeVal; - warnKey.textContent = ""; - openaiHint.textContent = ""; - ollamaHint.textContent = ""; - if (m === "openai" || m === "openai_responses") { - ollamaHint.textContent = ""; - if (state.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint"); - if (selProf.has_key && !String(keyInp.value || "").trim()) { - warnKey.textContent = t("models.warnKeyInDb"); - } - } else if (m === "google") { - openaiHint.textContent = ""; - ollamaHint.textContent = ""; - } else if (m === "ollama") { - ollamaHint.textContent = t("models.ollamaHint"); - } - - btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin; - - modelsGrantsLinkRow.innerHTML = ""; - if (state.can_manage_llm_grants) { - modelsGrantsLinkRow.style.display = "block"; - modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` })); - modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") })); - } else { - modelsGrantsLinkRow.style.display = "none"; - } - - paintEval(); - } - - activeSelect.addEventListener("change", async () => { - if (!canPickActive) return; - try { - await apiPost("/admin/api/models/active", { profile_id: activeSelect.value }); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }); - chatModelSelectorVisibleCb.addEventListener("change", async () => { - if (!canConfigureChatUi) return; - try { - await apiPost("/admin/api/models/chat-ui", { - chat_model_selector_visible: !!chatModelSelectorVisibleCb.checked, - }); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }); - opsAiSpecialistSelect.addEventListener("change", async () => { - paintOpsAiBindings(); - }); - opsAiProfileSelect.addEventListener("change", async () => { - if (!canConfigureBindings) return; - try { - const rid = String(opsAiSpecialistSelect.value || "").trim(); - const next = Object.assign({}, (state && state.ops_ai_bindings) || {}); - next[rid] = String(opsAiProfileSelect.value || ""); - await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next }); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }); - - const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => { - if (!canMutateProfiles) return; - try { - await apiPost("/admin/api/models/profiles", { - name: newName.value.trim() || t("models.newProfileDefault"), - mode: newMode.value, - }); - newName.value = ""; - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }}); - - const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => { - if (!canMutateProfiles) return; - const sel = (state.profiles || []).find((p) => String(p.id) === String(state.active_llm_profile_id || "")); - if (sel && sel.mutable === false) return; - const pid = String(state.active_llm_profile_id || ""); - const builtin = String(state.builtin_ollama_profile_id || ""); - try { - let modeSave = modeSel.value; - if (pid === builtin) modeSave = "ollama"; - await apiRequest("PATCH", "/admin/api/models/profiles/" + encodeURIComponent(pid), { - name: profName.value.trim() || t("models.newProfileDefault"), - mode: modeSave, - model: modelInp.value.trim(), - base_url: baseInp.value.trim(), - thinking_mode_enabled: !!thinkingModeCb.checked, - reasoning_effort: String(reasoningEffortSel.value || ""), - }); - await apiRequest("POST", "/admin/api/models/profiles/" + encodeURIComponent(pid) + "/secret", { - remember: rememberCb.checked, - secret: keyInp.value, - }); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }}); - - const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => { - if (!canMutateProfiles) return; - const pid = String(state.active_llm_profile_id || ""); - const builtin = String(state.builtin_ollama_profile_id || ""); - if (pid === builtin) { - status.textContent = t("models.cannotDeleteBuiltin"); - return; - } - if (!globalThis.confirm(t("models.deleteConfirm"))) return; - try { - await apiRequest("DELETE", "/admin/api/models/profiles/" + encodeURIComponent(pid), {}); - await refresh(); - } catch (e) { - status.textContent = String(e.message || e); - } - }}); - - const btnExpertCreate = el("button", { - class: "btn btn--primary", - text: "Create expert", - onclick: async () => { - if (!hasPermission("admin:tenant:write")) return; - const eid = String(expertNewId.value || "").trim(); - if (!eid) { - expertsStatus.textContent = "expert id required"; - return; - } - if (!String(expertNewNameEn.value || "").trim()) { - expertsStatus.textContent = "English name is required"; - return; - } - if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) { - expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required"; - return; - } - try { - const res = await apiPost("/admin/api/experts", { - id: eid, - display_name_en: String(expertNewNameEn.value || "").trim(), - display_name_zh: String(expertNewNameZh.value || "").trim(), - role: "expert", - files: { - "SOUL.md": expertSoul.value, - "ROLE_SYSTEM.md": expertRoleSystem.value, - }, - }); - if (!res || res.ok !== true) throw new Error(String((res && res.error) || "create_failed")); - expertNewId.value = ""; - expertNewNameEn.value = ""; - expertNewNameZh.value = ""; - expertsStatus.textContent = "expert created"; - markPrewarmReminder("expert_created"); - await refresh(); - expertsSelect.value = String(res.created || ""); - fillExpertFields(currentExpertItem()); - } catch (e) { - expertsStatus.textContent = String((e && e.message) || e); - } - }, - }); - - const btnExpertSave = el("button", { - class: "btn btn--primary", - text: "Save expert files", - onclick: async () => { - if (!hasPermission("admin:tenant:write")) return; - const item = currentExpertItem(); - if (!item) return; - if (!String(expertNameEn.value || "").trim()) { - expertsStatus.textContent = "English name is required"; - return; - } - if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) { - expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required"; - return; - } - try { - const eid = String(item.id || ""); - const res = await apiRequest("PATCH", "/admin/api/experts/" + encodeURIComponent(eid), { - display_name_en: String(expertNameEn.value || "").trim(), - display_name_zh: String(expertNameZh.value || "").trim(), - role: String(expertRoleSel.value || "expert"), - files: { - "SOUL.md": expertSoul.value, - "ROLE_SYSTEM.md": expertRoleSystem.value, - }, - }); - if (!res || res.ok !== true) throw new Error(String((res && res.error) || "update_failed")); - expertsStatus.textContent = "expert saved"; - markPrewarmReminder("expert_updated"); - await refresh(); - expertsSelect.value = eid; - fillExpertFields(currentExpertItem()); - } catch (e) { - expertsStatus.textContent = String((e && e.message) || e); - } - }, - }); - - const btnExpertDelete = el("button", { - class: "btn btn--danger", - text: "Delete expert", - onclick: async () => { - if (!hasPermission("admin:tenant:write")) return; - const item = currentExpertItem(); - if (!item) return; - if (item.builtin || String(item.role || "") === "system") { - expertsStatus.textContent = "system expert cannot be deleted"; - return; - } - const eid = String(item.id || ""); - if (!globalThis.confirm(`Delete expert ${eid}?`)) return; - try { - const res = await apiRequest("DELETE", "/admin/api/experts/" + encodeURIComponent(eid), {}); - if (!res || res.ok !== true) throw new Error(String((res && res.error) || "delete_failed")); - expertsStatus.textContent = "expert deleted"; - markPrewarmReminder("expert_deleted"); - await refresh(); - } catch (e) { - expertsStatus.textContent = String((e && e.message) || e); - } - }, - }); - - expertsSelect.addEventListener("change", () => { - fillExpertFields(currentExpertItem()); - }); - - await refresh(); - - if (!state) { - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.models") }), - el("div", { class: "muted", text: t("models.loadFailed") }), - el("pre", { class: "pre", text: status.textContent || "" }), - ]), - ]); - } - - if (state.ok === true && Array.isArray(state.profiles) && state.profiles.length === 0) { - const noProfBody = [ - el("div", { class: "card__title", text: t("title.models") }), - el("div", { class: "muted", text: t("models.noProfiles") }), - ]; - if (state.db_path) { - noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` })); - } - noProfBody.push(status); - return el("div", {}, [el("div", { class: "card" }, noProfBody)]); - } - - if (state.ok !== true) { - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.models") }), - el("div", { class: "muted", text: t("models.loadFailed") }), - el("pre", { class: "pre", text: JSON.stringify(state, null, 2) }), - status, - ]), - ]); - } - - const topBits = [readonlyHint, status, modelsGrantsLinkRow]; - if (state.db_path) { - topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` })); - } - // Show secret migration helper when legacy secrets exist. - topBits.push(secretsCard); - - return renderPageShell({ - title: t("title.models"), - subtitle: "模型切换、绑定与评估配置", - sections: [ - { id: "models-overview", label: "概览" }, - { id: "models-bindings", label: "绑定" }, - { id: "models-ops-ai", label: "内部API" }, - { id: "models-api", label: "API配置" }, - { id: "models-experts", label: "Experts" }, - { id: "models-eval", label: "评估" }, - ], - }, [ - ...topBits, - el("div", { class: "page-grid page-grid--two" }, [ - renderSectionCard(t("models.sectionActive"), "", [ - el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]), - el("div", { class: "row" }, [el("label", { text: t("models.chatModelSelector") }), chatModelSelectorVisibleCb]), - el("div", { class: "muted", text: t("models.chatModelSelectorHint") }), - ], { id: "models-overview" }), - renderSectionCard(t("models.sectionNew"), "", [ - el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), newName]), - el("div", { class: "row" }, [el("label", { text: t("models.mode") }), newMode]), - el("div", { class: "row" }, [btnCreate]), - ]), - ]), - renderSectionCard(t("models.sectionBindings"), t("models.agentBindingHelp"), [ - el("div", { class: "muted", text: t("models.bindingsExtraHint") }), - el("div", { class: "muted", text: t("models.bindingsScopeHint") }), - bindingWrap, - ], { id: "models-bindings" }), - renderSectionCard("内部 API模型配置", "仅用于 v1 / 专家模式:选择专家并绑定 API 配置。", [ - el("div", { class: "row" }, [el("label", { text: "专家" }), opsAiSpecialistSelect]), - el("div", { class: "row" }, [el("label", { text: "API配置" }), opsAiProfileSelect]), - ], { id: "models-ops-ai" }), - renderSectionCard(t("models.sectionApi"), "", [ - builtinCap, - readonlyProfileHint, - el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), profName]), - el("div", { class: "row" }, [el("label", { text: t("models.mode") }), modeSel]), - el("div", { class: "row" }, [el("label", { text: t("models.model") }), modelInp]), - el("div", { class: "row" }, [el("label", { text: t("models.baseUrl") }), baseInp]), - el("div", { class: "row" }, [el("label", { text: t("models.thinkMode") }), thinkingModeCb]), - el("div", { class: "muted", text: t("models.thinkModeHint") }), - el("div", { class: "row" }, [el("label", { text: t("models.reasoningEffort") }), reasoningEffortSel]), - warnKey, - openaiHint, - ollamaHint, - el("div", { class: "row" }, [el("label", { text: t("models.apiKey") }), keyInp]), - el("div", { class: "row" }, [el("label", { text: t("models.rememberKey") }), rememberCb]), - el("div", { class: "row" }, [btnSave]), - el("div", { class: "row" }, [btnDelete]), - ], { id: "models-api" }), - renderSectionCard("Experts", "Runtime registry and workspace prompt files are split into two sections below.", [ - el("div", { class: "card u-pad-block" }, [ - el("div", { class: "card__title", text: "Runtime Expert Registry" }), - el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]), - el("div", { class: "row" }, [btnExpertDelete]), - el("div", { class: "row" }, [el("label", { text: "Create ID" }), expertNewId]), - el("div", { class: "row" }, [el("label", { text: "Create Name(en)" }), expertNewNameEn]), - el("div", { class: "row" }, [el("label", { text: "Create Name(zh)" }), expertNewNameZh]), - el("div", { class: "row" }, [btnExpertCreate]), - el("div", { class: "row" }, [el("label", { text: "Name(en)" }), expertNameEn]), - el("div", { class: "row" }, [el("label", { text: "Name(zh)" }), expertNameZh]), - el("div", { class: "row" }, [el("label", { text: "Role" }), expertRoleSel]), - ]), - el("div", { class: "card u-pad-block" }, [ - el("div", { class: "card__title", text: "Workspace Prompt Files" }), - el("div", { class: "muted", text: "SOUL.md or ROLE_SYSTEM.md is required." }), - el("div", { class: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]), - el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]), - el("div", { class: "row" }, [btnExpertSave]), - ]), - expertsStatus, - ], { id: "models-experts" }), - el("div", { class: "card section-card u-hidden", id: "models-eval" }, [evalDetails]), - ]); -} - -const PLUGINS_PAGE_SIZE = 15; - -function pluginsFold(summaryText, innerNodes) { - const det = el("details", { class: "details plugins-fold" }); - det.appendChild(el("summary", { text: summaryText })); - const inner = el("div", { class: "plugins-fold__inner" }); - innerNodes.forEach((n) => inner.appendChild(n)); - det.appendChild(inner); - return det; -} - -/** totalHolder.value = row count; pageRef.value = 1-based page */ -function pluginsPagerBar(totalHolder, pageRef, onRepaint) { - const wrap = el("div", { - class: "row plugins-pager", - style: "gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;", - }); - const lab = el("span", { class: "muted", text: "" }); - const maxP = () => Math.max(1, Math.ceil(Math.max(0, totalHolder.value) / PLUGINS_PAGE_SIZE)); - const prevBtn = el("button", { class: "btn btn--small", text: "上一页" }); - const nextBtn = el("button", { class: "btn btn--small", text: "下一页" }); - const sync = () => { - const total = totalHolder.value; - let cur = pageRef.value; - const mp = maxP(); - if (cur > mp) { - cur = mp; - pageRef.value = cur; - } - if (cur < 1) { - cur = 1; - pageRef.value = 1; - } - const start = total === 0 ? 0 : (cur - 1) * PLUGINS_PAGE_SIZE + 1; - const end = Math.min(cur * PLUGINS_PAGE_SIZE, total); - lab.textContent = total ? `第 ${start}–${end} / 共 ${total} 条` : "无数据"; - prevBtn.disabled = cur <= 1 || total === 0; - nextBtn.disabled = cur >= mp || total === 0; - }; - prevBtn.addEventListener("click", () => { - if (pageRef.value > 1) { - pageRef.value--; - onRepaint(); - sync(); - } - }); - nextBtn.addEventListener("click", () => { - if (pageRef.value < maxP()) { - pageRef.value++; - onRepaint(); - sync(); - } - }); - wrap.appendChild(prevBtn); - wrap.appendChild(lab); - wrap.appendChild(nextBtn); - sync(); - return { wrap, sync }; -} - -async function renderPlugins() { - const p = await apiGet("/admin/api/plugins"); - let toolPolicy = { - disable_tool_confirm: false, - enforced_retry_mode: "first_round_only", - tool_loop_state_machine: true, - tool_signature_budget: 2, - turn_max_tool_workers: 8, - turn_max_tool_rounds: 100, - turn_max_context_messages: 80, - turn_runner_impl: "oclaw", - sse_queue_maxsize: 2000, - tool_log_max_chars: 200000, - enable_mcp_tools: true, - enable_plugin_tools: false, - enable_run_command: true, - tool_context_truncate_enabled: true, - chat_show_ttft_debug: false, - tool_llm_message_max_chars: 0, - mcp_filesystem_extra_roots: "", - mcp_env_allowlist: "", - oclaw_retryable_error_codes: "", - oclaw_retry_codes_strict_mode: false, - wecom_longconn_workers: 2, - wecom_longconn_inbound_queue_maxsize: 200, - }; - let mcp = { servers: [] }; - let mcpFailures = { items: [] }; - let marketTrending = { items: [] }; - let depStatus = { items: [] }; - let mcpBinding = { available_specialists: ["generalist"], servers: [], mapping: {} }; - let mcpUsage = { summary: [], calls: [] }; - let mcpUpdateState = { byServer: {} }; - let mcpActionMenuEl = null; - const closeMcpActionMenu = () => { - if (mcpActionMenuEl && mcpActionMenuEl.parentNode) { - mcpActionMenuEl.parentNode.removeChild(mcpActionMenuEl); - } - mcpActionMenuEl = null; - }; - try { - mcp = await apiGet("/admin/api/mcp/servers"); - } catch (_) {} - try { - mcpFailures = await apiGet("/admin/api/mcp/failures?limit=20"); - } catch (_) {} - try { - marketTrending = await apiGet("/admin/api/mcp/market/trending?per_source_limit=4"); - } catch (_) {} - try { - depStatus = await apiGet("/admin/api/mcp/dependencies"); - } catch (_) {} - try { - mcpBinding = await apiGet("/admin/api/mcp/binding"); - } catch (_) {} - try { - toolPolicy = await apiGet("/admin/api/tool-policy"); - } catch (_) {} - try { - mcpUsage = await apiGet("/admin/api/mcp/usage?limit=500"); - } catch (_) {} - const pluginCatalog = Array.isArray(p.plugins) ? p.plugins : []; - const pluginPageRef = { value: 1 }; - const pluginTotalHolder = { value: pluginCatalog.length }; - const pluginTbody = el("tbody"); - const buildPluginRow = (x) => - el("tr", {}, [ - el("td", { text: String(x.plugin_name || "") }), - el("td", { text: String(x.plugin_version || "") }), - el("td", { text: String(x.entry_point || "") }), - el("td", { text: String(x.enabled ? 1 : 0) }), - ]); - const repaintPlugins = () => { - pluginTbody.innerHTML = ""; - const start = (pluginPageRef.value - 1) * PLUGINS_PAGE_SIZE; - pluginCatalog.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { - pluginTbody.appendChild(buildPluginRow(x)); - }); - }; - repaintPlugins(); - const pluginPager = pluginsPagerBar(pluginTotalHolder, pluginPageRef, repaintPlugins); - const sourceType = el("select", { class: "input" }, [ - el("option", { value: "github", text: "github" }), - el("option", { value: "npm", text: "npm" }), - el("option", { value: "pypi", text: "pypi" }), - ]); - const sourceRef = el("input", { class: "input", placeholder: "source_ref (repo url / package)" }); - const version = el("input", { class: "input", placeholder: "version(optional)" }); - const entryCmd = el("input", { class: "input", placeholder: "entry_command (required for runtime)" }); - const entryArgs = el("input", { class: "input", placeholder: "entry_args (space separated)" }); - const marketQ = el("input", { class: "input", placeholder: "search MCP market (github/npm/pypi)" }); - const marketBtn = el("button", { class: "btn", text: "Search" }); - const marketRefreshBtn = el("button", { class: "btn", text: "Refresh Trending" }); - const checkAllBtn = el("button", { - class: "btn", - text: "Check Installed", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/check-all", { enabled_only: true }); - const items = Array.isArray(r.items) ? r.items : []; - const parts = items.map((x) => { - const sid = String(x.server_id || "-"); - if (x.ok) return `${sid}:ok(${Number(x.tools_synced || 0)})`; - return `${sid}:err(${String(x.error_code || "unknown")})`; - }); - installStatus.textContent = `[check-all] ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`; - router(); - }, - }); - const checkUpdatesBtn = el("button", { - class: "btn", - text: "Check Updates", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/check-updates", { enabled_only: true }); - const items = Array.isArray(r.items) ? r.items : []; - const next = {}; - items.forEach((x) => { - const sid = String(x.server_id || "").trim(); - if (!sid) return; - next[sid] = x; - }); - mcpUpdateState.byServer = next; - const updates = items.filter((x) => !!x.has_update).map((x) => String(x.server_id || "-")); - installStatus.textContent = - `[check-updates] total=${Number(r.total || 0)} updates=${Number(r.update_count || 0)} ` + - (updates.length ? `(${updates.join(", ")})` : "(none)"); - repaintMcpInstalled(); - }, - }); - const updateOutdatedBtn = el("button", { - class: "btn", - text: "Update Outdated Only", - onclick: async () => { - const byServer = mcpUpdateState.byServer && typeof mcpUpdateState.byServer === "object" ? mcpUpdateState.byServer : {}; - const outdated = Object.keys(byServer).filter((sid) => !!(byServer[sid] && byServer[sid].has_update)); - if (!outdated.length) { - installStatus.textContent = "[update-outdated] no outdated MCPs. Click Check Updates first."; - return; - } - const results = []; - for (const sid of outdated) { - const r = await apiPost("/admin/api/mcp/update", { - server_id: sid, - update_to_latest: true, - sync_tools: true, - }); - results.push({ server_id: sid, ok: !!r.ok, raw: r }); - } - const okCount = results.filter((x) => x.ok).length; - installStatus.textContent = - `[update-outdated] total=${results.length} ok=${okCount} err=${results.length - okCount} ` + - results.map((x) => `${x.server_id}:${x.ok ? "ok" : "err"}`).join(" | "); - markPrewarmReminder("mcp_updated"); - router(); - }, - }); - const e2eCheckBtn = el("button", { - class: "btn", - text: "E2E Check", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/e2e-check", {}); - const items = Array.isArray(r.items) ? r.items : []; - const parts = items.map((x) => { - const sid = String(x.server_id || "-"); - return `${sid}:${x.ok ? "ok" : ("err(" + String(x.error || "unknown") + ")")}`; - }); - installStatus.textContent = `[e2e] ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} session=${String(r.session_id || "-")} ${parts.join(" | ")}`; - router(); - }, - }); - const updateAllBtn = el("button", { - class: "btn", - text: "Update Installed", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/update", { - enabled_only: true, - update_to_latest: true, - sync_tools: true, - }); - const items = Array.isArray(r.items) ? r.items : []; - const parts = items.map((x) => { - const sid = String(x.server_id || "-"); - if (x.ok) return `${sid}:ok(sync=${Number(x.tools_synced || 0)})`; - return `${sid}:err(${String(x.error_code || "unknown")})`; - }); - installStatus.textContent = - `[update-all] total=${Number(r.total || 0)} ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`; - markPrewarmReminder("mcp_updated"); - router(); - }, - }); - const repairWeakIncludeDisabledCb = el("input", { type: "checkbox" }); - repairWeakIncludeDisabledCb.checked = false; - const repairWeakBtn = el("button", { - class: "btn", - text: "Repair weak", - title: - "MCPs with health≠ok or zero cached tools: run health + sync. By default only enabled rows; tick “Include disabled” to repair disabled installs too.", - onclick: async () => { - const enabledOnly = !repairWeakIncludeDisabledCb.checked; - const r = await apiPost("/admin/api/mcp/repair-weak", { enabled_only: enabledOnly }); - const items = Array.isArray(r.items) ? r.items : []; - const parts = items.map((x) => { - const sid = String(x.server_id || "-"); - if (x.ok) return `${sid}:ok(${Number(x.tools_synced || 0)})`; - return `${sid}:err(${String(x.error_code || "unknown")})`; - }); - const scope = enabledOnly ? "enabled_only" : "include_disabled"; - installStatus.textContent = - `[repair-weak:${scope}] selected=${Number(r.selected || 0)} skip_ok=${Number(r.skipped_healthy || 0)} ` + - `ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`; - router(); - }, - }); - const repairWeakScopeLabel = el("label", { class: "row", style: "align-items:center;gap:6px;" }, [ - repairWeakIncludeDisabledCb, - el("span", { class: "muted", text: "Include disabled" }), - ]); - const marketWrap = el("div"); - const marketTableMount = el("div"); - const marketPagerMount = el("div"); - marketWrap.appendChild(marketTableMount); - marketWrap.appendChild(marketPagerMount); - let marketSearchItems = []; - const marketSearchPageRef = { value: 1 }; - const marketSearchTotalHolder = { value: 0 }; - const trendingWrap = el("div"); - const trendingTableMount = el("div"); - const trendingPagerMount = el("div"); - trendingWrap.appendChild(trendingTableMount); - trendingWrap.appendChild(trendingPagerMount); - let trendingItemsCache = []; - const trendingPageRef = { value: 1 }; - const trendingTotalHolder = { value: 0 }; - const depWrap = el("div"); - const depTableMount = el("div"); - const depPagerMount = el("div"); - depWrap.appendChild(depTableMount); - depWrap.appendChild(depPagerMount); - const depItemsList = Array.isArray(depStatus.items) ? depStatus.items : []; - const depPageRef = { value: 1 }; - const depTotalHolder = { value: depItemsList.length }; - const bindingStatus = el("div", { class: "muted", text: "" }); - const installStatus = el("div", { class: "muted", text: "" }); - const preflightFixWrap = el("div"); - const toolPolicyStatus = el("div", { class: "muted", text: "" }); - const turnMaxWorkersInput = el("input", { - class: "input u-max-w-120", - type: "number", - min: "1", - max: "32", - value: String(Number(toolPolicy.turn_max_tool_workers || 8)), - }); - const turnMaxRoundsInput = el("input", { - class: "input u-max-w-120", - type: "number", - min: "1", - max: "300", - value: String(Number(toolPolicy.turn_max_tool_rounds || 100)), - }); - const turnMaxCtxInput = el("input", { - class: "input u-max-w-120", - type: "number", - min: "10", - max: "400", - value: String(Number(toolPolicy.turn_max_context_messages || 80)), - }); - const sseQueueMaxsizeInput = el("input", { - class: "input u-max-w-140", - type: "number", - min: "200", - max: "50000", - value: String(Number(toolPolicy.sse_queue_maxsize || 2000)), - }); - const toolLogMaxCharsInput = el("input", { - class: "input u-max-w-140", - type: "number", - min: "20000", - max: "2000000", - value: String(Number(toolPolicy.tool_log_max_chars || 200000)), - }); - const enableMcpToolsCb = el("input", { type: "checkbox" }); - enableMcpToolsCb.checked = !!toolPolicy.enable_mcp_tools; - const enablePluginToolsCb = el("input", { type: "checkbox" }); - enablePluginToolsCb.checked = !!toolPolicy.enable_plugin_tools; - const enableRunCommandCb = el("input", { type: "checkbox" }); - enableRunCommandCb.checked = !!toolPolicy.enable_run_command; - const toolContextTruncateCb = el("input", { type: "checkbox" }); - toolContextTruncateCb.checked = !!toolPolicy.tool_context_truncate_enabled; - const chatShowTtftDebugCb = el("input", { type: "checkbox" }); - chatShowTtftDebugCb.checked = !!toolPolicy.chat_show_ttft_debug; - const toolLlmMessageMaxCharsInput = el("input", { - class: "input u-max-w-140", - type: "number", - min: "0", - max: "500000", - value: String(Number(toolPolicy.tool_llm_message_max_chars ?? 0)), - }); - const mcpFilesystemExtraRootsInput = el("input", { - class: "input", - value: String(toolPolicy.mcp_filesystem_extra_roots || ""), - placeholder: "D:\\work|D:\\docs", - }); - const mcpEnvAllowlistInput = el("input", { - class: "input", - value: String(toolPolicy.mcp_env_allowlist || ""), - placeholder: "BRAVE_API_KEY,GOOGLE_OAUTH_CREDENTIALS,...", - }); - const oclawRetryableErrorCodesInput = el("input", { - class: "input", - value: String(toolPolicy.oclaw_retryable_error_codes || ""), - placeholder: "provider_timeout,provider_rate_limited,...", - }); - const oclawRetryCodesStrictModeCb = el("input", { type: "checkbox" }); - oclawRetryCodesStrictModeCb.checked = !!toolPolicy.oclaw_retry_codes_strict_mode; - const wecomLongconnWorkersInput = el("input", { - class: "input u-max-w-120", - type: "number", - min: "1", - max: "8", - value: String(Number(toolPolicy.wecom_longconn_workers || 2)), - }); - const wecomLongconnInboundQueueInput = el("input", { - class: "input u-max-w-140", - type: "number", - min: "20", - max: "5000", - value: String(Number(toolPolicy.wecom_longconn_inbound_queue_maxsize || 200)), - }); - const saveToolPolicyBtn = el("button", { - class: "btn", - text: "Save Tool Policy", - onclick: async () => { - const r = await apiPost("/admin/api/tool-policy", { - turn_max_tool_workers: Number(turnMaxWorkersInput.value || 8), - turn_max_tool_rounds: Number(turnMaxRoundsInput.value || 100), - turn_max_context_messages: Number(turnMaxCtxInput.value || 80), - sse_queue_maxsize: Number(sseQueueMaxsizeInput.value || 2000), - tool_log_max_chars: Number(toolLogMaxCharsInput.value || 200000), - enable_mcp_tools: !!enableMcpToolsCb.checked, - enable_plugin_tools: !!enablePluginToolsCb.checked, - enable_run_command: !!enableRunCommandCb.checked, - tool_context_truncate_enabled: !!toolContextTruncateCb.checked, - chat_show_ttft_debug: !!chatShowTtftDebugCb.checked, - tool_llm_message_max_chars: Number(toolLlmMessageMaxCharsInput.value || 0), - mcp_filesystem_extra_roots: String(mcpFilesystemExtraRootsInput.value || ""), - mcp_env_allowlist: String(mcpEnvAllowlistInput.value || ""), - oclaw_retryable_error_codes: String(oclawRetryableErrorCodesInput.value || ""), - oclaw_retry_codes_strict_mode: !!oclawRetryCodesStrictModeCb.checked, - wecom_longconn_workers: Number(wecomLongconnWorkersInput.value || 2), - wecom_longconn_inbound_queue_maxsize: Number(wecomLongconnInboundQueueInput.value || 200), - }); - const unknownRetry = Array.isArray(r.unknown_retryable_error_codes) ? r.unknown_retryable_error_codes : []; - if (unknownRetry.length) { - toolPolicyStatus.textContent = `[tool-policy] saved with warnings: unknown_retryable_error_codes=${unknownRetry.join(", ")} | ` + JSON.stringify(r); - } else { - toolPolicyStatus.textContent = `[tool-policy] ` + JSON.stringify(r); - } - toolPolicyStatus.textContent += " | restart gateway/desktop to apply run_command toggle"; - }, - }); - const availableSpecialists = Array.isArray(mcpBinding.available_specialists) && mcpBinding.available_specialists.length - ? mcpBinding.available_specialists.map((x) => String(x)) - : ["generalist"]; - const bindingServers = Array.isArray(mcpBinding.servers) - ? mcpBinding.servers.filter((x) => x && String(x.server_id || "").trim()) - : []; - let bindingDraft = mcpBinding.mapping && typeof mcpBinding.mapping === "object" ? { ...mcpBinding.mapping } : {}; - const allSpecialistIds = () => { - const s = new Set(availableSpecialists.map((x) => String(x))); - Object.keys(bindingDraft).forEach((k) => s.add(String(k))); - return Array.from(s).sort(); - }; - const specialistsBoundToServer = (sid) => { - const out = []; - allSpecialistIds().forEach((sp) => { - const arr = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : []; - if (arr.includes(String(sid))) out.push(sp); - }); - return out; - }; - let repaintExpertBindingDashboard = () => {}; - const specialistSelect = el("select", { class: "input" }, availableSpecialists.map((sp) => - el("option", { value: sp, text: sp }), - )); - const bindingListWrap = el("div"); - const bindingListRowsMount = el("div"); - const bindingListPagerMount = el("div"); - bindingListWrap.appendChild(bindingListRowsMount); - bindingListWrap.appendChild(bindingListPagerMount); - const bindingListPageRef = { value: 1 }; - const bindingListTotalHolder = { value: 0 }; - const bindingReverseWrap = el("div"); - const bindingReverseTableMount = el("div"); - const bindingReversePagerMount = el("div"); - bindingReverseWrap.appendChild(bindingReverseTableMount); - bindingReverseWrap.appendChild(bindingReversePagerMount); - const bindingReversePageRef = { value: 1 }; - const bindingReverseTotalHolder = { value: 0 }; - const flashInstalledRow = (sid) => { - const row = document.getElementById(`mcp-installed-${sid}`); - if (!row) { - bindingStatus.textContent = `[binding] installed row not found: ${sid}`; - return; - } - row.scrollIntoView({ behavior: "smooth", block: "center" }); - const oldBg = row.style.backgroundColor; - row.style.backgroundColor = "rgba(255, 215, 0, 0.22)"; - setTimeout(() => { - row.style.backgroundColor = oldBg || ""; - }, 1800); - }; - const renderBindingReverse = () => { - bindingReverseTotalHolder.value = bindingServers.length; - const start = (bindingReversePageRef.value - 1) * PLUGINS_PAGE_SIZE; - const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE); - const rows = slice.map((srv) => { - const sid = String(srv.server_id || ""); - const specialists = allSpecialistIds().filter((sp) => { - const mapped = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : []; - return mapped.includes(sid); - }); - const locateBtn = el("button", { - class: "btn btn--small", - text: "Locate", - onclick: () => flashInstalledRow(sid), - }); - return el("tr", {}, [ - tdCell(sid, 38), - tdCell(specialists.length ? specialists.join(", ") : "-", 62), - el("td", {}, [locateBtn]), - ]); - }); - bindingReverseTableMount.innerHTML = ""; - bindingReverseTableMount.appendChild( - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "server_id" }), el("th", { text: "bound specialists" }), el("th", { text: "action" })])]), - el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "3" })])]), - ]), - ]), - ); - bindingReversePagerMount.innerHTML = ""; - const brBar = pluginsPagerBar(bindingReverseTotalHolder, bindingReversePageRef, renderBindingReverse); - bindingReversePagerMount.appendChild(brBar.wrap); - repaintExpertBindingDashboard(); - }; - const renderBindingList = () => { - const current = String(specialistSelect.value || ""); - const existing = Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : []; - const selected = new Set(existing); - bindingListTotalHolder.value = bindingServers.length; - const start = (bindingListPageRef.value - 1) * PLUGINS_PAGE_SIZE; - const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE); - bindingListRowsMount.innerHTML = ""; - if (!bindingServers.length) { - bindingListRowsMount.appendChild(el("div", { class: "muted", text: "No MCP servers installed yet." })); - bindingListPagerMount.innerHTML = ""; - const blBar0 = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList); - bindingListPagerMount.appendChild(blBar0.wrap); - repaintExpertBindingDashboard(); - return; - } - slice.forEach((srv) => { - const sid = String(srv.server_id || ""); - const cb = el("input", { type: "checkbox" }); - cb.checked = selected.has(sid); - cb.addEventListener("change", () => { - const prev = new Set(Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : []); - if (cb.checked) prev.add(sid); - else prev.delete(sid); - bindingDraft[current] = Array.from(prev); - renderBindingReverse(); - }); - const label = `${sid}${srv.enabled ? "" : " (disabled)"}`; - bindingListRowsMount.appendChild(el("label", { class: "row" }, [cb, el("span", { text: label })])); - }); - bindingListPagerMount.innerHTML = ""; - const blBar = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList); - bindingListPagerMount.appendChild(blBar.wrap); - repaintExpertBindingDashboard(); - }; - specialistSelect.addEventListener("change", () => { - bindingListPageRef.value = 1; - bindingReversePageRef.value = 1; - renderBindingList(); - renderBindingReverse(); - }); - const selectAllBindingBtn = el("button", { - class: "btn", - text: "Select All", - onclick: () => { - const current = String(specialistSelect.value || ""); - bindingDraft[current] = bindingServers.map((x) => String(x.server_id || "")).filter((x) => x); - renderBindingList(); - renderBindingReverse(); - }, - }); - const clearBindingBtn = el("button", { - class: "btn", - text: "Clear", - onclick: () => { - const current = String(specialistSelect.value || ""); - bindingDraft[current] = []; - renderBindingList(); - renderBindingReverse(); - }, - }); - const saveBindingBtn = el("button", { - class: "btn", - text: "Save Binding", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/binding", { mapping: bindingDraft }); - bindingStatus.textContent = `[binding] ` + JSON.stringify(r); - markPrewarmReminder("mcp_binding_changed"); - router(); - }, - }); - renderBindingList(); - renderBindingReverse(); - const jsonInstallInput = el("textarea", { - class: "input", - placeholder: - "Paste install JSON: one object, array of objects, { servers:[...] }, { payload:{...} }, or Cursor-style { mcpServers:{...} }", - rows: "6", - }); - const cliInstallInput = el("input", { - class: "input", - placeholder: "CLI one-liner, e.g. npx -y mcp-fetch-server | pip install mcp-server-time | pip install git+https://... && python -m ", - }); - const _parseCliInstall = (raw) => { - const s = String(raw || "").trim(); - if (!s) return { ok: false, error: "empty_command" }; - // Support "A && B" one-liners (we only parse safe shapes; never shell-eval). - const segs = s.split(/\s*&&\s*/).map((x) => String(x || "").trim()).filter(Boolean); - const s0 = String(segs[0] || "").trim(); - const s1 = String(segs[1] || "").trim(); - // normalize spaces for simple token parse (no shell eval). - const parts = s0.split(/\s+/).filter(Boolean); - const low0 = String(parts[0] || "").toLowerCase(); - const low1 = String(parts[1] || "").toLowerCase(); - const idxPkg = (arr) => arr.findIndex((x) => !String(x || "").startsWith("-")); - if (low0 === "npx" || (low0 === "npm" && low1 === "exec")) { - // npx -y - const rest = low0 === "npx" ? parts.slice(1) : parts.slice(2); - const i = idxPkg(rest); - const pkg = i >= 0 ? String(rest[i] || "").trim() : ""; - if (!pkg) return { ok: false, error: "npx_package_missing" }; - const tail = i >= 0 ? rest.slice(i + 1).map((x) => String(x || "").trim()).filter(Boolean) : []; - return { - ok: true, - payload: { - source_type: "npm", - source_ref: pkg, - server_id: pkg, - entry_command: "npx", - // Keep package tail args (e.g. "run ") for MCP CLIs that require subcommands/auth. - entry_args: ["-y", pkg, ...tail], - }, - }; - } - if (low0 === "npm" && low1 === "install") { - // npm install [-g] - const rest = parts.slice(2); - const i = idxPkg(rest); - const pkg = i >= 0 ? String(rest[i] || "").trim() : ""; - if (!pkg) return { ok: false, error: "npm_package_missing" }; - return { - ok: true, - payload: { - source_type: "npm", - source_ref: pkg, - server_id: pkg, - entry_command: "npx", - entry_args: ["-y", pkg], - }, - }; - } - if ((low0 === "pip" && low1 === "install") || (low0 === "python" && low1 === "-m" && String(parts[2] || "").toLowerCase() === "pip")) { - const rest = low0 === "pip" ? parts.slice(2) : parts.slice(4); - const i = idxPkg(rest); - const pkg = i >= 0 ? String(rest[i] || "").trim() : ""; - if (!pkg) return { ok: false, error: "pypi_package_missing" }; - // If pip install uses git+... URL, require explicit entry module via "&& python -m ". - if (pkg.toLowerCase().startsWith("git+")) { - const p1 = s1.split(/\s+/).filter(Boolean); - const p10 = String(p1[0] || "").toLowerCase(); - const p11 = String(p1[1] || "").toLowerCase(); - const mod = (p10 === "python" && p11 === "-m") ? String(p1[2] || "").trim() : ""; - if (!mod) return { ok: false, error: "git_pip_requires_python_m" }; - const repoName = pkg.replace(/^git\+/i, "").split(/[?#]/)[0].split("/").filter(Boolean).pop() || "mcp-server"; - const sid = String(repoName).replace(/\.git$/i, ""); - return { - ok: true, - payload: { - source_type: "pypi", - source_ref: pkg, - server_id: sid, - entry_command: "python", - entry_args: ["-m", mod], - }, - }; - } - return { - ok: true, - payload: { - source_type: "pypi", - source_ref: pkg, - server_id: pkg, - entry_command: "python", - entry_args: ["-m", pkg.replace(/-/g, "_")], - }, - }; - } - return { ok: false, error: "unsupported_cli_command" }; - }; - const installBtn = el("button", { - class: "btn btn--primary", - text: "Install MCP", - onclick: async () => { - installStatus.textContent = "[install] running preflight..."; - try { - const payload = { - source_type: sourceType.value, - source_ref: sourceRef.value.trim(), - version: version.value.trim(), - entry_command: entryCmd.value.trim(), - entry_args: entryArgs.value.trim() ? entryArgs.value.trim().split(/\s+/) : [], - }; - const pre = await apiPost("/admin/api/mcp/preflight", payload); - if (!pre.ok) { - installStatus.textContent = `[preflight] ${String(pre.error_code || "")} ${String(pre.error || "")}`.trim(); - const fixes = Array.isArray(pre.fix_suggestions) ? pre.fix_suggestions : []; - preflightFixWrap.innerHTML = ""; - if (fixes.length) { - const rows = fixes.map((f) => { - const cmd = String((f && f.command) || ""); - const copyBtn = el("button", { - class: "btn", - text: "Copy", - onclick: async () => { - try { - await navigator.clipboard.writeText(cmd); - installStatus.textContent = `[copied] ${cmd}`; - } catch (_) { - installStatus.textContent = `[copy_failed] ${cmd}`; - } - }, - }); - return el("tr", {}, [ - tdCell(String((f && f.title) || ""), 24), - tdCell(cmd, 72), - el("td", {}, [copyBtn]), - ]); - }); - preflightFixWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "fix" }), el("th", { text: "command/url" }), el("th", { text: "action" })])]), - el("tbody", {}, rows), - ])])); - } - return; - } - preflightFixWrap.innerHTML = ""; - const warns = Array.isArray(pre.warnings) ? pre.warnings : []; - if (warns.length) { - installStatus.textContent = `[preflight warnings] ${warns.join(", ")}`; - } - const res = await apiPost("/admin/api/mcp/install", payload); - installStatus.textContent = JSON.stringify(res); - markPrewarmReminder("mcp_installed"); - router(); - } catch (err) { - installStatus.textContent = `[install] failed: ${String((err && err.message) || err || "install_failed")}`; - } - }, - }); - const jsonInstallBtn = el("button", { - class: "btn", - text: "Install from JSON", - onclick: async () => { - preflightFixWrap.innerHTML = ""; - const raw = String(jsonInstallInput.value || "").trim(); - if (!raw) { - installStatus.textContent = "[json] empty payload"; - return; - } - installStatus.textContent = "[json] installing..."; - try { - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - installStatus.textContent = `[json] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`; - return; - } - let list; - let detectedMcpServersShape = false; - if (Array.isArray(parsed)) { - list = parsed; - } else if (parsed && typeof parsed === "object" && Array.isArray(parsed.servers)) { - list = parsed.servers; - } else if (parsed && typeof parsed === "object" && parsed.mcpServers && typeof parsed.mcpServers === "object") { - detectedMcpServersShape = true; - installStatus.textContent = - "[json] detected mcpServers shape; stdio uses command/args (local), HTTP/SSE uses mcp-remote bridge"; - const envVarRe = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; - list = Object.entries(parsed.mcpServers).map(([rawKey, rawServer]) => { - const key = String(rawKey || "").trim(); - const s = rawServer && typeof rawServer === "object" ? rawServer : {}; - const transport = String(s.type || "").trim().toLowerCase(); - const baseUrl = String(s.baseUrl || s.url || "").trim(); - const headers = s.headers && typeof s.headers === "object" ? s.headers : {}; - - // Build env schema from ${ENV_VAR} placeholders in headers. - const envSchema = {}; - Object.entries(headers).forEach(([hk, hv]) => { - const v = String(hv || ""); - let m; - while ((m = envVarRe.exec(v)) !== null) { - const envName = String(m[1] || "").trim(); - if (!envName) continue; - envSchema[envName] = { - required: true, - description: `Auto-detected from header ${String(hk || "").trim()}`, - }; - } - }); - - // Convert remote MCP (streamable HTTP / SSE) to stdio bridge via mcp-remote. - // Equivalent runtime command: npx -y mcp-remote --header "K: V" ... - // - // Note: mcp-remote uses transport strategy "http-first" and may fall back to "sse-only" - // automatically, so both shapes can share the same bridge command. - if ( - transport === "streamablehttp" || - transport === "streamable_http" || - transport === "streamable-http" || - transport === "sse" - ) { - const entryArgs = ["-y", "mcp-remote"]; - if (baseUrl) entryArgs.push(baseUrl); - Object.entries(headers).forEach(([hk, hv]) => { - const hn = String(hk || "").trim(); - const hvs = String(hv || "").trim(); - if (!hn || !hvs) return; - entryArgs.push("--header", `${hn}: ${hvs}`); - }); - return { - source_type: "npm", - source_ref: "mcp-remote", - server_id: key || String(s.name || "mcp-remote").trim() || "mcp-remote", - entry_command: "npx", - entry_args: entryArgs, - env_schema: envSchema, - required_permissions: Array.isArray(s.required_permissions) ? s.required_permissions.map((x) => String(x)) : [], - risk_level: String(s.risk_level || "high"), - enabled: Object.prototype.hasOwnProperty.call(s, "isActive") ? !!s.isActive : true, - timeout_s: Number(s.timeout_s || 30), - dry_run: false, - }; - } - - // Best-effort passthrough for stdio-like definitions (Cursor command/args/env). - const cmd = String(s.command || s.entry_command || "").trim(); - const cmdArgs = Array.isArray(s.args) ? s.args.map((x) => String(x)) : []; - const stdioLocal = !!cmd; - const cursorEnv = s.env && typeof s.env === "object" ? s.env : {}; - const envFromCursor = {}; - Object.entries(cursorEnv).forEach(([ek, ev]) => { - const envName = String(ek || "").trim(); - if (!envName) return; - envFromCursor[envName] = { - type: "string", - default: String(ev == null ? "" : ev), - description: "From mcpServers env; copy to mcp_local.env if runtime needs it", - }; - }); - const mergedEnvSchema = { ...envFromCursor, ...envSchema }; - if (s.env_schema && typeof s.env_schema === "object") { - Object.assign(mergedEnvSchema, s.env_schema); - } - const explicitSourceType = String(s.source_type || "").trim(); - return { - source_type: explicitSourceType || (stdioLocal ? "local" : "npm"), - source_ref: String(s.source_ref || (stdioLocal ? key : "mcp-remote")).trim() || (stdioLocal ? key : "mcp-remote"), - server_id: key || String(s.name || s.server_id || "mcp-server").trim() || "mcp-server", - entry_command: cmd, - entry_args: cmdArgs, - env_schema: mergedEnvSchema, - required_permissions: Array.isArray(s.required_permissions) ? s.required_permissions.map((x) => String(x)) : [], - risk_level: String(s.risk_level || "high"), - enabled: Object.prototype.hasOwnProperty.call(s, "isActive") ? !!s.isActive : true, - timeout_s: Number(s.timeout_s || 30), - dry_run: false, - }; - }); - } else if (parsed && typeof parsed === "object" && parsed.payload && typeof parsed.payload === "object") { - list = [parsed.payload]; - } else { - list = [parsed]; - } - const results = []; - for (const item of list) { - if (!item || typeof item !== "object") { - results.push({ ok: false, error: "item_not_object" }); - continue; - } - const payload = { - source_type: String(item.source_type || "").trim(), - source_ref: String(item.source_ref || "").trim(), - server_id: String(item.server_id || "").trim(), - version: String(item.version || "").trim(), - entry_command: String(item.entry_command || "").trim(), - entry_args: Array.isArray(item.entry_args) ? item.entry_args.map((x) => String(x)) : [], - env_schema: item.env_schema && typeof item.env_schema === "object" ? item.env_schema : {}, - required_permissions: Array.isArray(item.required_permissions) - ? item.required_permissions.map((x) => String(x)) - : [], - risk_level: String(item.risk_level || "high"), - enabled: Object.prototype.hasOwnProperty.call(item, "enabled") ? !!item.enabled : true, - timeout_s: Number(item.timeout_s || 30), - dry_run: !!item.dry_run, - }; - const pre = await apiPost("/admin/api/mcp/preflight", payload); - if (!pre.ok) { - results.push({ - ok: false, - server_id: payload.server_id || payload.source_ref || "-", - phase: "preflight", - error_code: pre.error_code || "", - error: pre.error || "preflight_failed", - }); - continue; - } - const res = await apiPost("/admin/api/mcp/install", payload); - results.push({ - ok: !!res.ok, - server_id: res.server_id || payload.server_id || payload.source_ref || "-", - phase: "install", - error_code: res.error_code || "", - error: res.error || "", - }); - } - installStatus.textContent = JSON.stringify(results); - if (detectedMcpServersShape) { - installStatus.textContent = - "[json] mcpServers import finished (using mcp-remote bridge where needed)\n" + installStatus.textContent; - } - markPrewarmReminder("mcp_batch_installed"); - router(); - } catch (err) { - installStatus.textContent = `[json] failed: ${String((err && err.message) || err || "install_failed")}`; - } - }, - }); - const cliInstallModal = el("div", { class: "session-monitor-modal u-hidden" }); - const cliInstallModalTitle = el("div", { class: "card__title", text: "MCP CLI install" }); - const cliInstallModalBody = el("pre", { class: "pre", text: "" }); - const closeCliInstallModal = () => { - cliInstallModal.style.display = "none"; - }; - const openCliInstallModal = (title, text) => { - cliInstallModalTitle.textContent = String(title || "MCP CLI install"); - cliInstallModalBody.textContent = String(text || ""); - cliInstallModal.style.display = "flex"; - }; - const setCliInstallModal = (title, text) => { - if (title) cliInstallModalTitle.textContent = String(title); - if (text != null) cliInstallModalBody.textContent = String(text); - }; - cliInstallModal.addEventListener("click", (e) => { - if (e.target === cliInstallModal) closeCliInstallModal(); - }); - cliInstallModal.appendChild( - el("div", { class: "card session-monitor-modal__card", style: "width:min(720px,96vw);" }, [ - cliInstallModalTitle, - cliInstallModalBody, - el("div", { class: "row u-row-end" }, [ - el("button", { class: "btn", text: "Close", onclick: closeCliInstallModal }), - ]), - ]), - ); - const cliInstallBtn = el("button", { - class: "btn", - text: "Install from CLI", - onclick: async () => { - preflightFixWrap.innerHTML = ""; - openCliInstallModal("MCP CLI install", "Parsing command..."); - const parsed = _parseCliInstall(cliInstallInput.value); - if (!parsed.ok) { - installStatus.textContent = `[cli] ${String(parsed.error || "parse_failed")}`; - setCliInstallModal("MCP CLI install failed", installStatus.textContent); - return; - } - const payload = { - source_type: String(parsed.payload.source_type || "").trim(), - source_ref: String(parsed.payload.source_ref || "").trim(), - server_id: String(parsed.payload.server_id || "").trim(), - entry_command: String(parsed.payload.entry_command || "").trim(), - entry_args: Array.isArray(parsed.payload.entry_args) ? parsed.payload.entry_args.map((x) => String(x)) : [], - version: "", - }; - setCliInstallModal("MCP CLI install", "Running preflight..."); - const pre = await apiPost("/admin/api/mcp/preflight", payload); - if (!pre.ok) { - installStatus.textContent = `[cli/preflight] ${String(pre.error_code || "")} ${String(pre.error || "")}`.trim(); - setCliInstallModal("MCP CLI preflight failed", installStatus.textContent); - return; - } - setCliInstallModal("MCP CLI install", "Installing..."); - const res = await apiPost("/admin/api/mcp/install", payload); - installStatus.textContent = `[cli] ` + JSON.stringify(res); - if (res && res.ok) { - setCliInstallModal("MCP CLI install success", installStatus.textContent); - markPrewarmReminder("mcp_installed"); - router(); - } else { - setCliInstallModal("MCP CLI install failed", installStatus.textContent); - } - }, - }); - const mcpServerList = Array.isArray(mcp.servers) ? mcp.servers : []; - const expertBindingDashTbody = el("tbody"); - repaintExpertBindingDashboard = () => { - expertBindingDashTbody.innerHTML = ""; - const ids = allSpecialistIds(); - if (!ids.length) { - expertBindingDashTbody.appendChild(el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })])); - return; - } - ids.forEach((sp) => { - const sids = new Set((bindingDraft[sp] || []).map(String).filter(Boolean)); - let toolCnt = 0; - mcpServerList.forEach((row) => { - const rsid = String(row.server_id || ""); - if (!sids.has(rsid)) return; - const tools = Array.isArray(row.tools) ? row.tools : []; - toolCnt += tools.length; - }); - expertBindingDashTbody.appendChild(el("tr", {}, [ - tdCell(sp, 28), - tdCell(String(sids.size), 10), - tdCell(String(toolCnt), 12), - ])); - }); - }; - repaintExpertBindingDashboard(); - const mcpInstalledPageRef = { value: 1 }; - const mcpInstalledTotalHolder = { value: mcpServerList.length }; - const mcpInstalledTbody = el("tbody"); - const buildMcpInstalledRow = (x) => { - const sid = String(x.server_id || ""); - const toggleBtn = el("button", { - class: "btn", - text: x.enabled ? "Disable" : "Enable", - onclick: async () => { - closeMcpActionMenu(); - await apiPost("/admin/api/mcp/toggle", { server_id: sid, enabled: !x.enabled }); - markPrewarmReminder("mcp_toggled"); - router(); - }, - }); - const healthBtn = el("button", { - class: "btn", - text: "Health", - onclick: async () => { - closeMcpActionMenu(); - const r = await apiPost("/admin/api/mcp/healthcheck", { server_id: sid }); - installStatus.textContent = `[health:${sid}] ` + JSON.stringify(r); - router(); - }, - }); - const syncBtn = el("button", { - class: "btn", - text: "Sync Tools", - onclick: async () => { - closeMcpActionMenu(); - const r = await apiPost("/admin/api/mcp/tools/sync", { server_id: sid }); - installStatus.textContent = `[sync:${sid}] ` + JSON.stringify(r); - router(); - }, - }); - const reinstallBtn = el("button", { - class: "chat-sess-menu-item", - text: "Reinstall", - onclick: async () => { - closeMcpActionMenu(); - const r = await apiPost("/admin/api/mcp/reinstall", { server_id: sid }); - installStatus.textContent = `[reinstall:${sid}] ` + JSON.stringify(r); - markPrewarmReminder("mcp_reinstalled"); - router(); - }, - }); - const updateBtn = el("button", { - class: "chat-sess-menu-item", - text: "Update", - onclick: async () => { - closeMcpActionMenu(); - const r = await apiPost("/admin/api/mcp/update", { - server_id: sid, - update_to_latest: true, - sync_tools: true, - }); - installStatus.textContent = `[update:${sid}] ` + JSON.stringify(r); - markPrewarmReminder("mcp_updated"); - router(); - }, - }); - const uninstallBtn = el("button", { - class: "chat-sess-menu-item", - text: "Uninstall", - onclick: async () => { - closeMcpActionMenu(); - if (!window.confirm(`Uninstall ${sid} and remove from MCP registry?`)) return; - const r = await apiPost("/admin/api/mcp/uninstall", { server_id: sid, remove_record: true }); - installStatus.textContent = `[uninstall:${sid}] ` + JSON.stringify(r); - markPrewarmReminder("mcp_uninstalled"); - router(); - }, - }); - const deleteBtn = el("button", { - class: "chat-sess-menu-item", - text: "Delete", - onclick: async () => { - closeMcpActionMenu(); - if (!window.confirm(`Delete ${sid} from MCP registry only?`)) return; - const r = await apiPost("/admin/api/mcp/delete", { server_id: sid }); - installStatus.textContent = `[delete:${sid}] ` + JSON.stringify(r); - markPrewarmReminder("mcp_deleted"); - router(); - }, - }); - toggleBtn.className = "chat-sess-menu-item"; - healthBtn.className = "chat-sess-menu-item"; - syncBtn.className = "chat-sess-menu-item"; - const actionMenuBtn = el("button", { - class: "chat-sess-more", - text: "⋯", - title: "MCP actions", - onclick: (ev) => { - ev.stopPropagation(); - closeMcpActionMenu(); - const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ - toggleBtn, - healthBtn, - syncBtn, - updateBtn, - reinstallBtn, - uninstallBtn, - deleteBtn, - ]); - const rect = ev.currentTarget.getBoundingClientRect(); - document.body.appendChild(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left - 120; - 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`; - mcpActionMenuEl = menu; - const close = (e) => { - if (!menu.contains(e.target)) { - closeMcpActionMenu(); - document.removeEventListener("click", close); - } - }; - setTimeout(() => document.addEventListener("click", close), 0); - }, - }); - const tools = Array.isArray(x.tools) ? x.tools.map((t) => String(t.tool_name || "")).join(", ") : ""; - const upd = (mcpUpdateState.byServer && mcpUpdateState.byServer[sid]) || null; - const updText = upd - ? (upd.check_error - ? `err:${String(upd.check_error || "").slice(0, 80)}` - : (upd.has_update - ? `update ${String(upd.current_version || "-")} -> ${String(upd.latest_version || "-")}` - : "up-to-date")) - : "-"; - const healthObj = x.health && typeof x.health === "object" ? x.health : {}; - const healthStatus = String(healthObj.status || "-"); - const healthDetail = healthObj.detail && typeof healthObj.detail === "object" ? healthObj.detail : {}; - const healthErrCode = String(healthDetail.error_code || ""); - const healthErrMsg = String(healthDetail.error || ""); - const healthText = healthErrCode ? `${healthStatus}:${healthErrCode}` : healthStatus; - const healthTitle = healthErrCode || healthErrMsg - ? `${healthErrCode || "error"} ${healthErrMsg}`.trim() - : (healthDetail.synced_tools != null ? `synced_tools=${Number(healthDetail.synced_tools || 0)}` : healthStatus); - return el("tr", { id: `mcp-installed-${sid}` }, [ - tdCell(sid, 28), - tdCell(String(x.source_type || ""), 10), - tdCell(String(x.source_ref || ""), 42), - tdCell(String(x.version || ""), 16), - tdCell(String(x.entry_command || ""), 24), - tdCell(tools || "-", 48), - tdCell(updText, 28), - tdCell(String(x.enabled ? 1 : 0), 8), - el("td", { text: healthText, title: healthTitle }), - el("td", { class: "table__cell-actions" }, [actionMenuBtn]), - ]); - }; - const repaintMcpInstalled = () => { - mcpInstalledTbody.innerHTML = ""; - const start = (mcpInstalledPageRef.value - 1) * PLUGINS_PAGE_SIZE; - mcpServerList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { - mcpInstalledTbody.appendChild(buildMcpInstalledRow(x)); - }); - }; - repaintMcpInstalled(); - const mcpInstalledPager = pluginsPagerBar(mcpInstalledTotalHolder, mcpInstalledPageRef, repaintMcpInstalled); - const renderMarket = async () => { - const q = marketQ.value.trim(); - if (!q) { - marketTableMount.innerHTML = ""; - marketPagerMount.innerHTML = ""; - marketSearchItems = []; - marketSearchTotalHolder.value = 0; - return; - } - const resp = await apiGet("/admin/api/mcp/market/search?q=" + encodeURIComponent(q) + "&per_source_limit=60"); - marketSearchItems = Array.isArray(resp.items) ? resp.items : []; - marketSearchTotalHolder.value = marketSearchItems.length; - marketSearchPageRef.value = 1; - const paintMarket = () => { - const start = (marketSearchPageRef.value - 1) * PLUGINS_PAGE_SIZE; - const pageItems = marketSearchItems.slice(start, start + PLUGINS_PAGE_SIZE); - const bodyRows = pageItems.map((it) => { - const useBtn = el("button", { - class: "btn", - text: "Use", - onclick: () => { - sourceType.value = String(it.source_type || "github"); - sourceRef.value = String(it.source_ref || ""); - version.value = String(it.version || ""); - const tpl = (it && typeof it.install_template === "object") ? it.install_template : {}; - entryCmd.value = String(tpl.entry_command || entryCmd.value || ""); - const args = Array.isArray(tpl.entry_args) ? tpl.entry_args.map((x) => String(x)).join(" ") : ""; - if (args) entryArgs.value = args; - }, - }); - return el("tr", {}, [ - tdCell(String(it.source_type || ""), 10), - tdCell(String(it.name || ""), 24), - tdCell(String(it.description || ""), 60), - tdCell(String(it.source_ref || ""), 40), - tdCell(String(it.version || ""), 12), - el("td", {}, [useBtn]), - ]); - }); - marketTableMount.innerHTML = ""; - marketTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "source" }), - el("th", { text: "name" }), - el("th", { text: "description" }), - el("th", { text: "ref" }), - el("th", { text: "version" }), - el("th", { text: "action" }), - ])]), - el("tbody", {}, bodyRows.length ? bodyRows : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])]), - ])])); - }; - paintMarket(); - marketPagerMount.innerHTML = ""; - const mkBar = pluginsPagerBar(marketSearchTotalHolder, marketSearchPageRef, paintMarket); - marketPagerMount.appendChild(mkBar.wrap); - }; - marketBtn.addEventListener("click", renderMarket); - const renderTrending = async () => { - trendingItemsCache = Array.isArray(marketTrending.items) ? marketTrending.items : []; - trendingTotalHolder.value = trendingItemsCache.length; - trendingPageRef.value = 1; - const paintTrending = () => { - const start = (trendingPageRef.value - 1) * PLUGINS_PAGE_SIZE; - const pageItems = trendingItemsCache.slice(start, start + PLUGINS_PAGE_SIZE); - const row = pageItems.map((x) => { - const useBtn = el("button", { - class: "btn", - text: "Use", - onclick: () => { - sourceType.value = String(x.source_type || "github"); - sourceRef.value = String(x.source_ref || ""); - version.value = String(x.version || ""); - const tpl = (x && typeof x.install_template === "object") ? x.install_template : {}; - entryCmd.value = String(tpl.entry_command || entryCmd.value || ""); - const args = Array.isArray(tpl.entry_args) ? tpl.entry_args.map((k) => String(k)).join(" ") : ""; - if (args) entryArgs.value = args; - }, - }); - return el("tr", {}, [ - tdCell(String(x.source_type || ""), 10), - tdCell(String(x.name || ""), 26), - tdCell(String(x.description || ""), 70), - tdCell(String(x.stars || 0), 8), - el("td", {}, [useBtn]), - ]); - }); - trendingTableMount.innerHTML = ""; - trendingTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "source" }), el("th", { text: "name" }), el("th", { text: "description" }), el("th", { text: "stars" }), el("th", { text: "action" })])]), - el("tbody", {}, row.length ? row : [el("tr", {}, [el("td", { text: "-", colspan: "5" })])]), - ])])); - }; - paintTrending(); - trendingPagerMount.innerHTML = ""; - const trBar = pluginsPagerBar(trendingTotalHolder, trendingPageRef, paintTrending); - trendingPagerMount.appendChild(trBar.wrap); - }; - const renderDeps = () => { - depTotalHolder.value = depItemsList.length; - const paintDeps = () => { - const start = (depPageRef.value - 1) * PLUGINS_PAGE_SIZE; - const slice = depItemsList.slice(start, start + PLUGINS_PAGE_SIZE); - const rows = slice.map((x) => - el("tr", {}, [ - tdCell(String(x.name || ""), 12), - tdCell(String(x.ok ? "ok" : "missing"), 12), - tdCell(String(x.version || ""), 28), - tdCell(String(x.path || ""), 50), - ]), - ); - depTableMount.innerHTML = ""; - depTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "dep" }), el("th", { text: "status" }), el("th", { text: "version" }), el("th", { text: "path" })])]), - el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "4" })])]), - ])])); - }; - paintDeps(); - depPagerMount.innerHTML = ""; - const depBar = pluginsPagerBar(depTotalHolder, depPageRef, paintDeps); - depPagerMount.appendChild(depBar.wrap); - }; - marketRefreshBtn.addEventListener("click", async () => { - marketTrending = await apiGet("/admin/api/mcp/market/trending?per_source_limit=4&refresh=1"); - await renderTrending(); - }); - renderTrending(); - renderDeps(); - const failureText = Array.isArray(mcpFailures.items) - ? mcpFailures.items.map((x) => `${x.server_id}/${x.error_code || "-"}:${x.count}`).join(" | ") - : ""; - const usageSummaryList = Array.isArray(mcpUsage.summary) ? mcpUsage.summary : []; - const usageCallsList = Array.isArray(mcpUsage.calls) ? mcpUsage.calls : []; - const usageSummaryPageRef = { value: 1 }; - const usageSummaryTotalHolder = { value: usageSummaryList.length }; - const usageSummaryTbody = el("tbody"); - const usageCallsPageRef = { value: 1 }; - const usageCallsTotalHolder = { value: usageCallsList.length }; - const usageCallsTbody = el("tbody"); - const buildUsageSummaryRow = (x) => - el("tr", {}, [ - tdCell(String(x.server_id || ""), 24), - tdCell(String(x.mcp_tool_name || x.tool_name || ""), 30), - tdCell(String(x.specialist || "-"), 16), - tdCell(String(x.count || 0), 10), - tdCell(String(x.last_ts || ""), 24), - ]); - const buildUsageCallRow = (x) => - el("tr", {}, [ - tdCell(String(x.timestamp || ""), 22), - tdCell(String(x.server_id || ""), 18), - tdCell(String(x.mcp_tool_name || x.tool_name || ""), 22), - tdCell(String(x.specialist || "-"), 14), - tdCell(String(x.session_id || ""), 22), - tdCell(String(x.duration_ms || 0), 10), - ]); - const repaintUsageSummary = () => { - usageSummaryTbody.innerHTML = ""; - const start = (usageSummaryPageRef.value - 1) * PLUGINS_PAGE_SIZE; - usageSummaryList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { - usageSummaryTbody.appendChild(buildUsageSummaryRow(x)); - }); - }; - const repaintUsageCalls = () => { - usageCallsTbody.innerHTML = ""; - const start = (usageCallsPageRef.value - 1) * PLUGINS_PAGE_SIZE; - usageCallsList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { - usageCallsTbody.appendChild(buildUsageCallRow(x)); - }); - }; - repaintUsageSummary(); - repaintUsageCalls(); - const usageSummaryPager = pluginsPagerBar(usageSummaryTotalHolder, usageSummaryPageRef, repaintUsageSummary); - const usageCallsPager = pluginsPagerBar(usageCallsTotalHolder, usageCallsPageRef, repaintUsageCalls); - const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [ - el("div", { class: "muted", text: "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表" }), - el("div", { class: "row" }, [ - el("label", { text: "Turn max tool workers (1-32)" }), - turnMaxWorkersInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Turn max tool rounds (1-300)" }), - turnMaxRoundsInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Turn max context messages (10-400)" }), - turnMaxCtxInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "SSE queue maxsize (200-50000)" }), - sseQueueMaxsizeInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Tool log max chars (20000-2000000)" }), - toolLogMaxCharsInput, - ]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [toolContextTruncateCb, document.createTextNode(" Compress tool result in agent context (50 chars + hint)")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")])]), - el("div", { class: "row" }, [ - el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }), - toolLlmMessageMaxCharsInput, - ]), - el("div", { class: "muted", text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000." }), - el("div", { class: "row" }, [ - el("label", { text: "MCP filesystem extra roots (| separated)" }), - mcpFilesystemExtraRootsInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "MCP env allowlist (comma separated)" }), - mcpEnvAllowlistInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "oclaw retryable error codes (comma separated)" }), - oclawRetryableErrorCodesInput, - ]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [oclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")])]), - el("div", { class: "row" }, [ - el("label", { text: "WeCom longconn workers (1-8)" }), - wecomLongconnWorkersInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "WeCom inbound queue maxsize (20-5000)" }), - wecomLongconnInboundQueueInput, - ]), - el("div", { class: "row" }, [saveToolPolicyBtn]), - toolPolicyStatus, - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: t("table.name") }), el("th", { text: t("table.version") }), el("th", { text: t("table.entryPoint") }), el("th", { text: t("table.enabled") })])]), - pluginTbody, - ]), - ]), - pluginPager.wrap, - ]); - const foldMcpMarket = pluginsFold("【2】MCP 市场 / 依赖 / Trending / 检索结果", [ - el("div", { class: "muted", text: failureText ? `Failure summary: ${failureText}` : "Failure summary: -" }), - el("div", { class: "muted", text: "本地依赖检查" }), - depWrap, - el("div", { class: "row" }, [marketQ, marketBtn, marketRefreshBtn]), - el("div", { class: "muted", text: "Trending" }), - trendingWrap, - el("div", { class: "muted", text: "Market search" }), - marketWrap, - ]); - const foldMcpInstall = pluginsFold("【3】MCP 安装(表单 / JSON / 运维)", [ - el("div", { class: "row" }, [sourceType, sourceRef, version]), - el("div", { class: "row" }, [entryCmd, entryArgs, installBtn]), - el("div", { class: "muted", text: "CLI direct install (paste one command)" }), - el("div", { class: "row" }, [cliInstallInput, cliInstallBtn]), - el("div", { class: "muted", text: "常用命令行安装示例(可先本机验证,再填上方表单)" }), - el("pre", { - class: "pre", - text: -`# npm 包(本地安装) -npm install mcp-fetch-server - -# 直接运行(推荐) -npx -y mcp-fetch-server - -# 全局安装后运行 -npm install -g mcp-fetch-server -mcp-fetch-server - -# Python 包示例 -pip install mcp-server-time -python -m mcp_server_time - -# Python(Git URL / VCS)示例:必须显式指定 entry module -pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m code_sandbox_mcp`, - }), - el("div", { class: "muted", text: "JSON install (single object or array)" }), - jsonInstallInput, - el("div", { class: "row" }, [jsonInstallBtn]), - el("div", { class: "row", style: "flex-wrap:wrap;align-items:center;gap:8px;" }, [ - checkUpdatesBtn, - updateOutdatedBtn, - checkAllBtn, - e2eCheckBtn, - updateAllBtn, - repairWeakBtn, - repairWeakScopeLabel, - ]), - installStatus, - preflightFixWrap, - ]); - const mcpExportJsonBtn = el("button", { - class: "btn", - text: "Export JSON (download)", - title: "Download uninstall/reinstallable snapshot (same shape as “Install from JSON”)", - onclick: async () => { - let r; - try { - r = await apiGet("/admin/api/mcp/export"); - } catch (err) { - installStatus.textContent = "[export] " + String((err && err.message) || err); - return; - } - if (!r || r.ok !== true || !r.document) { - installStatus.textContent = "[export] failed: " + JSON.stringify(r); - return; - } - const text = JSON.stringify(r.document, null, 2) + "\n"; - const blob = new Blob([text], { type: "application/json" }); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = "mcp_registry_migrated.json"; - a.click(); - URL.revokeObjectURL(a.href); - installStatus.textContent = - "[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : ""); - }, - }); - const foldMcpInstalled = pluginsFold(`【4】已安装 MCP 服务(${mcpServerList.length})`, [ - el("div", { class: "row", style: "align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;" }, [ - mcpExportJsonBtn, - el("div", { - class: "muted", - text: "新安装/重装/卸载(删记录)成功后自动写入: src/_local/mcp_registry_migrated.json,便于换机迁移。", - }), - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "server_id" }), - el("th", { text: "source" }), - el("th", { text: "ref" }), - el("th", { text: "version" }), - el("th", { text: "entry" }), - el("th", { text: "tools" }), - el("th", { text: "update" }), - el("th", { text: "enabled" }), - el("th", { text: "health" }), - el("th", { text: "actions" }), - ])]), - mcpInstalledTbody, - ]), - ]), - mcpInstalledPager.wrap, - ]); - const foldMcpUsage = pluginsFold( - `【5】MCP 用量(summary ${usageSummaryList.length} / calls ${usageCallsList.length})`, - [ - el("div", { class: "muted", text: "MCP Usage Summary" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "server_id" }), - el("th", { text: "tool_name" }), - el("th", { text: "specialist" }), - el("th", { text: "count" }), - el("th", { text: "last_called_at" }), - ])]), - usageSummaryTbody, - ]), - ]), - usageSummaryPager.wrap, - el("div", { class: "muted u-mt-10", text: "Recent MCP Calls" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "timestamp" }), - el("th", { text: "server_id" }), - el("th", { text: "tool_name" }), - el("th", { text: "specialist" }), - el("th", { text: "session_id" }), - el("th", { text: "duration_ms" }), - ])]), - usageCallsTbody, - ]), - ]), - usageCallsPager.wrap, - ], - ); - const foldExpertBindingDash = pluginsFold("【6】专家 MCP 绑定看板(自动)", [ - el("div", { - class: "muted", - text: "按当前绑定草稿与已安装 MCP 的 tools 列表汇总;专家列表来自 SPECIALISTS 与绑定 mapping 键,随扩展自动增减。", - }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "专家" }), - el("th", { text: "绑定 MCP 数" }), - el("th", { text: "tools 数(各 server 累加)" }), - ])]), - expertBindingDashTbody, - ]), - ]), - ]); - const foldMcpBinding = pluginsFold("【7】MCP 专家绑定(编辑)", [ - el("div", { class: "muted", text: "Bind MCP servers to specialists (many-to-many)." }), - el("div", { class: "row" }, [el("label", { text: "Specialist" }), specialistSelect, selectAllBindingBtn, clearBindingBtn, saveBindingBtn]), - bindingListWrap, - el("div", { class: "muted", text: "Reverse view: server -> specialists" }), - bindingReverseWrap, - bindingStatus, - ]); - return renderPageShell({ - title: t("plugins.title"), - subtitle: "按功能分区展示,默认折叠;表格支持分页(每页 " + PLUGINS_PAGE_SIZE + " 条)。", - sections: [ - { id: "plugins-tool-policy", label: "策略" }, - { id: "plugins-market", label: "市场" }, - { id: "plugins-install", label: "安装" }, - { id: "plugins-instances", label: "实例" }, - { id: "plugins-binding", label: "专家绑定" }, - ], - }, [ - el("div", { class: "page-grid page-grid--single" }, [ - el("div", { id: "plugins-tool-policy" }, [foldToolPolicy]), - el("div", { id: "plugins-market" }, [foldMcpMarket]), - el("div", { id: "plugins-install" }, [foldMcpInstall]), - el("div", { id: "plugins-instances" }, [foldMcpInstalled]), - foldMcpUsage, - foldExpertBindingDash, - el("div", { id: "plugins-binding" }, [foldMcpBinding]), - ]), - cliInstallModal, - ]); -} - -async function renderAdminAudit() { - const action = el("input", { class: "input", placeholder: t("adminAudit.action") }); - const actor = el("input", { class: "input", placeholder: t("adminAudit.actor") }); - const status = el("input", { class: "input", placeholder: t("adminAudit.status") }); - const tbody = el("tbody"); - const pager = el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px;" }); - const pageInfo = el("span", { class: "muted", text: tf("sessionMonitor.pageInfo", { page: 1, totalPages: 1 }) }); - const totalInfo = el("span", { class: "muted", text: tf("adminAudit.total", { total: 0 }) }); - const pageInput = el("input", { - class: "input", - type: "number", - min: "1", - step: "1", - placeholder: t("adminAudit.jumpPlaceholder"), - style: "width:90px;", - }); - let page = 1; - const pageSize = 50; - let total = 0; - const totalPages = () => Math.max(1, Math.ceil((Number(total) || 0) / pageSize)); - const setPager = () => { - const tp = totalPages(); - pageInfo.textContent = tf("sessionMonitor.pageInfo", { page, totalPages: tp }); - totalInfo.textContent = tf("adminAudit.total", { total }); - pageInput.value = String(page); - btnPrev.disabled = page <= 1; - btnNext.disabled = page >= tp; - }; - const load = async () => { - const p = new URLSearchParams(); - p.set("limit", String(pageSize)); - p.set("offset", String((Math.max(1, page) - 1) * pageSize)); - if (action.value.trim()) p.set("action", action.value.trim()); - if (actor.value.trim()) p.set("actor_user_id", actor.value.trim()); - if (status.value.trim()) p.set("status", status.value.trim()); - const resp = await apiGet("/admin/api/admin-audit?" + p.toString()); - total = Math.max(0, Number(resp.total || 0) || 0); - const rows = Array.isArray(resp.items) ? resp.items : []; - tbody.innerHTML = ""; - if (!rows.length) { - tbody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); - setPager(); - return; - } - rows.forEach((r) => { - tbody.appendChild(el("tr", {}, [ - tdCell(r.timestamp || "", 24), - tdCell(formatAuditActor(r), 28), - tdCell(r.action || "", 20), - tdCell(r.target_type || "", 16), - tdCell(r.target_id || "", 24), - tdCell(r.status || "", 12), - tdCell(r.actor_tenant_id || "", 24), - tdCell(JSON.stringify(r.detail || {}), 120), - ])); - }); - setPager(); - }; - const btn = el("button", { - class: "btn btn--primary", - text: t("audit.query"), - onclick: async () => { - page = 1; - await load(); - }, - }); - const btnPrev = el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pagePrev"), - disabled: true, - onclick: async () => { - if (page <= 1) return; - page -= 1; - await load(); - }, - }); - const btnNext = el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pageNext"), - disabled: true, - onclick: async () => { - const tp = totalPages(); - if (page >= tp) return; - page += 1; - await load(); - }, - }); - const btnJump = el("button", { - class: "btn btn--small", - text: t("adminAudit.jump"), - onclick: async () => { - const tp = totalPages(); - let target = parseInt(String(pageInput.value || "").trim(), 10); - if (!Number.isFinite(target)) target = page; - target = Math.max(1, Math.min(tp, target)); - if (target === page) { - setPager(); - return; - } - page = target; - await load(); - }, - }); - pageInput.addEventListener("keydown", async (ev) => { - if (ev.key !== "Enter") return; - ev.preventDefault(); - btnJump.click(); - }); - pager.appendChild(btnPrev); - pager.appendChild(btnNext); - pager.appendChild(pageInfo); - pager.appendChild(totalInfo); - pager.appendChild(pageInput); - pager.appendChild(btnJump); - await load(); - return el("div", {}, [ - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("adminAudit.title") }), - el("div", { class: "row" }, [action, actor, status, btn]), - pager, - el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: t("table.timestamp") }), - el("th", { text: t("adminAudit.actor") }), - el("th", { text: t("adminAudit.action") }), - el("th", { text: "target_type" }), - el("th", { text: "target_id" }), - el("th", { text: t("adminAudit.status") }), - el("th", { text: "tenant_id" }), - el("th", { text: t("table.payload") }), - ])]), - tbody, - ])]), - ]), - ]); -} - -async function renderSessionMonitor() { - const userQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterUser") }); - const sessQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterSession") }); - const activeOnlyChk = el("input", { type: "checkbox" }); - const selectedHint = el("div", { class: "muted", text: t("sessionMonitor.selectUserFirst") }); - const userBody = el("tbody"); - const sessBody = el("tbody"); - const sessionPagerTop = el("div", { class: "row" }); - const sessionPagerBottom = el("div", { class: "row" }); - const detailBody = el("tbody"); - const detailStatus = el("div", { class: "muted", text: t("sessionMonitor.noMessages") }); - const detailPager = el("div", { class: "row" }); - const detailRoleFilter = el("select", { class: "input" }); - const detailModal = el("div", { class: "session-monitor-modal u-hidden" }); - const detailModalCard = el("div", { class: "session-monitor-modal__card" }); - const totalsWrap = el("div", { class: "row" }); - let selectedUserId = ""; - let selectedSessionId = ""; - let sessionLimit = 20; - let sessionOffset = 0; - let sessionTotal = 0; - let detailMessages = []; - let detailPage = 1; - const detailPageSize = 20; - let sessionMenuEl = null; - - const rebuildDetailRoleFilter = () => { - const saved = String(localStorage.getItem(SESSION_MONITOR_ROLE_FILTER_KEY) || "").trim(); - const prev = String(detailRoleFilter.value || saved || "all"); - detailRoleFilter.innerHTML = ""; - detailRoleFilter.appendChild(el("option", { value: "all", text: t("sessionMonitor.roleAll") })); - detailRoleFilter.appendChild(el("option", { value: "user", text: t("sessionMonitor.roleUser") })); - detailRoleFilter.appendChild(el("option", { value: "assistant", text: t("sessionMonitor.roleAssistant") })); - detailRoleFilter.appendChild(el("option", { value: "tool", text: t("sessionMonitor.roleTool") })); - detailRoleFilter.value = ["all", "user", "assistant", "tool"].includes(prev) ? prev : "all"; - }; - - const buildPageInfo = (page, totalPages) => tf("sessionMonitor.pageInfo", { page, totalPages }); - - const downloadSessionExport = async (sessionId, format) => { - const q = format === "json" ? "format=json" : "format=md"; - const path = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`; - const url = resolveAdminApiUrl(path); - const tok = getStoredAuthToken(); - const res = await fetch(url, { - headers: tok ? { authorization: `Bearer ${tok}` } : {}, - }); - if (!res.ok) throw new Error(`export_failed_${res.status}`); - const blob = await res.blob(); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = `chat-${String(sessionId || "").slice(0, 8)}.${format === "json" ? "json" : "md"}`; - a.click(); - URL.revokeObjectURL(a.href); - }; - - const closeSessionMenu = () => { - if (sessionMenuEl && sessionMenuEl.parentNode) { - sessionMenuEl.remove(); - } - sessionMenuEl = null; - }; - - const closeDetailModal = () => { - detailModal.style.display = "none"; - }; - - const renderTotals = (totals) => { - totalsWrap.innerHTML = ""; - const cards = [ - { key: "total_tokens_est", label: t("sessionMonitor.totalTokensEst") }, - { key: "active_sessions_30m", label: t("sessionMonitor.activeSessions30m") }, - { key: "active_logins_30m", label: t("sessionMonitor.activeLogins30m") }, - { key: "users_count", label: t("sessionMonitor.usersCount") }, - ]; - cards.forEach((c) => { - totalsWrap.appendChild( - el("div", { class: "kv", text: `${c.label}: ${String((totals && totals[c.key]) ?? 0)}` }), - ); - }); - }; - - const renderDetailPager = () => { - detailPager.innerHTML = ""; - const role = String(detailRoleFilter.value || "all"); - const filtered = Array.isArray(detailMessages) - ? detailMessages.filter((m) => { - if (role === "all") return true; - const r = String(m.role || "").toLowerCase(); - return role === "tool" ? r === "tool" || r === "function" : r === role; - }) - : []; - const total = filtered.length; - const totalPages = Math.max(1, Math.ceil(total / detailPageSize)); - const info = el("span", { class: "muted", text: buildPageInfo(detailPage, totalPages) }); - const btnPrev = el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pagePrev"), - disabled: detailPage <= 1, - onclick: () => { - if (detailPage <= 1) return; - detailPage -= 1; - renderDetailMessages(); - }, - }); - const btnNext = el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pageNext"), - disabled: detailPage >= totalPages, - onclick: () => { - if (detailPage >= totalPages) return; - detailPage += 1; - renderDetailMessages(); - }, - }); - detailPager.appendChild(btnPrev); - detailPager.appendChild(btnNext); - detailPager.appendChild(info); - }; - - const renderDetailMessages = () => { - detailBody.innerHTML = ""; - if (!selectedSessionId) { - detailStatus.textContent = t("sessionMonitor.selectUserFirst"); - detailPager.innerHTML = ""; - return; - } - const role = String(detailRoleFilter.value || "all"); - const all = Array.isArray(detailMessages) ? detailMessages : []; - const filtered = all.filter((m) => { - if (role === "all") return true; - const r = String(m.role || "").toLowerCase(); - return role === "tool" ? r === "tool" || r === "function" : r === role; - }); - if (!filtered.length) { - detailStatus.textContent = t("sessionMonitor.noMessages"); - detailPager.innerHTML = ""; - return; - } - const totalPages = Math.max(1, Math.ceil(filtered.length / detailPageSize)); - if (detailPage > totalPages) detailPage = totalPages; - const start = (detailPage - 1) * detailPageSize; - const rows = filtered.slice(start, start + detailPageSize); - detailStatus.textContent = `session_id: ${selectedSessionId} | ${filtered.length}/${all.length}`; - rows.forEach((m) => { - detailBody.appendChild( - el("tr", {}, [ - tdCell(m.id || "", 8), - tdCell(m.role || "", 10), - tdCell(m.content || "", 120), - tdCell(m.timestamp || "", 24), - ]), - ); - }); - renderDetailPager(); - }; - - const loadSessionDetail = async (sessionId, opts = {}) => { - const openModal = Boolean(opts && opts.openModal); - selectedSessionId = String(sessionId || ""); - detailPage = 1; - detailBody.innerHTML = ""; - detailStatus.textContent = `${t("chat.loading")}...`; - detailPager.innerHTML = ""; - if (!selectedSessionId) { - detailStatus.textContent = t("sessionMonitor.noMessages"); - if (openModal) detailModal.style.display = "flex"; - return; - } - try { - const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(selectedSessionId)}/messages`); - detailMessages = Array.isArray(resp.messages) ? resp.messages : []; - renderDetailMessages(); - } catch (err) { - detailMessages = []; - detailBody.innerHTML = ""; - detailPager.innerHTML = ""; - detailStatus.textContent = `${t("chat.error")}: ${String(err && err.message ? err.message : err)}`; - } - if (openModal) detailModal.style.display = "flex"; - }; - - const renderSessionPager = () => { - const totalPages = Math.max(1, Math.ceil(Math.max(0, sessionTotal) / sessionLimit)); - const current = Math.floor(sessionOffset / sessionLimit) + 1; - const row = el("div", { class: "row" }, [ - el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pagePrev"), - disabled: sessionOffset <= 0, - onclick: async () => { - if (sessionOffset <= 0) return; - sessionOffset = Math.max(0, sessionOffset - sessionLimit); - await loadSessions(); - }, - }), - el("button", { - class: "btn btn--small", - text: t("sessionMonitor.pageNext"), - disabled: sessionOffset + sessionLimit >= sessionTotal, - onclick: async () => { - if (sessionOffset + sessionLimit >= sessionTotal) return; - sessionOffset += sessionLimit; - await loadSessions(); - }, - }), - el("span", { class: "muted", text: buildPageInfo(current, totalPages) }), - ]); - return row; - }; - - const updateSessionPagers = () => { - sessionPagerTop.innerHTML = ""; - sessionPagerBottom.innerHTML = ""; - sessionPagerTop.appendChild(renderSessionPager()); - sessionPagerBottom.appendChild(renderSessionPager()); - }; - detailRoleFilter.addEventListener("change", () => { - try { - localStorage.setItem(SESSION_MONITOR_ROLE_FILTER_KEY, String(detailRoleFilter.value || "all")); - } catch (_) {} - detailPage = 1; - renderDetailMessages(); - }); - - const loadSessions = async () => { - sessBody.innerHTML = ""; - if (!selectedUserId) { - sessBody.appendChild(el("tr", {}, [el("td", { text: t("sessionMonitor.selectUserFirst"), colspan: "8" })])); - selectedHint.textContent = t("sessionMonitor.selectUserFirst"); - sessionTotal = 0; - updateSessionPagers(); - return; - } - const sp = new URLSearchParams(); - sp.set("user_id", selectedUserId); - sp.set("limit", String(sessionLimit)); - sp.set("offset", String(sessionOffset)); - if (sessQ.value.trim()) sp.set("q", sessQ.value.trim()); - if (activeOnlyChk.checked) sp.set("active_only", "1"); - const resp = await apiGet("/admin/api/chat/admin/sessions?" + sp.toString()); - const rows = Array.isArray(resp.sessions) ? resp.sessions : []; - sessionTotal = Number(resp.total || 0); - selectedHint.textContent = `${t("table.userId")}: ${selectedUserId}`; - if (!rows.length) { - sessBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); - if (selectedSessionId && !rows.some((x) => String(x.session_id || "") === selectedSessionId)) { - selectedSessionId = ""; - detailMessages = []; - renderDetailMessages(); - } - updateSessionPagers(); - return; - } - rows.forEach((r) => { - const sid = String(r.session_id || ""); - const btnMore = el("button", { - class: "chat-sess-more" + (sid === selectedSessionId ? " chat-sess-more--active" : ""), - text: "⋯", - title: t("chat.sessionMenu"), - onclick: (ev) => { - ev.stopPropagation(); - closeSessionMenu(); - const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ - el("button", { - class: "chat-sess-menu-item", - text: t("sessionMonitor.viewDetail"), - onclick: async () => { - closeSessionMenu(); - await loadSessionDetail(sid, { openModal: true }); - }, - }), - el("button", { - class: "chat-sess-menu-item", - text: t("sessionMonitor.viewAudit"), - onclick: () => { - closeSessionMenu(); - location.hash = `#/audit?session_id=${encodeURIComponent(sid)}`; - }, - }), - el("button", { - class: "chat-sess-menu-item", - text: t("sessionMonitor.exportMd"), - onclick: async () => { - closeSessionMenu(); - await downloadSessionExport(sid, "md"); - }, - }), - el("button", { - class: "chat-sess-menu-item", - text: t("sessionMonitor.exportJson"), - onclick: async () => { - closeSessionMenu(); - await downloadSessionExport(sid, "json"); - }, - }), - ]); - const rect = ev.currentTarget.getBoundingClientRect(); - document.body.appendChild(menu); - // Clamp into viewport; flip above if near bottom. - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left - 120; - 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`; - sessionMenuEl = menu; - const close = (e) => { - if (!menu.contains(e.target)) { - closeSessionMenu(); - document.removeEventListener("click", close); - } - }; - setTimeout(() => document.addEventListener("click", close), 0); - }, - }); - sessBody.appendChild( - el( - "tr", - { - "data-session-id": sid, - class: sid === selectedSessionId ? "session-monitor-row--active" : "", - onclick: async () => { - selectedSessionId = sid; - closeSessionMenu(); - const sessionRows = Array.from(sessBody.querySelectorAll("tr[data-session-id]")); - sessionRows.forEach((tr) => { - const curr = String(tr.getAttribute("data-session-id") || ""); - tr.classList.toggle("session-monitor-row--active", curr === selectedSessionId); - }); - }, - }, - [ - tdCell(sid, 24), - tdCell(r.title || "", 30), - tdCell(r.username || "", 16), - tdCell(r.message_count || "", 10), - tdCell(r.last_message_at || "", 24), - tdCell(r.is_active_30m ? "yes" : "no", 8), - el("td", { class: "table__cell-actions" }, [btnMore]), - ], - ), - ); - }); - updateSessionPagers(); - }; - - const loadUsers = async () => { - const p = new URLSearchParams(); - p.set("limit", "200"); - if (userQ.value.trim()) p.set("q", userQ.value.trim()); - const resp = await apiGet("/admin/api/chat/admin/user-stats?" + p.toString()); - renderTotals(resp.totals || {}); - const rows = Array.isArray(resp.users) ? resp.users : []; - userBody.innerHTML = ""; - if (!rows.length) { - userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "9" })])); - selectedUserId = ""; - selectedSessionId = ""; - detailMessages = []; - await loadSessions(); - return; - } - if (!selectedUserId || !rows.some((x) => String(x.user_id || "") === selectedUserId)) { - selectedUserId = String(rows[0].user_id || ""); - } - rows.forEach((r) => { - const uid = String(r.user_id || ""); - const pickBtn = el("button", { - class: "btn btn--small", - "data-user-pick": "1", - "data-user-id": uid, - text: uid === selectedUserId ? "●" : "○", - onclick: async () => { - selectedUserId = uid; - sessionOffset = 0; - selectedSessionId = ""; - detailMessages = []; - const pickBtns = Array.from(userBody.querySelectorAll("button[data-user-pick='1']")); - pickBtns.forEach((btnEl) => { - const id = String(btnEl.getAttribute("data-user-id") || ""); - btnEl.textContent = id === selectedUserId ? "●" : "○"; - }); - await loadSessions(); - }, - }); - userBody.appendChild( - el("tr", {}, [ - el("td", {}, [pickBtn]), - tdCell(r.username || "", 16), - tdCell(r.display_name || "", 16), - tdCell(r.role || "", 10), - tdCell(r.sessions_count || "", 10), - tdCell(r.active_sessions_30m || "", 10), - tdCell(r.active_login_30m || "", 10), - tdCell(r.total_tokens_est || "", 14), - tdCell(r.last_message_at || "", 24), - ]), - ); - }); - await loadSessions(); - }; - - const btnQueryUsers = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: loadUsers }); - const btnQuerySessions = el("button", { - class: "btn", - text: t("audit.query"), - onclick: async () => { - sessionOffset = 0; - selectedSessionId = ""; - detailMessages = []; - await loadSessions(); - }, - }); - const activeOnlyLabel = el("label", { class: "row" }, [ - activeOnlyChk, - el("span", { class: "muted", text: t("sessionMonitor.activeSessions30m") }), - ]); - - await loadUsers(); - rebuildDetailRoleFilter(); - const btnCloseDetail = el("button", { - class: "btn btn--small", - text: t("sessionMonitor.closeDetail"), - onclick: () => { - selectedSessionId = ""; - detailMessages = []; - detailBody.innerHTML = ""; - detailStatus.textContent = t("sessionMonitor.noMessages"); - detailPager.innerHTML = ""; - closeDetailModal(); - loadSessions().catch(() => {}); - }, - }); - const detailTable = el("table", { class: "table table--compact session-monitor-detail-table" }, [ - el("thead", {}, [ - el("tr", {}, [ - el("th", { text: "id" }), - el("th", { text: "role" }), - el("th", { text: "content" }), - el("th", { text: t("table.timestamp") }), - ]), - ]), - detailBody, - ]); - detailModalCard.appendChild( - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("sessionMonitor.messages") }), - el("div", { class: "row" }, [ - el("span", { class: "muted", text: t("sessionMonitor.roleFilter") }), - detailRoleFilter, - btnCloseDetail, - ]), - detailStatus, - detailPager, - el("div", { class: "table-wrap" }, [ - detailTable, - ]), - ]), - ); - enableTableColumnResize(detailTable, [2, 3]); - detailModal.appendChild(detailModalCard); - detailModal.addEventListener("click", (ev) => { - if (ev.target === detailModal) closeDetailModal(); - }); - return renderPageShell({ - title: t("title.sessionMonitor"), - subtitle: "按用户、会话与消息详情进行巡检与导出", - sections: [ - { id: "sm-totals", label: "总览" }, - { id: "sm-users", label: "用户" }, - { id: "sm-sessions", label: "会话" }, - ], - }, [ - renderSectionCard(t("sessionMonitor.totals"), "", [totalsWrap], { id: "sm-totals" }), - renderSectionCard(t("sessionMonitor.userStats"), "", [ - el("div", { class: "row" }, [userQ, btnQueryUsers]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [ - el("tr", {}, [ - el("th", { text: "#" }), - el("th", { text: t("table.username") }), - el("th", { text: t("table.name") }), - el("th", { text: t("tenants.role") }), - el("th", { text: t("table.sessionsCount") }), - el("th", { text: t("table.activeSessions30m") }), - el("th", { text: t("table.activeLogins30m") }), - el("th", { text: t("table.totalTokensEst") }), - el("th", { text: t("table.timestamp") }), - ]), - ]), - userBody, - ]), - ]), - ], { id: "sm-users" }), - renderSectionCard(t("sessionMonitor.sessions"), "", [ - el("div", { class: "row" }, [sessQ, activeOnlyLabel, btnQuerySessions]), - selectedHint, - sessionPagerTop, - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact session-monitor-session-table" }, [ - el("thead", {}, [ - el("tr", {}, [ - el("th", { text: "session_id" }), - el("th", { text: t("table.name") }), - el("th", { text: t("table.username") }), - el("th", { text: "msg_count" }), - el("th", { text: t("table.timestamp") }), - el("th", { text: t("table.activeSessions30m") }), - el("th", { text: t("table.action") }), - ]), - ]), - sessBody, - ]), - ]), - sessionPagerBottom, - ], { id: "sm-sessions" }), - detailModal, - ]); -} - -async function renderWorkspacePaths() { - const sessionTid = String((authSession && authSession.tenant_id) || "").trim(); - const sessionUid = String((authSession && authSession.user_id) || "").trim(); - const canUserRead = hasPermission("admin:user:read"); - const canWsRead = hasPermission("admin:workspace_paths:read"); - const canWsWrite = hasPermission("admin:workspace_paths:write"); - const selfService = !canUserRead && canWsRead; - if (!sessionTid || (!canUserRead && !canWsRead)) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.workspacePaths") }), - el("div", { class: "muted", text: t("common.forbidden") }), - ]); - } - if (selfService && !sessionUid) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.workspacePaths") }), - el("div", { class: "muted", text: t("tenants.noSessionTenant") }), - ]); - } - let allTenants = []; - try { - const allTenantsResp = await apiGet("/admin/api/tenants"); - allTenants = allTenantsResp.tenants || []; - } catch (_) { - allTenants = [{ id: sessionTid, name: "", created_at: "" }]; - } - if (!allTenants.length) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.workspacePaths") }), - el("div", { class: "muted", text: t("tenants.noTenants") }), - ]); - } - const tenantSel = el("select", { class: "input" }); - for (const item of allTenants) { - const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8); - tenantSel.appendChild(el("option", { value: String(item.id || ""), text: optLabel })); - } - const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid); - tenantSel.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0; - if (selfService) { - tenantSel.disabled = true; - tenantSel.title = t("workspacePaths.selfOnly"); - } - const userSel = el("select", { class: "input" }); - const selfUserLabel = el("div", { - class: "muted", - text: formatUserLabel({ - username: authSession && authSession.username, - display_name: authSession && authSession.display_name, - }) + (sessionUid ? ` · ${sessionUid.slice(0, 8)}…` : ""), - }); - if (selfService) { - userSel.style.display = "none"; - } - const extraInput = el("textarea", { - class: "input", - rows: "4", - placeholder: "D:\\\\|E:\\\\|D:\\\\repos\\\\other", - style: "min-height:88px;font-family:monospace;", - }); - const allowAnyCb = el("input", { type: "checkbox" }); - const allowHighToolsCb = el("input", { type: "checkbox" }); - const status = el("div", { class: "muted", text: "" }); - const canWrite = hasPermission("admin:user:write") || canWsWrite; - const canTogglePublicHigh = hasPermission("admin:user:write"); - if (!canTogglePublicHigh) { - allowHighToolsCb.disabled = true; - } - - const getEffectiveTid = () => (selfService ? sessionTid : String(tenantSel.value || "")); - const getEffectiveUid = () => (selfService ? sessionUid : String(userSel.value || "")); - - const loadUsers = async () => { - if (selfService) return; - const tid = String(tenantSel.value || ""); - userSel.innerHTML = ""; - if (!tid) return; - const resp = await apiGet( - "/admin/api/users?tenant_id=" + - encodeURIComponent(tid) + - "&include_inactive=1&q=&limit=500", - ); - const users = Array.isArray(resp.users) ? resp.users : []; - for (const u of users) { - const id = String(u.id || ""); - if (!id) continue; - const label = formatUserLabel(u); - userSel.appendChild(el("option", { value: id, text: label })); - } - if (userSel.options.length) userSel.selectedIndex = 0; - }; - - const loadPolicy = async () => { - const tid = getEffectiveTid(); - const uid = getEffectiveUid(); - if (!tid || !uid) { - status.textContent = ""; - return; - } - status.textContent = "…"; - try { - const r = await apiGet( - "/admin/api/users/workspace-path-policy?tenant_id=" + - encodeURIComponent(tid) + - "&user_id=" + - encodeURIComponent(uid), - ); - if (!r.ok) { - status.textContent = String(r.error || "error"); - return; - } - const pol = r.policy || {}; - extraInput.value = String(pol.extra_roots || ""); - allowAnyCb.checked = !!pol.allow_any_path; - allowHighToolsCb.checked = !!r.public_tools_allow_high; - status.textContent = (r.from_db ? t("workspacePaths.fromDb") + " · " : "") + JSON.stringify(pol); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }; - - tenantSel.addEventListener("change", async () => { - await loadUsers(); - await loadPolicy(); - }); - userSel.addEventListener("change", () => { - loadPolicy(); - }); - - await loadUsers(); - await loadPolicy(); - - const loadBtn = el("button", { - class: "btn", - text: t("workspacePaths.load"), - onclick: () => loadPolicy(), - }); - const saveBtn = el("button", { - class: "btn btn--primary", - text: t("workspacePaths.save"), - disabled: canWrite ? undefined : "disabled", - onclick: async () => { - if (!canWrite) return; - const tid = getEffectiveTid(); - const uid = getEffectiveUid(); - if (!tid || !uid) return; - status.textContent = "…"; - try { - const payload = { - tenant_id: tid, - user_id: uid, - extra_roots: extraInput.value, - allow_any_path: !!allowAnyCb.checked, - }; - if (canTogglePublicHigh) { - payload.public_tools_allow_high = !!allowHighToolsCb.checked; - } - const r = await apiPost("/admin/api/users/workspace-path-policy", payload); - if (!r.ok) { - status.textContent = String(r.error || "error"); - return; - } - await loadPolicy(); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }, - }); - - const userRowChildren = selfService ? [selfUserLabel] : [userSel]; - const cardParts = [ - el("div", { class: "card__title", text: t("title.workspacePaths") }), - el("div", { class: "muted", text: t("workspacePaths.help") }), - ]; - if (selfService) { - cardParts.push(el("div", { class: "muted u-mt-6", text: t("workspacePaths.selfOnly") })); - } - cardParts.push( - el("div", { class: "u-h-10" }), - el("div", { class: "row" }, [ - el("label", { text: t("workspacePaths.tenant") }), - tenantSel, - ]), - el("div", { class: "row" }, [ - el("label", { text: t("workspacePaths.user") }), - ...userRowChildren, - ]), - el("div", { class: "row" }, [el("label", { text: t("workspacePaths.extraRoots") })]), - extraInput, - el("label", { class: "row", style: "align-items:center;gap:8px;margin-top:8px;" }, [ - allowAnyCb, - el("span", { text: t("workspacePaths.allowAny") }), - ]), - el("div", { class: "muted u-muted-block", text: t("workspacePaths.allowAnyHint") }), - el("label", { class: "row", style: "align-items:center;gap:8px;margin-top:10px;" }, [ - allowHighToolsCb, - el("span", { text: t("workspacePaths.allowHighTools") }), - ]), - el("div", { class: "muted u-muted-block", text: t("workspacePaths.allowHighToolsHint") }), - el("div", { class: "row", style: "margin-top:10px;gap:8px;" }, [loadBtn, saveBtn]), - el("div", { class: "muted u-mt-8" }, [el("span", { text: t("workspacePaths.status") + ": " }), status]), - ); - return el("div", { class: "card" }, cardParts); -} - -async function renderAttachments() { - const status = el("div", { class: "muted", text: "" }); - const hint = el("div", { class: "muted", style: "line-height:1.45;", text: t("attachments.excelPolicyHint") }); - const loadBtn = el("button", { class: "btn", type: "button", text: t("action.refresh") }); - const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("attachments.save") }); - const resetBtn = el("button", { class: "btn", type: "button", text: t("attachments.resetDefaults") }); - - const rowInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const colInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const cellInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const maxSheetsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const largePreviewRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const toolEnabledInput = el("input", { type: "checkbox" }); - const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" }); - const imageReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" }); - const videoReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" }); - const videoTranscriptChunkSizeInput = el("input", { class: "input", type: "number", min: "1", max: "8000", step: "1" }); - const videoTranscriptChunkOverlapInput = el("input", { class: "input", type: "number", min: "1", max: "4000", step: "1" }); - const archiveMaxDepthInput = el("input", { class: "input", type: "number", min: "1", max: "10", step: "1" }); - const archiveMaxFileCountInput = el("input", { class: "input", type: "number", min: "1", max: "20000", step: "1" }); - const archiveMaxEntryBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - const archiveMaxTotalBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); - - const clampTimeout = (raw, fallback = 8000) => { - const n = parseInt(String(raw ?? "").trim(), 10); - if (!Number.isFinite(n)) return fallback; - return Math.max(100, Math.min(120000, n)); - }; - - const parsePositiveInt = (v) => { - const n = parseInt(String(v ?? "").trim(), 10); - if (!Number.isFinite(n) || n < 1) return null; - return n; - }; - - const readUiLimits = () => { - const rows = parsePositiveInt(rowInput.value); - const cols = parsePositiveInt(colInput.value); - const chars = parsePositiveInt(cellInput.value); - const maxSheets = parsePositiveInt(maxSheetsInput.value); - const previewRows = parsePositiveInt(largePreviewRowsInput.value); - const minRows = parsePositiveInt(toolMinRowsInput.value); - const maxBytes = parsePositiveInt(toolMaxBytesInput.value); - const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value); - const imageReplayCapChars = parsePositiveInt(imageReplayCapInput.value); - const videoReplayCapChars = parsePositiveInt(videoReplayCapInput.value); - const videoTranscriptChunkSize = parsePositiveInt(videoTranscriptChunkSizeInput.value); - const videoTranscriptChunkOverlap = parsePositiveInt(videoTranscriptChunkOverlapInput.value); - const archiveMaxDepth = parsePositiveInt(archiveMaxDepthInput.value); - const archiveMaxFileCount = parsePositiveInt(archiveMaxFileCountInput.value); - const archiveMaxEntryBytes = parsePositiveInt(archiveMaxEntryBytesInput.value); - const archiveMaxTotalBytes = parsePositiveInt(archiveMaxTotalBytesInput.value); - if ( - rows === null || - cols === null || - chars === null || - maxSheets === null || - previewRows === null || - minRows === null || - maxBytes === null || - sqlTimeoutMs === null || - imageReplayCapChars === null || - videoReplayCapChars === null || - videoTranscriptChunkSize === null || - videoTranscriptChunkOverlap === null || - archiveMaxDepth === null || - archiveMaxFileCount === null || - archiveMaxEntryBytes === null || - archiveMaxTotalBytes === null - ) { - throw new Error(t("attachments.invalidNumber")); - } - if (previewRows > 200 && !window.confirm(t("attachments.highPreviewWarn"))) { - throw new Error("cancelled"); - } - return { - max_rows_read: rows, - max_columns: cols, - max_cell_chars: chars, - max_excel_sheets: maxSheets, - large_table_preview_rows: previewRows, - tool_mode_enabled: !!toolEnabledInput.checked, - tool_mode_min_rows: minRows, - tool_mode_max_bytes: maxBytes, - sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000), - image_result_replay_cap_chars: Math.max(600, Math.min(30000, imageReplayCapChars)), - video_result_replay_cap_chars: Math.max(600, Math.min(30000, videoReplayCapChars)), - video_transcript_chunk_size: Math.max(1, Math.min(8000, videoTranscriptChunkSize)), - video_transcript_chunk_overlap: Math.max(1, Math.min(4000, videoTranscriptChunkOverlap)), - archive_max_depth: Math.max(1, Math.min(10, archiveMaxDepth)), - archive_max_file_count: Math.max(1, Math.min(20000, archiveMaxFileCount)), - archive_max_entry_bytes: Math.max(1, archiveMaxEntryBytes), - archive_max_total_uncompressed_bytes: Math.max(1, archiveMaxTotalBytes), - }; - }; - - const applyUiLimits = (limits) => { - const l = limits && typeof limits === "object" ? limits : {}; - rowInput.value = String(l.max_rows_read || 5000); - colInput.value = String(l.max_columns || 200); - cellInput.value = String(l.max_cell_chars || 500); - maxSheetsInput.value = String(l.max_excel_sheets || 50); - largePreviewRowsInput.value = String(l.large_table_preview_rows || 20); - toolEnabledInput.checked = !!l.tool_mode_enabled; - toolMinRowsInput.value = String(l.tool_mode_min_rows || 5000); - toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280); - sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000)); - imageReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.image_result_replay_cap_chars) || 4000))); - videoReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.video_result_replay_cap_chars) || 4000))); - videoTranscriptChunkSizeInput.value = String(Math.max(1, Math.min(8000, parsePositiveInt(l.video_transcript_chunk_size) || 1600))); - videoTranscriptChunkOverlapInput.value = String(Math.max(1, Math.min(4000, parsePositiveInt(l.video_transcript_chunk_overlap) || 200))); - archiveMaxDepthInput.value = String(Math.max(1, Math.min(10, parsePositiveInt(l.archive_max_depth) || 2))); - archiveMaxFileCountInput.value = String(Math.max(1, Math.min(20000, parsePositiveInt(l.archive_max_file_count) || 200))); - archiveMaxEntryBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_entry_bytes) || 10485760)); - archiveMaxTotalBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_total_uncompressed_bytes) || 52428800)); - }; - - const load = async () => { - status.textContent = t("chat.loading"); - try { - const r = await apiGet("/admin/api/chat/settings/attachment-limits"); - const limits = r && typeof r.limits === "object" ? r.limits : {}; - applyUiLimits(limits); - status.textContent = ""; - } catch (_) { - status.textContent = t("attachments.loadError"); - } - }; - - const save = async () => { - status.textContent = t("chat.sending"); - try { - const next = readUiLimits(); - await apiPost("/admin/api/chat/settings/attachment-limits", { limits: next }); - status.textContent = t("attachments.saved"); - setTimeout(() => { - if (status.textContent === t("attachments.saved")) status.textContent = ""; - }, 1500); - } catch (e) { - if (String(e && e.message ? e.message : e) === "cancelled") { - status.textContent = ""; - return; - } - status.textContent = `${t("common.error")}: ${String(e)}`; - } - }; - - const reset = async () => { - status.textContent = t("chat.sending"); - try { - const resp = await apiPost("/admin/api/chat/settings/attachment-limits", { limits: null }); - const limits = resp && typeof resp.limits === "object" ? resp.limits : {}; - applyUiLimits(limits); - status.textContent = t("attachments.saved"); - setTimeout(() => { - if (status.textContent === t("attachments.saved")) status.textContent = ""; - }, 1500); - } catch (e) { - status.textContent = `${t("common.error")}: ${String(e)}`; - } - }; - - loadBtn.addEventListener("click", load); - saveBtn.addEventListener("click", save); - resetBtn.addEventListener("click", reset); - await load(); - - const inputRow = (labelText, inputEl) => - el("div", { style: "display:grid;gap:6px;margin-top:8px;" }, [ - el("div", { class: "muted", text: labelText }), - inputEl, - ]); - - return renderPageShell({ - title: t("attachments.title"), - subtitle: "附件处理阈值、预算与回放策略配置", - actions: [loadBtn, resetBtn, saveBtn], - sections: [ - { id: "attach-overview", label: "说明" }, - { id: "attach-table", label: "表格" }, - { id: "attach-media", label: "图像/视频" }, - { id: "attach-archive", label: "压缩包" }, - ], - }, [ - renderSectionCard("策略说明", t("attachments.excelPolicy"), [hint], { id: "attach-overview" }), - el("div", { class: "page-grid page-grid--two" }, [ - renderSectionCard("表格与工具模式", "", [ - inputRow(t("attachments.maxRowsRead"), rowInput), - inputRow(t("attachments.maxColumns"), colInput), - inputRow(t("attachments.maxCellChars"), cellInput), - inputRow(t("attachments.maxExcelSheets"), maxSheetsInput), - inputRow(t("attachments.largePreviewRows"), largePreviewRowsInput), - el("label", { class: "muted", style: "display:flex;gap:8px;align-items:center;margin-top:10px;cursor:pointer;" }, [ - toolEnabledInput, - el("span", { text: t("attachments.toolModeEnabled") }), - ]), - inputRow(t("attachments.toolModeMinRows"), toolMinRowsInput), - inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput), - inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput), - el("div", { class: "muted u-muted-block", text: t("attachments.sqlTimeoutHint") }), - ], { id: "attach-table" }), - renderSectionCard("图像与视频策略", "", [ - inputRow(t("attachments.imageReplayCapChars"), imageReplayCapInput), - el("div", { class: "muted u-muted-block", text: t("attachments.imageReplayCapHint") }), - inputRow(t("attachments.videoReplayCapChars"), videoReplayCapInput), - el("div", { class: "muted u-muted-block", text: t("attachments.videoReplayCapHint") }), - inputRow(t("attachments.videoTranscriptChunkSize"), videoTranscriptChunkSizeInput), - inputRow(t("attachments.videoTranscriptChunkOverlap"), videoTranscriptChunkOverlapInput), - el("div", { class: "muted u-muted-block", text: t("attachments.videoTranscriptChunkHint") }), - ], { id: "attach-media" }), - ]), - renderSectionCard("压缩包预算策略", "", [ - inputRow(t("attachments.archiveMaxDepth"), archiveMaxDepthInput), - inputRow(t("attachments.archiveMaxFileCount"), archiveMaxFileCountInput), - inputRow(t("attachments.archiveMaxEntryBytes"), archiveMaxEntryBytesInput), - inputRow(t("attachments.archiveMaxTotalBytes"), archiveMaxTotalBytesInput), - el("div", { class: "muted u-muted-block", text: t("attachments.archivePolicyHint") }), - el("div", { class: "u-h-8" }), - status, - ], { id: "attach-archive" }), - ]); -} - -async function renderProfile() { - const status = el("div", { class: "muted", text: "" }); - const dnInput = el("input", { class: "input", type: "text", maxlength: "120" }); - const metaUser = el("div", { class: "muted pre", text: "" }); - const metaIds = el("div", { class: "muted pre", text: "" }); - const avatarPreview = el("img", { - class: "profile-avatar-preview u-hidden u-avatar-chip", - alt: "", - }); - const avatarRow = el("div", { class: "row", style: "align-items:center;gap:16px;flex-wrap:wrap;" }, [ - el("div", {}, [avatarPreview]), - el("div", { style: "min-width:200px;flex:1;" }, [ - el("div", { class: "muted", style: "margin-bottom:6px;", text: t("profile.avatar") }), - el("div", { class: "muted", style: "margin-bottom:8px;font-size:12px;line-height:1.4;", text: t("profile.avatarHint") }), - ]), - ]); - const fileInput = el("input", { class: "u-hidden", type: "file", accept: "image/png,image/jpeg,image/jpg,image/webp,image/gif" }); - const chooseBtn = el("button", { class: "btn", type: "button", text: t("profile.chooseImage") }); - const removeBtn = el("button", { class: "btn", type: "button", text: t("profile.removeAvatar") }); - const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("profile.save") }); - const chatBtn = el("button", { class: "btn", type: "button", text: t("profile.openChat") }); - - function showBuiltinDefaultAvatar() { - try { - const prev = avatarPreview.dataset.blobUrl; - if (prev) URL.revokeObjectURL(prev); - } catch (_) {} - delete avatarPreview.dataset.blobUrl; - avatarPreview.src = resolveAdminApiUrl("/admin/assets/default-user-avatar.svg"); - avatarPreview.style.objectFit = "contain"; - avatarPreview.style.padding = "5px"; - avatarPreview.style.background = "rgba(255,255,255,0.08)"; - avatarPreview.style.display = "block"; - } - - /** cannot send Authorization; load avatar via fetch + blob */ - async function refreshAvatarBlob(p) { - const aid = p && String(p.avatar_attachment_id || "").trim(); - if (!aid) { - showBuiltinDefaultAvatar(); - return; - } - try { - const tok = getStoredAuthToken(); - const u = resolveAdminApiUrl(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`); - const res = await fetch(u, { headers: tok ? { authorization: `Bearer ${tok}` } : {} }); - if (!res.ok) throw new Error("avatar fetch"); - const blob = await res.blob(); - try { - const prev = avatarPreview.dataset.blobUrl; - if (prev) URL.revokeObjectURL(prev); - } catch (_) {} - const url = URL.createObjectURL(blob); - avatarPreview.dataset.blobUrl = url; - avatarPreview.src = url; - avatarPreview.style.objectFit = "cover"; - avatarPreview.style.padding = "0"; - avatarPreview.style.background = "transparent"; - avatarPreview.style.display = "block"; - } catch (_) { - showBuiltinDefaultAvatar(); - } - } - - async function load() { - status.textContent = t("chat.loading"); - try { - const r = await apiGet("/admin/api/chat/profile"); - if (!r || !r.ok || !r.profile) throw new Error("profile"); - const p = r.profile; - dnInput.value = String(p.display_name || ""); - metaUser.textContent = `${t("profile.username")}: ${String(p.username || "—")}\n${t("profile.role")}: ${String(p.role || "—")}`; - metaIds.textContent = `${t("profile.userId")}: ${String(p.id || "—")}\n${t("profile.tenantId")}: ${String(p.tenant_id || "—")}\n${t("profile.createdAt")}: ${String(p.created_at || "—")}`; - await refreshAvatarBlob(p); - status.textContent = ""; - } catch (e) { - status.textContent = `${t("profile.loadError")}: ${String(e && e.message ? e.message : e)}`; - } - } - - chooseBtn.addEventListener("click", () => fileInput.click()); - fileInput.addEventListener("change", async () => { - const f = fileInput.files && fileInput.files[0]; - fileInput.value = ""; - if (!f) return; - status.textContent = t("chat.sending"); - try { - const fd = new FormData(); - fd.append("file", f, f.name || "avatar.png"); - const r = await apiPostFormData("/admin/api/chat/profile/avatar", fd); - if (!r || !r.ok) throw new Error("upload"); - status.textContent = t("profile.saved"); - await load(); - } catch (e) { - status.textContent = `${t("profile.uploadError")}: ${String(e && e.message ? e.message : e)}`; - } - }); - removeBtn.addEventListener("click", async () => { - status.textContent = t("chat.sending"); - try { - await apiDeleteJson("/admin/api/chat/profile/avatar"); - status.textContent = t("profile.saved"); - await load(); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }); - saveBtn.addEventListener("click", async () => { - status.textContent = t("chat.sending"); - try { - await apiRequest("PATCH", "/admin/api/chat/profile", { display_name: dnInput.value.trim() }); - status.textContent = t("profile.saved"); - await load(); - try { - const sess = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "{}"); - if (sess && typeof sess === "object") { - sess.display_name = dnInput.value.trim(); - authStoreSet(AUTH_SESSION_KEY, JSON.stringify(sess)); - authSession = sess; - } - } catch (_) {} - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }); - chatBtn.addEventListener("click", () => { - window.location.assign(resolveChatUrl()); - }); - - await load(); - - const profileCard = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.profile") }), - el("div", { class: "muted", style: "margin-bottom:10px;line-height:1.45;", text: t("profile.help") }), - el("div", { class: "row" }, [el("label", { text: t("profile.displayName") }), dnInput]), - el("div", { class: "u-h-8" }), - avatarRow, - el("div", { class: "row", style: "margin-top:10px;gap:8px;flex-wrap:wrap;" }, [fileInput, chooseBtn, removeBtn]), - el("div", { class: "row", style: "margin-top:12px;gap:8px;flex-wrap:wrap;" }, [saveBtn, chatBtn]), - el("div", { class: "u-h-8" }), - el("div", { style: "margin-top:14px" }, [metaUser]), - el("div", { class: "u-mt-6" }, [metaIds]), - el("div", { class: "muted u-mt-10" }, [status]), - ]); - return el("div", {}, [profileCard]); -} - -async function renderSkills() { - const SKILL_AUDIT_RETRYABLE_ONLY_KEY = "ops_admin_skill_audit_retryable_only"; - const status = el("div", { class: "muted", text: "" }); - const rowsState = { items: [] }; - const auditState = { items: [] }; - const nameInp = el("input", { class: "input", placeholder: "skill name" }); - const descInp = el("input", { class: "input", placeholder: "description" }); - const bodyInp = el("textarea", { class: "input", rows: "4", placeholder: "SKILL.md body markdown" }); - const regInp = el("input", { class: "input", placeholder: "registry archive URL (zip/tar)" }); - const localDirInp = el("input", { class: "input", placeholder: "local skill dir (contains SKILL.md)" }); - const marketStatus = el("div", { class: "muted", text: "" }); - const skillModeStatus = el("div", { class: "muted", text: "" }); - const skillPromptModeCb = el("input", { type: "checkbox" }); - const skillMarketProviderSelect = el("select", { class: "input", style: "min-width:160px;" }, [ - el("option", { value: "clawhub", text: "clawhub (ClawHub)" }), - ]); - const marketQ = el("input", { class: "input", placeholder: "search skills (keyword)" }); - const marketLimitInp = el("input", { class: "input", placeholder: "limit", value: "40", style: "max-width:120px;" }); - const marketTbody = el("tbody"); - const marketDetailPre = el("pre", { class: "muted pre", text: "" }); - let marketItems = []; - - const loadSkillMode = async () => { - try { - const r = await apiGet("/admin/api/skills/mode"); - skillPromptModeCb.checked = !!r.prompt_in_system; - skillMarketProviderSelect.value = "clawhub"; - skillModeStatus.textContent = ""; - } catch (e) { - skillModeStatus.textContent = `mode: ${String(e && e.message ? e.message : e)}`; - } - }; - const saveSkillMode = async () => { - skillModeStatus.textContent = `${t("chat.loading")}...`; - try { - const r = await apiPost("/admin/api/skills/mode", { - prompt_in_system: !!skillPromptModeCb.checked, - market_provider: "clawhub", - }); - skillPromptModeCb.checked = !!r.prompt_in_system; - skillMarketProviderSelect.value = "clawhub"; - skillModeStatus.textContent = `saved: prompt=${String(!!r.prompt_in_system)} market=clawhub`; - } catch (e) { - skillModeStatus.textContent = `mode: ${String(e && e.message ? e.message : e)}`; - } - }; - - const loadMarket = async (q) => { - const qq = String(q || "").trim(); - const lim = Math.max(1, Math.min(200, parseInt(String(marketLimitInp.value || "40"), 10) || 40)); - marketStatus.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet(`/admin/api/skills/market/search?q=${encodeURIComponent(qq)}&limit=${encodeURIComponent(String(lim))}`); - marketItems = Array.isArray(resp.items) ? resp.items : []; - marketStatus.textContent = `results: ${marketItems.length}`; - repaintMarket(); - } catch (e) { - marketItems = []; - marketStatus.textContent = `${t("common.error")}: ${String(e && e.message ? e.message : e)}`; - repaintMarket(); - } - }; - - const loadMarketDetail = async (slug) => { - const s = String(slug || "").trim(); - if (!s) return; - marketDetailPre.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet(`/admin/api/skills/market/detail?slug=${encodeURIComponent(s)}`); - marketDetailPre.textContent = JSON.stringify(resp.detail || {}, null, 2); - } catch (e) { - marketDetailPre.textContent = `${t("common.error")}: ${String(e && e.message ? e.message : e)}`; - } - }; - - const installFromMarket = async (slug, version) => { - const s = String(slug || "").trim(); - if (!s) return; - marketStatus.textContent = `installing: ${s}...`; - openSkillInstallModal(`Installing ${s}...`); - try { - const r = await apiPost("/admin/api/skills/market/install", { slug: s, version: version ? String(version) : undefined, overwrite: false }); - assertSkillMutationOk(r, "Market install failed"); - status.textContent = `install-market success: ${JSON.stringify(r.result || {})}`; - marketStatus.textContent = `installed: ${s}`; - await refreshSkillsState(); - finishSkillInstallModal(true, `${s} installed successfully.`); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - marketStatus.textContent = `install failed: ${s}`; - finishSkillInstallModal(false, `Install failed: ${String(e && e.message ? e.message : e)}`); - } - }; - - const repaintMarket = () => { - marketTbody.innerHTML = ""; - (marketItems || []).forEach((x) => { - const slug = String(x.slug || ""); - const ver = String(x.version || ""); - const btnDetail = el("button", { class: "btn btn--small", text: "Detail", onclick: async () => await loadMarketDetail(slug) }); - marketTbody.appendChild( - el("tr", {}, [ - el("td", { text: slug }), - el("td", { text: String(x.name || "") }), - el("td", { text: ver }), - el("td", { text: shortText(String(x.description || ""), 80) }), - el("td", {}, [btnDetail]), - ]), - ); - }); - }; - - const btnMarketSearch = el("button", { class: "btn", text: "Search", onclick: async () => await loadMarket(marketQ.value) }); - const btnMarketLatest = el("button", { class: "btn", text: "Latest", onclick: async () => await loadMarket("") }); - const marketBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Skill market (ClawHub / CocoLoop)" }), - el("div", { class: "u-h-8" }), - el("div", { class: "row u-row-wrap-mb" }, [marketQ, marketLimitInp, btnMarketSearch, btnMarketLatest]), - marketStatus, - el("div", { class: "u-h-8" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "slug" }), el("th", { text: "name" }), el("th", { text: "version" }), el("th", { text: "description" }), el("th", { text: "action" })])]), - marketTbody, - ]), - ]), - el("div", { class: "u-h-8" }), - el("div", { class: "muted", text: "Detail" }), - marketDetailPre, - ]); - - const skillBindingState = { roles: [], names: [], mapping: {}, enabled: false }; - const skillBindingStatus = el("div", { class: "muted", text: "" }); - const skillBindingPersistHint = el("div", { - class: "muted", - style: "margin:6px 0;line-height:1.5;", - text: "Checkboxes and per-role skill lists are not saved until you click Save skill role binding.", - }); - const skillBindingEnvHint = el("div", { - class: "muted", - style: "margin:6px 0;line-height:1.5;display:none;", - }); - const skillEffectiveState = { items: [] }; - const skillBindingEnabledCb = el("input", { type: "checkbox" }); - const skillRoleSelect = el("select", { class: "input" }, []); - const skillBindingListWrap = el("div"); - const skillBindingDashTbody = el("tbody"); - const skillEffectiveTbody = el("tbody"); - const renderSkillBindingDashboard = () => { - skillBindingDashTbody.innerHTML = ""; - const roles = Array.isArray(skillBindingState.roles) ? skillBindingState.roles : []; - const mapping = skillBindingState.mapping && typeof skillBindingState.mapping === "object" ? skillBindingState.mapping : {}; - roles.forEach((role) => { - const direct = new Set(Array.isArray(mapping[role]) ? mapping[role].map((x) => String(x)) : []); - const effective = new Set(Array.from(direct)); - skillBindingDashTbody.appendChild( - el("tr", {}, [ - el("td", { text: role }), - el("td", { text: String(direct.size) }), - el("td", { text: String(effective.size) }), - ]), - ); - }); - if (!roles.length) { - skillBindingDashTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "3" })])); - } - }; - const renderSkillEffectiveDashboard = () => { - skillEffectiveTbody.innerHTML = ""; - const rows = Array.isArray(skillEffectiveState.items) ? skillEffectiveState.items : []; - rows.forEach((x) => { - const names = Array.isArray(x.names_preview) ? x.names_preview.map((v) => String(v || "").trim()).filter(Boolean) : []; - const docsOnlyNames = Array.isArray(x.docs_only_names_preview) - ? x.docs_only_names_preview.map((v) => String(v || "").trim()).filter(Boolean) - : []; - const previewText = names.slice(0, 6).join(", "); - const namesCell = names.length - ? el("details", {}, [ - el("summary", { text: previewText || "(empty)" }), - el("div", { class: "muted u-mt-4 u-wrap-lh", text: names.join(", ") }), - ]) - : el("span", { class: "muted", text: "-" }); - const docsOnlyCell = docsOnlyNames.length - ? el("details", {}, [ - el("summary", { text: docsOnlyNames.slice(0, 4).join(", ") }), - el("div", { class: "muted u-mt-4 u-wrap-lh", text: docsOnlyNames.join(", ") }), - ]) - : el("span", { class: "muted", text: "-" }); - skillEffectiveTbody.appendChild( - el("tr", {}, [ - el("td", { text: String(x.role || "") }), - el("td", { text: String(x.total || 0) }), - el("td", { text: String(x.workspace_total || 0) }), - el("td", { text: String(x.workspace_direct || 0) }), - el("td", { text: String(x.workspace_resolved_tool_match || 0) }), - el("td", { text: String(x.workspace_docs_only || 0) }), - el("td", { text: String(x.mcp_total || 0) }), - el("td", { text: String(x.tool_total || 0) }), - el("td", {}, [docsOnlyCell]), - el("td", {}, [namesCell]), - ]), - ); - }); - if (!rows.length) { - skillEffectiveTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "10" })])); - } - }; - const loadSkillEffective = async () => { - try { - const r = await apiGet("/admin/api/skills/effective"); - skillEffectiveState.items = Array.isArray(r.items) ? r.items : []; - renderSkillEffectiveDashboard(); - } catch (e) { - skillEffectiveState.items = []; - renderSkillEffectiveDashboard(); - skillBindingStatus.textContent = `effective: ${String(e && e.message ? e.message : e)}`; - } - }; - const renderSkillBindingList = () => { - const current = String(skillRoleSelect.value || ""); - const draft = skillBindingState.mapping; - const existing = Array.isArray(draft[current]) ? draft[current].map((x) => String(x)) : []; - const selected = new Set(existing); - skillBindingListWrap.innerHTML = ""; - const skills = - skillBindingState.names.length > 0 - ? skillBindingState.names - : (Array.isArray(rowsState.items) ? rowsState.items.map((x) => String(x.name || "").trim()).filter(Boolean) : []); - if (!skills.length) { - skillBindingListWrap.appendChild(el("div", { class: "muted", text: "No installed skills yet." })); - return; - } - skills.forEach((nm) => { - const cb = el("input", { type: "checkbox" }); - cb.checked = selected.has(nm); - cb.addEventListener("change", () => { - const prev = new Set(Array.isArray(draft[current]) ? draft[current].map((x) => String(x)) : []); - if (cb.checked) prev.add(nm); - else prev.delete(nm); - draft[current] = Array.from(prev); - }); - skillBindingListWrap.appendChild(el("label", { class: "row u-row-center" }, [cb, el("span", { text: nm })])); - }); - }; - const loadSkillBinding = async () => { - try { - const r = await apiGet("/admin/api/skills/binding"); - skillBindingState.roles = Array.isArray(r.available_roles) ? r.available_roles : []; - skillBindingState.names = (Array.isArray(r.installed_skills) ? r.installed_skills : []) - .map((x) => String(x.name || "").trim()) - .filter(Boolean); - skillBindingState.mapping = r.mapping && typeof r.mapping === "object" ? { ...r.mapping } : {}; - skillBindingState.enabled = !!r.enabled; - skillBindingEnabledCb.checked = skillBindingState.enabled; - if (r.enabled_env_present) { - skillBindingEnvHint.style.display = "block"; - const stored = !!r.enabled_stored; - const eff = !!r.enabled; - skillBindingEnvHint.textContent = - "Process env AIA_SKILL_ROLE_BINDING_ENABLED is set: it overrides the Admin/SQLite value at runtime. " + - "The checkbox reflects the effective (env) value. Remove or unset that variable on the oclaw process to use Admin only. " + - `(stored in DB: ${String(stored)}, effective: ${String(eff)}).`; - } else { - skillBindingEnvHint.style.display = "none"; - skillBindingEnvHint.textContent = ""; - } - skillRoleSelect.innerHTML = ""; - skillBindingState.roles.forEach((role) => { - skillRoleSelect.appendChild(el("option", { value: role, text: role })); - }); - if (skillBindingState.roles.length && !skillRoleSelect.value) { - skillRoleSelect.value = skillBindingState.roles[0]; - } - renderSkillBindingList(); - renderSkillBindingDashboard(); - await loadSkillEffective(); - skillBindingStatus.textContent = ""; - } catch (e) { - skillBindingStatus.textContent = `binding: ${String(e && e.message ? e.message : e)}`; - skillBindingListWrap.innerHTML = ""; - } - }; - skillRoleSelect.addEventListener("change", () => renderSkillBindingList()); - const btnSaveSkillBinding = el("button", { - class: "btn btn--primary", - text: "Save skill role binding", - onclick: async () => { - try { - const r = await apiPost("/admin/api/skills/binding", { - enabled: !!skillBindingEnabledCb.checked, - mapping: skillBindingState.mapping, - }); - skillBindingState.mapping = r.mapping && typeof r.mapping === "object" ? { ...r.mapping } : {}; - skillBindingState.enabled = !!r.enabled; - skillBindingEnabledCb.checked = skillBindingState.enabled; - if (r.enabled_env_present) { - skillBindingEnvHint.style.display = "block"; - const stored = !!r.enabled_stored; - const eff = !!r.enabled; - skillBindingEnvHint.textContent = - "Process env AIA_SKILL_ROLE_BINDING_ENABLED is set: it overrides the Admin/SQLite value at runtime. " + - "The checkbox reflects the effective (env) value. Remove or unset that variable on the oclaw process to use Admin only. " + - `(stored in DB: ${String(stored)}, effective: ${String(eff)}).`; - } else { - skillBindingEnvHint.style.display = "none"; - skillBindingEnvHint.textContent = ""; - } - skillBindingStatus.textContent = `saved: enabled=${String(r.enabled)}`; - renderSkillBindingList(); - renderSkillBindingDashboard(); - await loadSkillEffective(); - } catch (e) { - skillBindingStatus.textContent = String(e && e.message ? e.message : e); - } - }, - }); - const skillBindingBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Skill role binding (specialists)" }), - el("div", { - class: "muted u-muted-block-lg", - text: - "When enabled, the model skills catalog is filtered per role: each role sees only skills bound to that role, plus skills under skills/_workspace/public/. If nothing is bound yet, non-public skills are hidden until you assign them.", - }), - skillBindingStatus, - skillBindingPersistHint, - skillBindingEnvHint, - el("label", { class: "row", style: "gap:8px;align-items:center;margin-top:6px;" }, [ - skillBindingEnabledCb, - el("span", { text: "Enable role binding (AIA_SKILL_ROLE_BINDING_ENABLED)" }), - ]), - el("div", { class: "row u-row-wrap-mt" }, [ - el("label", { text: "Role" }), - skillRoleSelect, - btnSaveSkillBinding, - ]), - el("div", { class: "muted u-label-mt", text: "Role binding dashboard" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "role" }), el("th", { text: "direct skills" }), el("th", { text: "effective" })])]), - skillBindingDashTbody, - ]), - ]), - el("div", { class: "muted u-label-mt", text: "Effective skills dashboard (binding + MCP + tools)" }), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "role" }), - el("th", { text: "total effective" }), - el("th", { text: "workspace" }), - el("th", { text: "direct bind" }), - el("th", { text: "workspace resolved" }), - el("th", { text: "workspace docs-only" }), - el("th", { text: "mcp converted" }), - el("th", { text: "other tools" }), - el("th", { text: "docs-only names" }), - el("th", { text: "names preview" }), - ])]), - skillEffectiveTbody, - ]), - ]), - skillBindingListWrap, - ]); - - const internalToolsState = { role: "", available_roles: [], tools: [], skipped_public: [], skipped_expert: [] }; - const lazyLoadState = { internalLoaded: false, llmLoaded: false, selfCheckLoaded: false, skillHealthLoaded: false }; - const internalToolsStatus = el("div", { class: "muted", text: "" }); - const internalRoleSelect = el("select", { class: "input" }, []); - const internalToolsTbody = el("tbody"); - const internalSkippedPre = el("pre", { class: "muted pre", text: "" }); - const renderInternalTools = () => { - internalToolsTbody.innerHTML = ""; - const rows = Array.isArray(internalToolsState.tools) ? internalToolsState.tools : []; - rows.forEach((x) => { - internalToolsTbody.appendChild( - el("tr", {}, [ - el("td", { text: String(x.source || "") }), - el("td", { text: String(x.name || "") }), - el("td", { text: shortText(String(x.description || ""), 120) }), - el("td", { text: String((Array.isArray(x.tags) ? x.tags.join(", ") : x.tags) || "") }), - el("td", { text: String(x.read_only ? "1" : "0") }), - el("td", { text: String(x.risk_level || "") }), - ]), - ); - }); - if (!rows.length) { - internalToolsTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "6" })])); - } - const skipped = { - skipped_public: Array.isArray(internalToolsState.skipped_public) ? internalToolsState.skipped_public : [], - skipped_expert: Array.isArray(internalToolsState.skipped_expert) ? internalToolsState.skipped_expert : [], - }; - internalSkippedPre.textContent = JSON.stringify(skipped, null, 2); - }; - const loadInternalToolsPreview = async (role) => { - const r = String(role || internalRoleSelect.value || "").trim(); - if (!r) return; - internalToolsStatus.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet(`/admin/api/tools/internal/preview?role=${encodeURIComponent(r)}`); - internalToolsState.role = String(resp.role || ""); - internalToolsState.available_roles = Array.isArray(resp.available_roles) ? resp.available_roles : []; - internalToolsState.tools = Array.isArray(resp.tools) ? resp.tools : []; - internalToolsState.skipped_public = Array.isArray(resp.skipped_public) ? resp.skipped_public : []; - internalToolsState.skipped_expert = Array.isArray(resp.skipped_expert) ? resp.skipped_expert : []; - internalToolsStatus.textContent = `role=${internalToolsState.role} tools=${internalToolsState.tools.length}`; - renderInternalTools(); - } catch (e) { - internalToolsState.tools = []; - internalToolsStatus.textContent = `preview: ${String(e && e.message ? e.message : e)}`; - renderInternalTools(); - } - }; - const btnInternalPreview = el("button", { - class: "btn btn--primary", - text: "Preview", - onclick: async () => await loadInternalToolsPreview(internalRoleSelect.value), - }); - const internalToolsBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Internal tools preview (public + expert)" }), - el("div", { class: "muted u-muted-block-lg", text: "Preview dynamically loaded builtin tools for a role (public shared + expert-scoped)." }), - internalToolsStatus, - el("div", { class: "row u-row-wrap-mt" }, [ - el("label", { text: "Role" }), - internalRoleSelect, - btnInternalPreview, - el("button", { - class: "btn", - text: "Clear cache", - onclick: async () => { - try { - await apiPost("/admin/api/tools/internal/reload", {}); - internalToolsStatus.textContent = "cache cleared"; - await loadInternalToolsPreview(internalRoleSelect.value); - } catch (e) { - internalToolsStatus.textContent = `reload: ${String(e && e.message ? e.message : e)}`; - } - }, - }), - ]), - el("div", { class: "table-wrap u-mt-8" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "source" }), - el("th", { text: "name" }), - el("th", { text: "description" }), - el("th", { text: "tags" }), - el("th", { text: "read_only" }), - el("th", { text: "risk" }), - ])]), - internalToolsTbody, - ]), - ]), - el("div", { class: "muted u-mt-8", text: "Skipped modules / factory errors" }), - internalSkippedPre, - ]); - - const llmToolsState = { role: "", tools_raw: [], tools_wired: [], removed_mcp_names: [], meta: {} }; - const llmToolsStatus = el("div", { class: "muted", text: "" }); - const llmRoleSelect = el("select", { class: "input" }, []); - const llmTracePlanCb = el("input", { type: "checkbox" }); - const llmToolsTbody = el("tbody"); - const llmRemovedPre = el("pre", { class: "muted pre", text: "" }); - const llmModeBadge = el("span", { class: "badge badge--mode-restricted", text: "restricted" }); - const llmDiffPre = el("pre", { class: "muted pre", text: "" }); - const llmDiffTbody = el("tbody"); - const llmDiffTableWrap = el("div", { class: "table-wrap u-mt-8" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "type" }), - el("th", { text: "name" }), - el("th", { text: "kind" }), - el("th", { text: "details" }), - ])]), - llmDiffTbody, - ]), - ]); - const selfCheckStatus = el("div", { class: "muted", text: "" }); - const selfCheckSummary = el("div", { class: "muted", text: "" }); - const selfCheckTbody = el("tbody"); - const selfCheckState = { data: null }; - const renderSelfCheck = (data) => { - const body = data && typeof data === "object" ? data : {}; - selfCheckState.data = body; - const summary = body.summary && typeof body.summary === "object" ? body.summary : {}; - const rolesTotal = Number(body.roles_total || 0); - const totalInternal = Number(summary.total_internal_tools || 0); - const totalWired = Number(summary.total_wired_tools || 0); - const items = Array.isArray(body.items) ? body.items : []; - selfCheckSummary.textContent = `roles=${rolesTotal} internal=${totalInternal} wired=${totalWired}`; - selfCheckTbody.innerHTML = ""; - items.forEach((x) => { - const removedMcp = Number(x.removed_mcp_total || 0); - const wired = Number(x.wired_count || 0); - const mode = String(x.role_mode || "restricted"); - let health = "ok"; - if (mode === "forbidden" && wired === 0) health = "forbidden"; - else if (mode === "unrestricted") health = "unrestricted"; - else if (removedMcp > 0) health = "warning"; - const badgeCls = - health === "warning" - ? "badge badge--mode-restricted" - : health === "forbidden" - ? "badge badge--bad" - : health === "unrestricted" - ? "badge badge--mode-unrestricted" - : "badge badge--ok"; - selfCheckTbody.appendChild( - el("tr", {}, [ - el("td", { text: String(x.role || "") }), - el("td", {}, [el("span", { class: badgeCls, text: health })]), - el("td", { text: String(x.role_mode || "") }), - el("td", { text: String(x.internal_count || 0) }), - el("td", { text: String(x.raw_count || 0) }), - el("td", { text: String(x.wired_count || 0) }), - el("td", { text: String(x.removed_mcp_total || 0) }), - ]), - ); - }); - if (!items.length) selfCheckTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "8" })])); - }; - const runSelfCheck = async () => { - selfCheckStatus.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet("/admin/api/tools/self-check"); - renderSelfCheck(resp); - selfCheckStatus.textContent = "self-check complete"; - } catch (e) { - selfCheckStatus.textContent = `self-check: ${String(e && e.message ? e.message : e)}`; - renderSelfCheck({}); - } - }; - const exportSelfCheckJson = () => { - const data = selfCheckState.data; - if (!data || typeof data !== "object") { - selfCheckStatus.textContent = "no self-check data to export"; - return; - } - try { - const blob = new Blob([`${JSON.stringify(data, null, 2)}\n`], { type: "application/json;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const a = document.createElement("a"); - a.href = url; - a.download = `tools-self-check-${ts}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - selfCheckStatus.textContent = "self-check exported"; - } catch (e) { - selfCheckStatus.textContent = `export failed: ${String(e && e.message ? e.message : e)}`; - } - }; - const selfCheckBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Tools self-check" }), - el("div", { class: "muted u-muted-block-lg", text: "Run one-shot diagnostics across roles for internal/wired tools, role mode and permanent bans." }), - selfCheckStatus, - el("div", { class: "row u-row-center-mt" }, [ - el("button", { class: "btn btn--primary", text: "Run self-check", onclick: runSelfCheck }), - el("button", { class: "btn", text: "Export self-check JSON", onclick: exportSelfCheckJson }), - selfCheckSummary, - ]), - el("div", { class: "table-wrap u-mt-8" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "role" }), - el("th", { text: "health" }), - el("th", { text: "mode" }), - el("th", { text: "internal" }), - el("th", { text: "raw" }), - el("th", { text: "wired" }), - el("th", { text: "removed mcp" }), - ])]), - selfCheckTbody, - ]), - ]), - ]); - const skillHealthStatus = el("div", { class: "muted", text: "" }); - const skillHealthSummary = el("div", { class: "muted", text: "" }); - const skillHealthClassifyTbody = el("tbody"); - const skillHealthExecTbody = el("tbody"); - const skillHealthFailedOnlyCb = el("input", { type: "checkbox" }); - const skillHealthState = { data: null }; - const renderSkillHealthExecRows = () => { - const body = skillHealthState.data && typeof skillHealthState.data === "object" ? skillHealthState.data : {}; - const checks = Array.isArray(body.execution_checks) ? body.execution_checks : []; - const failedOnly = !!skillHealthFailedOnlyCb.checked; - skillHealthExecTbody.innerHTML = ""; - checks - .filter((x) => (failedOnly ? !x.ok : true)) - .slice(0, 30) - .forEach((x) => { - const ok = !!x.ok; - const code = String(x.error_code || ""); - const truncated = !!x.output_truncated; - skillHealthExecTbody.appendChild( - el("tr", {}, [ - el("td", { text: String(x.name || "") }), - el("td", {}, [ok ? el("span", { class: "badge badge--ok", text: "ok" }) : el("span", { class: "badge badge--bad", text: "fail" })]), - el("td", { text: code || "-" }), - el("td", { text: truncated ? "1" : "0" }), - el("td", {}, [ - el("button", { - class: "btn", - text: "Test-run with args...", - disabled: !String(x.name || "").trim(), - onclick: () => { - const nm = String(x.name || "").trim(); - if (!nm) return; - openSkillTestRunModal(nm); - }, - }), - ]), - ]), - ); - }); - if (!skillHealthExecTbody.children.length) { - skillHealthExecTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "5" })])); - } - }; - const renderSkillHealthCheck = (data) => { - const body = data && typeof data === "object" ? data : {}; - skillHealthState.data = body; - const counts = body.classification_counts && typeof body.classification_counts === "object" ? body.classification_counts : {}; - const checks = Array.isArray(body.execution_checks) ? body.execution_checks : []; - const skillsTotal = Number(body.skills_total || 0); - const execTotal = Number(body.executable_total || 0); - const checked = Number(body.execution_checked_total || 0); - skillHealthSummary.textContent = `skills=${skillsTotal} executable=${execTotal} checked=${checked}`; - skillHealthClassifyTbody.innerHTML = ""; - const countRows = Object.entries(counts).sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0)); - countRows.forEach(([k, v]) => { - skillHealthClassifyTbody.appendChild(el("tr", {}, [el("td", { text: String(k || "") }), el("td", { text: String(Number(v || 0)) })])); - }); - if (!countRows.length) { - skillHealthClassifyTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "2" })])); - } - renderSkillHealthExecRows(); - }; - skillHealthFailedOnlyCb.addEventListener("change", () => renderSkillHealthExecRows()); - const runSkillHealthCheck = async () => { - skillHealthStatus.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet("/admin/api/skills/self-check?include_execution=true"); - renderSkillHealthCheck(resp); - skillHealthStatus.textContent = "skill self-check complete"; - } catch (e) { - skillHealthStatus.textContent = `skill self-check: ${String(e && e.message ? e.message : e)}`; - renderSkillHealthCheck({}); - } - }; - const exportSkillHealthJson = () => { - const data = skillHealthState.data; - if (!data || typeof data !== "object") { - skillHealthStatus.textContent = "no skills self-check data to export"; - return; - } - try { - const blob = new Blob([`${JSON.stringify(data, null, 2)}\n`], { type: "application/json;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const a = document.createElement("a"); - a.href = url; - a.download = `skills-self-check-${ts}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - skillHealthStatus.textContent = "skills self-check exported"; - } catch (e) { - skillHealthStatus.textContent = `export failed: ${String(e && e.message ? e.message : e)}`; - } - }; - const skillHealthBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Skills health self-check" }), - el("div", { class: "muted u-muted-block-lg", text: "Run executable skill diagnostics and show failure code distribution." }), - skillHealthStatus, - el("div", { class: "row u-row-center-mt" }, [ - el("button", { class: "btn btn--primary", text: "Run skills self-check", onclick: runSkillHealthCheck }), - el("button", { class: "btn", text: "Export skills self-check JSON", onclick: exportSkillHealthJson }), - el("label", { class: "row u-row-center" }, [ - skillHealthFailedOnlyCb, - el("span", { class: "muted", text: "Only failed items" }), - ]), - skillHealthSummary, - ]), - el("div", { class: "row", style: "gap:12px;align-items:flex-start;flex-wrap:wrap;margin-top:8px;" }, [ - el("div", { class: "table-wrap", style: "min-width:280px;flex:1;" }, [ - el("div", { class: "muted u-mb-6", text: "classification_counts" }), - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "code" }), el("th", { text: "count" })])]), - skillHealthClassifyTbody, - ]), - ]), - el("div", { class: "table-wrap", style: "min-width:360px;flex:2;" }, [ - el("div", { class: "muted u-mb-6", text: "execution_checks (top 30)" }), - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "skill" }), el("th", { text: "status" }), el("th", { text: "error_code" }), el("th", { text: "output_truncated" }), el("th", { text: "action" })])]), - skillHealthExecTbody, - ]), - ]), - ]), - ]); - - const _renderLLMToolRows = (tools) => { - llmToolsTbody.innerHTML = ""; - const rows = Array.isArray(tools) ? tools : []; - rows.forEach((ent) => { - const fn = ent && typeof ent === "object" ? ent.function : null; - const name = fn && typeof fn === "object" ? String(fn.name || "") : ""; - const desc = fn && typeof fn === "object" ? String(fn.description || "") : ""; - const params = fn && typeof fn === "object" ? fn.parameters : null; - llmToolsTbody.appendChild( - el("tr", {}, [ - el("td", { text: name }), - el("td", { text: shortText(desc, 140) }), - el("td", { text: name.startsWith("mcp__") ? "mcp" : "internal" }), - el("td", { text: params ? shortText(JSON.stringify(params), 120) : "" }), - ]), - ); - }); - if (!rows.length) { - llmToolsTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "4" })])); - } - }; - - const loadLLMToolsPreview = async (role) => { - const r = String(role || llmRoleSelect.value || "").trim(); - if (!r) return; - llmToolsStatus.textContent = `${t("chat.loading")}...`; - try { - const resp = await apiGet(`/admin/api/tools/llm/preview?role=${encodeURIComponent(r)}`); - llmToolsState.role = String(resp.role || ""); - llmToolsState.tools_raw = Array.isArray(resp.tools_raw) ? resp.tools_raw : []; - llmToolsState.tools_wired = Array.isArray(resp.tools_wired) ? resp.tools_wired : []; - llmToolsState.removed_mcp_names = Array.isArray(resp.removed_mcp_names) ? resp.removed_mcp_names : []; - llmToolsState.meta = resp && typeof resp === "object" ? resp : {}; - const mode = String(resp.role_mode || "restricted"); - llmModeBadge.textContent = mode; - llmModeBadge.className = `badge badge--mode-${mode}`; - llmToolsStatus.textContent = `role=${llmToolsState.role} raw=${llmToolsState.tools_raw.length} wired=${llmToolsState.tools_wired.length} mcp_enabled=${String(!!resp.mcp_enabled)}`; - llmRemovedPre.textContent = JSON.stringify( - { - removed_mcp_names: llmToolsState.removed_mcp_names, - role_mode: resp.role_mode, - skipped_public: resp.skipped_public || [], - skipped_expert: resp.skipped_expert || [], - }, - null, - 2, - ); - const _toMap = (arr) => { - const m = new Map(); - (Array.isArray(arr) ? arr : []).forEach((ent) => { - const fn = ent && typeof ent === "object" ? ent.function : null; - const nm = fn && typeof fn === "object" ? String(fn.name || "") : ""; - if (!nm) return; - const desc = fn && typeof fn === "object" ? String(fn.description || "") : ""; - const params = fn && typeof fn === "object" ? fn.parameters : null; - m.set(nm, { description: desc, parameters: params }); - }); - return m; - }; - const rawMap = _toMap(llmToolsState.tools_raw); - const wiredMap = _toMap(llmToolsState.tools_wired); - const rawNames = Array.from(rawMap.keys()); - const wiredNames = Array.from(wiredMap.keys()); - const rawSet = new Set(rawNames); - const wiredSet = new Set(wiredNames); - const removed = rawNames.filter((n) => !wiredSet.has(n)).sort(); - const added = wiredNames.filter((n) => !rawSet.has(n)).sort(); - const changed = []; - wiredNames.forEach((n) => { - if (!rawSet.has(n)) return; - const a = rawMap.get(n) || {}; - const b = wiredMap.get(n) || {}; - const d0 = String(a.description || ""); - const d1 = String(b.description || ""); - const p0 = a.parameters ? JSON.stringify(a.parameters) : ""; - const p1 = b.parameters ? JSON.stringify(b.parameters) : ""; - if (d0 !== d1 || p0 !== p1) changed.push(n); - }); - llmDiffPre.textContent = JSON.stringify( - { - added, - removed, - changed, - counts: { raw: rawNames.length, wired: wiredNames.length, added: added.length, removed: removed.length, changed: changed.length }, - }, - null, - 2, - ); - - llmDiffTbody.innerHTML = ""; - const _kind = (nm) => (String(nm || "").startsWith("mcp__") ? "mcp" : "internal"); - const _pushRow = (tp, nm, detailEl) => { - llmDiffTbody.appendChild( - el("tr", {}, [ - el("td", { text: tp }), - el("td", { text: nm }), - el("td", { text: _kind(nm) }), - el("td", {}, [detailEl]), - ]), - ); - }; - const _detailsCompare = (nm) => { - const a = rawMap.get(nm) || {}; - const b = wiredMap.get(nm) || {}; - const d0 = String(a.description || ""); - const d1 = String(b.description || ""); - const p0 = a.parameters ? JSON.stringify(a.parameters, null, 2) : ""; - const p1 = b.parameters ? JSON.stringify(b.parameters, null, 2) : ""; - const box = el("details", {}, [ - el("summary", { class: "u-drag", text: "compare" }), - el("div", { class: "muted u-mt-6" }, [el("div", { text: "raw.description" }), el("pre", { class: "muted pre", text: d0 })]), - el("div", { class: "muted u-mt-6" }, [el("div", { text: "wired.description" }), el("pre", { class: "muted pre", text: d1 })]), - el("div", { class: "muted u-mt-6" }, [el("div", { text: "raw.parameters" }), el("pre", { class: "muted pre", text: p0 })]), - el("div", { class: "muted u-mt-6" }, [el("div", { text: "wired.parameters" }), el("pre", { class: "muted pre", text: p1 })]), - ]); - return box; - }; - added.forEach((nm) => _pushRow("added", nm, el("span", { class: "muted", text: "present in wired only" }))); - removed.forEach((nm) => _pushRow("removed", nm, el("span", { class: "muted", text: "present in raw only" }))); - changed.sort().forEach((nm) => _pushRow("changed", nm, _detailsCompare(nm))); - if (!added.length && !removed.length && !changed.length) { - llmDiffTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "4" })])); - } - - _renderLLMToolRows(llmToolsState.tools_wired); - } catch (e) { - llmToolsState.tools_wired = []; - llmToolsStatus.textContent = `preview: ${String(e && e.message ? e.message : e)}`; - llmRemovedPre.textContent = ""; - llmDiffPre.textContent = ""; - llmDiffTbody.innerHTML = ""; - _renderLLMToolRows([]); - } - }; - - const btnLLMPreview = el("button", { - class: "btn btn--primary", - text: "Preview", - onclick: async () => await loadLLMToolsPreview(llmRoleSelect.value), - }); - const loadExposureTraceSetting = async () => { - try { - const resp = await apiGet("/admin/api/tools/exposure-trace-setting"); - llmTracePlanCb.checked = !!(resp && resp.enabled); - } catch (_) { - llmTracePlanCb.checked = false; - } - }; - const saveExposureTraceSetting = async () => { - try { - const resp = await apiPost("/admin/api/tools/exposure-trace-setting", { enabled: !!llmTracePlanCb.checked }); - llmTracePlanCb.checked = !!(resp && resp.enabled); - llmToolsStatus.textContent = `trace_exposure_plan=${llmTracePlanCb.checked ? "on" : "off"}`; - } catch (e) { - llmToolsStatus.textContent = `trace setting: ${String(e && e.message ? e.message : e)}`; - } - }; - const btnLLMToggle = el("button", { - class: "btn", - text: "Toggle raw/wired", - onclick: () => { - const showing = String(btnLLMToggle.dataset.showing || "wired"); - const next = showing === "wired" ? "raw" : "wired"; - btnLLMToggle.dataset.showing = next; - _renderLLMToolRows(next === "wired" ? llmToolsState.tools_wired : llmToolsState.tools_raw); - btnLLMToggle.textContent = next === "wired" ? "Toggle raw/wired" : "Toggle raw/wired"; - }, - }); - const llmToolsBox = el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "LLM tools preview (after wire policy)" }), - el("div", { class: "muted u-muted-block-lg", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + MCP specialist binding)." }), - llmToolsStatus, - el("div", { class: "row u-row-wrap-mt" }, [ - el("label", { text: "Role" }), - llmRoleSelect, - btnLLMPreview, - btnLLMToggle, - el("button", { - class: "btn", - text: "Clear cache", - onclick: async () => { - try { - await apiPost("/admin/api/tools/internal/reload", {}); - llmToolsStatus.textContent = "cache cleared"; - await loadLLMToolsPreview(llmRoleSelect.value); - } catch (e) { - llmToolsStatus.textContent = `reload: ${String(e && e.message ? e.message : e)}`; - } - }, - }), - el("span", { class: "muted", text: "role_mode:" }), - llmModeBadge, - el("label", { class: "row u-row-center" }, [ - llmTracePlanCb, - el("span", { class: "muted", text: "Trace exposure plan" }), - ]), - el("button", { class: "btn", text: "Save trace setting", onclick: saveExposureTraceSetting }), - ]), - el("div", { class: "table-wrap u-mt-8" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "description" }), el("th", { text: "kind" }), el("th", { text: "parameters" })])]), - llmToolsTbody, - ]), - ]), - el("div", { class: "muted u-mt-8", text: "Diff / diagnostics" }), - llmRemovedPre, - el("div", { class: "muted u-mt-8", text: "raw vs wired diff (added/removed/changed)" }), - llmDiffPre, - llmDiffTableWrap, - ]); - - const tbody = el("tbody"); - const auditTbody = el("tbody"); - let skillActionMenuEl = null; - let skillTestRunName = ""; - const closeSkillActionMenu = () => { - if (skillActionMenuEl && skillActionMenuEl.parentNode) { - skillActionMenuEl.parentNode.removeChild(skillActionMenuEl); - } - skillActionMenuEl = null; - }; - const skillTestRunModal = el("div", { class: "session-monitor-modal u-hidden" }); - const skillTestRunTitle = el("div", { class: "card__title", text: "Skill test run" }); - const skillTestRunArgs = el("textarea", { class: "input", rows: "8", style: "width:100%;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;", placeholder: "{}" }); - const skillInstallModal = el("div", { class: "session-monitor-modal u-hidden" }); - const skillInstallTitle = el("div", { class: "card__title", text: "Skill installation" }); - const skillInstallMsg = el("div", { class: "muted", style: "line-height:1.5;" }); - const closeSkillInstallModal = () => { - skillInstallModal.style.display = "none"; - }; - const openSkillInstallModal = (msg) => { - skillInstallTitle.textContent = "Skill installation"; - skillInstallMsg.textContent = String(msg || ""); - skillInstallModal.style.display = "flex"; - }; - const finishSkillInstallModal = (ok, msg) => { - skillInstallTitle.textContent = ok ? "Skill installation success" : "Skill installation failed"; - skillInstallMsg.textContent = String(msg || ""); - setTimeout(() => { - closeSkillInstallModal(); - }, 1200); - }; - skillInstallModal.addEventListener("click", (e) => { - if (e.target === skillInstallModal) closeSkillInstallModal(); - }); - const skillInstallCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(560px,96vw);" }, [ - skillInstallTitle, - skillInstallMsg, - el("div", { class: "row u-row-end" }, [ - el("button", { class: "btn", text: "Close", onclick: closeSkillInstallModal }), - ]), - ]); - skillInstallModal.appendChild(skillInstallCard); - const closeSkillTestRunModal = () => { - skillTestRunModal.style.display = "none"; - skillTestRunName = ""; - }; - const openSkillTestRunModal = (name) => { - skillTestRunName = String(name || ""); - skillTestRunTitle.textContent = `Test run: ${skillTestRunName}`; - skillTestRunArgs.value = "{}"; - skillTestRunModal.style.display = "flex"; - setTimeout(() => skillTestRunArgs.focus(), 0); - }; - skillTestRunModal.addEventListener("click", (e) => { - if (e.target === skillTestRunModal) closeSkillTestRunModal(); - }); - const skillTestRunRunBtn = el("button", { - class: "btn btn--primary", - text: "Run", - onclick: async () => { - if (!skillTestRunName) return; - let args = {}; - try { - const raw = String(skillTestRunArgs.value || "").trim(); - args = raw ? JSON.parse(raw) : {}; - } catch (e) { - status.textContent = `invalid json: ${String(e && e.message ? e.message : e)}`; - return; - } - try { - const r = await apiPost("/admin/api/skills/test-run", { name: skillTestRunName, args }); - status.textContent = `test-run: ${skillTestRunName} => ${JSON.stringify((r && r.result) || {}, null, 0)}`; - closeSkillTestRunModal(); - } catch (e) { - status.textContent = `test-run: ${String(e && e.message ? e.message : e)}`; - } - }, - }); - const skillTestRunCancelBtn = el("button", { class: "btn", text: "Cancel", onclick: closeSkillTestRunModal }); - const skillTestRunCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(760px,96vw);" }, [ - skillTestRunTitle, - el("div", { class: "muted", style: "margin-bottom:8px;", text: "Input JSON args passed to the executable skill runtime." }), - skillTestRunArgs, - el("div", { class: "row u-row-end" }, [skillTestRunCancelBtn, skillTestRunRunBtn]), - ]); - skillTestRunModal.appendChild(skillTestRunCard); - const retryableOnlyCb = el("input", { type: "checkbox" }); - const retryableOnlyStored = String(localStorage.getItem(SKILL_AUDIT_RETRYABLE_ONLY_KEY) || "").trim().toLowerCase(); - retryableOnlyCb.checked = retryableOnlyStored ? retryableOnlyStored !== "0" && retryableOnlyStored !== "false" : true; - const _parseSourceFromAudit = (it) => { - const d = it && typeof it.detail === "object" ? it.detail : {}; - const s = String(d.source || "").trim().toLowerCase(); - if (s === "local" || s === "registry" || s === "auto") return s; - return ""; - }; - const _parseRetryTargetFromAudit = (it) => { - const d = it && typeof it.detail === "object" ? it.detail : {}; - const inputTarget = String(d.input_target || "").trim(); - if (inputTarget) return inputTarget; - return String((it && it.target_id) || "").trim(); - }; - const _isRetryableAudit = (it) => { - const action = String((it && it.action) || "").trim().toLowerCase(); - if (action !== "skill_install_failed") return false; - const src = _parseSourceFromAudit(it); - const target = _parseRetryTargetFromAudit(it); - return Boolean(src && target); - }; - const loadRows = async () => { - const data = await apiGet("/admin/api/skills"); - rowsState.items = Array.isArray(data.items) ? data.items : []; - }; - const loadAudits = async () => { - const data = await apiGet("/admin/api/admin-audit?limit=80"); - const items = Array.isArray(data.items) ? data.items : []; - auditState.items = items - .filter((x) => String(x.action || "").startsWith("skill_")) - .slice(0, 20); - }; - const repaint = () => { - const rows = rowsState.items || []; - tbody.innerHTML = ""; - rows.forEach((x) => { - const name = String(x.name || ""); - const enabled = !!x.enabled; - const executable = !!x.executable; - const runtime = x && typeof x.runtime === "object" ? x.runtime : null; - const runtimeType = runtime && typeof runtime.type === "string" ? String(runtime.type) : ""; - const runtimeEntry = runtime && typeof runtime.entry === "string" ? String(runtime.entry) : ""; - const toggleBtn = el("button", { - class: "chat-sess-menu-item", - text: enabled ? "Disable" : "Enable", - onclick: async () => { - try { - closeSkillActionMenu(); - await apiPost(enabled ? "/admin/api/skills/disable" : "/admin/api/skills/enable", { name }); - status.textContent = `${enabled ? "Disabled" : "Enabled"}: ${name}`; - await loadRows(); - repaint(); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }, - }); - const testRunBtn = el("button", { - class: "chat-sess-menu-item", - text: "Test run...", - disabled: !executable, - onclick: () => { - closeSkillActionMenu(); - openSkillTestRunModal(name); - }, - }); - const repairDepsBtn = el("button", { - class: "chat-sess-menu-item", - text: "Repair deps", - onclick: async () => { - closeSkillActionMenu(); - try { - status.textContent = `repair deps: ${name}...`; - const r = await apiPost("/admin/api/skills/repair-deps", { name }); - assertSkillMutationOk(r, "Repair deps failed"); - status.textContent = `repair deps: ${JSON.stringify((r && r.result) || {}, null, 0)}`; - await loadRows(); - await loadAudits(); - repaint(); - } catch (e) { - status.textContent = `repair deps failed: ${String(e && e.message ? e.message : e)}`; - } - }, - }); - const uninstallBtn = el("button", { - class: "chat-sess-menu-item", - text: "Uninstall", - onclick: async () => { - closeSkillActionMenu(); - if (!confirm(`Uninstall skill "${name}"?`)) return; - try { - status.textContent = `uninstalling: ${name}...`; - const r = await apiPost("/admin/api/skills/uninstall", { name }); - assertSkillMutationOk(r, "Uninstall failed"); - status.textContent = `uninstall success: ${JSON.stringify((r && r.result) || {}, null, 0)}`; - await loadRows(); - await loadAudits(); - repaint(); - } catch (e) { - status.textContent = `uninstall failed: ${String(e && e.message ? e.message : e)}`; - } - }, - }); - const actionMenuBtn = el("button", { - class: "chat-sess-more", - text: "⋯", - title: "Skill actions", - onclick: (ev) => { - ev.stopPropagation(); - closeSkillActionMenu(); - const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ - toggleBtn, - testRunBtn, - repairDepsBtn, - uninstallBtn, - ]); - const rect = ev.currentTarget.getBoundingClientRect(); - document.body.appendChild(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left - 120; - 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`; - skillActionMenuEl = menu; - const close = (e) => { - if (!menu.contains(e.target)) { - closeSkillActionMenu(); - document.removeEventListener("click", close); - } - }; - setTimeout(() => document.addEventListener("click", close), 0); - }, - }); - tbody.appendChild( - el("tr", {}, [ - el("td", { text: name }), - el("td", { text: String(x.description || "") }), - el("td", { text: String(x.enabled ? "1" : "0") }), - el("td", {}, [executable ? el("span", { class: "badge badge--ok", text: `${runtimeType || "runtime"}` }) : el("span", { class: "muted", text: "docs-only" })]), - el("td", { text: executable ? `${runtimeEntry}` : "-" }), - el("td", { text: String(x.skill_dir || "") }), - el("td", { class: "table__cell-actions" }, [actionMenuBtn]), - ]), - ); - }); - - auditTbody.innerHTML = ""; - const auditRows = (auditState.items || []).filter((x) => (!retryableOnlyCb.checked ? true : _isRetryableAudit(x))); - for (const x of auditRows) { - const src = _parseSourceFromAudit(x); - const isRetryable = _isRetryableAudit(x); - const retryBtn = el("button", { - class: "btn", - text: "Retry", - disabled: !isRetryable, - onclick: async () => { - try { - const target = _parseRetryTargetFromAudit(x); - if (!src || !target || !isRetryable) { - status.textContent = "retry skipped: missing source/target"; - return; - } - const r = await apiPost("/admin/api/skills/retry-install", { source: src, target }); - assertSkillMutationOk(r, "Retry install failed"); - status.textContent = `retry: ${JSON.stringify(r.result || {})}`; - await loadRows(); - await loadAudits(); - repaint(); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }, - }); - auditTbody.appendChild( - el("tr", {}, [ - el("td", { text: String(x.timestamp || "") }), - el("td", { text: String(x.action || "") }), - el("td", { text: String(x.target_id || "") }), - el("td", { text: String(x.status || "") }), - el("td", { text: shortText(JSON.stringify(x.detail || {}), 180) }), - el("td", {}, [retryBtn]), - ]), - ); - } - }; - const refreshSkillsState = async () => { - await loadRows(); - await loadAudits(); - await loadSkillBinding(); - repaint(); - renderSkillBindingList(); - }; - try { - await loadRows(); - await loadAudits(); - await loadSkillBinding(); - await loadSkillMode(); - const rolesForPreview = (Array.isArray(skillBindingState.roles) && skillBindingState.roles.length ? skillBindingState.roles : ["generalist", "ops", "memory"]).map((x) => String(x)); - internalRoleSelect.innerHTML = ""; - rolesForPreview.forEach((role) => internalRoleSelect.appendChild(el("option", { value: role, text: role }))); - internalRoleSelect.value = rolesForPreview.includes("generalist") ? "generalist" : rolesForPreview[0]; - internalToolsStatus.textContent = "expand this section to load preview"; - llmRoleSelect.innerHTML = ""; - rolesForPreview.forEach((role) => llmRoleSelect.appendChild(el("option", { value: role, text: role }))); - llmRoleSelect.value = internalRoleSelect.value; - llmToolsStatus.textContent = "expand this section to load preview"; - selfCheckStatus.textContent = "expand this section to run self-check"; - } catch (e) { - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.skills") }), - el("div", { class: "muted", text: String(e && e.message ? e.message : e) }), - ]); - } - repaint(); - - const btnCreate = el("button", { - class: "btn btn--primary", - text: "Create Skill", - onclick: async () => { - try { - const r = await apiPost("/admin/api/skills/create", { - name: String(nameInp.value || "").trim(), - description: String(descInp.value || "").trim(), - body_markdown: String(bodyInp.value || ""), - }); - assertSkillMutationOk(r, "Create skill failed"); - status.textContent = `create: ${JSON.stringify(r.result || {})}`; - await loadRows(); - await loadAudits(); - repaint(); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }, - }); - const btnInstallRegistry = el("button", { - class: "btn", - text: "Install From Registry", - onclick: async () => { - const prev = btnInstallRegistry.textContent; - btnInstallRegistry.disabled = true; - btnInstallRegistry.textContent = "Installing..."; - status.textContent = "installing from registry..."; - openSkillInstallModal("Installing skill from registry..."); - try { - const r = await apiPost("/admin/api/skills/install-registry", { - archive_url: String(regInp.value || "").trim(), - }); - assertSkillMutationOk(r, "Registry install failed"); - status.textContent = `install-registry success: ${JSON.stringify(r.result || {})}`; - await refreshSkillsState(); - finishSkillInstallModal(true, "Registry skill installed successfully."); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - finishSkillInstallModal(false, `Install failed: ${String(e && e.message ? e.message : e)}`); - } finally { - btnInstallRegistry.disabled = false; - btnInstallRegistry.textContent = prev; - } - }, - }); - const btnInstallLocal = el("button", { - class: "btn", - text: "Install Local Dir", - onclick: async () => { - const prev = btnInstallLocal.textContent; - btnInstallLocal.disabled = true; - btnInstallLocal.textContent = "Installing..."; - status.textContent = "installing from local dir..."; - openSkillInstallModal("Installing skill from local directory..."); - try { - const r = await apiPost("/admin/api/skills/install", { - source_dir: String(localDirInp.value || "").trim(), - }); - assertSkillMutationOk(r, "Local install failed"); - status.textContent = `install-local success: ${JSON.stringify(r.result || {})}`; - await refreshSkillsState(); - finishSkillInstallModal(true, "Local skill installed successfully."); - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - finishSkillInstallModal(false, `Install failed: ${String(e && e.message ? e.message : e)}`); - } finally { - btnInstallLocal.disabled = false; - btnInstallLocal.textContent = prev; - } - }, - }); - const btnRefresh = el("button", { - class: "btn", - text: t("action.refresh"), - onclick: async () => { - try { - await refreshSkillsState(); - status.textContent = "refreshed"; - } catch (e) { - status.textContent = String(e && e.message ? e.message : e); - } - }, - }); - const btnRepairDepsAll = el("button", { - class: "btn", - text: "Repair all deps", - onclick: async () => { - const prev = btnRepairDepsAll.textContent; - btnRepairDepsAll.disabled = true; - btnRepairDepsAll.textContent = "Repairing..."; - try { - const r = await apiPost("/admin/api/skills/repair-deps-all", {}); - const s = r && typeof r.summary === "object" ? r.summary : {}; - status.textContent = `repair all deps: total=${Number(s.total || 0)} ok=${Number(s.ok_count || 0)} warn=${Number(s.warn_count || 0)} fail=${Number(s.fail_count || 0)}`; - await refreshSkillsState(); - } catch (e) { - status.textContent = `repair all deps failed: ${String(e && e.message ? e.message : e)}`; - } finally { - btnRepairDepsAll.disabled = false; - btnRepairDepsAll.textContent = prev; - } - }, - }); - retryableOnlyCb.addEventListener("change", () => { - localStorage.setItem(SKILL_AUDIT_RETRYABLE_ONLY_KEY, retryableOnlyCb.checked ? "1" : "0"); - repaint(); - }); - const docsHint = el("div", { class: "muted", style: "margin-bottom:10px;line-height:1.5;" }, [ - el("div", { text: t("skills.docsTitle") }), - el("div", { text: t("skills.docsTroubleshooting") }), - el("div", { text: t("skills.docsTraceTaxonomy") }), - ]); - internalToolsBox.addEventListener("toggle", async () => { - if (!internalToolsBox.open || lazyLoadState.internalLoaded) return; - lazyLoadState.internalLoaded = true; - await loadInternalToolsPreview(internalRoleSelect.value); - }); - llmToolsBox.addEventListener("toggle", async () => { - if (!llmToolsBox.open || lazyLoadState.llmLoaded) return; - lazyLoadState.llmLoaded = true; - await loadExposureTraceSetting(); - await loadLLMToolsPreview(llmRoleSelect.value); - }); - selfCheckBox.addEventListener("toggle", async () => { - if (!selfCheckBox.open || lazyLoadState.selfCheckLoaded) return; - lazyLoadState.selfCheckLoaded = true; - await runSelfCheck(); - }); - skillHealthBox.addEventListener("toggle", async () => { - if (!skillHealthBox.open || lazyLoadState.skillHealthLoaded) return; - lazyLoadState.skillHealthLoaded = true; - await runSkillHealthCheck(); - }); - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.skills") }), - el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;" }, [ - el("label", { class: "row u-row-center" }, [skillPromptModeCb, el("span", { text: "Prompt mode (inject SKILL.md)" })]), - el("label", { class: "row", style: "gap:6px;align-items:center;flex-wrap:wrap;" }, [ - el("span", { text: "Market (AIA_SKILL_MARKET_PROVIDER)" }), - skillMarketProviderSelect, - ]), - el("button", { class: "btn", text: "Save skill mode", onclick: saveSkillMode }), - skillModeStatus, - ]), - docsHint, - marketBox, - el("details", { class: "u-spacer-v" }, [ - el("summary", { class: "u-drag", text: "Skill installation (local / registry)" }), - el("div", { class: "u-h-8" }), - el("div", { class: "muted", style: "margin-bottom:8px;line-height:1.5;" }, [ - el("div", { text: "Local dir: 目录内必须包含 SKILL.md(支持绝对路径)。" }), - el("div", { text: "Registry: zip/tar 的 URL(支持 file:// 本地 URI)。" }), - ]), - el("div", { class: "row u-row-wrap-mb" }, [regInp, btnInstallRegistry]), - el("div", { class: "row u-row-wrap-mb" }, [localDirInp, btnInstallLocal]), - ]), - skillBindingBox, - selfCheckBox, - skillHealthBox, - internalToolsBox, - llmToolsBox, - el("div", { class: "row u-row-wrap-mb" }, [nameInp, descInp]), - el("div", { style: "margin-bottom:8px;" }, [bodyInp]), - el("div", { class: "row u-row-wrap-mb" }, [btnCreate]), - el("div", { class: "row u-row-wrap-mb" }, [btnRefresh, btnRepairDepsAll]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "name" }), - el("th", { text: "description" }), - el("th", { text: "enabled" }), - el("th", { text: "kind" }), - el("th", { text: "runtime entry" }), - el("th", { text: "path" }), - el("th", { text: "action" }), - ])]), - tbody, - ]), - ]), - el("div", { class: "u-h-10" }), - el("div", { class: "row", style: "gap:8px;align-items:center;" }, [ - el("div", { class: "muted", text: "Recent skill install audit" }), - el("label", { class: "row u-row-center" }, [ - retryableOnlyCb, - el("span", { class: "muted", text: "Only retryable failures" }), - ]), - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: "timestamp" }), el("th", { text: "action" }), el("th", { text: "target" }), el("th", { text: "status" }), el("th", { text: "detail" }), el("th", { text: "retry" })])]), - auditTbody, - ]), - ]), - skillTestRunModal, - skillInstallModal, - status, - ]); -} - -function formatScheduledJobDelivery(job) { - const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {}; - const parts = []; - if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin")); - if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp")); - return parts.length ? parts.join(" + ") : "—"; -} - -function formatScheduledJobPlaybook(job) { - const steps = Number(job && job.steps_n != null ? job.steps_n : 0) || 0; - if (job && job.playbook) return tf("scheduledJobs.playbookYes", { n: String(steps) }); - if (job && job.has_recipe) return tf("scheduledJobs.playbookPartial", { n: String(steps) }); - return t("scheduledJobs.playbookNo"); -} - -function buildScheduledJobDeliveryPayload(job, waChatId) { - const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {}; - const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {}; - const wx = - existing.weixin && typeof existing.weixin === "object" - ? { ...existing.weixin } - : { enabled: true, fixed: true }; - const chat = String(waChatId || "").trim(); - wa.enabled = Boolean(chat); - wa.chat_id = chat; - wa.target_type = chat.includes("@g.us") ? "group" : "direct"; - if (wx.enabled === undefined) wx.enabled = true; - return { whatsapp: wa, weixin: wx }; -} - -async function renderScheduledJobs() { - let scheduledJobMenuEl = null; - let editingJob = null; - const canWrite = hasPermission("admin:runtime:write"); - const closeScheduledJobMenu = () => { - if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) { - scheduledJobMenuEl.remove(); - } - scheduledJobMenuEl = null; - }; - - const status = el("select", { class: "input input--compact" }, [ - el("option", { value: "", text: t("scheduledJobs.all") }), - el("option", { value: "active", text: t("scheduledJobs.statusActive") }), - el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }), - ]); - const tbody = el("tbody", {}); - const runsBox = el("pre", { class: "code", style: "max-height:220px;overflow:auto;white-space:pre-wrap;" }); - const msg = el("div", { class: "muted", text: "" }); - - const field = (label, control, wide) => - el("div", { class: wide ? "form-field form-field--full" : "form-field" }, [ - el("label", { class: "form-field__label", text: label }), - control, - ]); - - const editModal = el("div", { class: "session-monitor-modal u-hidden" }); - const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") }); - const editNameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") }); - const editKindInput = el("select", { class: "input" }, [ - el("option", { value: "cron", text: "cron" }), - el("option", { value: "once", text: "once" }), - el("option", { value: "interval", text: "interval" }), - ]); - const editExprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") }); - const editPromptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") }); - const editSpecialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist") }); - const editWaChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") }); - const editWeixinInfo = el("div", { class: "muted", text: "" }); - const closeEditModal = () => { - editingJob = null; - editModal.style.display = "none"; - }; - const openEditModal = (job) => { - if (!job || !job.id) return; - editingJob = job; - editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`; - editNameInput.value = String(job.name || ""); - editKindInput.value = String(job.schedule_kind || "cron"); - editExprInput.value = String(job.schedule_expr || ""); - editPromptInput.value = String(job.prompt_text || ""); - editSpecialistInput.value = String(job.specialist || "generalist"); - const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {}; - editWaChatInput.value = String(wa.chat_id || ""); - const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {}; - editWeixinInfo.textContent = wx.external_user_id - ? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) }) - : weixinInfo.textContent || t("scheduledJobs.weixinMissing"); - editModal.style.display = "flex"; - setTimeout(() => editNameInput.focus(), 0); - }; - editModal.addEventListener("click", (e) => { - if (e.target === editModal) closeEditModal(); - }); - const editSaveBtn = el("button", { - class: "btn btn--primary", - text: t("action.save"), - onclick: async () => { - if (!editingJob || !editingJob.id) return; - const name = String(editNameInput.value || "").trim(); - const promptText = String(editPromptInput.value || "").trim(); - const scheduleExpr = String(editExprInput.value || "").trim(); - if (!name || !promptText || !scheduleExpr) { - msg.textContent = "name, prompt_text, schedule_expr are required"; - return; - } - try { - await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, { - name, - schedule_kind: editKindInput.value, - schedule_expr: scheduleExpr, - prompt_text: promptText, - specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist", - delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value), - }); - closeEditModal(); - msg.textContent = t("scheduledJobs.updated"); - await loadJobs(); - } catch (e) { - msg.textContent = String(e && e.message ? e.message : e); - } - }, - }); - const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [ - editTitle, - editWeixinInfo, - el("div", { class: "form-stack" }, [ - field(t("scheduledJobs.fieldName"), editNameInput, true), - el("div", { class: "form-grid" }, [ - field(t("scheduledJobs.fieldKind"), editKindInput), - field(t("scheduledJobs.fieldExpr"), editExprInput), - ]), - field(t("scheduledJobs.fieldPrompt"), editPromptInput, true), - el("div", { class: "form-grid" }, [ - field(t("scheduledJobs.fieldSpecialist"), editSpecialistInput), - field(t("scheduledJobs.fieldWhatsapp"), editWaChatInput), - ]), - el("div", { class: "form-actions" }, [ - el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }), - editSaveBtn, - ]), - ]), - ]); - editModal.appendChild(editModalCard); - - async function loadLatestRuns(items) { - const jobs = Array.isArray(items) ? items : []; - if (!jobs.length) { - runsBox.textContent = t("scheduledJobs.noRuns"); - return; - } - const withLast = jobs.filter((j) => String(j.last_run_at || "").trim()); - const target = withLast.length ? withLast[0] : jobs[0]; - if (!target || !target.id) { - runsBox.textContent = t("scheduledJobs.noRuns"); - return; - } - const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`); - const runItems = runs.items || []; - runsBox.textContent = runItems.length - ? JSON.stringify(runItems, null, 2) - : t("scheduledJobs.noRuns"); - } - - async function loadJobs() { - closeScheduledJobMenu(); - msg.textContent = t("scheduledJobs.loading"); - const st = String(status.value || "").trim(); - const q = st ? `?status=${encodeURIComponent(st)}` : ""; - const resp = await apiGet(`/admin/api/scheduled-jobs${q}`); - tbody.replaceChildren(); - for (const job of resp.items || []) { - const jobId = String(job.id || ""); - const btnMore = el("button", { - class: "chat-sess-more", - text: "⋯", - title: t("scheduledJobs.menuTitle"), - onclick: (ev) => { - ev.stopPropagation(); - closeScheduledJobMenu(); - const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [ - el("button", { - class: "chat-sess-menu-item", - text: t("scheduledJobs.viewRuns"), - onclick: async () => { - closeScheduledJobMenu(); - const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`); - runsBox.textContent = JSON.stringify(runs.items || [], null, 2); - }, - }), - ...(canWrite - ? [ - el("button", { - class: "chat-sess-menu-item", - text: t("scheduledJobs.edit"), - onclick: () => { - closeScheduledJobMenu(); - openEditModal(job); - }, - }), - ] - : []), - el("button", { - class: "chat-sess-menu-item", - text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"), - onclick: async () => { - closeScheduledJobMenu(); - const path = - job.status === "active" - ? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause` - : `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`; - await apiPost(path, {}); - await loadJobs(); - }, - }), - el("button", { - class: "chat-sess-menu-item", - text: t("scheduledJobs.runNow"), - onclick: async () => { - closeScheduledJobMenu(); - const out = await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {}); - if (out && out.skipped) { - msg.textContent = t("scheduledJobs.skippedOverlap"); - } else { - msg.textContent = t("scheduledJobs.triggered"); - } - await loadJobs(); - }, - }), - ...(canWrite - ? [ - el("button", { - class: "chat-sess-menu-item", - text: t("scheduledJobs.delete"), - onclick: async () => { - closeScheduledJobMenu(); - if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return; - await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), { - method: "DELETE", - headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" }, - }); - await loadJobs(); - }, - }), - ] - : []), - ]); - const rect = ev.currentTarget.getBoundingClientRect(); - document.body.appendChild(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left - 120; - 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`; - scheduledJobMenuEl = menu; - const close = (e) => { - if (!menu.contains(e.target)) { - closeScheduledJobMenu(); - document.removeEventListener("click", close); - } - }; - setTimeout(() => document.addEventListener("click", close), 0); - }, - }); - const lastRun = job.last_run_at - ? `${formatSystemLocalDateTime(String(job.last_run_at))}${job.last_run_status ? ` (${job.last_run_status})` : ""}` - : "—"; - const tr = el("tr", {}, [ - tdCell(job.name || "", 24), - tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28), - tdCell(formatScheduledJobPlaybook(job), 18), - tdCell(job.status || "", 10), - tdCell(job.next_run_at || "—", 20), - tdCell(lastRun, 24), - tdCell(job.specialist || "", 14), - tdCell(formatScheduledJobDelivery(job), 12), - el("td", { class: "table__cell--actions" }, [ - el("div", { class: "table__cell-actions" }, [btnMore]), - ]), - ]); - tbody.appendChild(tr); - } - msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) }); - await loadLatestRuns(resp.items || []); - } - - const nameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") }); - const kindInput = el("select", { class: "input" }, [ - el("option", { value: "cron", text: "cron" }), - el("option", { value: "once", text: "once" }), - el("option", { value: "interval", text: "interval" }), - ]); - const exprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") }); - const promptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") }); - const specialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist"), value: "generalist" }); - const waChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") }); - const weixinInfo = el("div", { class: "muted", text: "" }); - - async function loadMeta() { - const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets"); - const wx = meta.weixin_binding || {}; - weixinInfo.textContent = wx.external_user_id - ? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) }) - : t("scheduledJobs.weixinMissing"); - } - - await loadMeta(); - await loadJobs(); - - const createCardChildren = [ - el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }), - weixinInfo, - el("div", { class: "form-stack" }, [ - field(t("scheduledJobs.fieldName"), nameInput, true), - el("div", { class: "form-grid" }, [ - field(t("scheduledJobs.fieldKind"), kindInput), - field(t("scheduledJobs.fieldExpr"), exprInput), - ]), - field(t("scheduledJobs.fieldPrompt"), promptInput, true), - el("div", { class: "form-grid" }, [ - field(t("scheduledJobs.fieldSpecialist"), specialistInput), - field(t("scheduledJobs.fieldWhatsapp"), waChatInput), - ]), - el("div", { class: "form-actions" }, [ - el("button", { - class: "btn btn--primary", - text: t("scheduledJobs.create"), - onclick: async () => { - await apiPost("/admin/api/scheduled-jobs", { - name: nameInput.value, - schedule_kind: kindInput.value, - schedule_expr: exprInput.value, - prompt_text: promptInput.value, - specialist: specialistInput.value, - whatsapp_chat_id: waChatInput.value, - delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value), - }); - await loadJobs(); - }, - }), - ]), - ]), - ]; - - return el("div", {}, [ - editModal, - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.scheduledJobs") }), - el("div", { class: "row", style: "gap:8px;align-items:center;" }, [ - status, - el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }), - msg, - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("colgroup", {}, [ - el("col", { style: "width:14%" }), - el("col", { style: "width:14%" }), - el("col", { class: "u-w-12p" }), - el("col", { style: "width:8%" }), - el("col", { class: "u-w-12p" }), - el("col", { class: "u-w-12p" }), - el("col", { style: "width:8%" }), - el("col", { style: "width:10%" }), - el("col", { style: "width:10%" }), - ]), - el("thead", {}, [ - el("tr", {}, [ - el("th", { text: t("scheduledJobs.colName") }), - el("th", { text: t("scheduledJobs.colSchedule") }), - el("th", { text: t("scheduledJobs.colPlaybook") }), - el("th", { text: t("scheduledJobs.colStatus") }), - el("th", { text: t("scheduledJobs.colNextRun") }), - el("th", { text: t("scheduledJobs.colLastRun") }), - el("th", { text: t("scheduledJobs.colSpecialist") }), - el("th", { text: t("scheduledJobs.colDelivery") }), - el("th", { text: t("scheduledJobs.colActions") }), - ]), - ]), - tbody, - ]), - ]), - ]), - ...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []), - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }), - runsBox, - ]), - ]); -} - -async function router() { - const route = getRoute(); - const page = route.page; - const tok = getStoredAuthToken(); - if (!authSession || !tok) { - if (authSession || tok) { - authStoreRemove(AUTH_TOKEN_KEY); - authStoreRemove(AUTH_SESSION_KEY); - authSession = null; - } - try { - await apiPost("/admin/api/auth/bootstrap", {}); - } catch (_) {} - const loginCard = await renderLogin(); - mount(loginCard); - return; - } - applyI18nStatic(); - setActive(page); - document.querySelectorAll(".nav__item").forEach((a) => { - const p = String(a.dataset.page || ""); - if (p === "stack" && !hasPermission("admin:runtime:write")) a.style.display = "none"; - else if (p === "scheduled-jobs" && !hasPermission("admin:read")) a.style.display = "none"; - else if (p === "users" && !hasPermission("admin:user:read")) a.style.display = "none"; - else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none"; - else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none"; - else if (p === "plugins" && !hasPermission("admin:user:write")) a.style.display = "none"; - else if (p === "skills" && !hasPermission("admin:read")) a.style.display = "none"; - else if (p === "attachments" && !isAdministratorUsername()) a.style.display = "none"; - else if (p === "workspace-paths" && !hasPermission("admin:user:read") && !hasPermission("admin:workspace_paths:read")) a.style.display = "none"; - else if (p === "api-grants" && !canManageApiGrants()) a.style.display = "none"; - else a.style.display = ""; - }); - const user = document.getElementById("authUser"); - if (user) { - const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || ""); - const role = String((authSession && authSession.role) || ""); - user.textContent = name ? `${name} (${role})` : ""; - } - const forbiddenCard = () => - el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]); - let view; - if (page === "stack") { - mount( - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.stack") }), - el("div", { class: "muted", text: t("chat.loading") }), - ]), - ); - view = await renderStack(); - } - else if (page === "scheduled-jobs") { - view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard(); - } - else if (page === "users") { - view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard(); - } else if (page === "memory") view = await renderMemory(); - else if (page === "models") view = await renderModels(); - else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") { - view = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("common.notFound") }), - el("div", { class: "muted", text: "This admin page was removed in the product simplification pass." }), - ]); - } else if (page === "audit") view = await renderAudit(route.params.get("session_id") || ""); - else if (false && page === "session-monitor") { - view = isAdministratorUsername() ? await renderSessionMonitor() : el("div", { class: "card" }, [el("div", { class: "card__title", text: t("sessionMonitor.onlyAdministrator") })]); - } else if (false && page === "admin-audit") { - view = hasPermission("admin:user:write") ? await renderAdminAudit() : forbiddenCard(); - } else if (page === "plugins") { - mount( - el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("title.plugins") }), - el("div", { class: "muted", text: t("chat.loading") }), - ]), - ); - view = hasPermission("admin:user:write") ? await renderPlugins() : forbiddenCard(); - } else if (page === "skills") { - view = hasPermission("admin:read") ? await renderSkills() : forbiddenCard(); - } else if (page === "attachments") { - view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard(); - } else if (page === "workspace-paths") { - view = - hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read") - ? await renderWorkspacePaths() - : forbiddenCard(); - } else if (page === "profile") { - view = await renderProfile(); - } else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]); - mount(view); -} - -async function renderLogin() { - applyI18nStatic(); - const username = el("input", { class: "input", placeholder: t("auth.username"), value: "" }); - const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") }); - 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(), - password: password.value.trim(), - purpose: "console", - }); - if (!resp.ok || !resp.token) { - const err = String(resp.error || ""); - status.textContent = err === "user_disabled" ? t("auth.disabled") : t("auth.invalid"); - return; - } - const newTok = String(resp.token || "").trim(); - authStoreSet(AUTH_TOKEN_KEY, newTok); - authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); - authSession = resp.session || null; - await router(); - }; - 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 }); - // Focus after mount so users can type immediately. - setTimeout(() => { - try { - username.focus(); - } catch (_) {} - }, 0); - return el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("auth.login") }), - el("div", { class: "row" }, [username]), - userHint, - el("div", { class: "row" }, [password]), - el("div", { class: "row" }, [btn]), - status, - ]); -} - -window.addEventListener("hashchange", router); -const btnRefreshEl = document.getElementById("btnRefresh"); -if (btnRefreshEl) btnRefreshEl.addEventListener("click", router); -const btnLangEl = document.getElementById("btnLang"); -if (btnLangEl) btnLangEl.addEventListener("click", () => { - toggleLang(); - router(); -}); -const adminThemeSelectEl = document.getElementById("adminThemeSelect"); -if (adminThemeSelectEl && window.OclawAdminTheme) { - adminThemeSelectEl.addEventListener("change", () => { - try { - window.OclawAdminTheme.persistAdminTheme(adminThemeSelectEl.value); - } catch (_) {} - }); -} -const btnBackChatEl = document.getElementById("btnBackChat"); -if (btnBackChatEl) { - try { - btnBackChatEl.setAttribute("href", resolveChatUrl()); - } catch (_) {} - btnBackChatEl.addEventListener("click", (ev) => { - // Keep SPA behavior explicit, while href remains fallback if JS fails earlier. - ev.preventDefault(); - window.location.assign(resolveChatUrl()); - }); -} -const btnLogoutEl = document.getElementById("btnLogout"); -if (btnLogoutEl) btnLogoutEl.addEventListener("click", async () => { - try { - await apiPost("/admin/api/auth/logout", {}); - } catch (_) {} - authStoreRemove(AUTH_TOKEN_KEY); - authStoreRemove(AUTH_SESSION_KEY); - authSession = null; - await router(); -}); -try { - authSession = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "null"); -} catch (_) { - authSession = null; -} -router().catch((err) => { - mount(el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.error") }), el("div", { class: "pre", text: String(err) })])); -}); - +import "./js/main.js"; diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 52cb4628..502aaa5f 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -779,11 +779,7 @@ - + @@ -811,9 +820,9 @@
- 正在加载… + 正在加载…
-
+
正在启动本地界面;若长时间停留,请确认网关已启动。桌面端可查看 desktop.log / backend.log。
diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index bcd35921..7ec32067 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -1,6158 +1 @@ -/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */ - -const PAGE_SIZE = 35; -/** Initial / page size for session history (older messages load on scroll-up). */ -const CHAT_MESSAGES_FETCH_LIMIT = 80; - -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} 条 · 上滑加载更早", - "chat.loadingOlder": "正在加载更早消息…", - "chat.historyComplete": "已到最早消息", - "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.netx": "NetX 暗色", - "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.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.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": "Assistant", - "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 latest {shown}/{total} · scroll up for older", - "chat.loadingOlder": "Loading older messages…", - "chat.historyComplete": "Reached the start of this chat", - "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.netx": "NetX Dark", - "theme.deepseek": "DeepSeek", - "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.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.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_USER_MENU_MODE_KEY = "ops_chat_user_menu_mode"; -const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle"; -const EXECUTION_MODE_AGENT = "agent"; -const EXECUTION_MODE_PLAN = "plan"; -/** Default on: reasoning/tool fold matches streamed behavior; new browsers have no localStorage yet. */ -const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = true; -const REASONING_BLOCK_MAX_CHARS = 12000; -const CHAT_ENABLE_WIKI_EVENT_POLLER = false; -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, a.chat-att-ref__action, .chat-att-chip") || - String(inner.textContent || "").trim().length > 0; - if (!hasVisible) { - inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") })); - } - return wrapAssistantMessage(inner, tsIso); -} - -function _buildRenderRows(msgs) { - const src = Array.isArray(msgs) ? msgs : []; - const ordered = src - .map((m, idx) => ({ m, idx })) - .sort((a, b) => { - const ta = Date.parse(String((a.m && a.m.timestamp) || "")) || 0; - const tb = Date.parse(String((b.m && b.m.timestamp) || "")) || 0; - if (ta !== tb) return ta - tb; - const ia = parseInt(String((a.m && a.m.id) || "0"), 10); - const ib = parseInt(String((b.m && b.m.id) || "0"), 10); - if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; - return a.idx - b.idx; - }) - .map((x) => x.m); - const rows = []; - let agg = null; - const flush = () => { - if (!agg) return; - if (!Array.isArray(agg._items) || !agg._items.length) { - agg = null; - return; - } - rows.push(agg); - agg = null; - }; - for (const m of ordered) { - const role = String((m && m.role) || "").toLowerCase(); - const content = String((m && m.content) || ""); - const eventType = _normalizeEventType(m && m.event_type); - if (role === "user") { - flush(); - rows.push(m); - continue; - } - if (role === "assistant" || role === "tool" || role === "function") { - const turnUuid = _messageTurnUuid(m); - if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) { - flush(); - } - if (role === "assistant" && _isScheduledProactiveMessage(m)) { - flush(); - _pushScheduledAssistantRow(rows, m, content, eventType); - continue; - } - if (!agg) { - agg = { - role: "assistant", - content: "", - timestamp: (m && m.timestamp) != null ? m.timestamp : "", - attachments: null, - _items: [], - _message_ids: [], - _turn_uuid: turnUuid, - }; - } else if (turnUuid && !agg._turn_uuid) { - agg._turn_uuid = turnUuid; - } - if (m && m.id != null) agg._message_ids.push(m.id); - if (role === "assistant") { - // thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows). - const ep = _parseEventPayload(m && m.event_payload); - if (ep) { - const rc = String(ep.reasoning_content || "").trim(); - if (rc) { - agg._items.push({ kind: "reasoning", text: rc }); - } - } - if (eventType === "reasoning") { - if (String(content || "").trim()) { - agg._items.push({ kind: "reasoning", text: content }); - } - } else if (eventType === "tool_call") { - const hasText = !!String(content || "").trim(); - const attsParsed = parseAttachments(m && m.attachments); - if (hasText || attsParsed.length) { - const piece = { kind: "assistant_text", text: content, assistantEventType: "tool_call" }; - if (attsParsed.length) piece.attachments = attsParsed; - agg._items.push(piece); - } - } else if (_isAssistantBodyEventType(eventType)) { - const hasText = !!String(content || "").trim(); - const attsParsed = parseAttachments(m && m.attachments); - if (hasText || attsParsed.length) { - const piece = { - kind: "assistant_text", - text: content, - assistantEventType: eventType || "assistant_text", - }; - if (attsParsed.length) piece.attachments = attsParsed; - agg._items.push(piece); - } - } - const tc = m.tool_calls; - if (tc != null && tc !== "") { - let line = ""; - try { - line = typeof tc === "string" ? tc : JSON.stringify(tc, null, 0); - } catch (_) { - line = String(tc); - } - if (line) agg._items.push({ kind: "tool_call", text: line }); - } - if (m.attachments) agg.attachments = m.attachments; - } else { - const hasToolText = !!String(content || "").trim(); - const toolAtts = parseAttachments(m && m.attachments); - if (hasToolText || toolAtts.length) { - const piece = { kind: "tool_result", text: content }; - if (toolAtts.length) piece.attachments = toolAtts; - else if (m.attachments) piece.attachments = m.attachments; - agg._items.push(piece); - if (m.attachments) agg.attachments = m.attachments; - } - } - continue; - } - flush(); - rows.push(m); - } - flush(); - return rows; -} - -/** True when persisted history does not show a completed assistant reply for the latest turn (e.g. ends with user only). */ -function _needsWsTextFallbackFromRenderRows(renderRows) { - const rows = Array.isArray(renderRows) ? renderRows : []; - if (!rows.length) return true; - const last = rows[rows.length - 1]; - const lr = String((last && last.role) || "").toLowerCase(); - if (lr === "user") return true; - if (lr !== "assistant") return true; - const items = last && Array.isArray(last._items) ? last._items : []; - for (const it of items) { - const k = String((it && it.kind) || "").toLowerCase(); - if (k === "assistant_text" && String((it && it.text) || "").trim()) return false; - if (k === "reasoning" && String((it && it.text) || "").trim()) return false; - if (k === "tool_result") { - if (String((it && it.text) || "").trim()) return false; - if (Array.isArray(it.attachments) && it.attachments.length) return false; - } - } - if (String((last && last.content) || "").trim()) return false; - return true; -} - -function 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(); - // Legacy manager / manager_self / main aliases display as generalist. - if (s === "manager" || s === "manager_self" || s === "main" || s === "comprehensive") { - return t("chat.specialistGeneralistShort"); - } - if (s === "ops") return t("chat.specialistOpsShort"); - if (s === "image") return t("chat.specialistImageShort"); - if (s === "video") return t("chat.specialistVideoShort"); - if (s === "memory") return t("chat.specialistMemoryShort"); - return t("chat.specialistGeneralistShort"); -} -function memoryModeShortLabel(memoryMode) { - const mm = String(memoryMode || "default").toLowerCase(); - if (mm === "store_only") return t("chat.memoryModeStoreOnlyShort"); - return t("chat.memoryModeDefaultShort"); -} - -function _ensureToastHost() { - let host = document.querySelector(".chat-toast-host"); - if (host) return host; - host = el("div", { class: "chat-toast-host" }); - document.body.appendChild(host); - return host; -} - -function showToast(text, { kind = "info", ttlMs = 4200 } = {}) { - const host = _ensureToastHost(); - const node = el("div", { - class: kind === "error" ? "chat-toast chat-toast--error" : "chat-toast", - text: String(text || ""), - }); - host.appendChild(node); - const kill = () => { - try { - node.style.opacity = "0"; - node.style.transform = "translateY(6px)"; - } catch (_) {} - setTimeout(() => { - try { - node.remove(); - } catch (_) {} - }, 220); - }; - setTimeout(kill, Math.max(1200, Math.min(Number(ttlMs || 4200), 20000))); - return node; -} - -let _jobsPanelPollTimer = null; -let _jobsBadgePollTimer = null; -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 u-z-modal", - }); - const card = el("div", { - class: "chat-confirm-card chat-jobs-card u-jobs-card", - }); - const title = el("div", { class: "card__title", text: t("chat.jobsTitle") }); - const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsClose") }); - const refreshBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsRefresh") }); - const killAllBtn = el("button", { - type: "button", - class: "btn btn--danger", - text: t("chat.jobsKillAll"), - }); - const head = el("div", { class: "row u-row-between" }, [ - title, - el("div", { class: "row u-gap-8" }, [refreshBtn, killAllBtn, closeBtn]), - ]); - const hint = el("div", { class: "muted u-hint", text: t("chat.jobsHint") }); - const summary = el("div", { class: "muted u-fs-12" }); - const list = el("div", { - class: "chat-jobs-list u-jobs-list", - }); - - const close = () => { - if (_jobsPanelPollTimer) { - clearInterval(_jobsPanelPollTimer); - _jobsPanelPollTimer = null; - } - try { - backdrop.remove(); - } catch (_) {} - refreshJobsBadge(); - }; - closeBtn.addEventListener("click", close); - backdrop.addEventListener("click", (ev) => { - if (ev.target === backdrop) close(); - }); - - const paint = async () => { - list.innerHTML = ""; - list.appendChild(el("div", { class: "muted", text: t("chat.loading") })); - try { - const r = await apiGet("/admin/api/chat/jobs?limit=50"); - const jobs = Array.isArray(r && r.jobs) ? r.jobs : []; - const running = Number((r && r.running_count) || 0); - updateJobsBadge(running); - summary.textContent = t("chat.jobsRunning", { n: running }) + ` · total ${Number((r && r.total) || jobs.length)}`; - list.innerHTML = ""; - if (!jobs.length) { - list.appendChild(el("div", { class: "muted", text: t("chat.jobsEmpty") })); - return; - } - for (const job of jobs) { - const status = String(job.status || ""); - const runningJob = status === "running"; - const row = el("div", { class: `chat-jobs-row chat-jobs-row--${status || "unknown"}` }); - const top = el("div", { class: "chat-jobs-row__top" }, [ - el("span", { class: "chat-jobs-row__name", text: String(job.name || job.job_id || "-") }), - el("span", { class: `chat-jobs-pill chat-jobs-pill--${status}`, text: _jobStatusLabel(status) }), - ]); - const meta = el( - "div", - { class: "chat-jobs-row__meta muted" }, - [ - el("div", { text: `id: ${job.job_id || "-"}` }), - el("div", { text: `pid: ${job.pid != null ? job.pid : "-"} · timeout: ${job.timeout_s || "-"}s` }), - el("div", { text: `created: ${_fmtJobTs(job.created_at)} · finished: ${_fmtJobTs(job.finished_at)}` }), - el("div", { - class: "chat-jobs-row__cmd", - text: String(job.command || ""), - title: String(job.command || ""), - }), - ], - ); - const actions = el("div", { class: "chat-jobs-row__actions row u-gap-8" }); - if (runningJob) { - const killBtn = el("button", { - type: "button", - class: "btn btn--danger", - text: t("chat.jobsKill"), - onclick: async () => { - if (!window.confirm(t("chat.jobsKillConfirm"))) return; - killBtn.disabled = true; - try { - await apiPost(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}/cancel`, {}); - await paint(); - } catch (e) { - showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" }); - } finally { - killBtn.disabled = false; - } - }, - }); - actions.appendChild(killBtn); - } else { - const purgeBtn = el("button", { - type: "button", - class: "btn", - text: t("chat.jobsPurge"), - onclick: async () => { - if (!window.confirm(t("chat.jobsPurgeConfirm"))) return; - purgeBtn.disabled = true; - try { - await apiDelete(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}`); - await paint(); - } catch (e) { - showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" }); - } finally { - purgeBtn.disabled = false; - } - }, - }); - actions.appendChild(purgeBtn); - } - const detailBtn = el("button", { - type: "button", - class: "btn", - text: 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 u-z-modal", - }); - const card = el("div", { - class: "chat-confirm-card u-profile-card", - }); - const title = el("div", { class: "card__title", text: t("chat.wikiPreviewTitle", { path: p }) }); - const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.wikiPreviewClose") }); - const head = el("div", { class: "row u-row-between" }, [ - title, - closeBtn, - ]); - const body = el("div", { class: "chat-msg__md", html: `
${escapeHtml(t("chat.loading"))}
` }); - const close = () => { - try { - backdrop.remove(); - } catch (_) {} - }; - closeBtn.addEventListener("click", close); - backdrop.addEventListener("click", (ev) => { - if (ev.target === backdrop) close(); - }); - card.appendChild(head); - card.appendChild(body); - backdrop.appendChild(card); - document.body.appendChild(backdrop); - try { - const resp = await apiGet( - `/admin/api/chat/sessions/${encodeURIComponent(sid)}/wiki-file?path=${encodeURIComponent(p)}&max_chars=120000`, - ); - body.innerHTML = renderMarkdownHtml(String((resp && resp.content) || "")); - } catch (e) { - body.innerHTML = `
${escapeHtml(`${t("chat.error")}: ${String(e)}`)}
`; - } -} - -function reasonLabel(reason) { - const r = String(reason || "").trim(); - if (!r) return "-"; - const key = `chat.reason.${r}`; - const localized = t(key); - return localized === key ? r : localized; -} - -async function fetchDynamicExpertStats() { - try { - const r = await apiGet("/admin/api/chat/admin/dynamic-expert-stats?limit=200"); - if (!r || !r.ok) return null; - return { - dynamic: parseInt(String(r.dynamic_used_count || "0"), 10) || 0, - fallback: parseInt(String(r.fallback_generalist_count || "0"), 10) || 0, - rate: Number(r.dynamic_used_rate || 0), - reasons: r.dispatch_reasons && typeof r.dispatch_reasons === "object" ? r.dispatch_reasons : {}, - reasonLabels: - r.dispatch_reason_labels && typeof r.dispatch_reason_labels === "object" ? r.dispatch_reason_labels : {}, - }; - } catch (_) { - return null; - } -} - -async function getDispatchReasonLabelsConfig() { - try { - const r = await apiGet("/admin/api/chat/settings/dispatch-reason-labels"); - if (!r || !r.ok) return { overrides: {}, effective: {} }; - return { - overrides: r.overrides && typeof r.overrides === "object" ? r.overrides : {}, - effective: r.effective && typeof r.effective === "object" ? r.effective : {}, - }; - } catch (_) { - return { overrides: {}, effective: {} }; - } -} - -async function setDispatchReasonLabelsConfig(overridesOrNull) { - return await apiPost("/admin/api/chat/settings/dispatch-reason-labels", { - overrides: overridesOrNull, - }); -} - -async function openDispatchLabelsEditor(statusEl) { - const cfg = await getDispatchReasonLabelsConfig(); - const initText = Object.keys(cfg.overrides || {}).length - ? JSON.stringify(cfg.overrides, null, 2) - : ""; - const backdrop = el("div", { - class: "chat-confirm-backdrop u-z-modal-top", - }); - const card = el("div", { - class: "card u-json-card", - }); - const title = el("div", { class: "card__title", text: t("chat.dispatchLabelsTitle") }); - const tip = el("div", { class: "muted", text: t("chat.dispatchLabelsPrompt") }); - const err = el("div", { class: "muted u-text-danger" }); - const ta = el("textarea", { - class: "input u-mono-area", - }); - ta.value = initText; - const previewTitle = el("div", { class: "muted", text: t("chat.dispatchLabelsEffectivePreview") }); - const diffOnlyWrap = el("label", { class: "muted u-check-row" }); - const diffOnlyCb = el("input", { type: "checkbox" }); - diffOnlyWrap.appendChild(diffOnlyCb); - diffOnlyWrap.appendChild(el("span", { text: t("chat.dispatchLabelsDiffOnly") })); - const preview = el("textarea", { - class: "input u-mono-area-sm", - readonly: "readonly", - }); - const btnSave = el("button", { type: "button", class: "btn btn--primary", text: t("chat.dispatchLabelsSave") }); - const btnClear = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsClear") }); - const btnDefaults = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsRestoreDefaults") }); - const btnExport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsExport") }); - const btnImport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsImport") }); - const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") }); - const fileInput = el("input", { class: "u-hidden", type: "file", accept: "application/json,.json" }); - const close = () => backdrop.remove(); - const saveCurrent = async () => { - const raw = String(ta.value || "").trim(); - try { - if (!raw) { - await setDispatchReasonLabelsConfig(null); - } else { - const obj = JSON.parse(raw); - await setDispatchReasonLabelsConfig(obj); - } - if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved"); - close(); - } catch (e) { - const msg = String(e || ""); - err.textContent = msg.toLowerCase().includes("json") - ? t("chat.dispatchLabelsInvalidJson") - : `${t("chat.error")}: ${msg}`; - } - }; - const hasUnsavedChanges = () => String(ta.value || "").trim() !== initText.trim(); - const closeWithGuard = () => { - if (hasUnsavedChanges()) { - if (!window.confirm(t("chat.dispatchLabelsUnsavedConfirm"))) return; - } - close(); - }; - const refreshPreview = () => { - const raw = String(ta.value || "").trim(); - if (!raw) { - preview.value = JSON.stringify(cfg.effective || {}, null, 2); - return; - } - try { - const userObj = JSON.parse(raw); - const base = cfg.effective && typeof cfg.effective === "object" ? cfg.effective : {}; - const merged = { ...base }; - const changed = {}; - if (userObj && typeof userObj === "object") { - for (const [k, v] of Object.entries(userObj)) { - if (!v || typeof v !== "object") continue; - const next = { - zh: String((v && v.zh) || (merged[String(k)] && merged[String(k)].zh) || ""), - en: String((v && v.en) || (merged[String(k)] && merged[String(k)].en) || ""), - }; - const prev = merged[String(k)] && typeof merged[String(k)] === "object" ? merged[String(k)] : {}; - merged[String(k)] = next; - if (String(next.zh || "") !== String(prev.zh || "") || String(next.en || "") !== String(prev.en || "")) { - changed[String(k)] = next; - } - } - } - preview.value = JSON.stringify(diffOnlyCb.checked ? changed : merged, null, 2); - } catch (_) { - preview.value = t("chat.dispatchLabelsInvalidJson"); - } - }; - ta.addEventListener("input", refreshPreview); - diffOnlyCb.addEventListener("change", refreshPreview); - refreshPreview(); - - btnCancel.addEventListener("click", closeWithGuard); - btnExport.addEventListener("click", () => { - const raw = String(preview.value || "").trim(); - const content = raw || "{}"; - const blob = new Blob([content], { type: "application/json;charset=utf-8" }); - const a = document.createElement("a"); - const now = new Date(); - const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; - a.href = URL.createObjectURL(blob); - a.download = `dispatch-reason-labels-effective-${ts}.json`; - a.click(); - URL.revokeObjectURL(a.href); - }); - btnImport.addEventListener("click", () => fileInput.click()); - btnDefaults.addEventListener("click", () => { - ta.value = JSON.stringify(cfg.effective || {}, null, 2); - err.textContent = ""; - refreshPreview(); - }); - fileInput.addEventListener("change", async () => { - const f = fileInput.files && fileInput.files[0]; - fileInput.value = ""; - if (!f) return; - try { - const txt = await f.text(); - const obj = JSON.parse(String(txt || "")); - ta.value = JSON.stringify(obj, null, 2); - err.textContent = ""; - refreshPreview(); - } catch (_) { - err.textContent = t("chat.dispatchLabelsInvalidJson"); - } - }); - btnClear.addEventListener("click", async () => { - try { - await setDispatchReasonLabelsConfig(null); - if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved"); - close(); - } catch (e) { - err.textContent = `${t("chat.error")}: ${String(e)}`; - } - }); - btnSave.addEventListener("click", saveCurrent); - ta.addEventListener("keydown", (ev) => { - const isSave = (ev.ctrlKey || ev.metaKey) && String(ev.key || "").toLowerCase() === "s"; - if (!isSave) return; - ev.preventDefault(); - saveCurrent(); - }); - - card.appendChild(title); - card.appendChild(tip); - card.appendChild(el("div", { class: "u-h-8" })); - card.appendChild(ta); - card.appendChild(el("div", { class: "u-h-8" })); - card.appendChild(previewTitle); - card.appendChild(diffOnlyWrap); - card.appendChild(preview); - card.appendChild(el("div", { class: "u-h-8" })); - card.appendChild(err); - card.appendChild( - el("div", { class: "row u-row-end-sm" }, [ - btnDefaults, - btnImport, - btnExport, - btnClear, - btnCancel, - btnSave, - ]), - ); - backdrop.appendChild(card); - backdrop.appendChild(fileInput); - backdrop.addEventListener("click", (ev) => { - if (ev.target === backdrop) closeWithGuard(); - }); - document.body.appendChild(backdrop); -} - -function 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); -} - -function withTimeout(promise, ms, label) { - let timer = null; - const timeoutMs = Math.max(200, Number(ms) || 0); - return Promise.race([ - Promise.resolve(promise), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(label || `timeout:${timeoutMs}`)), timeoutMs); - }), - ]).finally(() => { - if (timer) clearTimeout(timer); - }); -} - -async function apiGet(path) { - const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; - const headers = { accept: "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(path, { headers }); - if (res.status === 401) { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_SESSION_KEY); - authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); - } - if (!res.ok) throw new Error(`GET ${path} ${res.status}`); - return await res.json(); -} - -async function apiPost(path, body) { - const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; - const headers = { "content-type": "application/json", accept: "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(path, { - method: "POST", - headers, - body: JSON.stringify(body ?? {}), - }); - if (res.status === 401) { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_SESSION_KEY); - authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); - } - if (!res.ok) throw new Error(`POST ${path} ${res.status}`); - return await res.json(); -} - -async function apiPatch(path, body) { - const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; - const headers = { "content-type": "application/json", accept: "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(path, { - method: "PATCH", - headers, - body: JSON.stringify(body ?? {}), - }); - if (res.status === 401) { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_SESSION_KEY); - authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); - } - if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`); - return await res.json(); -} - -async function apiDelete(path) { - const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; - const headers = { accept: "application/json" }; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(path, { method: "DELETE", headers }); - if (res.status === 401) { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem(AUTH_SESSION_KEY); - authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); - } - if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`); - return await res.json(); -} - -function el(tag, attrs = {}, children = []) { - const e = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === "class") e.className = v; - else if (k === "text") e.textContent = v; - else if (k === "html") e.innerHTML = v; - else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v); - else e.setAttribute(k, v); - } - for (const c of children) e.appendChild(c); - return e; -} - -function escapeHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** Pretty-print channel session titles like `whatsapp|AI nms · Oliver · wa-default+86…@s.whatsapp.net`. */ -function formatChatSessionTitle(raw, peerNameHint) { - const full = String(raw || "").trim(); - const badgeMap = { - whatsapp: "WA", - wechat: "WX", - weixin: "WX", - wecom: "WC", - telegram: "TG", - discord: "DC", - slack: "SL", - local: "Local", - }; - if (!full) { - return { channel: "local", badge: badgeMap.local, label: "—", sublabel: "", full: "" }; - } - const m = full.match(/^(whatsapp|wechat|weixin|wecom|telegram|discord|slack)\|(.+)$/i); - if (!m) { - return { channel: "local", badge: badgeMap.local, label: full, sublabel: "", full }; - } - const channel = String(m[1] || "").toLowerCase(); - const rest = String(m[2] || "").trim(); - const segments = rest - .split(/\s*·\s*/) - .map((s) => String(s || "").trim()) - .filter(Boolean); - - const isTech = (s) => /[+]/.test(s) || /@/.test(s) || /^wa-default/i.test(s); - let techIdx = -1; - for (let i = 0; i < segments.length; i += 1) { - if (isTech(segments[i])) techIdx = i; - } - let labels = []; - let tech = rest; - if (techIdx >= 0) { - labels = segments.slice(0, techIdx); - tech = segments.slice(techIdx).join(" · "); - } else if (segments.length > 1) { - labels = segments.slice(0, -1); - tech = segments[segments.length - 1] || ""; - } - - // Legacy titles buried group name after the JID: account+jid@domain+GroupName - let techBody = String(tech || "").replace(/^wa-default\+/i, ""); - const plusParts = techBody.split("+").map((s) => String(s || "").trim()).filter(Boolean); - const idParts = []; - const legacyNames = []; - for (const p of plusParts) { - if (p.includes("@") || /^[0-9]{8,}$/.test(p) || /^[0-9a-f]{16,}$/i.test(p)) idParts.push(p); - else legacyNames.push(p); - } - for (let i = legacyNames.length - 1; i >= 0; i -= 1) { - const n = legacyNames[i]; - if (n && !labels.includes(n)) labels.unshift(n); // group name first - } - let idRaw = idParts.join("+") || (legacyNames.length ? "" : techBody); - let idShort = idRaw - .replace(/@s\.whatsapp\.net$/i, "") - .replace(/@g\.us$/i, "") - .replace(/@lid$/i, "") - .replace(/@im\.bot[^\s]*/i, "") - .replace(/@[^\s]+$/i, ""); - if (/^[0-9a-f]{20,}$/i.test(idShort)) idShort = `${idShort.slice(0, 8)}…`; - else if (idShort.length > 18) idShort = `${idShort.slice(0, 16)}…`; - - const hint = String(peerNameHint || "").trim(); - if (hint && !labels.some((l) => l.toLowerCase() === hint.toLowerCase())) { - // Prefer keeping server-provided group/nick labels; append contact hint when useful. - if (!labels.length) labels.push(hint); - else if (labels.length === 1 && labels[0] !== hint) labels.push(hint); - } - - const badge = badgeMap[channel] || channel.slice(0, 2).toUpperCase(); - let label = ""; - if (labels.length) { - label = labels.join(" · "); - // DM / unknown: single nick still benefits from a short id. - if (labels.length === 1 && idShort && labels[0] === hint) { - label = `${labels[0]} · ${idShort}`; - } - } else { - label = idShort || full; - } - return { - channel, - badge, - label, - sublabel: "", - full, - idKey: idRaw || techBody, - }; -} - -function _peerNameLookupKeys(title, pretty) { - const keys = []; - const push = (v) => { - const s = String(v || "").trim().toLowerCase(); - if (s) keys.push(s); - }; - push(pretty && pretty.idKey); - const raw = String(title || ""); - const m = raw.match(/^(whatsapp|wechat|weixin|wecom)\|(.+)$/i); - if (m) { - let body = String(m[2] || ""); - const segs = body.split(/\s*·\s*/).map((s) => String(s || "").trim()).filter(Boolean); - let tech = body; - for (let i = segs.length - 1; i >= 0; i -= 1) { - if (/[+]/.test(segs[i]) || /@/.test(segs[i]) || /^wa-default/i.test(segs[i])) { - tech = segs[i]; - break; - } - } - push(tech); - push(tech.replace(/^wa-default\+/i, "")); - const noAt = tech.replace(/@.*$/, ""); - push(noAt); - push(noAt.replace(/^wa-default\+/i, "")); - // Drop trailing +GroupName legacy suffix for contact id matching. - const core = noAt.replace(/^wa-default\+/i, "").split("+")[0] || ""; - push(core); - push(core.replace(/\D/g, "")); - } - return keys; -} - -let _channelPeerNameMap = new Map(); - -function lookupChannelPeerName(title, pretty) { - for (const k of _peerNameLookupKeys(title, pretty)) { - const hit = _channelPeerNameMap.get(k); - if (hit) return hit; - } - return ""; -} - -async function refreshChannelPeerNameMap() { - const next = new Map(); - try { - const r = await apiGet("/admin/api/whatsapp/access"); - const contacts = Array.isArray(r && r.contacts) ? r.contacts : []; - for (const c of contacts) { - const name = String((c && c.push_name) || "").trim(); - if (!name) continue; - const eid = String((c && c.external_user_id) || "").trim(); - const phone = String((c && c.phone) || "").trim(); - for (const raw of [eid, phone, eid.replace(/@.*$/, ""), phone.replace(/\D/g, "")]) { - const k = String(raw || "").trim().toLowerCase(); - if (k) next.set(k, name); - } - } - } catch (_) {} - _channelPeerNameMap = next; -} - -let _chatLightboxKeyHandler = null; -let _chatLightboxPrevOverflow = ""; - -function closeChatImageLightbox() { - if (_chatLightboxKeyHandler) { - document.removeEventListener("keydown", _chatLightboxKeyHandler); - _chatLightboxKeyHandler = null; - } - document.querySelector(".chat-img-lightbox")?.remove(); - document.body.style.overflow = _chatLightboxPrevOverflow || ""; -} - -function dismissChatMenus() { - document.querySelectorAll(".chat-sess-menu-pop, .chat-menu-scrim").forEach((n) => { - try { - n.remove(); - } catch (_) {} - }); -} - -function isChatStreaming() { - return !!document.querySelector(".chat-composer-shell--busy"); -} - -/** Remove full-screen layers on ``document.body`` that survive ``#app`` remounts (boot/lang/popstate). */ -function clearChatPageBlockers() { - closeChatImageLightbox(); - dismissChatMenus(); - document.body.style.overflow = ""; - document.querySelectorAll(".chat-confirm-backdrop").forEach((n) => { - try { - n.remove(); - } catch (_) {} - }); -} - -function attachChatMenuDismiss(menu) { - const scrim = el("div", { - class: "chat-menu-scrim u-overlay-scrim", - }); - const closeAll = () => { - dismissChatMenus(); - document.removeEventListener("click", onDocClick, true); - document.removeEventListener("keydown", onKey, true); - }; - const onDocClick = (e) => { - if (menu.contains(e.target)) return; - closeAll(); - }; - const onKey = (e) => { - if (e.key === "Escape") closeAll(); - }; - scrim.addEventListener("click", closeAll); - document.body.appendChild(scrim); - document.body.appendChild(menu); - setTimeout(() => { - document.addEventListener("click", onDocClick, true); - document.addEventListener("keydown", onKey, true); - }, 0); - return closeAll; -} - -function openChatImageLightbox(src, alt) { - if (!src || !String(src).trim()) return; - closeChatImageLightbox(); - _chatLightboxPrevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - _chatLightboxKeyHandler = (ev) => { - if (ev.key === "Escape") closeChatImageLightbox(); - }; - document.addEventListener("keydown", _chatLightboxKeyHandler); - - const backdrop = el("div", { - class: "chat-img-lightbox", - role: "dialog", - "aria-modal": "true", - "aria-label": t("chat.imageViewerHint"), - }); - const inner = el("div", { class: "chat-img-lightbox__inner" }); - let scale = 1.0; - const clamp = (v) => Math.max(0.2, Math.min(5.0, Number(v || 1))); - const applyScale = () => { - scale = clamp(scale); - viewport.style.transform = `scale(${scale})`; - zoomText.textContent = `${Math.round(scale * 100)}%`; - }; - const fileStem = (() => { - const raw = String(alt || "").trim() || "image"; - const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, "_").slice(0, 64); - return safe || "image"; - })(); - const ext = (() => { - const s = String(src || ""); - if (/^data:image\/png/i.test(s)) return ".png"; - if (/^data:image\/webp/i.test(s)) return ".webp"; - if (/^data:image\/gif/i.test(s)) return ".gif"; - if (/^data:image\/bmp/i.test(s)) return ".bmp"; - if (/^data:image\/jpeg/i.test(s) || /^data:image\/jpg/i.test(s)) return ".jpg"; - return ".png"; - })(); - const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" }); - const btnMinus = el("button", { - type: "button", - class: "chat-mermaid-lightbox__btn", - text: "−", - title: 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 `
${escapeHtml(raw).replace(/\n/g, "
")}
`; -} - -let _mermaidBootstrapped = false; -let _mermaidRetryTimer = null; -window.__oclawHydrateMermaidAll = () => { - try { - const root = document.getElementById("app") || document.body; - hydrateMermaidIn(root); - } catch (_) {} -}; -function hydrateMermaidIn(root) { - const host = root && root.querySelectorAll ? root : null; - if (!host) return; - const codeNodes = host.querySelectorAll("pre > code.language-mermaid, pre > code.lang-mermaid"); - for (const code of codeNodes) { - const pre = code.parentElement; - if (!pre || !pre.parentElement) continue; - const txt = String(code.textContent || "").trim(); - if (!txt) continue; - const box = document.createElement("div"); - box.className = "mermaid"; - box.setAttribute("data-mermaid-raw", txt); - box.textContent = txt; - pre.parentElement.replaceChild(box, pre); - } - const _attachFallback = (node, errText = "") => { - if (!node || node.querySelector(".mermaid-fallback")) return; - const raw = String(node.getAttribute("data-mermaid-raw") || "").trim() || String(node.textContent || "").trim(); - const msg = String(errText || "").trim(); - node.innerHTML = `
${msg ? `
${escapeHtml(msg)}
` : ""}
${escapeHtml(raw)}
`; - }; - if (typeof mermaid === "undefined") { - if (_mermaidRetryTimer != null) return; - _mermaidRetryTimer = setTimeout(() => { - _mermaidRetryTimer = null; - try { - window.__oclawHydrateMermaidAll(); - } catch (_) {} - }, 350); - return; - } - try { - if (!_mermaidBootstrapped && typeof mermaid.initialize === "function") { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "loose", - theme: "base", - themeVariables: { - background: "transparent", - primaryColor: "#1f2937", - primaryBorderColor: "#94a3b8", - primaryTextColor: "#e5e7eb", - lineColor: "#94a3b8", - textColor: "#e5e7eb", - fontFamily: "Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif", - }, - }); - _mermaidBootstrapped = true; - } - const nodes = Array.from(host.querySelectorAll(".mermaid")).filter((n) => n && n.isConnected); - if (!nodes.length) return; - // Mermaid skips already processed nodes. In streaming / rerender scenarios, DOM can be replaced. - // Remove the marker so Mermaid treats these nodes as fresh. - for (const n of nodes) { - try { - n.removeAttribute("data-processed"); - } catch (_) {} - } - if (typeof mermaid.run === "function") { - Promise.resolve(mermaid.run({ nodes })) - .then(() => { - for (const n of nodes) { - if (!n || !n.isConnected) continue; - if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)"); - } - }) - .catch((e) => { - const msg = String((e && e.message) || e || "Mermaid render failed"); - for (const n of nodes) _attachFallback(n, msg); - }); - } else if (typeof mermaid.init === "function") { - try { - mermaid.init(undefined, nodes); - for (const n of nodes) { - if (!n || !n.isConnected) continue; - if (!n.querySelector("svg")) _attachFallback(n, "Mermaid render failed (no svg output)"); - } - } catch (e) { - const msg = String((e && e.message) || e || "Mermaid render failed"); - for (const n of nodes) _attachFallback(n, msg); - } - } - } catch (e) { - const msg = String((e && e.message) || e || "Mermaid render failed"); - const nodes = host.querySelectorAll(".mermaid"); - for (const n of nodes) _attachFallback(n, msg); - } -} - -const RE_REDACTED_THINKING = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); -const RE_THINK_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); -const RE_THINKING_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); -const RE_THOUGHT_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); - -function _findEarliestReasoningBlock(remaining) { - let best = null; - for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) { - const m = re.exec(remaining); - re.lastIndex = 0; - if (!m) continue; - const start = m.index; - if (!best || start < best.start) { - best = { start, end: start + m[0].length, inner: (m[1] || "").trim() }; - } - } - return best; -} - -function parseReasoningSegments(raw) { - const parts = []; - let remaining = String(raw ?? ""); - while (remaining.length) { - const hit = _findEarliestReasoningBlock(remaining); - if (!hit) { - parts.push({ type: "text", text: remaining }); - break; - } - if (hit.start > 0) parts.push({ type: "text", text: remaining.slice(0, hit.start) }); - parts.push({ type: "reasoning", text: hit.inner }); - remaining = remaining.slice(hit.end); - } - return parts.length ? parts : [{ type: "text", text: "" }]; -} - -// Oclaw-style: strip // blocks from visible text, -// while keeping code fences intact. This avoids "reasoning leaking" into正文 and -// handles truncated streams (unfinished tags) safely. -const _OC_QUICK_TAG_RE = /<\s*\/?\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking|final)\b/i; -const _OC_FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/gi; -const _OC_THINKING_TAG_RE = /<\s*(\/?)\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/gi; - -function _findCodeFenceRegions(text) { - const regions = []; - const re = /```/g; - let start = null; - for (;;) { - const m = re.exec(text); - if (!m) break; - if (start == null) start = m.index; - else { - regions.push([start, m.index + 3]); - start = null; - } - } - return regions; -} - -function _isInsideRegion(idx, regions) { - for (const [a, b] of regions) { - if (idx >= a && idx < b) return true; - } - return false; -} - -function stripReasoningTagsFromText(text, { mode = "strict" } = {}) { - const raw = String(text || ""); - if (!raw || !_OC_QUICK_TAG_RE.test(raw)) return raw; - _OC_QUICK_TAG_RE.lastIndex = 0; - - // Remove tags (but keep content). - let cleaned = raw; - if (_OC_FINAL_TAG_RE.test(cleaned)) { - _OC_FINAL_TAG_RE.lastIndex = 0; - const fences = _findCodeFenceRegions(cleaned); - const matches = []; - for (const m of cleaned.matchAll(_OC_FINAL_TAG_RE)) { - const idx = m.index ?? 0; - matches.push({ idx, len: m[0].length, inCode: _isInsideRegion(idx, fences) }); - } - for (let i = matches.length - 1; i >= 0; i -= 1) { - const mm = matches[i]; - if (mm.inCode) continue; - cleaned = cleaned.slice(0, mm.idx) + cleaned.slice(mm.idx + mm.len); - } - } else { - _OC_FINAL_TAG_RE.lastIndex = 0; - } - - const fences = _findCodeFenceRegions(cleaned); - _OC_THINKING_TAG_RE.lastIndex = 0; - let out = ""; - let last = 0; - let inThinking = false; - for (const m of cleaned.matchAll(_OC_THINKING_TAG_RE)) { - const idx = m.index ?? 0; - const isClose = m[1] === "/"; - if (_isInsideRegion(idx, fences)) continue; - if (!inThinking) { - out += cleaned.slice(last, idx); - if (!isClose) inThinking = true; - } else if (isClose) { - inThinking = false; - } - last = idx + m[0].length; - } - if (!inThinking || mode === "preserve") out += cleaned.slice(last); - return out; -} - -function extractThinkingFromText(text) { - const raw = String(text || ""); - if (!raw) return ""; - const blocks = []; - for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG, RE_THINKING_TAG, RE_THOUGHT_TAG]) { - const rr = new RegExp(re.source, "gi"); - for (const m of raw.matchAll(rr)) { - const inner = String(m[1] || "").trim(); - if (inner) blocks.push(inner); - } - } - return blocks.join("\n"); -} - -function extractWsAssistantText(message) { - if (!message || typeof message !== "object") return ""; - const m = message; - if (typeof m.content === "string") return decodeEscapedNewlines(String(m.content || "")); - if (typeof m.text === "string") return decodeEscapedNewlines(String(m.text || "")); - const c = Array.isArray(m.content) ? m.content : []; - const parts = c - .map((x) => { - if (!x || typeof x !== "object") return ""; - if (String(x.type || "") === "text") return decodeEscapedNewlines(String(x.text || "")); - return ""; - }) - .filter(Boolean); - return parts.join("\n"); -} - -function decodeEscapedNewlines(text) { - return String(text || "").replace(/\\n/g, "\n").replace(/\\N/g, "\n"); -} - -function normalizeStreamText(raw) { - return String(raw || "") - .replace(/^\s*\n+/, "") - .replace(/\n{3,}/g, "\n\n"); -} - -// Streaming surface should be smooth and avoid newline explosions. -function normalizeStreamDisplayText(raw) { - return String(raw || "") - .replace(/\r/g, "") - .replace(/^\s*\n+/, "") - .replace(/\n+/g, " ") - .replace(/[ \t]{2,}/g, " ") - .trimStart(); -} - -// Streaming-only: user prefers a more "natural" flow without many newlines. -function normalizeStreamBodyForUi(raw) { - return String(raw || "") - .replace(/^\s*\n+/, "") - .replace(/\r/g, "") - // Collapse pathological newline runs but keep paragraph breaks. - // 3+ newlines => 2 newlines; trim leading newlines. - .replace(/\n{3,}/g, "\n\n"); -} - -// Oclaw-style streaming stitcher: reduce boundary artifacts without destroying layout. -function createStreamStitcher() { - let prevTail = ""; - return { - push(delta) { - let s = String(delta || ""); - if (!s) return ""; - s = s.replace(/\r/g, ""); - // If previous ended with newline and delta starts with newline(s), drop the leading ones. - if (prevTail.endsWith("\n") && s.startsWith("\n")) { - s = s.replace(/^\n+/, "\n"); - } - // If previous ended with a space and delta starts with space/newline, trim the head. - if (/[ \t]$/.test(prevTail) && /^[ \t\n]/.test(s)) { - s = s.replace(/^[ \t\n]+/, " "); - } - // Avoid 3+ newlines caused by chunk boundaries. - s = s.replace(/\n{3,}/g, "\n\n"); - prevTail = (prevTail + s).slice(-12); - return s; - }, - }; -} - -function formatToolPanelText(name, payload, options = {}) { - const streamMode = !!(options && options.streamMode); - const truncateToolPanel = (s) => { - const raw = String(s || "").trim(); - if (streamMode) return raw; - const maxChars = 4000; - if (raw.length <= maxChars) return raw; - return `${raw.slice(0, maxChars)}\n\n…(为保证界面性能,已截断 ${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( - `${escapeHtml(b[j])}`, - ); - j += 1; - } - } - while (j < n) { - out.push( - `${escapeHtml(b[j])}`, - ); - j += 1; - } - return out.join(""); -} - -function formatChatTimestamp(iso) { - const s = String(iso || "").trim(); - if (!s) return ""; - const d = new Date(s); - if (Number.isNaN(d.getTime())) return s; - const loc = currentLang === "zh" ? "zh-CN" : "en-US"; - try { - return d.toLocaleString(loc, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - } catch (_) { - return s; - } -} - -function prependMessageTime(node, tsIso) { - const txt = formatChatTimestamp(tsIso); - if (!txt || !node) return; - node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild); -} - -const CHAT_BOT_LOGO_SRC = "/admin/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" }); - if (aid) card.title = `id: ${aid}`; - card.appendChild(el("div", { class: "chat-att-ref__name", text: name })); - card.appendChild(el("div", { class: "chat-att-ref__meta", text: `${typ} · ${mime}${sizeLabel ? ` · ${sizeLabel}` : ""}` })); - const actions = el("div", { class: "chat-att-ref__actions" }); - if (aid) { - const url = await fetchAttachmentBlobUrl(aid); - if (url) { - actions.appendChild( - el("a", { - class: "chat-att-ref__action", - href: url, - target: "_blank", - rel: "noopener noreferrer", - download: name || undefined, - text: t("chat.attachment.download"), - }), - ); - if (String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) { - card.appendChild( - el("video", { - class: "chat-att-video u-media-preview", - controls: true, - preload: "metadata", - src: url, - }), - ); - } - } - } else { - const remote = String(att.url || "").trim(); - if (remote && String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) { - card.appendChild( - el("video", { - class: "chat-att-video u-media-preview", - controls: true, - preload: "metadata", - src: remote, - }), - ); - actions.appendChild( - el("a", { - class: "chat-att-ref__action", - href: remote, - target: "_blank", - rel: "noopener noreferrer", - text: t("chat.attachment.download"), - }), - ); - } - } - const canPreviewText = !!aid && (String(typ || "") === "text_ref" || isTextLikeMime(mime)); - if (canPreviewText) { - const preview = el("button", { - type: "button", - class: "chat-att-ref__action", - text: t("chat.attachment.preview"), - }); - const pre = el("pre", { class: "chat-att-ref__preview" }); - preview.addEventListener("click", async () => { - if (!pre.hidden) { - pre.hidden = true; - preview.textContent = t("chat.attachment.preview"); - return; - } - preview.disabled = true; - preview.textContent = t("chat.attachment.previewLoading"); - try { - const txt = await fetchAttachmentTextPreview(aid); - pre.textContent = txt || t("chat.attachment.previewEmpty"); - } catch (_) { - pre.textContent = t("chat.attachment.previewError"); - } finally { - pre.hidden = false; - preview.disabled = false; - preview.textContent = t("chat.attachment.preview"); - } - }); - actions.appendChild(preview); - card.appendChild(actions); - card.appendChild(pre); - } else if (actions.childNodes.length) { - card.appendChild(actions); - } - return card; - }; - for (const att of list) { - if (!att || typeof att !== "object") continue; - const typ = String(att.type || ""); - if (typ === "image_ref") { - const aid = String(att.attachment_id || att.attachmentId || "").trim(); - const url = aid ? await fetchAttachmentBlobUrl(aid) : null; - if (url) { - const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || "") }); - wrap.appendChild(img); - } else { - wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") })); - } - } else if (typ === "relay_pointer") { - let aid = String(att.attachment_id || att.attachmentId || "").trim(); - if (!aid) { - const p = String(att.pointer_uri || "").trim(); - const m = p.match(/^relay:\/\/attachments\/[^/]+\/([a-f0-9]{8,64})$/i); - if (m && m[1]) aid = String(m[1]).toLowerCase(); - } - const mime = String(att.mime || att.mime_type || "").toLowerCase(); - const url = aid ? await fetchAttachmentBlobUrl(aid) : null; - if (url && (!mime || mime.startsWith("image/"))) { - const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || att.rel_path || "relay image") }); - wrap.appendChild(img); - } else { - const title = String(att.name || att.rel_path || att.pointer_uri || "relay pointer"); - wrap.appendChild(el("span", { class: "chat-att-chip", text: `📎 ${title}` })); - } - } else if (typ === "image_url") { - const src = String(att.url || att.image_url || "").trim(); - if (!src) continue; - const img = el("img", { - class: "chat-att-img", - src, - alt: String(att.name || "generated image"), - decoding: "async", - referrerPolicy: "no-referrer", - }); - img.addEventListener( - "error", - () => { - try { - const parent = img.parentNode; - if (!parent) return; - parent.replaceChild( - el("a", { - class: "chat-att-ref__action", - href: src, - target: "_blank", - rel: "noopener noreferrer", - text: - 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); - } - } - if (options.prependBefore && options.prependBefore.parentNode === messagesEl) { - messagesEl.insertBefore(bubble, options.prependBefore); - } else if (options.prepend) { - messagesEl.insertBefore(bubble, messagesEl.firstChild); - } else { - messagesEl.appendChild(bubble); - } - hydrateMermaidIn(bubble); -} - -function mount(node) { - clearChatPageBlockers(); - const c = document.getElementById("app"); - c.innerHTML = ""; - c.appendChild(node); -} - -function buildChatBrandLogoNode() { - const img = el("img", { - class: "chat-nav__brandLogo", - src: CHAT_BOT_LOGO_SRC, - alt: "site logo", - }); - img.addEventListener( - "error", - () => { - if (img.dataset.logoFallbackApplied === "1") return; - img.dataset.logoFallbackApplied = "1"; - img.src = CHAT_BOT_LOGO_FALLBACK_SRC; - }, - { once: true }, - ); - return el("div", { class: "chat-nav__brandWrap" }, [img]); -} - -function resolveAdminHashUrl(hashPath, sessionId) { - const path = (location.pathname || "").replace(/\/+$/, "") || "/"; - const cleanHash = String(hashPath || "").replace(/^#?\/?/, ""); - const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ""; - if (path.endsWith("/chat")) { - const base = path.slice(0, -5); - return base.endsWith("/admin") ? `${base}#/${cleanHash}${q}` : `${base}/admin#/${cleanHash}${q}`; - } - return `/admin#/${cleanHash}${q}`; -} - -function openAdminFromChat(hashPath, sessionId) { - const target = resolveAdminHashUrl(hashPath, sessionId); - // Keep navigation in the same window so desktop shell feels native. - window.location.assign(target); -} - -function syncAuthUserLabel() { - const user = document.getElementById("authUser"); - if (!user) return; - user.innerHTML = ""; - const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || ""); - const isAdminViewer = String((authSession && authSession.username) || "") - .trim() - .toLowerCase() === "administrator"; - if (!name) return; - const openUserMenu = (anchorEl) => { - clearChatPageBlockers(); - const items = [ - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "profile", - text: t("chat.myProfile"), - }), - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "jobs", - text: t("chat.jobs"), - }), - ]; - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("div", { class: "muted u-menu-label", text: t("theme.label") }), - ); - const themeSelMenu = el("select", { - class: "input u-menu-select", - }); - try { - (window.OclawAdminTheme && window.OclawAdminTheme.THEMES ? window.OclawAdminTheme.THEMES : ["netx"]).forEach((tid) => { - themeSelMenu.appendChild(el("option", { value: tid, text: t(`theme.${tid}`) })); - }); - themeSelMenu.value = window.OclawAdminTheme ? window.OclawAdminTheme.currentAdminTheme() : "netx"; - } catch (_) {} - themeSelMenu.addEventListener("change", () => { - try { - if (window.OclawAdminTheme) window.OclawAdminTheme.persistAdminTheme(themeSelMenu.value); - } catch (_) {} - }); - items.push(themeSelMenu); - const bridge = window.__chatUserMenuPrefs; - if (bridge && typeof bridge === "object") { - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push(el("div", { class: "muted u-menu-label", text: t("chat.modeLabel") })); - const modeSel = el("select", { class: "input u-menu-select" }); - try { - const rows = Array.isArray(bridge.getModeOptions && bridge.getModeOptions()) ? bridge.getModeOptions() : []; - rows.forEach((r) => modeSel.appendChild(el("option", { value: String(r.value || ""), text: String(r.label || r.value || "") }))); - modeSel.value = String((bridge.getModeValue && bridge.getModeValue()) || ""); - } catch (_) {} - modeSel.addEventListener("change", async () => { - const v = modeSel.value; - try { - modeSel.disabled = true; - if (bridge.setModeValue) await bridge.setModeValue(v); - } catch (_) { - // errors are surfaced by saveUserGlobalModePreference() - } finally { - modeSel.disabled = false; - } - }); - items.push(modeSel); - const reasonWrap = el("label", { class: "switch-wrap u-switch-menu" }, [ - el("input", { type: "checkbox", class: "switch-input" }), - el("span", { class: "switch-slider" }), - el("span", { class: "muted", text: t("chat.tools") }), - ]); - const reasonCb = reasonWrap.querySelector("input.switch-input"); - try { - reasonCb.checked = !!(bridge.getReasoningVisible && bridge.getReasoningVisible()); - } catch (_) {} - reasonCb.addEventListener("change", () => { - try { - Promise.resolve(bridge.setReasoningVisible && bridge.setReasoningVisible(!!reasonCb.checked)).catch(() => {}); - } catch (_) {} - }); - items.push(reasonWrap); - } - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "lang", - text: t("lang.switch"), - }), - ); - items.push(el("div", { class: "chat-sess-menu-sep" })); - items.push( - el("button", { - type: "button", - class: "chat-sess-menu-item", - "data-menu-action": "logout", - text: t("auth.logout"), - }), - ); - const menu = el("div", { - class: "chat-sess-menu-pop u-pop-menu", - }, items); - // Inline position/z-index: chat.html used to override class z-index below the scrim. - menu.style.position = "fixed"; - menu.style.zIndex = "300"; - const rect = (anchorEl || moreBtn).getBoundingClientRect(); - attachChatMenuDismiss(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left; - let top = rect.bottom + 4; - if (top + mrect.height > window.innerHeight - pad) { - top = rect.top - 4 - mrect.height; - } - left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); - top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; - }; - const nameBtn = el("button", { - type: "button", - class: "chat-sess-btn", - text: name, - title: name, - onclick: (ev) => { - ev.stopPropagation(); - openUserMenu(ev.currentTarget); - }, - }); - const moreBtn = el("button", { - type: "button", - class: "chat-sess-more", - text: "⋯", - title: t("chat.sessionMenu"), - onclick: (ev) => { - ev.stopPropagation(); - openUserMenu(ev.currentTarget); - }, - }); - const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]); - user.appendChild(row); -} - -async function fileToPayloadEntry(file) { - return new Promise((resolve, reject) => { - const r = new FileReader(); - r.onload = () => { - const dataUrl = String(r.result || ""); - const idx = dataUrl.indexOf(","); - const data_base64 = idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl; - resolve({ name: file.name || "file", data_base64 }); - }; - r.onerror = () => reject(r.error || new Error("file_read_failed")); - r.readAsDataURL(file); - }); -} - -async function renderLogin() { - applyI18nStatic(); - 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 u-modal-card-sm" }, [ - el("div", { class: "card__title", text: t("auth.login") }), - el("div", { class: "chat-login-fields" }, [username, password]), - el("div", { class: "row chat-login-actions" }, [btn]), - status, - ]); - setTimeout(() => { - try { - username.focus(); - } catch (_) {} - }, 0); - return el("div", { class: "chat-app--login" }, [card]); -} - -async function downloadExport(sessionId, format) { - const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; - const q = format === "json" ? "format=json" : "format=md"; - const url = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`; - const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} }); - if (!res.ok) throw new Error(String(res.status)); - const blob = await res.blob(); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = `chat-${sessionId.slice(0, 8)}.${format === "json" ? "json" : "md"}`; - a.click(); - URL.revokeObjectURL(a.href); -} - -const CHAT_ICON_CLIP = - ''; -const CHAT_ICON_SEND = - ''; -const CHAT_ICON_STOP = - ''; - -let _statusReasonPairs = []; - -async function renderChatUi() { - let sessionId = getSessionIdFromUrl(); - try { - const curScope = JSON.stringify({ - t: String((authSession && authSession.tenant_id) || ""), - u: String((authSession && authSession.user_id) || ""), - }); - const prev = localStorage.getItem(CHAT_URL_SCOPE_KEY) || ""; - if (prev && prev !== curScope) { - replaceSessionUrl(""); - sessionId = ""; - } - localStorage.setItem(CHAT_URL_SCOPE_KEY, curScope); - } catch (_) {} - const statusBar = el("div", { class: "chat-status", text: t("chat.loading") }); - const sessionsListEl = el("div", { class: "chat-sessions__list" }); - const loadMoreWrap = el("div", { class: "chat-load-more" }); - const messagesEl = el("div", { class: "chat-messages" }); - bindChatImageViewer(messagesEl); - 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(); - if (messagesEl.scrollTop <= 80) { - loadOlderMessagesForActive().catch(() => {}); - } - }); - 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", { class: "u-hidden", type: "file", multiple: "multiple", 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 = "generalist"; - const EXCLUDED_SPECIALISTS = new Set(["main", "memory", "manager_self", "pycache", "__pycache__", "comprehensive"]); - let specialistCatalog = []; - /** Hidden: mode is global-only (⋯ menu). Kept for specialist option list in `publishUserMenuPrefsBridge`. */ - const modeSelect = el("select", { - class: "input u-hidden", - "aria-hidden": "true", - tabIndex: -1, - }); - let globalMenuModeValue = String(localStorage.getItem(CHAT_USER_MENU_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); - if (globalMenuModeValue === "comprehensive" || globalMenuModeValue === "main") globalMenuModeValue = MAIN_MODE_VALUE; - const modelSelectBtn = el("button", { - type: "button", - class: "chat-composer-chip chat-composer-chip--select", - title: t("chat.activeModelLabel"), - text: "-", - }); - const modelSelectWrap = el("div", { class: "chat-model-select" }, [modelSelectBtn]); - let modelSelectNameToId = new Map(); - let modelSelectOptions = []; - let modelSelectedKey = ""; - const syncModelSelectBtn = () => { - const hit = modelSelectOptions.find((o) => o.key === modelSelectedKey); - modelSelectBtn.textContent = String((hit && hit.label) || "-"); - }; - const openModelSelectMenu = (anchorEl) => { - dismissChatMenus(); - const items = modelSelectOptions.length - ? modelSelectOptions.map((opt) => - el("button", { - type: "button", - class: - "chat-sess-menu-item" + - (opt.key === modelSelectedKey ? " chat-sess-menu-item--active" : ""), - text: opt.label, - onclick: async (ev) => { - ev.stopPropagation(); - dismissChatMenus(); - const key = String(opt.key || "").trim(); - if (!key || key === modelSelectedKey) return; - modelSelectedKey = key; - syncModelSelectBtn(); - const pid = String(modelSelectNameToId.get(key) || "").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)}`; - } - }, - }), - ) - : [el("div", { class: "chat-sess-menu-item muted", text: "-" })]; - const menu = el("div", { class: "chat-sess-menu-pop chat-model-select-pop u-pop-menu" }, items); - menu.style.position = "fixed"; - menu.style.zIndex = "300"; - const rect = (anchorEl || modelSelectBtn).getBoundingClientRect(); - attachChatMenuDismiss(menu); - const mrect = menu.getBoundingClientRect(); - const pad = 8; - let left = rect.left; - let top = rect.bottom + 6; - if (top + mrect.height > window.innerHeight - pad) { - top = Math.max(pad, rect.top - 6 - mrect.height); - } - left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; - menu.style.minWidth = `${Math.max(168, Math.ceil(rect.width))}px`; - }; - modelSelectBtn.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (modelSelectBtn.disabled) return; - openModelSelectMenu(ev.currentTarget); - }); - const normalizeExecutionMode = (v) => { - const raw = String(v || "").trim().toLowerCase(); - return raw === EXECUTION_MODE_PLAN ? EXECUTION_MODE_PLAN : EXECUTION_MODE_AGENT; - }; - 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); - const execSelect = el("select", { - class: "input u-select-exec", - }); - 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 u-inline-flex" }, [ - execSelect, - ]); - const refreshExecUi = () => { - // Plan mode removed; keep agent execution only. - execSelectWrap.style.display = "none"; - }; - const modeOptionLabel = (v) => { - const key = String(v || "").trim().toLowerCase(); - if (key === "generalist") return t("chat.specialistGeneralist"); - const row = specialistCatalog.find((x) => String(x.id || "").toLowerCase() === key); - if (!row) return key || t("chat.specialistGeneralist"); - 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 === "comprehensive" || key === "main") 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, "expert"); - localStorage.setItem(CHAT_USER_MENU_MODE_KEY, v); - 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 = () => { - let prev = String(globalMenuModeValue || MAIN_MODE_VALUE).toLowerCase(); - if (prev === "comprehensive" || prev === "main") prev = MAIN_MODE_VALUE; - modeSelect.innerHTML = ""; - 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; - globalMenuModeValue = 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); - modelSelectWrap.style.display = show ? "" : "none"; - modelSelectBtn.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}`; - modelSelectNameToId = new Map(); - modelSelectOptions = []; - 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); - modelSelectOptions.push({ - key, - id: pid, - label: String(row.model || row.name || pid), - }); - }); - if (p) { - const firstKey = Array.from(modelSelectNameToId.keys()).find((k) => modelSelectNameToId.get(k) === aid) || ""; - modelSelectedKey = firstKey || ""; - } else if (modelSelectOptions.length > 0) { - modelSelectedKey = String(modelSelectOptions[0].key || ""); - } else { - modelSelectedKey = ""; - } - syncModelSelectBtn(); - } catch (_) { - applyModelSelectorVisibility(null); - activeModelText.textContent = `${t("chat.activeModelLabel")}: -`; - modelSelectNameToId = new Map(); - modelSelectOptions = []; - modelSelectedKey = ""; - syncModelSelectBtn(); - } - }; - 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 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 (isSelectableSpecialist(gSp)) globalMenuModeValue = gSp; - else if (isSelectableSpecialist(gIm)) globalMenuModeValue = gIm; - else globalMenuModeValue = MAIN_MODE_VALUE; - } else { - 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()); - if (isSelectableSpecialist(gus)) globalMenuModeValue = gus; - else if (isSelectableSpecialist(gum)) globalMenuModeValue = gum; - else globalMenuModeValue = MAIN_MODE_VALUE; - } - } catch (_) { - if (m === "expert" && isSelectableSpecialist(s)) globalMenuModeValue = s; - else if (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 }); - 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 specialist = isSelectableSpecialist(modeVal) ? modeVal : MAIN_MODE_VALUE; - const resp = await apiPost("/admin/api/chat/user-mode", { - interaction_mode: "expert", - specialist, - }); - localStorage.setItem(CHAT_USER_MENU_MODE_KEY, specialist); - globalMenuModeValue = specialist; - 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()); - if (gum !== "expert" || gus !== specialist) { - const msg = - currentLang === "zh" - ? `模式未固化:后端返回 interaction_mode=${gum || "-"} specialist=${gus || "-"}(期望 expert/${specialist})` - : `Mode not persisted: server returned interaction_mode=${gum || "-"} specialist=${gus || "-"} (expected expert/${specialist})`; - 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 compressBtn = el("button", { - type: "button", - class: "chat-composer-chip", - 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 composerMetaBar = el("div", { class: "chat-composer-meta" }, [ - execSelectWrap, - modelSelectWrap, - compressBtn, - ]); - - 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 u-row-end" }, [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 u-w-full-mt-10", - type: "text", - value: String(initialValue || ""), - }); - 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 u-row-end" }, [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 u-row-wrap-mt-loose" }); - for (const a of acts) { - btnRow.appendChild( - el("button", { - type: "button", - class: "btn u-btn-compact", - 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(); - }, - 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 u-empty-pad", text: t("chat.noSessions") }), - ); - return; - } - for (const s of sessions) { - const sid = String(s.id || ""); - const title = String(s.title || sid || ""); - const peerHint = String(s.peer_name || s.peer_display_name || "").trim(); - let pretty = formatChatSessionTitle(title, peerHint); - if (!pretty.sublabel && pretty.channel) { - const looked = lookupChannelPeerName(title, pretty); - if (looked) pretty = formatChatSessionTitle(title, looked); - } - const row = el("div", { class: "chat-sess-row" + (idsMatch(activeId, sid) ? " chat-sess-row--active" : "") }); - const btnChildren = []; - if (pretty.badge) { - btnChildren.push( - el("span", { - class: `chat-sess-badge chat-sess-badge--${pretty.channel || "other"}`, - text: pretty.badge, - }), - ); - } - const labelKids = [el("span", { class: "chat-sess-btn__name", text: pretty.label })]; - if (pretty.sublabel) { - labelKids.push(el("span", { class: "chat-sess-btn__id", text: pretty.sublabel })); - } - btnChildren.push(el("span", { class: "chat-sess-btn__label" }, labelKids)); - const btn = el( - "button", - { - class: "chat-sess-btn" + (idsMatch(activeId, sid) ? " chat-sess-btn--active" : ""), - title: pretty.full || title, - onclick: () => { - activeId = sid; - replaceSessionUrl(sid); - paintSessions(); - Promise.all([loadMessagesForActive(), loadSessionModePreference()]).catch((e) => { - statusBar.textContent = `${t("chat.error")}: ${String(e)}`; - }); - startWikiPoller(); - }, - }, - btnChildren, - ); - 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 u-w-full-mt-8", - 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 || ""); - }, - }; - - let historyHasMore = false; - let historyOldestId = 0; - let historyLoadedCount = 0; - let historyTotal = 0; - let historyLoadingOlder = false; - let historyCapEl = null; - - const ensureHistoryCap = () => { - if (!historyHasMore && historyLoadedCount > 0 && historyTotal > 0 && historyLoadedCount >= historyTotal) { - if (historyCapEl && historyCapEl.parentNode) historyCapEl.remove(); - historyCapEl = null; - return; - } - if (!historyHasMore && !historyLoadingOlder) { - if (historyCapEl && historyCapEl.parentNode) historyCapEl.remove(); - historyCapEl = null; - return; - } - if (!historyCapEl) historyCapEl = el("div", { class: "muted chat-msg-cap" }); - historyCapEl.textContent = historyLoadingOlder - ? t("chat.loadingOlder") - : t("chat.historyTruncated", { - shown: String(historyLoadedCount || 0), - total: String(historyTotal || historyLoadedCount || 0), - }); - if (historyCapEl.parentNode !== messagesEl) { - messagesEl.insertBefore(historyCapEl, messagesEl.firstChild); - } - }; - - const loadMessagesForActive = async (opts = {}) => { - loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1; - const rid = loadMessagesForActive._rid; - shouldFollowMessages = true; - historyHasMore = false; - historyOldestId = 0; - historyLoadedCount = 0; - historyTotal = 0; - historyLoadingOlder = false; - if (!activeId) { - loadMessagesForActive._needsWsTextFallback = true; - messagesEl.innerHTML = ""; - historyCapEl = null; - 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); - historyTotal = intOr(resp.message_count, msgs.length); - historyLoadedCount = msgs.length; - historyHasMore = !!resp.has_more; - historyOldestId = msgs.length ? intOr(msgs[0].id, 0) : 0; - // 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 = ""; - historyCapEl = null; - ensureHistoryCap(); - 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 loadOlderMessagesForActive = async () => { - if (!activeId || !historyHasMore || historyLoadingOlder || !historyOldestId) return 0; - if (isChatStreaming()) return 0; - historyLoadingOlder = true; - ensureHistoryCap(); - const prevHeight = messagesEl.scrollHeight; - const prevTop = messagesEl.scrollTop; - const rid = loadMessagesForActive._rid || 0; - try { - const resp = await apiGet( - `/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}&before_id=${encodeURIComponent(String(historyOldestId))}`, - ); - if (rid !== loadMessagesForActive._rid) return 0; - const msgs = Array.isArray(resp.messages) ? resp.messages : []; - if (!msgs.length) { - historyHasMore = false; - ensureHistoryCap(); - return 0; - } - const renderRows = _buildRenderRows(msgs); - historyTotal = intOr(resp.message_count, historyTotal); - historyLoadedCount += msgs.length; - historyHasMore = !!resp.has_more; - historyOldestId = intOr(msgs[0].id, historyOldestId); - ensureHistoryCap(); - const anchor = historyCapEl && historyCapEl.parentNode === messagesEl ? historyCapEl.nextSibling : messagesEl.firstChild; - for (const m of renderRows) { - await appendMessageRow(messagesEl, m, { ...rowRenderOptions, prependBefore: anchor }); - } - const delta = messagesEl.scrollHeight - prevHeight; - messagesEl.scrollTop = prevTop + delta; - return renderRows.length; - } catch (e) { - if (typeof showToast === "function") { - showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error", ttlMs: 4500 }); - } - return -1; - } finally { - historyLoadingOlder = false; - ensureHistoryCap(); - } - }; - - const reloadSessionsOnly = async () => { - await refreshChannelPeerNameMap().catch(() => {}); - 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"); - await refreshChannelPeerNameMap().catch(() => {}); - 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"), - 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 = `
${escapeHtml(String(noteText || ""))}
`; - } - }; - 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( - `
${escapeHtml( - t("chat.marker.summary", { - p: String(markerState.p || 0), - e: markerState.e ? "1" : "0", - ep: String(markerState.ep || 0), - }), - )}
`, - ); - blocks.push( - `
${escapeHtml( - t("chat.marker.ttl", { - t: String(markerState.t || 0), - s: String(markerState.s || 0), - k: String(markerState.k || 0), - }), - )}
`, - ); - if ((Number(markerState.reclaimed || 0) || 0) > 0) { - blocks.push( - `
${escapeHtml( - t("chat.marker.reclaimed", { n: String(markerState.reclaimed || 0) }), - )}
`, - ); - } - } - let textIdx = 0; - let textBuf = ""; - const flushTextBuf = () => { - if (!String(textBuf || "").trim()) { - textBuf = ""; - return; - } - blocks.push( - `
${escapeHtml(decodeEscapedNewlines(textBuf)).replace(/\\n/g, "
")}
`, - ); - 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( - `
${escapeHtml(title)} · SQL audit -
-
input SQL
${escapeHtml(String(audit.inputSql || ""))}
-
executed SQL (added highlighted)
${executedDiffHtml}
-
-${autoLimit ? `
auto-added clause: ${escapeHtml(autoLimit)}
` : ""} -
- readonly_enforced=${escapeHtml(String(!!guard.readonly_enforced))} · - multi_statement_forbidden=${escapeHtml(String(!!guard.multi_statement_forbidden))} · - auto_limit_applied=${escapeHtml(String(!!guard.auto_limit_applied))} · - result_row_cap=${escapeHtml(String(guard.result_row_cap != null ? guard.result_row_cap : ""))} · - engine=${escapeHtml(String(audit.engine || ""))} · - rows_returned=${escapeHtml(String(audit.rowsReturned != null ? audit.rowsReturned : ""))} -
-
`, - ); - } else { - blocks.push( - `
${escapeHtml(title)}
${escapeHtml(body)}
`, - ); - } - for (const im of images) { - const src = String((im && im.src) || "").trim(); - if (!src) continue; - blocks.push(`
tool image
`); - } - } - } - 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: "expert", - idempotencyKey: String(turnId || ""), - specialist: 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(); - if (payload.runId) { - chatRunId = String(payload.runId); - currentAbortMeta = { sessionId: String(activeId || ""), runId: String(chatRunId || "") }; - } - _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" }, [ - btnJobs, - el("div", { id: "authUser", class: "chat-nav__user" }), - ]); - - return el("div", { class: "chat-layout chat-app" }, [ - el("div", { class: "chat-nav" }, [ - el("div", { class: "chat-nav__top" }, [ - buildChatBrandLogoNode(), - ]), - el("div", { class: "chat-nav__toolbar" }, [btnNew]), - el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]), - navFooter, - ]), - el("div", { class: "chat-main" }, [ - 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 u-pop-tip" }, [ - 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 withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout"); - } catch (_) {} - mount(await renderLogin()); - applyI18nStatic(); - syncAuthUserLabel(); - return; - } - try { - await withTimeout(syncLangFromServer(), 2500, "ui_lang_timeout"); - } catch (_) {} - try { - await withTimeout(loadMeProfile(), 2500, "me_profile_timeout"); - } catch (_) {} - try { - mount(await renderChatUi()); - } catch (err) { - mount( - el("div", { class: "chat-app--login" }, [ - el("div", { class: "card u-modal-card" }, [ - 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 u-modal-card" }, [ - el("div", { class: "card__title", text: t("common.error") }), - el("div", { class: "pre", text: String(err) }), - ]), - ]), - ); -}); +import "./js/chat/main.js"; diff --git a/interfaces/admin/static/index.html b/interfaces/admin/static/index.html index 7b65f5d4..6f0c6d75 100644 --- a/interfaces/admin/static/index.html +++ b/interfaces/admin/static/index.html @@ -5,9 +5,9 @@ oliver - - - + + +