Ship Cursor-only MCP admin and modular Admin/Chat UI.

Plugins/Skills install and edit via mcpServers JSON, with clearer row actions, soft reloads, and instant loading placeholders; also drop MCP market and harden related scheduler/logging/exec guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 19:38:26 +08:00
parent 2aff012b48
commit f6f129931f
61 changed files with 18057 additions and 16327 deletions

View file

@ -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()),
}

File diff suppressed because it is too large Load diff

View file

@ -779,11 +779,7 @@
<script defer src="/admin/assets/vendor/marked.min.js"></script>
<script defer src="/admin/assets/vendor/purify.min.js"></script>
<script defer src="/admin/assets/admin-theme.js"></script>
<script
defer
src="/admin/assets/chat.js?v=20260811-history-page"
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
></script>
<script type="module" src="/admin/assets/chat.js?v=20260812-chat-2" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load chat.js',h:'Confirm the gateway is up and /admin/assets/chat.js is reachable.'}:{t:'无法加载 chat.js',h:'请确认网关已启动且 /admin/assets/chat.js 可访问。'};var s=document.getElementById('chat-boot-splash');if(s){var a=s.querySelector('.chat-boot-splash__title span:last-child');var b=s.querySelector('.chat-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
<script
defer
@ -792,15 +788,28 @@
></script>
<script>
(function () {
var lang = "zh";
try {
lang = String(localStorage.getItem("ops_admin_lang") || "zh").toLowerCase();
} catch (e) {}
if (lang !== "en") lang = "zh";
var copy =
lang === "en"
? {
still: "Still loading…",
hint: "If the login form does not appear, refresh after confirming the gateway is up. Desktop: desktop.log / backend.log.",
}
: {
still: "加载仍在进行…",
hint: "若超过数秒仍无登录框,请确认网关已启动后刷新。桌面端可查看 desktop.log / backend.log。",
};
setTimeout(function () {
var s = document.getElementById("chat-boot-splash");
if (!s || !document.body.contains(s)) return;
var title = s.querySelector(".chat-boot-splash__title span:last-child");
var muted = s.querySelector(".chat-boot-splash__muted");
if (title) title.textContent = "加载仍在进行…";
if (muted) {
muted.textContent = "若超过数秒仍无登录框,请确认网关已启动后刷新。桌面端可查看 desktop.log / backend.log。";
}
if (title) title.textContent = copy.still;
if (muted) muted.textContent = copy.hint;
}, 4000);
})();
</script>
@ -811,9 +820,9 @@
<div class="chat-boot-splash__card">
<div class="chat-boot-splash__title">
<span class="chat-boot-splash__spinner" aria-hidden="true"></span>
<span>正在加载…</span>
<span data-i18n="boot.loading">正在加载…</span>
</div>
<div class="chat-boot-splash__muted">
<div class="chat-boot-splash__muted" data-i18n="boot.loadingHint">
正在启动本地界面;若长时间停留,请确认网关已启动。桌面端可查看 desktop.log / backend.log。
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css" />
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-17" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-17" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-17" />
<style>
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
.admin-boot-splash {
@ -63,25 +63,25 @@
</div>
<nav class="nav">
<div class="nav__group">
<div class="nav__groupTitle">系统与运行</div>
<div class="nav__groupTitle" data-i18n="nav.group.system">系统与运行</div>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="scheduled-jobs" href="#/scheduled-jobs" data-i18n="nav.scheduledJobs">定时任务</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
</div>
<div class="nav__group">
<div class="nav__groupTitle">模型与插件</div>
<div class="nav__groupTitle" data-i18n="nav.group.models">模型与插件</div>
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
<a class="nav__item" data-page="plugins" href="#/plugins" data-i18n="nav.plugins">Plugins</a>
<a class="nav__item" data-page="skills" href="#/skills" data-i18n="nav.skills">Skills</a>
</div>
<div class="nav__group">
<div class="nav__groupTitle">治理与审计</div>
<div class="nav__groupTitle" data-i18n="nav.group.governance">治理与审计</div>
<a class="nav__item" data-page="memory" href="#/memory" data-i18n="nav.memory">Memory</a>
<a class="nav__item" data-page="audit" href="#/audit" data-i18n="nav.audit">Audit & Trace</a>
</div>
<div class="nav__group">
<div class="nav__groupTitle">用户与配置</div>
<div class="nav__groupTitle" data-i18n="nav.group.users">用户与配置</div>
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
</div>
@ -118,17 +118,44 @@
<div id="admin-boot-splash" class="admin-boot-splash" role="status" aria-live="polite">
<div class="admin-boot-splash__title">
<span class="admin-boot-splash__spinner" aria-hidden="true"></span>
<span>正在加载管理台…</span>
<span data-i18n="boot.loading">正在加载管理台…</span>
</div>
<div class="admin-boot-splash__muted">若长时间停留在此,请检查网络/代理是否拦截外部资源。</div>
<div class="admin-boot-splash__muted" data-i18n="boot.loadingHint">若长时间停留在此,请检查网络/代理是否拦截外部资源。</div>
</div>
</section>
</main>
</div>
<script
defer
src="/admin/assets/app.js?v=20260507-2"
onerror="(function(){var s=document.getElementById('admin-boot-splash');if(s){s.querySelector('.admin-boot-splash__title span:last-child').textContent='无法加载 app.js';s.querySelector('.admin-boot-splash__muted').textContent='请确认 /admin/assets/app.js 可访问。';}})()"
></script>
<!-- Keep in sync with js/asset-v.js ADMIN_ASSET_V. Remaps resolved module URLs so nested imports bust cache. -->
<script type="importmap">
{
"imports": {
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-17",
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-17",
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-17",
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-17",
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-17",
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-17",
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-17",
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-17",
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-17",
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-17",
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-17",
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-17",
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-17",
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-17",
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-17",
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-17",
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-17",
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-17",
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-17",
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-17",
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-17",
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-17",
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-17",
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-17"
}
}
</script>
<script type="module" src="/admin/assets/app.js?v=20260812-17" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
</body>
</html>

View file

@ -0,0 +1,6 @@
/** Admin SPA asset cache token.
* Bump together with:
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
*/
export const ADMIN_ASSET_V = "20260812-17";

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
import messagesZh from "./messages-zh.js";
import messagesEn from "./messages-en.js";
import { state } from "../state.js";
export const I18N = { zh: messagesZh, en: messagesEn };
export const LANG_KEY = "ops_admin_lang";
export function t(key, vars) {
let s = (I18N[state.currentLang] && I18N[state.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;
}
export function applyI18nStatic() {
document.documentElement.lang = state.currentLang === "zh" ? "zh-CN" : "en";
document.querySelectorAll("[data-i18n]").forEach((node) => {
const key = node.getAttribute("data-i18n");
node.textContent = t(key);
});
}

View file

@ -0,0 +1,222 @@
export default {
"auth.chatLoginDenied": "This account cannot use chat (contact an admin).",
"auth.consoleAdminOnly": "Only users with the admin role may sign in to the console.",
"auth.disabled": "Account is disabled",
"auth.invalid": "Login failed",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.password": "Password",
"auth.username": "Username",
"boot.loadFailed": "Failed to load chat.js",
"boot.loadFailedHint": "Confirm the gateway is up and /admin/assets/chat.js is reachable.",
"boot.loading": "Loading chat…",
"boot.loadingHint": "If this stays too long, check whether the gateway is running.",
"boot.stillLoading": "Still loading…",
"boot.stillLoadingHint": "If the login form does not appear, refresh after confirming the gateway is up. Desktop: desktop.log / backend.log.",
"chat.activeModelLabel": "Model",
"chat.applyAfterTurn": "applies to history after current turn",
"chat.attach": "Attach",
"chat.attachment.download": "Download",
"chat.attachment.preview": "Preview",
"chat.attachment.previewEmpty": "(empty)",
"chat.attachment.previewError": "Preview failed",
"chat.attachment.previewLoading": "Loading…",
"chat.attachmentAcl": "Attachment ACL",
"chat.attachmentAclBackfill": "Backfill ACL",
"chat.attachmentAclBackfillFail": "Backfill failed: {error}",
"chat.attachmentAclBackfillOk": "Backfill done: inserted {inserted} rows (scanned {scanned_messages} messages)",
"chat.attachmentAclBackfillPrompt": "Backfill attachment_acl by scanning historical message attachments? Recommended during off-peak hours.",
"chat.attachmentLimits": "Attachment limits",
"chat.attachmentLimitsCancel": "Cancel",
"chat.attachmentLimitsCellChars": "Max chars per cell",
"chat.attachmentLimitsCols": "Max columns kept",
"chat.attachmentLimitsDenied": "Not authorized to change attachment limits",
"chat.attachmentLimitsHighPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?",
"chat.attachmentLimitsInvalidJson": "Invalid attachment limits JSON",
"chat.attachmentLimitsInvalidNumber": "Please enter valid positive integers",
"chat.attachmentLimitsLargePreviewRows": "Large-table preview rows",
"chat.attachmentLimitsMaxSheets": "Max Excel sheets",
"chat.attachmentLimitsPrompt": "Edit attachment limits JSON (empty to restore defaults)",
"chat.attachmentLimitsReset": "Reset defaults",
"chat.attachmentLimitsRows": "Max rows read",
"chat.attachmentLimitsSave": "Save",
"chat.attachmentLimitsSaved": "Attachment limits updated",
"chat.attachmentLimitsSqlTimeoutMs": "SQL timeout (ms)",
"chat.attachmentLimitsTitle": "Attachment limits",
"chat.attachmentLimitsToolEnabled": "Enable large-table tool mode",
"chat.attachmentLimitsToolMaxBytes": "Max file bytes for tool mode",
"chat.attachmentLimitsToolMinRows": "Min rows to trigger tool mode",
"chat.attachmentUploaded": "(attachment uploaded)",
"chat.audit": "Audit & trace",
"chat.backAdmin": "Admin console",
"chat.call": "call",
"chat.callBracket": "[call] {name}",
"chat.compressHistory": "Compress chat",
"chat.compressHistoryFail": "Compaction failed: {error}",
"chat.compressHistoryOk": "Compaction done: scanned {scanned} tool messages, rewritten {rewritten}, oversized compacted {compacted} (skipped {skipped} already compacted).",
"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.connecting": "Connecting…",
"chat.copy": "Copy",
"chat.copyFail": "Copy failed",
"chat.copyOk": "Copied",
"chat.delete": "Delete",
"chat.deleteConfirm": "Delete this session?",
"chat.deleteMessage": "Delete message",
"chat.deleteMessageConfirm": "Delete this message?",
"chat.dispatchLabelsCancel": "Cancel",
"chat.dispatchLabelsClear": "Clear",
"chat.dispatchLabelsDiffOnly": "Show changed only",
"chat.dispatchLabelsEdit": "Edit reason labels",
"chat.dispatchLabelsEffectivePreview": "Effective preview",
"chat.dispatchLabelsExport": "Export effective",
"chat.dispatchLabelsImport": "Import JSON",
"chat.dispatchLabelsInvalidJson": "Invalid JSON format",
"chat.dispatchLabelsPrompt": "Edit dispatch reason override JSON (empty to clear)",
"chat.dispatchLabelsRestoreDefaults": "Restore defaults template",
"chat.dispatchLabelsSave": "Save",
"chat.dispatchLabelsSaved": "Reason labels updated",
"chat.dispatchLabelsTitle": "Edit reason label overrides",
"chat.dispatchLabelsUnsavedConfirm": "You have unsaved changes. Close anyway?",
"chat.dynamicAllReasons": "View full mix",
"chat.dynamicAllReasonsTitle": "Full reason mix",
"chat.dynamicStats": "Dynamic experts (recent): hit {dynamic}, fallback {fallback}",
"chat.dynamicStatsDetail": "Hit rate {rate}% · Top reason {reason}",
"chat.dynamicTopReasons": "Reason mix {items}",
"chat.empty": "No messages yet",
"chat.encodingAttachments": "Encoding attachments…",
"chat.error": "Request failed",
"chat.execApplied": "Applied: {mode} / {specialist}",
"chat.execModeAgent": "Agent",
"chat.execModeApplied": "Execution: {mode}",
"chat.execModeLabel": "Execution",
"chat.execModePlan": "Plan",
"chat.expandFull": "Load full text (may be slow)",
"chat.exportJson": "Export JSON",
"chat.exportMd": "Export Markdown",
"chat.fork": "Duplicate chat",
"chat.historyComplete": "Reached the start of this chat",
"chat.historyTruncated": "Showing latest {shown}/{total} · scroll up for older",
"chat.imageBlockedHint": "Image blocked from inline display; open original URL",
"chat.imageViewerClose": "Close",
"chat.imageViewerDownload": "Download image",
"chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close",
"chat.jobs": "Background jobs",
"chat.jobsClose": "Close",
"chat.jobsEmpty": "No background jobs",
"chat.jobsHint": "Long agent jobs appear here and may keep running after the chat turn ends. Kill them manually if needed.",
"chat.jobsKill": "Kill",
"chat.jobsKillAll": "Kill all running",
"chat.jobsKillAllConfirm": "Kill all running jobs?",
"chat.jobsKillConfirm": "Kill this job? The process will be force-terminated.",
"chat.jobsPurge": "Purge",
"chat.jobsPurgeConfirm": "Remove local records/logs for this finished job?",
"chat.jobsRefresh": "Refresh",
"chat.jobsRunning": "{n} running",
"chat.jobsStatus.cancelled": "cancelled",
"chat.jobsStatus.failed": "failed",
"chat.jobsStatus.running": "running",
"chat.jobsStatus.succeeded": "succeeded",
"chat.jobsStatus.timeout": "timeout",
"chat.jobsTitle": "Background jobs",
"chat.langSwitchedWhileStreaming": "Language updated. Session is still streaming; refresh the page to reload all labels.",
"chat.liveFull": "[LIVE FULL]",
"chat.loadMore": "Load more",
"chat.loading": "Loading…",
"chat.loadingDots": "Loading...",
"chat.loadingOlder": "Loading older messages…",
"chat.logs": "Logs",
"chat.marker.reclaimed": "Turn markers reclaimed {n}",
"chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})",
"chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}",
"chat.memoryApplied": "Memory: {mode}",
"chat.memoryModeDefault": "Default (inject allowed)",
"chat.memoryModeDefaultShort": "default",
"chat.memoryModeLabel": "Memory Mode",
"chat.memoryModeStoreOnly": "Store only (no inject)",
"chat.memoryModeStoreOnlyShort": "store_only",
"chat.modeComprehensive": "Comprehensive",
"chat.modeComprehensiveShort": "Comprehensive",
"chat.modeExpert": "Expert",
"chat.modeExpertShort": "Expert",
"chat.modeLabel": "Mode",
"chat.modeNotPinned": "Mode not pinned: backend returned interaction_mode={mode} specialist={specialist} (want expert/{want})",
"chat.modelProfile": "Model",
"chat.myProfile": "Settings",
"chat.newSession": "New chat",
"chat.noSessions": "No sessions yet. Create one to start.",
"chat.pageTitle": "oliver",
"chat.placeholder": "Message…",
"chat.reason.dynamic_agent_build_failed": "Dynamic agent build failed",
"chat.reason.dynamic_agent_selected": "Dynamic agent selected",
"chat.reason.manager_factory_failed": "Specialist executor factory failed",
"chat.reason.manager_model_missing": "Manager model missing",
"chat.reason.manager_no_specialist_fallback": "No specialist; fallback to generalist",
"chat.reason.manager_route_missing": "Manager route missing",
"chat.reason.manager_select_failed": "Manager selection failed",
"chat.remove": "Remove",
"chat.rename": "Rename",
"chat.saveModeFailed": "Failed to save mode: {error}",
"chat.send": "Send",
"chat.sending": "Sending…",
"chat.sessionMenu": "Session actions",
"chat.sessions": "Sessions",
"chat.specialistGeneralist": "Generalist",
"chat.specialistGeneralistShort": "Generalist",
"chat.specialistImage": "Image",
"chat.specialistImageShort": "Image",
"chat.specialistLabel": "Specialist",
"chat.specialistManage": "Specialist Toggle",
"chat.specialistManageDenied": "Not authorized to change specialist flags",
"chat.specialistManagePromptOff": "Disable memory specialist?",
"chat.specialistManagePromptOn": "Enable memory specialist?",
"chat.specialistMemory": "Memory",
"chat.specialistMemoryShort": "Memory",
"chat.specialistOps": "Ops",
"chat.specialistOpsShort": "Ops",
"chat.specialistVideoShort": "Video",
"chat.stageLocalBoth": " · stage(start→run)={start}ms (run→first)={first}ms",
"chat.stageLocalStart": " · stage(start→run)={start}ms",
"chat.stageSeg": " · stage(A→G:{a2g} / G→M:{g2m} / M→F:{m2f})",
"chat.status.agent": "Agent",
"chat.status.end": "Done",
"chat.status.error": "Error",
"chat.status.oclaw": "Assistant",
"chat.status.plan": "Plan",
"chat.status.running": "Running",
"chat.status.start": "Starting",
"chat.status.tool": "Tool",
"chat.stop": "Stop",
"chat.timeoutRecovered": "Request timed out; recovered this turn from history.",
"chat.toolCall": "Tool call",
"chat.toolResult": "Tool result",
"chat.tools": "Reasoning",
"chat.tools.hidden": "Reasoning hidden",
"chat.tools.off": "Off",
"chat.tools.on": "On",
"chat.tools.visible": "Reasoning visible",
"chat.truncatedNote": "…(truncated {n} chars for UI performance)",
"chat.wikiPreviewClose": "Close",
"chat.wikiPreviewTitle": "Wiki preview: {path}",
"chat.wikiToastFailed": "Wiki capture failed: {error}",
"chat.wikiToastMerged": "Wiki captured: +{merged} (dedup skipped {skipped})",
"chat.wikiViewMerged": "View merged-turns",
"chat.wikiViewTopic": "View topic: {topic}",
"chat.wsInterruptedRecovered": "WS stream interrupted; recovered this turn from history.",
"chat.zoomIn": "Zoom in",
"chat.zoomOut": "Zoom out",
"chat.zoomReset": "Reset zoom",
"common.error": "Error",
"lang.switch": "中文",
"reasoning.processNotes": "Main-channel notes",
"reasoning.summary": "Model reasoning",
"theme.catppuccin": "Catppuccin Mocha",
"theme.deepseek": "DeepSeek",
"theme.dracula": "Dracula",
"theme.forest": "Forest",
"theme.github": "GitHub Dark",
"theme.label": "Color theme",
"theme.light": "Light",
"theme.netx": "NetX Dark",
"theme.nord": "Nord",
"tool.streamTitle": "Tools & progress (this turn)",
};

View file

@ -0,0 +1,222 @@
export default {
"auth.chatLoginDenied": "当前账号无法使用对话(请联系管理员)。",
"auth.consoleAdminOnly": "仅管理员(admin 角色)可登录管理台。",
"auth.disabled": "账号已被禁用,请联系管理员",
"auth.invalid": "登录失败,请检查凭据",
"auth.login": "登录",
"auth.logout": "退出登录",
"auth.password": "密码",
"auth.username": "用户名",
"boot.loadFailed": "无法加载 chat.js",
"boot.loadFailedHint": "请确认网关已启动且 /admin/assets/chat.js 可访问。",
"boot.loading": "正在加载对话…",
"boot.loadingHint": "若长时间停留在此,请检查网关是否已启动。",
"boot.stillLoading": "加载仍在进行…",
"boot.stillLoadingHint": "若超过数秒仍无登录框,请确认网关已启动后刷新。桌面端可查看 desktop.log / backend.log。",
"chat.activeModelLabel": "模型",
"chat.applyAfterTurn": "本轮结束后应用到历史消息",
"chat.attach": "附件",
"chat.attachment.download": "下载",
"chat.attachment.preview": "预览",
"chat.attachment.previewEmpty": "(空内容)",
"chat.attachment.previewError": "预览失败",
"chat.attachment.previewLoading": "加载中…",
"chat.attachmentAcl": "附件 ACL",
"chat.attachmentAclBackfill": "回填 ACL",
"chat.attachmentAclBackfillFail": "回填失败:{error}",
"chat.attachmentAclBackfillOk": "回填完成:插入 {inserted} 条(扫描 {scanned_messages} 条消息)",
"chat.attachmentAclBackfillPrompt": "回填 attachment_acl(扫描历史消息 attachments)?建议先在低峰期执行。",
"chat.attachmentLimits": "附件阈值",
"chat.attachmentLimitsCancel": "取消",
"chat.attachmentLimitsCellChars": "单元格最大字符数",
"chat.attachmentLimitsCols": "最大保留列数",
"chat.attachmentLimitsDenied": "无权限修改附件阈值",
"chat.attachmentLimitsHighPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?",
"chat.attachmentLimitsInvalidJson": "附件阈值 JSON 不合法",
"chat.attachmentLimitsInvalidNumber": "请输入有效正整数",
"chat.attachmentLimitsLargePreviewRows": "大表摘要预览行数",
"chat.attachmentLimitsMaxSheets": "Excel 最大 Sheet 数",
"chat.attachmentLimitsPrompt": "编辑附件阈值 JSON(留空恢复默认)",
"chat.attachmentLimitsReset": "恢复默认",
"chat.attachmentLimitsRows": "最大读取行数",
"chat.attachmentLimitsSave": "保存",
"chat.attachmentLimitsSaved": "附件阈值已更新",
"chat.attachmentLimitsSqlTimeoutMs": "SQL 超时(毫秒)",
"chat.attachmentLimitsTitle": "附件阈值配置",
"chat.attachmentLimitsToolEnabled": "启用大表工具模式",
"chat.attachmentLimitsToolMaxBytes": "工具模式最大文件字节数",
"chat.attachmentLimitsToolMinRows": "触发工具模式最小行数",
"chat.attachmentUploaded": "(已上传附件)",
"chat.audit": "审计与追踪",
"chat.backAdmin": "管理台",
"chat.call": "调用",
"chat.callBracket": "[调用] {name}",
"chat.compressHistory": "压缩对话",
"chat.compressHistoryFail": "压缩失败:{error}",
"chat.compressHistoryOk": "压缩完成:扫描 {scanned} 条 tool 消息,重写 {rewritten} 条,超限压缩 {compacted} 条(已跳过 {skipped} 条已压缩)。",
"chat.compressHistoryPrompt": "将本会话历史工具输出按回放策略写回压缩(不可逆)?建议仅在发现超大 tool_result/导出卡顿时使用。",
"chat.connecting": "连接中…",
"chat.copy": "复制",
"chat.copyFail": "复制失败",
"chat.copyOk": "已复制",
"chat.delete": "删除",
"chat.deleteConfirm": "删除此会话?",
"chat.deleteMessage": "删除消息",
"chat.deleteMessageConfirm": "删除这条消息?",
"chat.dispatchLabelsCancel": "取消",
"chat.dispatchLabelsClear": "清空",
"chat.dispatchLabelsDiffOnly": "仅显示变更项",
"chat.dispatchLabelsEdit": "编辑原因文案",
"chat.dispatchLabelsEffectivePreview": "Effective 预览",
"chat.dispatchLabelsExport": "导出 Effective",
"chat.dispatchLabelsImport": "导入 JSON",
"chat.dispatchLabelsInvalidJson": "JSON 格式不合法",
"chat.dispatchLabelsPrompt": "编辑 dispatch reason 覆盖 JSON(留空清空)",
"chat.dispatchLabelsRestoreDefaults": "恢复默认模板",
"chat.dispatchLabelsSave": "保存",
"chat.dispatchLabelsSaved": "原因文案已更新",
"chat.dispatchLabelsTitle": "编辑原因文案覆盖",
"chat.dispatchLabelsUnsavedConfirm": "当前有未保存变更,确认关闭?",
"chat.dynamicAllReasons": "查看完整分布",
"chat.dynamicAllReasonsTitle": "完整原因分布",
"chat.dynamicStats": "动态专家近窗:命中 {dynamic},回退 {fallback}",
"chat.dynamicStatsDetail": "命中率 {rate}% · Top原因 {reason}",
"chat.dynamicTopReasons": "原因分布 {items}",
"chat.empty": "暂无消息",
"chat.encodingAttachments": "编码附件中…",
"chat.error": "请求失败",
"chat.execApplied": "本轮执行:{mode} / {specialist}",
"chat.execModeAgent": "Agent",
"chat.execModeApplied": "执行态:{mode}",
"chat.execModeLabel": "执行态",
"chat.execModePlan": "Plan",
"chat.expandFull": "展开全文(可能较慢)",
"chat.exportJson": "导出 JSON",
"chat.exportMd": "导出 Markdown",
"chat.fork": "复制会话",
"chat.historyComplete": "已到最早消息",
"chat.historyTruncated": "已显示最近 {shown}/{total} 条 · 上滑加载更早",
"chat.imageBlockedHint": "图片无法嵌入显示(可能被防盗链),点击打开原始链接",
"chat.imageViewerClose": "关闭",
"chat.imageViewerDownload": "下载图片",
"chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭",
"chat.jobs": "后台任务",
"chat.jobsClose": "关闭",
"chat.jobsEmpty": "暂无后台任务",
"chat.jobsHint": "Agent 启动的长任务会列在这里;对话结束后进程仍可能继续。可手动终止以防风险。",
"chat.jobsKill": "终止",
"chat.jobsKillAll": "全部终止",
"chat.jobsKillAllConfirm": "确认终止全部运行中的任务?",
"chat.jobsKillConfirm": "确认终止该任务?进程将被强制结束。",
"chat.jobsPurge": "清除记录",
"chat.jobsPurgeConfirm": "清除该已结束任务的本地记录与日志?",
"chat.jobsRefresh": "刷新",
"chat.jobsRunning": "运行中 {n}",
"chat.jobsStatus.cancelled": "已取消",
"chat.jobsStatus.failed": "失败",
"chat.jobsStatus.running": "运行中",
"chat.jobsStatus.succeeded": "成功",
"chat.jobsStatus.timeout": "超时",
"chat.jobsTitle": "后台任务",
"chat.langSwitchedWhileStreaming": "语言已切换。当前会话仍在输出,刷新页面可更新全部界面文案。",
"chat.liveFull": "[实时全量]",
"chat.loadMore": "加载更多",
"chat.loading": "加载中…",
"chat.loadingDots": "加载中...",
"chat.loadingOlder": "正在加载更早消息…",
"chat.logs": "日志",
"chat.marker.reclaimed": "本轮回收 turn 标记 {n}",
"chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})",
"chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}",
"chat.memoryApplied": "记忆:{mode}",
"chat.memoryModeDefault": "默认(可注入)",
"chat.memoryModeDefaultShort": "默认",
"chat.memoryModeLabel": "记忆模式",
"chat.memoryModeStoreOnly": "仅记录不注入",
"chat.memoryModeStoreOnlyShort": "仅记录",
"chat.modeComprehensive": "综合",
"chat.modeComprehensiveShort": "综合",
"chat.modeExpert": "专家",
"chat.modeExpertShort": "专家",
"chat.modeLabel": "模式",
"chat.modeNotPinned": "模式未固化:后端返回 interaction_mode={mode} specialist={specialist}(期望 expert/{want})",
"chat.modelProfile": "模型",
"chat.myProfile": "设置",
"chat.newSession": "开启新对话",
"chat.noSessions": "暂无会话,点击新建开始。",
"chat.pageTitle": "oliver",
"chat.placeholder": "输入消息…",
"chat.reason.dynamic_agent_build_failed": "动态专家构建失败",
"chat.reason.dynamic_agent_selected": "动态专家命中",
"chat.reason.manager_factory_failed": "专家执行器创建失败",
"chat.reason.manager_model_missing": "全能者模型不可用",
"chat.reason.manager_no_specialist_fallback": "无可用专家,回退通用",
"chat.reason.manager_route_missing": "全能者路由缺失",
"chat.reason.manager_select_failed": "全能者决策失败",
"chat.remove": "移除",
"chat.rename": "重命名",
"chat.saveModeFailed": "保存“模式”失败:{error}",
"chat.send": "发送",
"chat.sending": "发送中…",
"chat.sessionMenu": "会话操作",
"chat.sessions": "会话",
"chat.specialistGeneralist": "通用",
"chat.specialistGeneralistShort": "通用",
"chat.specialistImage": "图像",
"chat.specialistImageShort": "图像",
"chat.specialistLabel": "专家",
"chat.specialistManage": "专家开关",
"chat.specialistManageDenied": "无权限修改专家开关",
"chat.specialistManagePromptOff": "是否关闭 memory 专家?",
"chat.specialistManagePromptOn": "是否启用 memory 专家?",
"chat.specialistMemory": "记忆",
"chat.specialistMemoryShort": "记忆",
"chat.specialistOps": "运维",
"chat.specialistOpsShort": "运维",
"chat.specialistVideoShort": "视频",
"chat.stageLocalBoth": " · 阶段耗时(启动→执行)={start}ms (执行→首字)={first}ms",
"chat.stageLocalStart": " · 阶段耗时(启动→执行)={start}ms",
"chat.stageSeg": " · 阶段(A→G:{a2g} / G→M:{g2m} / M→F:{m2f})",
"chat.status.agent": "专家",
"chat.status.end": "完成",
"chat.status.error": "异常",
"chat.status.oclaw": "助手",
"chat.status.plan": "规划",
"chat.status.running": "执行中",
"chat.status.start": "启动",
"chat.status.tool": "工具",
"chat.stop": "停止",
"chat.timeoutRecovered": "请求超时,已从历史补齐本轮结果。",
"chat.toolCall": "工具调用",
"chat.toolResult": "工具结果",
"chat.tools": "推理",
"chat.tools.hidden": "推理已隐藏",
"chat.tools.off": "关",
"chat.tools.on": "开",
"chat.tools.visible": "推理已显示",
"chat.truncatedNote": "…(为保证界面性能,已截断 {n} 字符)",
"chat.wikiPreviewClose": "关闭",
"chat.wikiPreviewTitle": "Wiki 预览:{path}",
"chat.wikiToastFailed": "Wiki 收录失败:{error}",
"chat.wikiToastMerged": "Wiki 已收录:+{merged}(去重跳过 {skipped})",
"chat.wikiViewMerged": "查看 merged-turns",
"chat.wikiViewTopic": "查看 topic:{topic}",
"chat.wsInterruptedRecovered": "WS流式中断,已从历史补齐本轮结果。",
"chat.zoomIn": "放大",
"chat.zoomOut": "缩小",
"chat.zoomReset": "重置缩放",
"common.error": "错误",
"lang.switch": "English",
"reasoning.processNotes": "主通道过程说明",
"reasoning.summary": "模型推理片段",
"theme.catppuccin": "Catppuccin Mocha",
"theme.deepseek": "DeepSeek",
"theme.dracula": "Dracula",
"theme.forest": "森绿",
"theme.github": "GitHub 暗色",
"theme.label": "界面配色",
"theme.light": "浅色",
"theme.netx": "NetX 暗色",
"theme.nord": "Nord",
"tool.streamTitle": "工具与进度(本轮)",
};

View file

@ -0,0 +1,128 @@
import { AUTH_TOKEN_KEY, AUTH_SESSION_KEY, CHAT_URL_SCOPE_KEY, showToast, openBackgroundJobsPanel, forceReloginRequested, clearAuthAndReloginFlagFromUrl, withTimeout, apiGet, apiPost, el, isChatStreaming, clearChatPageBlockers, loadMeProfile, mount, openAdminFromChat, syncAuthUserLabel, renderLogin, state, t, applyI18nStatic, LANG_KEY } from "./core.js";
import { renderChatUi } from "./ui.js";
async function syncLangFromServer() {
try {
const r = await apiGet("/admin/api/chat/settings/ui-lang");
if (r && r.lang && (r.lang === "zh" || r.lang === "en")) {
state.currentLang = r.lang;
localStorage.setItem(LANG_KEY, state.currentLang);
}
} catch (_) {}
}
async function boot() {
clearChatPageBlockers();
applyI18nStatic();
if (forceReloginRequested()) {
clearAuthAndReloginFlagFromUrl();
}
try {
state.authSession = JSON.parse(localStorage.getItem(AUTH_SESSION_KEY) || "null");
} catch (_) {
state.authSession = null;
}
const tok = String(localStorage.getItem(AUTH_TOKEN_KEY) || "").trim();
if (!state.authSession || !tok) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.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 (_) {}
state.authSession = null;
await boot();
return;
}
if (action === "lang") {
state.currentLang = state.currentLang === "zh" ? "en" : "zh";
localStorage.setItem(LANG_KEY, state.currentLang);
try {
await apiPost("/admin/api/chat/settings/ui-lang", { lang: state.currentLang });
} catch (_) {}
clearChatPageBlockers();
if (isChatStreaming()) {
applyI18nStatic();
syncAuthUserLabel();
showToast(t("chat.langSwitchedWhileStreaming"), { kind: "info", ttlMs: 6500 });
} else {
await boot();
}
return;
}
}
});
window.addEventListener("popstate", () => {
if (state.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) }),
]),
]),
);
});

View file

@ -0,0 +1,25 @@
const LANG_KEY = "ops_admin_lang";
const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle";
const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = true;
let lang = "zh";
try {
lang = String(localStorage.getItem(LANG_KEY) || "zh").toLowerCase();
} catch (_) {}
if (lang !== "zh" && lang !== "en") lang = "zh";
let showTool = ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT;
try {
const _rt = String(localStorage.getItem(CHAT_REASONING_TOGGLE_KEY) || "").trim().toLowerCase();
if (_rt) showTool = ["1", "true", "yes", "on"].includes(_rt);
} catch (_) {}
export const state = {
currentLang: lang,
authSession: null,
adminChatShowToolOutput: showTool,
meProfile: null,
statusReasonPairs: [],
jobsBadgeEl: null,
jobsBtnLabelEl: null,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,742 @@
import { state } from "./state.js";
import { t, tf, applyI18nStatic, toggleLang } from "./i18n/index.js";
const SESSION_MONITOR_ROLE_FILTER_KEY = "ops_session_monitor_role_filter";
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 (_) {}
})();
/** 合并展示名与登录名(列表/团队页共用) */
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 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);
state.authSession = null;
setTimeout(() => {
const fn = state.reauthHandler;
if (typeof fn === "function") fn();
}, 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"),
"scheduled-jobs": t("title.scheduledJobs"),
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 = state.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 = t("stack.configChangedPrewarm");
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
}
/**
* Compact row action menu (button + fixed popover).
* Must NOT use nested &lt;details&gt; inside plugins-fold details — clicks get swallowed.
* items: [{ label, onClick, danger?, disabled? }]
*/
function rowActions(label, items) {
let menuEl = null;
let onDoc = null;
const close = () => {
if (onDoc) {
document.removeEventListener("click", onDoc, true);
onDoc = null;
}
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
menuEl = null;
};
const btn = el("button", {
class: "chat-sess-more row-actions__btn",
type: "button",
text: String(label || "⋯"),
title: "actions",
"aria-label": "actions",
onclick: (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (menuEl) {
close();
try {
btn.blur();
} catch (_) {}
return;
}
const menu = el("div", { class: "chat-sess-menu-pop row-actions__menu u-overlay-fixed" });
menu.style.visibility = "hidden";
(Array.isArray(items) ? items : []).forEach((it) => {
if (!it || !it.label) return;
menu.appendChild(
el("button", {
class: "chat-sess-menu-item" + (it.danger ? " row-actions__item--danger" : ""),
type: "button",
text: String(it.label),
disabled: !!it.disabled,
onclick: async (e2) => {
e2.preventDefault();
e2.stopPropagation();
close();
try {
btn.blur();
} catch (_) {}
if (typeof it.onClick !== "function") return;
try {
await it.onClick();
} catch (err) {
console.warn("row action failed", err);
window.alert(String((err && err.message) || err || "action failed"));
}
},
}),
);
});
document.body.appendChild(menu);
const rect = ev.currentTarget.getBoundingClientRect();
const pad = 8;
const mrect = menu.getBoundingClientRect();
// Prefer opening to the left of the ⋯ (actions column is trailing).
let left = rect.right - mrect.width;
let top = rect.bottom + 4;
if (left < pad) {
left = Math.min(rect.left, window.innerWidth - pad - mrect.width);
}
if (top + mrect.height > window.innerHeight - pad) {
top = Math.max(pad, rect.top - 4 - 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.visibility = "visible";
menuEl = menu;
try {
btn.blur();
} catch (_) {}
onDoc = (e3) => {
if (menu.contains(e3.target) || btn.contains(e3.target)) return;
close();
};
setTimeout(() => document.addEventListener("click", onDoc, true), 0);
},
});
return btn;
}
/** Navigate/reload admin SPA without importing router (avoids cycles). */
function navigateAdmin() {
const fn = state.navigate;
if (typeof fn === "function") return fn();
return Promise.resolve();
}
export {
SESSION_MONITOR_ROLE_FILTER_KEY,
AUTH_TOKEN_KEY,
AUTH_SESSION_KEY,
CHAT_MEMORY_MODE_KEY,
authStoreGet,
authStoreSet,
authStoreRemove,
formatUserLabel,
formatAuditActor,
getRoute,
resolveAdminApiUrl,
resolveChatUrl,
getStoredAuthToken,
scheduleReauthAfter401,
_haltAfter401,
apiGet,
apiGetOptional,
apiGetNoHang,
apiPost,
assertSkillMutationOk,
apiRequest,
apiPostFormData,
apiDeleteJson,
_activeCopyCell,
_selectCopyCell,
_shouldIgnoreCopyShortcut,
_installCellCopyKeyboardShortcut,
attachCellCopyBehavior,
el,
tdCell,
enableTableColumnResize,
setActive,
mount,
renderPageShell,
renderSectionCard,
renderMetaChips,
shortText,
formatSystemLocalDateTime,
formatIds,
yesNo,
runtimePrewarmReminder,
markPrewarmReminder,
rowActions,
navigateAdmin,
state,
t,
tf,
applyI18nStatic,
toggleLang,
};

View file

@ -0,0 +1,40 @@
import messagesZh from "./messages-zh.js";
import messagesEn from "./messages-en.js";
import { state } from "../state.js";
export const I18N = {
zh: messagesZh,
en: messagesEn,
};
export const LANG_KEY = "ops_admin_lang";
export function t(key) {
return (I18N[state.currentLang] && I18N[state.currentLang][key]) || (I18N.en && I18N.en[key]) || key;
}
export function tf(key, vars = {}) {
const template = t(key);
return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
}
export function applyI18nStatic() {
document.documentElement.lang = state.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 (_) {}
}
}
export function toggleLang() {
state.currentLang = state.currentLang === "zh" ? "en" : "zh";
localStorage.setItem(LANG_KEY, state.currentLang);
}

View file

@ -0,0 +1,774 @@
export default {
"action.refresh": "Refresh",
"action.save": "Save",
"adminAudit.action": "Action",
"adminAudit.actor": "Actor",
"adminAudit.jump": "Go",
"adminAudit.jumpPlaceholder": "Page",
"adminAudit.status": "Status",
"adminAudit.title": "Admin Operation Audit",
"adminAudit.total": "Total {total}",
"apiGrants.forbidden": "Only the administrator console user with team write permission (or owner role) can manage API access grants.",
"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.noShareable": "(No shareable profiles)",
"apiGrants.openModels": "Go to Model Management",
"apiGrants.pickApi": "Profile to grant",
"apiGrants.teamHint": "Everyone in the tenant can then see this profile in their picker; endpoints and secrets stay under Model Management.",
"apiGrants.teamSection": "Grant to entire team",
"apiGrants.userHint": "Only that user sees the profile in their picker; they bind roles under Model Management.",
"apiGrants.userSection": "Grant to one user",
"attachments.archiveMaxDepth": "Archive max depth",
"attachments.archiveMaxEntryBytes": "Archive max entry uncompressed bytes",
"attachments.archiveMaxFileCount": "Archive max file count",
"attachments.archiveMaxTotalBytes": "Archive max total uncompressed bytes",
"attachments.archivePolicyHint": "Unified archive_processor budget for zip/tar/tgz/gz: limits depth, file count and uncompressed size.",
"attachments.card.archive": "Archive budget policy",
"attachments.card.media": "Image & video policy",
"attachments.card.policy": "Policy",
"attachments.card.tableMode": "Tables & tool mode",
"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.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?",
"attachments.imageReplayCapChars": "Image replay cap chars",
"attachments.imageReplayCapHint": "Replay cap for query_image_attachment OCR/description results in historical context. Range 600..30000, default 4000.",
"attachments.invalidNumber": "Please enter valid positive integers",
"attachments.largePreviewRows": "Large-table preview rows",
"attachments.loadError": "Load failed",
"attachments.maxCellChars": "Max chars per cell",
"attachments.maxColumns": "Max columns kept",
"attachments.maxExcelSheets": "Max Excel sheets",
"attachments.maxRowsRead": "Max rows read",
"attachments.resetDefaults": "Reset defaults",
"attachments.save": "Save",
"attachments.saved": "Saved",
"attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.",
"attachments.sqlTimeoutMs": "SQL timeout (ms)",
"attachments.subtitle": "Attachment limits, budgets, and replay policy",
"attachments.title": "Attachments",
"attachments.toc.archive": "Archives",
"attachments.toc.media": "Image/Video",
"attachments.toc.overview": "Overview",
"attachments.toc.table": "Tables",
"attachments.toolModeEnabled": "Enable large-table tool mode",
"attachments.toolModeMaxBytes": "Max file bytes for tool mode",
"attachments.toolModeMinRows": "Min rows to trigger tool mode",
"attachments.videoReplayCapChars": "Video replay cap chars",
"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.videoTranscriptChunkOverlap": "Video transcript chunk overlap",
"attachments.videoTranscriptChunkSize": "Video transcript chunk size",
"audit.auditTable": "Audit Table",
"audit.columns": "Columns",
"audit.empty": "No data",
"audit.note": "Supports dual view: table + raw JSON.",
"audit.query": "Query",
"audit.rawJson": "Raw JSON",
"audit.result": "Result",
"audit.sessionIdPlaceholder": "session_id (optional)",
"audit.specialistSummaryHint": "Specialist-side tool execution and delivery summary (specialist -> core)",
"audit.title": "Audit & Trace",
"audit.traceTable": "Trace Table",
"auth.consoleUsernameHint": "Username and password; menus still follow your permissions.",
"auth.disabled": "Account is disabled",
"auth.invalid": "Login failed",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.password": "Password",
"auth.username": "Username",
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "After any skill/tool/role/prompt change, run prewarm immediately; restart for complex changes. System also auto-prewarms every 10 minutes.",
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[prewarm] running in background; refreshing this page when finished…",
"boot.loadFailed": "Failed to load admin scripts",
"boot.loadFailedHint": "Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.",
"boot.loading": "Loading admin console…",
"boot.loadingHint": "If this stays too long, check whether the network/proxy blocks assets.",
"brand.title": "oliver",
"chat.empty": "No messages yet",
"chat.error": "Request failed",
"chat.loading": "Loading…",
"chat.newSession": "New chat",
"chat.noSessions": "No sessions yet. Create one to start.",
"chat.placeholder": "Message…",
"chat.send": "Send",
"chat.sending": "Sending…",
"chat.sessions": "Sessions",
"common.admin": "Admin",
"common.all": "All",
"common.auto": "Auto",
"common.blacklist": "Blacklist",
"common.chinese": "Chinese",
"common.delete": "Delete",
"common.deleteFailed": "Delete failed",
"common.english": "English",
"common.error": "Error",
"common.forbidden": "You do not have access to this page.",
"common.mode": "Mode",
"common.notFound": "Not found",
"common.notLoggedIn": "Not logged in or session expired. Please log in again.",
"common.pageRemoved": "This admin page was removed in the product simplification pass.",
"common.remove": "Remove",
"common.requestFailed": "Request failed",
"common.selectType": "Please select a type",
"common.update": "Update",
"common.updateFailed": "Update failed",
"common.whitelist": "Whitelist",
"lang.switch": "中文",
"memory.applyFilters": "Apply Filters",
"memory.avgScore": "Average Score",
"memory.backend": "Backend",
"memory.cleanup": "Cleanup low-confidence",
"memory.clearFilters": "Clear Filters",
"memory.config": "Vector Config",
"memory.delete": "Delete",
"memory.enabled": "Enable vector retrieval",
"memory.filters": "Filters",
"memory.hitCount": "Hit Count",
"memory.hits": "Recent Hits",
"memory.itemCount": "Memory Items",
"memory.items": "Memory Items",
"memory.minConfidence": "Min write confidence",
"memory.noData": "No memory data",
"memory.openPlugins": "Open plugins page",
"memory.reindex": "Reindex vectors",
"memory.save": "Save Config",
"memory.stats": "Stats",
"memory.tenantId": "team ID / tenant_id (optional)",
"memory.title": "Memory Governance",
"memory.topSources": "Recent Sources",
"memory.topk": "TopK",
"memory.userId": "user_id (optional)",
"memory.wikiCard": "Wiki",
"memory.wikiPluginFound": "Installed: {name} v{version}",
"memory.wikiPluginMissing": "memory-wiki plugin not found",
"memory.wikiStatus": "Plugin status",
"memory.writerEnabled": "Enable auto write",
"models.agentBindingHelp": "Optional per-role profile; leave empty to use the global selection above.",
"models.apiKey": "API key",
"models.baseUrl": "Base URL",
"models.baseUrlPlaceholder": "Optional",
"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.builtinLocked": "Built-in Ollama profile: mode is fixed; you can edit URL/model.",
"models.cannotDeleteBuiltin": "Cannot delete the built-in Ollama profile.",
"models.chatModelSelector": "Chat model selector",
"models.chatModelSelectorHint": "Control whether the model dropdown is visible on the Chat page.",
"models.createBtn": "Create",
"models.createNamePlaceholder": "Display name",
"models.dbPath": "SQLite file in use",
"models.delete": "Delete profile",
"models.deleteConfirm": "Delete this profile? This cannot be undone.",
"models.evalDownloadCsv": "Download full CSV",
"models.evalDownloadJson": "Download full JSON",
"models.evalLogs": "Recent eval logs",
"models.evalLogsPreview": "Preview",
"models.evalP95": "P95 latency",
"models.evalSuccess": "Success rate",
"models.evalToggle": "Agent Evaluation · click to expand (preview up to 100 rows)",
"models.evalTotal": "Samples",
"models.grantTenantBtn": "Entire team",
"models.grantsEmpty": "No per-user grants yet.",
"models.grantsGrantBtn": "Grant",
"models.grantsHint": "Only the administrator account can manage grants. Granted users see and can select this profile (shared API key).",
"models.grantsNavHint": "Control who can see and select shareable profiles (keys and endpoints are edited on Model Management).",
"models.grantsRevoke": "Revoke",
"models.grantsTitle": "Grant other users access to the selected profile",
"models.grantsUserHint": "Grant to one user:",
"models.labelApiProfile": "API profile",
"models.labelSpecialist": "Specialist",
"models.linkApiGrants": "Open API access grants",
"models.loadFailed": "Load failed; see error below.",
"models.membersTitle": "Model profiles visible to each member",
"models.mode": "Mode",
"models.mode.anthropic": "Anthropic",
"models.mode.google": "Google (Gemini)",
"models.mode.ollama": "Ollama",
"models.mode.openai": "OpenAI-compatible",
"models.mode.openai_responses": "OpenAI Responses",
"models.mode.rule": "Rule (no LLM)",
"models.model": "Model",
"models.newProfileDefault": "New profile",
"models.noEvalLogs": "No evaluation logs yet",
"models.noProfiles": "No LLM profiles.",
"models.ollamaHint": "Local Ollama defaults to http://127.0.0.1:11434; API key is usually empty.",
"models.openaiKeyHint": "Without a key, OPENAI_API_KEY from the environment may be used.",
"models.opsAiHint": "For v1 / expert mode: pick a specialist and bind an API profile.",
"models.opsAiTitle": "Internal API model config",
"models.phNameZh": "Chinese name (optional)",
"models.pickModel": "Profile",
"models.profileName": "Name",
"models.profileReadonlyHint": "This profile is shared by an administrator: you may select it for chat but cannot edit name, endpoint, or secret.",
"models.readonly": "Read-only: missing admin:tenant:write.",
"models.reasoningEffort": "Reasoning effort",
"models.reasoningEffortDefault": "Default",
"models.reasoningEffortHigh": "High",
"models.reasoningEffortLow": "Low",
"models.reasoningEffortMedium": "Medium",
"models.rememberKey": "Remember key (encrypted in DB)",
"models.revokeTenantGrant": "Revoke team",
"models.role.generalist": "Generalist",
"models.role.image": "Image",
"models.role.memory": "Memory Curator",
"models.role.ops": "Network ops",
"models.save": "Save",
"models.sectionActive": "Active profile",
"models.sectionApi": "API & secret",
"models.sectionBindings": "Agent ↔ profile bindings",
"models.sectionEval": "Agent Evaluation",
"models.sectionNew": "New profile",
"models.subtitle": "Model switching, bindings, and evaluation",
"models.tenantGrantOff": "Team: not granted",
"models.tenantGrantOn": "Team: granted",
"models.thinkMode": "Think mode (this API profile only)",
"models.thinkModeHint": "When on, replay full assistant reasoning_content for this profile only (default off).",
"models.toc.api": "API config",
"models.toc.bindings": "Bindings",
"models.toc.eval": "Eval",
"models.toc.opsAi": "Internal API",
"models.toc.overview": "Overview",
"models.useGlobal": "(use global)",
"models.vis.builtin": "Built-in",
"models.vis.global": "Global",
"models.vis.grantTenant": "Team grant",
"models.vis.grantUser": "User grant",
"models.vis.otherUser": "Other user",
"models.vis.owned": "Mine",
"models.warnKeyInDb": "A key exists in DB: empty field won’t overwrite until you enter a new key or uncheck remember.",
"nav.adminAudit": "Admin Audit",
"nav.apiGrants": "API access",
"nav.attachments": "Attachments",
"nav.audit": "Audit & Trace",
"nav.chat": "Chat",
"nav.group.governance": "Governance & audit",
"nav.group.models": "Models & plugins",
"nav.group.system": "System & runtime",
"nav.group.users": "Users & settings",
"nav.memory": "Memory",
"nav.models": "Models",
"nav.plugins": "Plugins",
"nav.profile": "User Info",
"nav.scheduledJobs": "Scheduled Jobs",
"nav.sessionMonitor": "Session Monitor",
"nav.skills": "Skills",
"nav.stack": "Runtime",
"nav.users": "Users",
"nav.workspacePaths": "Workspace paths",
"notice.noLogin": "v2 login enabled: internal network only",
"plugins.colBoundMcp": "Bound MCPs",
"plugins.colSpecialist": "Specialist",
"plugins.colTools": "tools (sum across servers)",
"plugins.fold.bindingDash": "[4] Specialist MCP binding board (auto)",
"plugins.fold.bindingDashHint": "Summarizes current binding draft vs installed MCP tools; specialists come from SPECIALISTS and mapping keys.",
"plugins.fold.bindingEdit": "[5] MCP specialist binding (edit)",
"plugins.fold.install": "[2] Install from Cursor JSON",
"plugins.fold.installCursor": "[2] Install from Cursor JSON",
"plugins.fold.installCursorHint": "Paste a Cursor mcp.json document with mcpServers, then Install. Export downloads the same shape for migration.",
"plugins.fold.installHint": "Paste Cursor mcpServers JSON, then Install.",
"plugins.fold.installed": "[3] Installed MCP servers ({n})",
"plugins.fold.migrateHint": "After install/config/uninstall, writes oclaw/_local/mcp_registry_migrated.json (Cursor mcpServers) for migration.",
"plugins.fold.toolPolicy": "[1] Tool policy & registered plugins ({n})",
"plugins.fold.toolPolicyHint": "Tool policy (concurrency / rounds / MCP·plugin toggles) and Python tool plugins",
"plugins.nextPage": "Next",
"plugins.noData": "No data",
"plugins.pageRange": "{start}–{end} of {total}",
"plugins.prevPage": "Prev",
"plugins.subtitle": "Grouped by feature, collapsed by default; tables paginate ({n}/page).",
"plugins.title": "Tool Plugins",
"plugins.toc.binding": "Bindings",
"plugins.toc.install": "Install",
"plugins.toc.instances": "Instances",
"plugins.toc.policy": "Policy",
"profile.avatar": "Avatar",
"profile.avatarHint": "PNG, JPEG, WebP, or GIF, up to 2 MB.",
"profile.chatPrefs": "Chat Preferences",
"profile.chooseImage": "Choose image",
"profile.createdAt": "Created at",
"profile.displayName": "Display name",
"profile.help": "Adjust display name, avatar, and account identifiers here. In chat, avatars are circular; the assistant uses the site logo.",
"profile.loadError": "Load failed",
"profile.memoryCurator": "Memory curator specialist",
"profile.memoryCuratorDisabled": "Disabled",
"profile.memoryCuratorEnabled": "Enabled",
"profile.memoryMode": "Memory mode",
"profile.memoryModeDefault": "Default (inject allowed)",
"profile.memoryModeStoreOnly": "Store only (no inject)",
"profile.openChat": "Open chat",
"profile.removeAvatar": "Remove avatar",
"profile.role": "Role",
"profile.save": "Save settings",
"profile.saved": "Saved",
"profile.tenantId": "Team ID",
"profile.uploadError": "Upload failed",
"profile.userId": "User ID",
"profile.username": "Username",
"scheduledJobs.all": "All",
"scheduledJobs.attentionTitle": "Needs attention",
"scheduledJobs.cancel": "Cancel",
"scheduledJobs.colActions": "actions",
"scheduledJobs.colDelivery": "delivery",
"scheduledJobs.colError": "error",
"scheduledJobs.colFailClass": "class",
"scheduledJobs.colFinished": "finished",
"scheduledJobs.colJob": "job",
"scheduledJobs.colLastRun": "last_run",
"scheduledJobs.colName": "name",
"scheduledJobs.colNextRun": "next_run",
"scheduledJobs.colPlaybook": "playbook",
"scheduledJobs.colRunStatus": "run status",
"scheduledJobs.colSchedule": "schedule",
"scheduledJobs.colSpecialist": "specialist",
"scheduledJobs.colStatus": "status",
"scheduledJobs.count": "{count} job(s)",
"scheduledJobs.create": "Create",
"scheduledJobs.createTitle": "Create job",
"scheduledJobs.delete": "Delete",
"scheduledJobs.deleteConfirm": "Delete this scheduled job?",
"scheduledJobs.deliveryWeixin": "WeChat",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"scheduledJobs.edit": "Edit",
"scheduledJobs.editTitle": "Edit job",
"scheduledJobs.failClass.auth": "auth",
"scheduledJobs.failClass.cancelled": "cancelled",
"scheduledJobs.failClass.delivery": "delivery",
"scheduledJobs.failClass.mcp": "mcp/tool",
"scheduledJobs.failClass.overlap": "overlap skip",
"scheduledJobs.failClass.runtime": "runtime",
"scheduledJobs.failClass.stale": "stale clear",
"scheduledJobs.failClass.timeout": "timeout",
"scheduledJobs.fieldExpr": "Expression",
"scheduledJobs.fieldKind": "Kind",
"scheduledJobs.fieldName": "Name",
"scheduledJobs.fieldPrompt": "Prompt",
"scheduledJobs.fieldSchedule": "Schedule",
"scheduledJobs.fieldSpecialist": "Specialist",
"scheduledJobs.fieldWhatsapp": "WhatsApp chat_id",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.menuTitle": "Job actions",
"scheduledJobs.noRuns": "No runs yet",
"scheduledJobs.pause": "Pause",
"scheduledJobs.phExpr": "cron / ISO time / seconds",
"scheduledJobs.phName": "Job name",
"scheduledJobs.phPrompt": "Prompt to run when due",
"scheduledJobs.phSpecialist": "e.g. generalist",
"scheduledJobs.phWhatsapp": "Optional group or DM chat_id",
"scheduledJobs.playbookNo": "reminder",
"scheduledJobs.playbookPartial": "recipe · {n} steps",
"scheduledJobs.playbookYes": "playbook · {n} steps",
"scheduledJobs.requiredFields": "name, prompt_text, and schedule_expr are required",
"scheduledJobs.resume": "Resume",
"scheduledJobs.runHistory": "Run history",
"scheduledJobs.runHistoryHint": "Recent runs (auto refresh)",
"scheduledJobs.runNow": "Run now",
"scheduledJobs.skippedOverlap": "Skipped (overlapping)",
"scheduledJobs.statsClasses": "Failure classes",
"scheduledJobs.statsFailRate": "Recent fail rate {pct}% ({failed} failed / {finished} finished)",
"scheduledJobs.statsJobsFailed": "Jobs last-failed: {n}",
"scheduledJobs.statsNone": "No failures in recent window",
"scheduledJobs.statsTitle": "Failure board",
"scheduledJobs.statusActive": "active",
"scheduledJobs.statusPaused": "paused",
"scheduledJobs.triggered": "Triggered.",
"scheduledJobs.updated": "Saved.",
"scheduledJobs.viewRuns": "View runs",
"scheduledJobs.weixinFixed": "Weixin fixed target: {id}",
"scheduledJobs.weixinMissing": "Weixin binding not found (administrator channel identity required)",
"secrets.migrateBtn": "Migrate now",
"secrets.migrateDone": "Migration done: settings={s}, profiles={p}",
"secrets.migrateForbidden": "Requires admin:tenant:write permission.",
"secrets.migrateHint": "Legacy b64-encoded secrets detected (weak protection). Set AIA_ASSISTANT_MASTER_KEY and migrate to a stronger encrypted format.",
"secrets.migrateNeedKey": "AIA_ASSISTANT_MASTER_KEY is not set; cannot migrate.",
"secrets.migrateNoop": "No legacy secrets found.",
"secrets.migrateTitle": "Secret migration",
"sessionMonitor.activeLogins30m": "Active logins (30m)",
"sessionMonitor.activeSessions30m": "Active sessions (30m)",
"sessionMonitor.closeDetail": "Close details",
"sessionMonitor.exportJson": "Export JSON",
"sessionMonitor.exportMd": "Export MD",
"sessionMonitor.filterSession": "Filter sessions",
"sessionMonitor.filterUser": "Filter users",
"sessionMonitor.messages": "Messages",
"sessionMonitor.noMessages": "No messages in this session.",
"sessionMonitor.onlyAdministrator": "This page is available to administrator only.",
"sessionMonitor.pageInfo": "Page {page} / {totalPages}",
"sessionMonitor.pageNext": "Next",
"sessionMonitor.pagePrev": "Prev",
"sessionMonitor.roleAll": "All",
"sessionMonitor.roleAssistant": "Assistant",
"sessionMonitor.roleFilter": "Role filter",
"sessionMonitor.roleTool": "Tool",
"sessionMonitor.roleUser": "User",
"sessionMonitor.selectUserFirst": "Select a user to view sessions.",
"sessionMonitor.sessions": "Sessions",
"sessionMonitor.totalTokensEst": "Total tokens (est)",
"sessionMonitor.totals": "Overview",
"sessionMonitor.userStats": "User stats",
"sessionMonitor.usersCount": "Users",
"sessionMonitor.viewAudit": "Open audit",
"sessionMonitor.viewDetail": "View details",
"skills.action.cancel": "Cancel",
"skills.action.clearCache": "Clear cache",
"skills.action.close": "Close",
"skills.action.create": "Create skill",
"skills.action.detail": "Detail",
"skills.action.disable": "Disable",
"skills.action.enable": "Enable",
"skills.action.exportSelfCheck": "Export self-check JSON",
"skills.action.exportSkillHealth": "Export skills self-check JSON",
"skills.action.failedOnly": "Only failed items",
"skills.action.install": "Install",
"skills.action.installLocal": "Install local dir",
"skills.action.installRegistry": "Install from registry",
"skills.action.installing": "Installing...",
"skills.action.latest": "Latest",
"skills.action.menuTitle": "Skill actions",
"skills.action.preview": "Preview",
"skills.action.refresh": "Refresh",
"skills.action.repairAllDeps": "Repair all deps",
"skills.action.repairDeps": "Repair deps",
"skills.action.repairing": "Repairing...",
"skills.action.retry": "Retry",
"skills.action.retryableOnly": "Only retryable failures",
"skills.action.run": "Run",
"skills.action.runSelfCheck": "Run self-check",
"skills.action.runSkillHealth": "Run skills self-check",
"skills.action.save": "Save",
"skills.action.saveBinding": "Save skill role binding",
"skills.action.saveTrace": "Save trace setting",
"skills.action.search": "Search",
"skills.action.testRun": "Test run...",
"skills.action.testRunArgs": "Test-run with args...",
"skills.action.toggleRawWired": "Toggle raw/wired",
"skills.action.traceExposure": "Trace exposure plan",
"skills.action.uninstall": "Uninstall",
"skills.col.action": "action",
"skills.col.code": "code",
"skills.col.count": "count",
"skills.col.description": "description",
"skills.col.detail": "detail",
"skills.col.details": "details",
"skills.col.directBind": "direct bind",
"skills.col.directSkills": "direct skills",
"skills.col.docsOnlyNames": "docs-only names",
"skills.col.effective": "effective",
"skills.col.enabled": "enabled",
"skills.col.errorCode": "error_code",
"skills.col.health": "health",
"skills.col.internal": "internal",
"skills.col.kind": "kind",
"skills.col.mcpConverted": "mcp converted",
"skills.col.mode": "mode",
"skills.col.name": "name",
"skills.col.namesPreview": "names preview",
"skills.col.otherTools": "other tools",
"skills.col.outputTruncated": "output_truncated",
"skills.col.parameters": "parameters",
"skills.col.path": "path",
"skills.col.raw": "raw",
"skills.col.readOnly": "read_only",
"skills.col.removedMcp": "removed mcp",
"skills.col.retry": "retry",
"skills.col.risk": "risk",
"skills.col.role": "role",
"skills.col.runtimeEntry": "runtime entry",
"skills.col.skill": "skill",
"skills.col.slug": "slug",
"skills.col.source": "source",
"skills.col.status": "status",
"skills.col.tags": "tags",
"skills.col.target": "target",
"skills.col.timestamp": "timestamp",
"skills.col.totalEffective": "total effective",
"skills.col.type": "type",
"skills.col.version": "version",
"skills.col.wired": "wired",
"skills.col.workspace": "workspace",
"skills.col.workspaceDocsOnly": "workspace docs-only",
"skills.col.workspaceResolved": "workspace resolved",
"skills.confirm.uninstall": "Uninstall skill \"{name}\"?",
"skills.docsTitle": "oclaw docs quick links:",
"skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md (trace fields and stages)",
"skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md (install/runtime troubleshooting)",
"skills.fold.audit": "Install audit",
"skills.fold.binding": "[4] Skill role binding",
"skills.fold.bindingDash": "Role binding dashboard",
"skills.fold.bindingHint": "When enabled, the skills catalog is filtered per role: each role sees only skills bound to that role, plus skills under skills/_workspace/public/. Unbound non-public skills stay hidden.",
"skills.fold.create": "Create skill",
"skills.fold.diagnostics": "[5] Diagnostics",
"skills.fold.effective": "Effective skills (binding + MCP + tools)",
"skills.fold.health": "Skills health self-check",
"skills.fold.install": "[2] Install",
"skills.fold.installHint": "Install from ClawHub market, local dir / registry archive, or create a new skill.",
"skills.fold.installed": "[3] Installed skills ({n})",
"skills.fold.internalTools": "Internal tools preview",
"skills.fold.llmTools": "LLM tools preview",
"skills.fold.localRegistry": "Local dir / registry",
"skills.fold.market": "ClawHub market",
"skills.fold.mode": "[1] Mode",
"skills.fold.modeHint": "Prompt mode injects SKILL.md into the system prompt. Market provider is ClawHub.",
"skills.fold.toolsSelfCheck": "Tools self-check",
"skills.hint.bindingEnv": "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: {stored}, effective: {effective}).",
"skills.hint.bindingPersist": "Checkboxes and per-role skill lists are not saved until you click Save skill role binding.",
"skills.hint.diffDiag": "Diff / diagnostics",
"skills.hint.expandToLoad": "Expand this section to load preview",
"skills.hint.expandToSelfCheck": "Expand this section to run self-check",
"skills.hint.internalTools": "Preview dynamically loaded builtin tools for a role (public shared + expert-scoped).",
"skills.hint.llmTools": "Preview the final tools injected to the model for a role (internal + MCP + role_mode + MCP specialist binding).",
"skills.hint.noSkills": "No installed skills yet.",
"skills.hint.rawWiredDiff": "raw vs wired diff (added/removed/changed)",
"skills.hint.skillHealth": "Run executable skill diagnostics and show failure code distribution.",
"skills.hint.skippedModules": "Skipped modules / factory errors",
"skills.hint.testRunArgs": "Input JSON args passed to the executable skill runtime.",
"skills.hint.toolsSelfCheck": "Run one-shot diagnostics across roles for internal/wired tools, role mode and permanent bans.",
"skills.hintLocalDir": "Local dir: directory must contain SKILL.md (absolute paths OK).",
"skills.hintRegistry": "Registry: zip/tar URL (file:// local URI supported).",
"skills.label.enableBinding": "Enable role binding (AIA_SKILL_ROLE_BINDING_ENABLED)",
"skills.label.promptMode": "Prompt mode (inject SKILL.md)",
"skills.label.role": "Role",
"skills.modal.install": "Skill installation",
"skills.modal.installFail": "Skill installation failed",
"skills.modal.installOk": "Skill installation success",
"skills.modal.testRun": "Skill test run",
"skills.modal.testRunNamed": "Test run: {name}",
"skills.ph.body": "SKILL.md body markdown",
"skills.ph.description": "description",
"skills.ph.limit": "limit",
"skills.ph.localDir": "local skill dir (contains SKILL.md)",
"skills.ph.marketSearch": "search skills (keyword)",
"skills.ph.name": "skill name",
"skills.ph.registry": "registry archive URL (zip/tar)",
"skills.subtitle": "Mode, install, installed list, role binding, and diagnostics — collapsed by default.",
"skills.title": "Skills",
"skills.toc.binding": "Binding",
"skills.toc.diagnostics": "Diagnostics",
"skills.toc.install": "Install",
"skills.toc.installed": "Installed",
"skills.toc.mode": "Mode",
"stack.alerts": "Alerts",
"stack.allRoles": "all roles",
"stack.cleanup": "Cleanup orphan workers",
"stack.cleanupDone": "Cleanup completed. Killed orphan processes: {count}",
"stack.cleanupNone": "No orphan processes found.",
"stack.clearByPolicy": "Clear by policy",
"stack.clearScanCache": "Clear scan cache",
"stack.configChangedPrewarm": "Config changed. Please run prewarm now (or restart).",
"stack.down": "stack down",
"stack.missingRequired": "Required services are not running. Start the stack first.",
"stack.missingServices": "Missing services: {services}",
"stack.mustCleanup": "Duplicate workers detected. Cleanup is required before continuing.",
"stack.noAlerts": "No anomalies detected.",
"stack.prewarmHistory": "Prewarm History (latest 20)",
"stack.prewarmNow": "Run prewarm now",
"stack.prewarmSubmitting": "[prewarm] submitting…",
"stack.prewarmTitle": "Prompt/Tool Prewarm",
"stack.prewarmedPrompts": "Prewarmed Prompts by Role",
"stack.prewarmedPromptsHint": "Shows prewarmed prompt content for specialists.",
"stack.reloadPrompts": "Reload prompts",
"stack.roleFilter": "Role filter",
"stack.scanCacheSubtitle": "{dir} · only clears history_entries_*.json and state_scan_*.json",
"stack.scanCacheTitle": "Scan cache files",
"stack.title": "Stack",
"stack.up": "stack up",
"status.running": "running",
"status.stopped": "stopped",
"table.action": "action",
"table.activeLogins30m": "active_logins(30m)",
"table.activeSessions30m": "active_sessions(30m)",
"table.channel": "channel",
"table.createdAt": "created_at",
"table.durationMs": "duration(ms)",
"table.enabled": "enabled",
"table.entryPoint": "entry_point",
"table.eventType": "event_type",
"table.externalUserId": "external_user_id",
"table.id": "id",
"table.inputAttachmentIds": "input_attachment_ids",
"table.name": "name",
"table.noOutputAttachment": "no_output_attachment",
"table.outputAttachmentIds": "output_attachment_ids",
"table.outputAttachmentUrls": "output_attachment_urls",
"table.parentSpanId": "parent_span_id",
"table.payload": "payload",
"table.pid": "pid",
"table.provider": "provider",
"table.reason": "reason",
"table.routeAttachmentIds": "route_attachment_ids",
"table.service": "service",
"table.sessionsCount": "sessions",
"table.spanId": "span_id",
"table.specialist": "specialist",
"table.status": "status",
"table.statusText": "status",
"table.timestamp": "timestamp",
"table.totalTokensEst": "total_tokens(est)",
"table.traceId": "trace_id",
"table.user": "user",
"table.userId": "user_id",
"table.username": "username",
"table.version": "version",
"tenants.allTenantsHint": "With multiple teams: “Logged-in team” is informational; the selected team in the dropdown is what you edit.",
"tenants.bindCodes": "Bind Codes",
"tenants.bindings": "Channel Bindings",
"tenants.chatLogin": "Web login",
"tenants.chatLoginNo": "No",
"tenants.chatLoginYes": "Yes (password set)",
"tenants.codeDeleteConfirm": "Delete this bind code?",
"tenants.codeDeleted": "Bind code deleted: {code}",
"tenants.codeOptional": "Code (optional, auto-generated if blank)",
"tenants.codeStatus": "Status",
"tenants.codeUnused": "Unused",
"tenants.codeUsed": "Used",
"tenants.codeUsedBy": "Used By",
"tenants.colScope": "Session marker",
"tenants.colTenantId": "Team ID",
"tenants.colTenantName": "Team name",
"tenants.create": "Create Team",
"tenants.createCode": "Create Bind Code",
"tenants.createPlaceholder": "Team name (default Team)",
"tenants.deleteCode": "Delete",
"tenants.deleteDisabledCurrent": "You cannot delete your current session team; log in with another team UUID on the login page first.",
"tenants.deleteDisabledLast": "At least one team must remain.",
"tenants.deleteTenant": "Delete team",
"tenants.deleteTenantConfirm": "Delete team “{name}”? All users, bindings, and data under it will be removed. This cannot be undone.",
"tenants.deleteUnbound": "Delete Unbound Users",
"tenants.deleteUnboundResult": "Unbound cleanup: deleted={deleted}, orphan={orphan}, bound={bound}, total={total}",
"tenants.deleted": "Deleted unbound users: {count}",
"tenants.errCannotDeleteCurrentTenant": "You cannot delete the team of your current session. Switch to another team first or use another account.",
"tenants.errLastTenant": "At least one team must remain.",
"tenants.errTenantDeleteMiss": "Delete had no effect (constraints may have blocked it). Check server logs.",
"tenants.noBindings": "No channel bindings under this team.",
"tenants.noSessionTenant": "Missing team in session. Please sign in again.",
"tenants.noTenants": "No teams found. Create one via bind flow or bootstrap script first.",
"tenants.noUsers": "No users under this team.",
"tenants.role": "Role",
"tenants.rowActions": "Actions",
"tenants.saved": "Configuration saved",
"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.scopeLoginHome": "Logged-in team",
"tenants.select": "Select Team",
"tenants.sessionTenantMissing": "The team for this session is not in the list. Sign in again or contact an admin.",
"tenants.sourceChannel": "Other channel",
"tenants.sourceConsole": "Console",
"tenants.sourceWecom": "WeCom",
"tenants.title": "Teams",
"tenants.userSource": "Source",
"tenants.users": "Users",
"theme.catppuccin": "Catppuccin Mocha",
"theme.deepseek": "DeepSeek",
"theme.dracula": "Dracula",
"theme.forest": "Forest",
"theme.github": "GitHub Dark",
"theme.label": "Color theme",
"theme.light": "Light",
"theme.netx": "NetX Dark",
"theme.nord": "Nord",
"title.adminAudit": "Admin Audit",
"title.apiGrants": "API access grants",
"title.attachments": "Attachments",
"title.audit": "Audit & Trace",
"title.memory": "Memory",
"title.models": "Model Management",
"title.plugins": "Plugins",
"title.profile": "User Info",
"title.scheduledJobs": "Scheduled Jobs",
"title.sessionMonitor": "Session Monitor",
"title.skills": "Skills",
"title.stack": "Runtime",
"title.users": "Users",
"title.workspacePaths": "Workspace paths (per user)",
"users.accountActions": "Account Actions",
"users.accountActive": "Active",
"users.accountDelete": "Delete Account",
"users.accountId": "Account ID",
"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.accountModeExpert": "Current account mode: expert ({specialist})",
"users.accountName": "Name",
"users.accountSave": "Save Account",
"users.botIdRequired": "Bot ID is required",
"users.clearBotSecret": "Clear bot secret",
"users.colActive": "Active",
"users.colSetRole": "Set role",
"users.colUser": "User",
"users.create": "Create User",
"users.createNamePlaceholder": "Name (login id, unique)",
"users.createNameRequired": "Enter a name.",
"users.delete": "Delete",
"users.deleteConfirm": "Delete this user? This cannot be undone.",
"users.disable": "Disable",
"users.enable": "Enable",
"users.hasBotSecret": "Bot secret set",
"users.loadForm": "Load into form",
"users.needUserRead": "admin:user:read is required to view and edit the user list.",
"users.noBindingsForUser": "No WeCom binding or saved instance yet; after the user binds on WeChat, a row appears—fill secrets and save.",
"users.noTenant": "Sign in first and choose a team scope",
"users.passwordOptional": "New password (optional)",
"users.search": "Search users",
"users.title": "User Management",
"users.update": "Update",
"users.wecomAccounts": "WeCom instances (named, multiple per user)",
"users.wecomBotId": "Bot ID",
"users.wecomBotSecret": "Bot Secret",
"users.wecomInstanceName": "Instance name",
"users.wecomUserEmpty": "Click a user row in the table above",
"users.wecomUserLabel": "User",
"wa.accessHint": "Default blacklist mode: unauthorized users go to Pending; denied users go to blacklist/denied list and can be whitelisted.",
"wa.accessTitle": "WhatsApp access control",
"wa.addContact": "Add contact",
"wa.addFailed": "Add failed",
"wa.addWhitelist": "Whitelist",
"wa.alertGroupHint": "NetX key alerts go to this group; independent of chat sessions.",
"wa.alertGroupTitle": "WhatsApp alert group",
"wa.approve": "Approve",
"wa.bindFailed": "Bind failed",
"wa.bindSpecialist": "Bind specialist",
"wa.blacklistMode": "Blacklist mode (deny by default)",
"wa.channelExpertOnly": "Channels are expert-only; pick the default specialist.",
"wa.colAction": "Action",
"wa.colDeniedAt": "Denied at",
"wa.colMessage": "Message",
"wa.colName": "Name",
"wa.colPhone": "Phone",
"wa.colTime": "Time",
"wa.colType": "Type",
"wa.contactNotFound": "Contact not found",
"wa.deniedHint": "Denied users are blacklisted and listed here; use Whitelist to restore access.",
"wa.deniedTitle": "Denied / blacklist records",
"wa.deny": "Deny",
"wa.filter": "Filter",
"wa.lang": "Lang",
"wa.messageLang": "Message lang",
"wa.mode": "Mode",
"wa.namePlaceholder": "Display name (optional)",
"wa.noAlertGroup": "No alert group bound",
"wa.noContacts": "No contacts",
"wa.noneDenied": "None",
"wa.nonePending": "None",
"wa.pendingTitle": "Pending requests",
"wa.phonePlaceholder": "Phone, e.g. +8615601877957",
"wa.pickPending": "Pick from pending requests…",
"wa.saveBinding": "Save binding",
"wa.saveConfig": "Save config",
"wa.selectGroup": "Select group…",
"wa.specialist": "Specialist",
"wa.testPush": "Test push",
"wa.whitelistMode": "Whitelist mode (allow by default)",
"wecom.autoBind": "Auto bind on first message",
"wecom.autoBindRole": "Auto bind role",
"wecom.autoBindTenant": "Auto bind team",
"wecom.botId": "Bot ID",
"wecom.botSecret": "Bot Secret",
"wecom.title": "WeCom Config",
"wecom.unbind": "Unbind WeCom",
"wecom.unbindConfirm": "Clear local WeCom config and all WeCom bindings/sessions?",
"wecom.unbindDone": "WeCom unbound. identities={ident}, sessions={sess}",
"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.extraRoots": "Extra roots (| separated)",
"workspacePaths.fromDb": "Saved in database",
"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.load": "Load",
"workspacePaths.save": "Save",
"workspacePaths.selfOnly": "You can only view and edit your own workspace path policy (not other users).",
"workspacePaths.status": "Status",
"workspacePaths.tenant": "Team",
"workspacePaths.user": "User",
};

View file

@ -0,0 +1,774 @@
export default {
"action.refresh": "刷新",
"action.save": "保存",
"adminAudit.action": "动作",
"adminAudit.actor": "操作者",
"adminAudit.jump": "跳转",
"adminAudit.jumpPlaceholder": "页码",
"adminAudit.status": "状态",
"adminAudit.title": "管理操作审计",
"adminAudit.total": "共 {total} 条",
"apiGrants.forbidden": "仅 administrator 账号且具备团队写权限(或 owner 角色)可管理 API 使用权。",
"apiGrants.intro": "选择下方列表中的模型配置(profile),将「使用权」授予整个团队或指定用户。密钥与 Base URL 仍在「模型管理」中配置;成员获得可见性后,在「模型管理」里为各角色绑定 profile。",
"apiGrants.noShareable": "(暂无可分享的模型配置)",
"apiGrants.openModels": "前往模型管理",
"apiGrants.pickApi": "选择要授权的 API",
"apiGrants.teamHint": "授予后,租户内所有成员在其「选用配置」列表中可见该 API;端点与密钥仍在「模型管理」中维护。",
"apiGrants.teamSection": "授权给整个团队",
"apiGrants.userHint": "仅该用户在其「选用配置」列表中可见;对方在「模型管理」里自行绑定各角色。",
"apiGrants.userSection": "授权给指定用户",
"attachments.archiveMaxDepth": "压缩包最大嵌套深度",
"attachments.archiveMaxEntryBytes": "压缩包单文件最大解压字节",
"attachments.archiveMaxFileCount": "压缩包最大文件数",
"attachments.archiveMaxTotalBytes": "压缩包总解压字节上限",
"attachments.archivePolicyHint": "archive_processor 的统一预算:支持 zip/tar/tgz/gz,限制嵌套层级、文件数与解压体积。",
"attachments.card.archive": "压缩包预算策略",
"attachments.card.media": "图像与视频策略",
"attachments.card.policy": "策略说明",
"attachments.card.tableMode": "表格与工具模式",
"attachments.excelPolicy": "Excel 策略",
"attachments.excelPolicyHint": "针对 Excel/表格附件的解析与 SQL 查询保护策略。该配置写入 oclaw.json(优先级低于环境变量)。",
"attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?",
"attachments.imageReplayCapChars": "图片结果回放上限字符数",
"attachments.imageReplayCapHint": "历史轮次中,query_image_attachment 的 OCR/描述结果回放上限。范围 600..30000,默认 4000。",
"attachments.invalidNumber": "请输入有效正整数",
"attachments.largePreviewRows": "大表摘要预览行数",
"attachments.loadError": "加载失败",
"attachments.maxCellChars": "单元格最大字符数",
"attachments.maxColumns": "最大保留列数",
"attachments.maxExcelSheets": "Excel 最大 Sheet 数",
"attachments.maxRowsRead": "最大读取行数",
"attachments.resetDefaults": "恢复默认",
"attachments.save": "保存",
"attachments.saved": "已保存",
"attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。",
"attachments.sqlTimeoutMs": "SQL 超时(ms)",
"attachments.subtitle": "附件处理阈值、预算与回放策略配置",
"attachments.title": "附件",
"attachments.toc.archive": "压缩包",
"attachments.toc.media": "图像/视频",
"attachments.toc.overview": "说明",
"attachments.toc.table": "表格",
"attachments.toolModeEnabled": "启用大表工具模式",
"attachments.toolModeMaxBytes": "工具模式最大文件字节数",
"attachments.toolModeMinRows": "触发工具模式最小行数",
"attachments.videoReplayCapChars": "视频转写结果回放上限字符数",
"attachments.videoReplayCapHint": "历史轮次中,query_video_attachment 的 transcript 结果回放上限。范围 600..30000,默认 4000。",
"attachments.videoTranscriptChunkHint": "query_video_attachment(task=transcript) 默认使用该分块参数落库,便于后续 query_text_attachment 检索。",
"attachments.videoTranscriptChunkOverlap": "视频转写分块重叠",
"attachments.videoTranscriptChunkSize": "视频转写分块大小",
"audit.auditTable": "审计表",
"audit.columns": "显示列",
"audit.empty": "暂无数据",
"audit.note": "支持表格 + JSON 双视图。",
"audit.query": "查询",
"audit.rawJson": "原始 JSON",
"audit.result": "结果",
"audit.sessionIdPlaceholder": "session_id(可选)",
"audit.specialistSummaryHint": "专家侧工具执行与交付摘要(specialist -> core)",
"audit.title": "审计与追踪",
"audit.traceTable": "追踪表",
"auth.consoleUsernameHint": "填写用户名与密码;各菜单仍按账号权限显示。",
"auth.disabled": "账号已被禁用,请联系管理员",
"auth.invalid": "登录失败,请检查凭据",
"auth.login": "登录",
"auth.logout": "退出登录",
"auth.password": "密码",
"auth.username": "用户名",
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "任何 skill/tool/角色/提示词变更后,请立即预热;复杂变更可直接重启。系统每10分钟自动异步预热一次。",
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[预热] 后台执行中,完成后自动刷新本页数据(含提示词)…",
"boot.loadFailed": "无法加载管理台脚本",
"boot.loadFailedHint": "请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。",
"boot.loading": "正在加载管理台…",
"boot.loadingHint": "若长时间停留在此,请检查网络/代理是否拦截外部资源。",
"brand.title": "oliver",
"chat.empty": "暂无消息",
"chat.error": "请求失败",
"chat.loading": "加载中…",
"chat.newSession": "新会话",
"chat.noSessions": "暂无会话,点击新建开始。",
"chat.placeholder": "输入消息…",
"chat.send": "发送",
"chat.sending": "发送中…",
"chat.sessions": "会话",
"common.admin": "管理员",
"common.all": "全部",
"common.auto": "自动",
"common.blacklist": "黑名单",
"common.chinese": "中文",
"common.delete": "删除",
"common.deleteFailed": "删除失败",
"common.english": "英文",
"common.error": "错误",
"common.forbidden": "无权访问该页面。",
"common.mode": "模式",
"common.notFound": "未找到页面",
"common.notLoggedIn": "未登录或会话已过期,请重新登录。",
"common.pageRemoved": "该管理页已在产品精简中移除。",
"common.remove": "删除",
"common.requestFailed": "操作失败",
"common.selectType": "请选择类型",
"common.update": "修改",
"common.updateFailed": "修改失败",
"common.whitelist": "白名单",
"lang.switch": "English",
"memory.applyFilters": "应用筛选",
"memory.avgScore": "平均得分",
"memory.backend": "后端",
"memory.cleanup": "清理低置信记忆",
"memory.clearFilters": "清空筛选",
"memory.config": "向量配置",
"memory.delete": "删除",
"memory.enabled": "开启向量检索",
"memory.filters": "筛选",
"memory.hitCount": "命中总数",
"memory.hits": "最近命中",
"memory.itemCount": "记忆条目数",
"memory.items": "记忆条目",
"memory.minConfidence": "最小写入置信度",
"memory.noData": "暂无记忆数据",
"memory.openPlugins": "打开插件页",
"memory.reindex": "重建向量索引",
"memory.save": "保存配置",
"memory.stats": "统计",
"memory.tenantId": "团队 ID / tenant_id(可选)",
"memory.title": "记忆治理",
"memory.topSources": "最近来源",
"memory.topk": "TopK",
"memory.userId": "user_id(可选)",
"memory.wikiCard": "Wiki",
"memory.wikiPluginFound": "已安装:{name} v{version}",
"memory.wikiPluginMissing": "未检测到 memory-wiki 插件",
"memory.wikiStatus": "插件状态",
"memory.writerEnabled": "开启自动写入",
"models.agentBindingHelp": "可为不同角色指定模型配置;留空则跟随上方全局选用。",
"models.apiKey": "API Key",
"models.baseUrl": "Base URL",
"models.baseUrlPlaceholder": "可选,留空用环境默认",
"models.bindingsExtraHint": "被定向授权或团队共享的 API 会出现在上方「选用配置」列表中;成员需在此为各角色自行选择要绑定的配置。",
"models.bindingsScopeHint": "选用与绑定下拉仅含:内置、您自建的配置,以及管理员授权给团队或您个人的配置(与当前账号可见列表一致)。",
"models.builtinLocked": "内置 Ollama 配置:模式固定为 Ollama,可改地址与模型名。",
"models.cannotDeleteBuiltin": "无法删除内置 Ollama 配置。",
"models.chatModelSelector": "聊天页模型选择器",
"models.chatModelSelectorHint": "控制 Chat 页底部的模型下拉是否显示。",
"models.createBtn": "创建",
"models.createNamePlaceholder": "配置显示名",
"models.dbPath": "当前使用的数据库文件",
"models.delete": "删除此配置",
"models.deleteConfirm": "确定删除该模型配置?此操作不可撤销。",
"models.evalDownloadCsv": "下载全量 CSV",
"models.evalDownloadJson": "下载全量 JSON",
"models.evalLogs": "最近评估日志",
"models.evalLogsPreview": "页内预览",
"models.evalP95": "P95 延迟",
"models.evalSuccess": "成功率",
"models.evalToggle": "Agent Evaluation · 点击展开(页内预览最多 100 条)",
"models.evalTotal": "样本数",
"models.grantTenantBtn": "全团队可用",
"models.grantsEmpty": "尚无定向授权用户。",
"models.grantsGrantBtn": "授权",
"models.grantsHint": "仅 administrator 可管理。被授权用户可在其模型列表中看到并选用此配置(共用密钥)。",
"models.grantsNavHint": "管理谁能看到并选用可分享的模型配置(不含在此页改密钥或端点)。",
"models.grantsRevoke": "撤销",
"models.grantsTitle": "授权其他用户使用当前配置",
"models.grantsUserHint": "定向授权给单个用户:",
"models.labelApiProfile": "API配置",
"models.labelSpecialist": "专家",
"models.linkApiGrants": "打开 API 使用授权",
"models.loadFailed": "加载失败,请展开下方错误信息。",
"models.membersTitle": "各成员可见的模型配置",
"models.mode": "模式",
"models.mode.anthropic": "Anthropic",
"models.mode.google": "Google (Gemini)",
"models.mode.ollama": "Ollama",
"models.mode.openai": "OpenAI 兼容",
"models.mode.openai_responses": "OpenAI Responses",
"models.mode.rule": "规则(无 LLM)",
"models.model": "模型名",
"models.newProfileDefault": "新配置",
"models.noEvalLogs": "暂无评估记录",
"models.noProfiles": "暂无模型配置数据。",
"models.ollamaHint": "Ollama 本地默认 http://127.0.0.1:11434;一般无需 API Key。",
"models.openaiKeyHint": "未配置密钥时,将尝试使用环境变量 OPENAI_API_KEY。",
"models.opsAiHint": "仅用于 v1 / 专家模式:选择专家并绑定 API 配置。",
"models.opsAiTitle": "内部 API模型配置",
"models.phNameZh": "中文名(可选)",
"models.pickModel": "选用配置",
"models.profileName": "配置名称",
"models.profileReadonlyHint": "当前配置为管理员授权,仅可选用与对话,不能改名称、端点或密钥。",
"models.readonly": "只读:当前账号无团队数据写权限(admin:tenant:write)。",
"models.reasoningEffort": "Reasoning 强度",
"models.reasoningEffortDefault": "默认",
"models.reasoningEffortHigh": "高",
"models.reasoningEffortLow": "低",
"models.reasoningEffortMedium": "中",
"models.rememberKey": "记住密钥(存库加密)",
"models.revokeTenantGrant": "撤销全团队",
"models.role.generalist": "通用",
"models.role.image": "图像",
"models.role.memory": "记忆整理",
"models.role.ops": "网络运维",
"models.save": "保存",
"models.sectionActive": "当前模型配置",
"models.sectionApi": "API 与密钥",
"models.sectionBindings": "智能体与模型绑定",
"models.sectionEval": "Agent Evaluation",
"models.sectionNew": "新建模型配置",
"models.subtitle": "模型切换、绑定与评估配置",
"models.tenantGrantOff": "全团队:未授权",
"models.tenantGrantOn": "全团队:已授权",
"models.thinkMode": "Think 模式(仅当前 API)",
"models.thinkModeHint": "开启后仅对此配置回放完整 reasoning_content(默认关闭)",
"models.toc.api": "API配置",
"models.toc.bindings": "绑定",
"models.toc.eval": "评估",
"models.toc.opsAi": "内部API",
"models.toc.overview": "概览",
"models.useGlobal": "(跟随全局)",
"models.vis.builtin": "内置",
"models.vis.global": "全局",
"models.vis.grantTenant": "团队共享",
"models.vis.grantUser": "定向授权",
"models.vis.otherUser": "他人配置",
"models.vis.owned": "我的",
"models.warnKeyInDb": "数据库中已有密钥:若输入框为空,保存时不会覆盖(请先填入新密钥或取消勾选「记住密钥」以清空)。",
"nav.adminAudit": "管理审计",
"nav.apiGrants": "API 使用授权",
"nav.attachments": "附件",
"nav.audit": "审计与追踪",
"nav.chat": "对话",
"nav.group.governance": "治理与审计",
"nav.group.models": "模型与插件",
"nav.group.system": "系统与运行",
"nav.group.users": "用户与配置",
"nav.memory": "记忆",
"nav.models": "模型管理",
"nav.plugins": "插件",
"nav.profile": "用户信息",
"nav.scheduledJobs": "定时任务",
"nav.sessionMonitor": "会话监控",
"nav.skills": "技能",
"nav.stack": "运行时",
"nav.users": "用户管理",
"nav.workspacePaths": "工作区路径",
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
"plugins.colBoundMcp": "绑定 MCP 数",
"plugins.colSpecialist": "专家",
"plugins.colTools": "tools 数(各 server 累加)",
"plugins.fold.bindingDash": "【4】专家 MCP 绑定看板(自动)",
"plugins.fold.bindingDashHint": "按当前绑定草稿与已安装 MCP 的 tools 列表汇总;专家列表来自 SPECIALISTS 与绑定 mapping 键,随扩展自动增减。",
"plugins.fold.bindingEdit": "【5】MCP 专家绑定(编辑)",
"plugins.fold.install": "【2】从 Cursor JSON 安装",
"plugins.fold.installCursor": "【2】从 Cursor JSON 安装",
"plugins.fold.installCursorHint": "粘贴含 mcpServers 的 Cursor mcp.json,点 Install。Export 下载同结构以便迁移。",
"plugins.fold.installHint": "粘贴 Cursor mcpServers JSON,然后安装。",
"plugins.fold.installed": "【3】已安装 MCP 服务({n})",
"plugins.fold.migrateHint": "安装/改配置/卸载成功后自动写入 oclaw/_local/mcp_registry_migrated.json(Cursor mcpServers),便于换机迁移。",
"plugins.fold.toolPolicy": "【1】工具策略与已注册插件({n})",
"plugins.fold.toolPolicyHint": "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表",
"plugins.nextPage": "下一页",
"plugins.noData": "无数据",
"plugins.pageRange": "第 {start}–{end} / 共 {total} 条",
"plugins.prevPage": "上一页",
"plugins.subtitle": "按功能分区展示,默认折叠;表格支持分页(每页 {n} 条)。",
"plugins.title": "工具插件",
"plugins.toc.binding": "专家绑定",
"plugins.toc.install": "安装",
"plugins.toc.instances": "实例",
"plugins.toc.policy": "策略",
"profile.avatar": "头像",
"profile.avatarHint": "支持 PNG、JPEG、WebP、GIF,最大 2MB。",
"profile.chatPrefs": "对话偏好",
"profile.chooseImage": "选择图片",
"profile.createdAt": "注册时间",
"profile.displayName": "显示名称",
"profile.help": "在此调整显示名称、头像及账号标识;对话页中头像为圆形,助手使用站点 Logo。",
"profile.loadError": "加载失败",
"profile.memoryCurator": "记忆整理专家",
"profile.memoryCuratorDisabled": "停用",
"profile.memoryCuratorEnabled": "启用",
"profile.memoryMode": "记忆模式",
"profile.memoryModeDefault": "默认(可注入)",
"profile.memoryModeStoreOnly": "仅记录不注入",
"profile.openChat": "打开对话",
"profile.removeAvatar": "移除头像",
"profile.role": "角色",
"profile.save": "保存设置",
"profile.saved": "已保存",
"profile.tenantId": "团队 ID",
"profile.uploadError": "上传失败",
"profile.userId": "用户 ID",
"profile.username": "用户名",
"scheduledJobs.all": "全部",
"scheduledJobs.attentionTitle": "需关注任务",
"scheduledJobs.cancel": "取消",
"scheduledJobs.colActions": "操作",
"scheduledJobs.colDelivery": "投递",
"scheduledJobs.colError": "错误",
"scheduledJobs.colFailClass": "分类",
"scheduledJobs.colFinished": "结束时间",
"scheduledJobs.colJob": "任务",
"scheduledJobs.colLastRun": "上次运行",
"scheduledJobs.colName": "名称",
"scheduledJobs.colNextRun": "下次运行",
"scheduledJobs.colPlaybook": "剧本",
"scheduledJobs.colRunStatus": "运行状态",
"scheduledJobs.colSchedule": "计划",
"scheduledJobs.colSpecialist": "专家",
"scheduledJobs.colStatus": "状态",
"scheduledJobs.count": "{count} 个任务",
"scheduledJobs.create": "创建",
"scheduledJobs.createTitle": "新建任务",
"scheduledJobs.delete": "删除",
"scheduledJobs.deleteConfirm": "确定删除该定时任务?",
"scheduledJobs.deliveryWeixin": "微信",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"scheduledJobs.edit": "编辑",
"scheduledJobs.editTitle": "编辑任务",
"scheduledJobs.failClass.auth": "权限",
"scheduledJobs.failClass.cancelled": "取消",
"scheduledJobs.failClass.delivery": "投递",
"scheduledJobs.failClass.mcp": "MCP/工具",
"scheduledJobs.failClass.overlap": "叠跑跳过",
"scheduledJobs.failClass.runtime": "运行时",
"scheduledJobs.failClass.stale": "僵死清理",
"scheduledJobs.failClass.timeout": "超时",
"scheduledJobs.fieldExpr": "表达式",
"scheduledJobs.fieldKind": "类型",
"scheduledJobs.fieldName": "名称",
"scheduledJobs.fieldPrompt": "提示词",
"scheduledJobs.fieldSchedule": "计划",
"scheduledJobs.fieldSpecialist": "专家",
"scheduledJobs.fieldWhatsapp": "WhatsApp chat_id",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.menuTitle": "任务操作",
"scheduledJobs.noRuns": "暂无运行记录",
"scheduledJobs.pause": "暂停",
"scheduledJobs.phExpr": "cron / ISO 时间 / 秒数",
"scheduledJobs.phName": "任务名称",
"scheduledJobs.phPrompt": "到期时要执行的提示词",
"scheduledJobs.phSpecialist": "例如 generalist",
"scheduledJobs.phWhatsapp": "可选,群或私聊 chat_id",
"scheduledJobs.playbookNo": "提醒",
"scheduledJobs.playbookPartial": "有 recipe · {n} 步",
"scheduledJobs.playbookYes": "playbook · {n} 步",
"scheduledJobs.requiredFields": "名称、提示词、表达式为必填",
"scheduledJobs.resume": "恢复",
"scheduledJobs.runHistory": "运行记录",
"scheduledJobs.runHistoryHint": "最近运行(自动刷新)",
"scheduledJobs.runNow": "立即运行",
"scheduledJobs.skippedOverlap": "已跳过(叠跑)",
"scheduledJobs.statsClasses": "失败分类",
"scheduledJobs.statsFailRate": "近窗失败率 {pct}%(失败 {failed} / 完成 {finished})",
"scheduledJobs.statsJobsFailed": "上次失败任务 {n}",
"scheduledJobs.statsNone": "近窗无失败",
"scheduledJobs.statsTitle": "失败看板",
"scheduledJobs.statusActive": "运行中",
"scheduledJobs.statusPaused": "已暂停",
"scheduledJobs.triggered": "已触发",
"scheduledJobs.updated": "已保存",
"scheduledJobs.viewRuns": "查看运行记录",
"scheduledJobs.weixinFixed": "微信固定投递:{id}",
"scheduledJobs.weixinMissing": "未找到微信绑定(需管理员渠道身份)",
"secrets.migrateBtn": "立即迁移",
"secrets.migrateDone": "迁移完成:settings={s},profiles={p}",
"secrets.migrateForbidden": "需要 admin:tenant:write 权限。",
"secrets.migrateHint": "检测到旧版 b64 编码的密钥(弱保护)。建议设置 AIA_ASSISTANT_MASTER_KEY 后一键迁移到更安全的加密格式。",
"secrets.migrateNeedKey": "未配置 AIA_ASSISTANT_MASTER_KEY,无法迁移。",
"secrets.migrateNoop": "未发现需要迁移的旧密钥。",
"secrets.migrateTitle": "密钥加密迁移",
"sessionMonitor.activeLogins30m": "活跃登录(30m)",
"sessionMonitor.activeSessions30m": "活跃会话(30m)",
"sessionMonitor.closeDetail": "关闭详情",
"sessionMonitor.exportJson": "导出 JSON",
"sessionMonitor.exportMd": "导出 MD",
"sessionMonitor.filterSession": "筛选会话",
"sessionMonitor.filterUser": "筛选用户",
"sessionMonitor.messages": "消息明细",
"sessionMonitor.noMessages": "该会话暂无消息。",
"sessionMonitor.onlyAdministrator": "仅 administrator 账号可访问该页面。",
"sessionMonitor.pageInfo": "第 {page} 页 / 共 {totalPages} 页",
"sessionMonitor.pageNext": "下一页",
"sessionMonitor.pagePrev": "上一页",
"sessionMonitor.roleAll": "全部",
"sessionMonitor.roleAssistant": "助手",
"sessionMonitor.roleFilter": "角色过滤",
"sessionMonitor.roleTool": "工具",
"sessionMonitor.roleUser": "用户",
"sessionMonitor.selectUserFirst": "请先选择一个用户查看会话。",
"sessionMonitor.sessions": "会话明细",
"sessionMonitor.totalTokensEst": "总 Token(估算)",
"sessionMonitor.totals": "总览统计",
"sessionMonitor.userStats": "用户统计",
"sessionMonitor.usersCount": "用户数",
"sessionMonitor.viewAudit": "查看审计",
"sessionMonitor.viewDetail": "查看详情",
"skills.action.cancel": "取消",
"skills.action.clearCache": "清除缓存",
"skills.action.close": "关闭",
"skills.action.create": "创建技能",
"skills.action.detail": "详情",
"skills.action.disable": "禁用",
"skills.action.enable": "启用",
"skills.action.exportSelfCheck": "导出工具自检 JSON",
"skills.action.exportSkillHealth": "导出技能自检 JSON",
"skills.action.failedOnly": "仅失败项",
"skills.action.install": "安装",
"skills.action.installLocal": "从本地目录安装",
"skills.action.installRegistry": "从 Registry 安装",
"skills.action.installing": "安装中...",
"skills.action.latest": "最新",
"skills.action.menuTitle": "技能操作",
"skills.action.preview": "预览",
"skills.action.refresh": "刷新",
"skills.action.repairAllDeps": "修复全部依赖",
"skills.action.repairDeps": "修复依赖",
"skills.action.repairing": "修复中...",
"skills.action.retry": "重试",
"skills.action.retryableOnly": "仅可重试失败",
"skills.action.run": "运行",
"skills.action.runSelfCheck": "运行工具自检",
"skills.action.runSkillHealth": "运行技能自检",
"skills.action.save": "保存",
"skills.action.saveBinding": "保存技能角色绑定",
"skills.action.saveTrace": "保存 trace 设置",
"skills.action.search": "搜索",
"skills.action.testRun": "试跑...",
"skills.action.testRunArgs": "带参数试跑...",
"skills.action.toggleRawWired": "切换 raw/wired",
"skills.action.traceExposure": "Trace exposure plan",
"skills.action.uninstall": "卸载",
"skills.col.action": "操作",
"skills.col.code": "code",
"skills.col.count": "count",
"skills.col.description": "描述",
"skills.col.detail": "详情",
"skills.col.details": "details",
"skills.col.directBind": "直接绑定",
"skills.col.directSkills": "直接技能数",
"skills.col.docsOnlyNames": "docs-only 名称",
"skills.col.effective": "生效",
"skills.col.enabled": "启用",
"skills.col.errorCode": "error_code",
"skills.col.health": "健康",
"skills.col.internal": "internal",
"skills.col.kind": "类型",
"skills.col.mcpConverted": "MCP 转换",
"skills.col.mode": "模式",
"skills.col.name": "名称",
"skills.col.namesPreview": "名称预览",
"skills.col.otherTools": "其他工具",
"skills.col.outputTruncated": "output_truncated",
"skills.col.parameters": "参数",
"skills.col.path": "路径",
"skills.col.raw": "raw",
"skills.col.readOnly": "只读",
"skills.col.removedMcp": "移除 MCP",
"skills.col.retry": "重试",
"skills.col.risk": "风险",
"skills.col.role": "角色",
"skills.col.runtimeEntry": "运行入口",
"skills.col.skill": "技能",
"skills.col.slug": "slug",
"skills.col.source": "来源",
"skills.col.status": "状态",
"skills.col.tags": "标签",
"skills.col.target": "目标",
"skills.col.timestamp": "时间",
"skills.col.totalEffective": "生效总数",
"skills.col.type": "类型",
"skills.col.version": "版本",
"skills.col.wired": "wired",
"skills.col.workspace": "工作区",
"skills.col.workspaceDocsOnly": "工作区 docs-only",
"skills.col.workspaceResolved": "工作区已解析",
"skills.confirm.uninstall": "卸载技能「{name}」?",
"skills.docsTitle": "oclaw 文档快捷入口:",
"skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md(trace 字段与阶段)",
"skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md(安装与运行排障)",
"skills.fold.audit": "安装审计",
"skills.fold.binding": "【4】技能角色绑定",
"skills.fold.bindingDash": "角色绑定看板",
"skills.fold.bindingHint": "启用后按角色过滤技能目录:各角色仅见已绑定技能,以及 skills/_workspace/public/ 下的技能。未绑定的非 public 技能会被隐藏。",
"skills.fold.create": "创建技能",
"skills.fold.diagnostics": "【5】诊断",
"skills.fold.effective": "生效技能(绑定 + MCP + 工具)",
"skills.fold.health": "技能健康自检",
"skills.fold.install": "【2】安装",
"skills.fold.installHint": "从 ClawHub 市场、本地目录 / Registry 归档安装,或新建技能。",
"skills.fold.installed": "【3】已安装技能({n})",
"skills.fold.internalTools": "内部工具预览",
"skills.fold.llmTools": "LLM 工具预览",
"skills.fold.localRegistry": "本地目录 / Registry",
"skills.fold.market": "ClawHub 市场",
"skills.fold.mode": "【1】模式",
"skills.fold.modeHint": "Prompt 模式会把 SKILL.md 注入系统提示。市场提供方固定为 ClawHub。",
"skills.fold.toolsSelfCheck": "工具自检",
"skills.hint.bindingEnv": "进程环境变量 AIA_SKILL_ROLE_BINDING_ENABLED 已设置:运行时会覆盖 Admin/SQLite 值。复选框显示生效(env)值。请在 oclaw 进程上取消该变量以仅使用 Admin。(库中存储:{stored},生效:{effective})。",
"skills.hint.bindingPersist": "勾选与各角色技能列表需点击「保存技能角色绑定」后才会写入。",
"skills.hint.diffDiag": "差异 / 诊断",
"skills.hint.expandToLoad": "展开此分区以加载预览",
"skills.hint.expandToSelfCheck": "展开此分区以运行自检",
"skills.hint.internalTools": "按角色预览动态加载的内置工具(public 共享 + 专家作用域)。",
"skills.hint.llmTools": "按角色预览最终注入模型的工具(内部 + MCP + role_mode + MCP 专家绑定)。",
"skills.hint.noSkills": "尚无已安装技能。",
"skills.hint.rawWiredDiff": "raw vs wired 差异(added/removed/changed)",
"skills.hint.skillHealth": "运行可执行技能诊断并展示失败码分布。",
"skills.hint.skippedModules": "跳过的模块 / 工厂错误",
"skills.hint.testRunArgs": "传入可执行技能运行时的 JSON 参数。",
"skills.hint.toolsSelfCheck": "一次性诊断各角色的 internal/wired 工具、角色模式与永久封禁。",
"skills.hintLocalDir": "Local dir: 目录内必须包含 SKILL.md(支持绝对路径)。",
"skills.hintRegistry": "Registry: zip/tar 的 URL(支持 file:// 本地 URI)。",
"skills.label.enableBinding": "启用角色绑定(AIA_SKILL_ROLE_BINDING_ENABLED)",
"skills.label.promptMode": "Prompt 模式(注入 SKILL.md)",
"skills.label.role": "角色",
"skills.modal.install": "技能安装",
"skills.modal.installFail": "技能安装失败",
"skills.modal.installOk": "技能安装成功",
"skills.modal.testRun": "技能试跑",
"skills.modal.testRunNamed": "试跑:{name}",
"skills.ph.body": "SKILL.md 正文 markdown",
"skills.ph.description": "描述",
"skills.ph.limit": "条数",
"skills.ph.localDir": "本地技能目录(含 SKILL.md)",
"skills.ph.marketSearch": "搜索技能(关键词)",
"skills.ph.name": "技能名",
"skills.ph.registry": "Registry 归档 URL(zip/tar)",
"skills.subtitle": "模式、安装、已安装列表、角色绑定与诊断 — 默认折叠。",
"skills.title": "技能",
"skills.toc.binding": "绑定",
"skills.toc.diagnostics": "诊断",
"skills.toc.install": "安装",
"skills.toc.installed": "已安装",
"skills.toc.mode": "模式",
"stack.alerts": "异常告警",
"stack.allRoles": "全部角色",
"stack.cleanup": "清理异常进程",
"stack.cleanupDone": "清理完成,已终止异常进程数:{count}",
"stack.cleanupNone": "未发现可清理的异常进程。",
"stack.clearByPolicy": "按策略清理",
"stack.clearScanCache": "清理扫描缓存",
"stack.configChangedPrewarm": "配置已变更,请立即预热(或重启)",
"stack.down": "停止栈",
"stack.missingRequired": "关键服务未运行,请先启动栈。",
"stack.missingServices": "缺失服务:{services}",
"stack.mustCleanup": "检测到重复 worker,必须先清理再继续操作。",
"stack.noAlerts": "未发现异常。",
"stack.prewarmHistory": "预热历史(最近20次)",
"stack.prewarmNow": "立即预热",
"stack.prewarmSubmitting": "[预热] 提交中…",
"stack.prewarmTitle": "提示词/工具预热",
"stack.prewarmedPrompts": "预热后提示词(按专家)",
"stack.prewarmedPromptsHint": "展示各专家预热后的提示词内容。",
"stack.reloadPrompts": "刷新提示词",
"stack.roleFilter": "角色筛选",
"stack.scanCacheSubtitle": "{dir} · 仅清理 history_entries_*.json 与 state_scan_*.json",
"stack.scanCacheTitle": "扫描缓存文件",
"stack.title": "服务栈",
"stack.up": "启动栈",
"status.running": "运行中",
"status.stopped": "已停止",
"table.action": "动作",
"table.activeLogins30m": "活跃登录(30m)",
"table.activeSessions30m": "活跃会话(30m)",
"table.channel": "渠道",
"table.createdAt": "创建时间",
"table.durationMs": "耗时(ms)",
"table.enabled": "启用",
"table.entryPoint": "入口点",
"table.eventType": "事件类型",
"table.externalUserId": "外部用户ID",
"table.id": "ID",
"table.inputAttachmentIds": "输入附件IDs",
"table.name": "名称",
"table.noOutputAttachment": "无输出附件",
"table.outputAttachmentIds": "输出附件IDs",
"table.outputAttachmentUrls": "输出附件URLs",
"table.parentSpanId": "parent_span_id",
"table.payload": "payload",
"table.pid": "进程号",
"table.provider": "提供方",
"table.reason": "原因",
"table.routeAttachmentIds": "路由附件IDs",
"table.service": "服务",
"table.sessionsCount": "会话数",
"table.spanId": "span_id",
"table.specialist": "专家",
"table.status": "状态",
"table.statusText": "状态",
"table.timestamp": "时间",
"table.totalTokensEst": "总 Token(估算)",
"table.traceId": "trace_id",
"table.user": "用户",
"table.userId": "用户ID",
"table.username": "用户名",
"table.version": "版本",
"tenants.allTenantsHint": "多团队时:「登录所属」仅作提示;实际操作范围以「选择团队」为准。",
"tenants.bindCodes": "绑定码",
"tenants.bindings": "渠道绑定",
"tenants.chatLogin": "Web 登录",
"tenants.chatLoginNo": "否",
"tenants.chatLoginYes": "可(已设密码)",
"tenants.codeDeleteConfirm": "确认删除该绑定码?",
"tenants.codeDeleted": "已删除绑定码:{code}",
"tenants.codeOptional": "Code(可留空自动生成)",
"tenants.codeStatus": "状态",
"tenants.codeUnused": "未使用",
"tenants.codeUsed": "已使用",
"tenants.codeUsedBy": "绑定人",
"tenants.colScope": "登录标识",
"tenants.colTenantId": "团队 ID",
"tenants.colTenantName": "团队名",
"tenants.create": "新建团队",
"tenants.createCode": "生成绑定码",
"tenants.createPlaceholder": "团队名称(默认 Team)",
"tenants.deleteCode": "删除",
"tenants.deleteDisabledCurrent": "不能删除当前登录会话所属团队;请先在登录页使用另一团队的 UUID 登录后再删。",
"tenants.deleteDisabledLast": "至少需保留一个团队。",
"tenants.deleteTenant": "删除团队",
"tenants.deleteTenantConfirm": "确定删除团队「{name}」?将级联删除该团队下用户、绑定与数据,且不可恢复。",
"tenants.deleteUnbound": "删除未绑定用户",
"tenants.deleteUnboundResult": "未绑定用户清理:deleted={deleted}, orphan={orphan}, bound={bound}, total={total}",
"tenants.deleted": "已删除未绑定用户数:{count}",
"tenants.errCannotDeleteCurrentTenant": "不能删除当前登录会话所属团队,请先切换到其他团队再删,或使用其他账号。",
"tenants.errLastTenant": "至少保留一个团队,无法删除。",
"tenants.errTenantDeleteMiss": "删除未生效(可能数据库约束失败),请查看服务端日志。",
"tenants.noBindings": "该团队暂无渠道绑定。",
"tenants.noSessionTenant": "缺少会话团队信息,请重新登录。",
"tenants.noTenants": "暂无团队数据。请先通过绑定流程或初始化脚本创建团队。",
"tenants.noUsers": "该团队暂无用户。",
"tenants.role": "角色",
"tenants.rowActions": "操作",
"tenants.saved": "配置已保存",
"tenants.scopeHint": "「选择团队」决定下方用户/渠道绑定/绑定码所针对的团队。administrator 账号可切换并管理任意团队。上表最后一列标出登录会话最初绑定的团队。",
"tenants.scopeLoginHome": "登录所属",
"tenants.select": "选择团队",
"tenants.sessionTenantMissing": "当前登录会话对应的团队不在列表中,请重新登录或联系管理员。",
"tenants.sourceChannel": "其他渠道",
"tenants.sourceConsole": "控制台",
"tenants.sourceWecom": "企业微信",
"tenants.title": "团队",
"tenants.userSource": "来源",
"tenants.users": "用户",
"theme.catppuccin": "Catppuccin Mocha",
"theme.deepseek": "DeepSeek",
"theme.dracula": "Dracula",
"theme.forest": "森绿",
"theme.github": "GitHub 暗色",
"theme.label": "配色",
"theme.light": "浅色",
"theme.netx": "NetX 暗色",
"theme.nord": "Nord",
"title.adminAudit": "管理审计",
"title.apiGrants": "API 使用授权",
"title.attachments": "附件",
"title.audit": "审计与追踪",
"title.memory": "记忆",
"title.models": "模型管理",
"title.plugins": "插件",
"title.profile": "用户信息",
"title.scheduledJobs": "定时任务",
"title.sessionMonitor": "会话监控",
"title.skills": "技能",
"title.stack": "运行时",
"title.users": "用户管理",
"title.workspacePaths": "工作区路径(按用户)",
"users.accountActions": "账号操作",
"users.accountActive": "启用",
"users.accountDelete": "删除账号",
"users.accountId": "账号ID",
"users.accountLoadHint": "选中用户后,表格列出绑定与实例;需编辑时点「填入表单」,再填写 Bot Secret 等并保存(不会自动填入表单)。",
"users.accountModeExpert": "当前账号模式:专家({specialist})",
"users.accountName": "名称",
"users.accountSave": "保存账号",
"users.botIdRequired": "请先填写 Bot ID",
"users.clearBotSecret": "清除Bot密钥",
"users.colActive": "启用",
"users.colSetRole": "调整角色",
"users.colUser": "用户",
"users.create": "创建用户",
"users.createNamePlaceholder": "名称(登录名,唯一)",
"users.createNameRequired": "请填写名称。",
"users.delete": "删除",
"users.deleteConfirm": "确定删除该用户?不可恢复。",
"users.disable": "禁用",
"users.enable": "启用",
"users.hasBotSecret": "已配Bot密钥",
"users.loadForm": "填入表单",
"users.needUserRead": "需要 admin:user:read 权限以查看与编辑用户列表。",
"users.noBindingsForUser": "暂无企微绑定或已保存实例;用户在微信侧绑定后会出现行,填齐参数后保存。",
"users.noTenant": "请先登录并选择团队",
"users.passwordOptional": "新密码(可选)",
"users.search": "搜索用户",
"users.title": "用户管理",
"users.update": "更新",
"users.wecomAccounts": "企微实例(每用户多个,具名)",
"users.wecomBotId": "Bot ID",
"users.wecomBotSecret": "Bot Secret",
"users.wecomInstanceName": "实例名",
"users.wecomUserEmpty": "请在上表点击一行选中用户",
"users.wecomUserLabel": "用户",
"wa.accessHint": "默认黑名单模式:未授权用户进入待审批;拒绝后进入黑名单/已拒绝列表,可一键加白名单。",
"wa.accessTitle": "WhatsApp 访问控制",
"wa.addContact": "添加联系人",
"wa.addFailed": "添加失败",
"wa.addWhitelist": "加白名单",
"wa.alertGroupHint": "NetX 关键告警将推送到此群;与 Chat 会话删除无关。",
"wa.alertGroupTitle": "WhatsApp 告警群绑定",
"wa.approve": "通过",
"wa.bindFailed": "绑定失败",
"wa.bindSpecialist": "绑定专家",
"wa.blacklistMode": "黑名单模式(默认拒绝,白名单放行)",
"wa.channelExpertOnly": "通道统一走专家模式;选择默认专家即可。",
"wa.colAction": "操作",
"wa.colDeniedAt": "拒绝时间",
"wa.colMessage": "消息",
"wa.colName": "名称",
"wa.colPhone": "电话",
"wa.colTime": "时间",
"wa.colType": "类型",
"wa.contactNotFound": "未找到可删除的联系人",
"wa.deniedHint": "点「拒绝」后用户会进入黑名单联系人,并保留在此列表;可一键加白名单恢复访问。",
"wa.deniedTitle": "已拒绝 / 黑名单记录",
"wa.deny": "拒绝",
"wa.filter": "筛选",
"wa.lang": "语言",
"wa.messageLang": "提示语言",
"wa.mode": "模式",
"wa.namePlaceholder": "显示名(可选)",
"wa.noAlertGroup": "尚未绑定告警群",
"wa.noContacts": "暂无联系人",
"wa.noneDenied": "无已拒绝记录",
"wa.nonePending": "无待审批",
"wa.pendingTitle": "待审批请求",
"wa.phonePlaceholder": "电话,如 +8615601877957",
"wa.pickPending": "从待审批选择联系人…",
"wa.saveBinding": "保存绑定",
"wa.saveConfig": "保存配置",
"wa.selectGroup": "选择群…",
"wa.specialist": "专家",
"wa.testPush": "测试推送",
"wa.whitelistMode": "白名单模式(默认放行,黑名单拒绝)",
"wecom.autoBind": "首条消息自动绑定",
"wecom.autoBindRole": "自动绑定角色",
"wecom.autoBindTenant": "自动绑定团队",
"wecom.botId": "Bot ID",
"wecom.botSecret": "Bot Secret",
"wecom.title": "企业微信配置",
"wecom.unbind": "解绑 WeCom",
"wecom.unbindConfirm": "将清空本地 WeCom 配置和全部 WeCom 绑定/会话,确认继续?",
"wecom.unbindDone": "已解绑 WeCom。identity={ident},session={sess}",
"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.extraRoots": "额外根路径(| 分隔)",
"workspacePaths.fromDb": "已保存到数据库",
"workspacePaths.help": "为每个用户配置除主工作区(AIA_WORKSPACE_ROOT / 项目根)外可访问的绝对路径,用竖线 | 分隔,例如 D:\\\\|E:\\\\repos\\\\other。与网关环境变量 AIA_WORKSPACE_EXTRA_ROOTS 合并生效。下方「额外根路径」会参与内置工具校验,并在用户聊天时合并进官方 MCP @modelcontextprotocol/server-filesystem 的启动参数(仅该用户)。",
"workspacePaths.load": "加载",
"workspacePaths.save": "保存",
"workspacePaths.selfOnly": "当前账号仅能查看并编辑自己的工作区路径策略(不能代他人配置)。",
"workspacePaths.status": "状态",
"workspacePaths.tenant": "团队",
"workspacePaths.user": "用户",
};

View file

@ -0,0 +1,54 @@
import { state, t, el, mount, toggleLang, applyI18nStatic, resolveChatUrl, apiPost, authStoreGet, authStoreSet, authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY } from "./core.js";
import { router } from "./router.js";
state.navigate = router;
state.reauthHandler = () => router().catch(() => {});
applyI18nStatic();
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);
state.authSession = null;
await router();
});
try {
state.authSession = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "null");
} catch (_) {
state.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) })]));
});

View file

@ -0,0 +1,229 @@
import { t, el, apiGet, apiPost, renderPageShell, renderSectionCard } from "../core.js";
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: t("attachments.subtitle"),
actions: [loadBtn, resetBtn, saveBtn],
sections: [
{ id: "attach-overview", label: t("attachments.toc.overview") },
{ id: "attach-table", label: t("attachments.toc.table") },
{ id: "attach-media", label: t("attachments.toc.media") },
{ id: "attach-archive", label: t("attachments.toc.archive") },
],
}, [
renderSectionCard(t("attachments.card.policy"), t("attachments.excelPolicy"), [hint], { id: "attach-overview" }),
el("div", { class: "page-grid page-grid--two" }, [
renderSectionCard(t("attachments.card.tableMode"), "", [
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(t("attachments.card.media"), "", [
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(t("attachments.card.archive"), "", [
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" }),
]);
}
export { renderAttachments };

View file

@ -0,0 +1,416 @@
import { t, el, tdCell, apiGet, formatIds, yesNo } from "../core.js";
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: t("audit.specialistSummaryHint") }),
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]);
}
export { renderAudit };

View file

@ -0,0 +1,32 @@
import { state } from "../core.js";
function hasPermission(permission) {
const role = String((state.authSession && state.authSession.role) || "");
const perms = new Set(Array.isArray(state.authSession && state.authSession.permissions) ? state.authSession.permissions : []);
if (role === "owner") return true;
return perms.has(permission);
}
/** 与 models_api._can_manage_llm_grants 一致:仅控制台 administrator 用户可管理授权 */
function canManageApiGrants() {
const un = String((state.authSession && state.authSession.username) || "").trim().toLowerCase();
if (un !== "administrator") return false;
const role = String((state.authSession && state.authSession.role) || "").trim();
if (role === "owner") return true;
return hasPermission("admin:tenant:write");
}
function isAdministratorUsername() {
return String((state.authSession && state.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();
}
export { hasPermission, canManageApiGrants, isAdministratorUsername, profileShareableForGrant };

View file

@ -0,0 +1,668 @@
import { t, tf, el, tdCell, apiGet, resolveAdminApiUrl, getStoredAuthToken, SESSION_MONITOR_ROLE_FILTER_KEY, formatAuditActor, renderPageShell, renderSectionCard, enableTableColumnResize } from "../core.js";
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,
]);
}
export { renderAdminAudit, renderSessionMonitor };

View file

@ -0,0 +1,253 @@
import { state, t, el, apiGet, apiPost, apiRequest, formatUserLabel } from "../core.js";
import { profileShareableForGrant } from "./authz.js";
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((state.authSession && state.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((state.authSession && state.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]);
}
export { renderApiGrants };

View file

@ -0,0 +1,52 @@
import { state, t, el, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js";
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 || {}));
state.authSession = resp.session || null;
await navigateAdmin();
};
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,
]);
}
export { renderLogin };

View file

@ -0,0 +1,261 @@
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin } from "../core.js";
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)}`;
navigateAdmin();
}});
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,
]),
]);
}
export { renderMemory };

View file

@ -0,0 +1,833 @@
import { state, t, el, tdCell, apiGet, apiPost, apiRequest, resolveAdminApiUrl, getStoredAuthToken, renderPageShell, renderSectionCard, markPrewarmReminder } from "../core.js";
import { hasPermission } from "./authz.js";
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((state.authSession && state.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: t("models.phNameZh") });
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: t("models.phNameZh") });
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: t("models.subtitle"),
sections: [
{ id: "models-overview", label: t("models.toc.overview") },
{ id: "models-bindings", label: t("models.toc.bindings") },
{ id: "models-ops-ai", label: t("models.toc.opsAi") },
{ id: "models-api", label: t("models.toc.api") },
{ id: "models-experts", label: "Experts" },
{ id: "models-eval", label: t("models.toc.eval") },
],
}, [
...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(t("models.opsAiTitle"), t("models.opsAiHint"), [
el("div", { class: "row" }, [el("label", { text: t("models.labelSpecialist") }), opsAiSpecialistSelect]),
el("div", { class: "row" }, [el("label", { text: t("models.labelApiProfile") }), 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;
export { renderModels };

View file

@ -0,0 +1,885 @@
import { t, el, tdCell, apiGet, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js";
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" });
// Keep interactive controls inside the fold from bubbling to <details>.
inner.addEventListener("click", (e) => e.stopPropagation());
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: t("plugins.prevPage") });
const nextBtn = el("button", { class: "btn btn--small", text: t("plugins.nextPage") });
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 ? tf("plugins.pageRange", { start, end, total }) : t("plugins.noData");
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 };
}
function cursorCommandSummary(cursor) {
if (!cursor || typeof cursor !== "object") return "-";
const url = String(cursor.url || cursor.baseUrl || "").trim();
if (url) return url;
const cmd = String(cursor.command || "").trim();
const args = Array.isArray(cursor.args) ? cursor.args.map((x) => String(x)).join(" ") : "";
const line = (cmd + (args ? " " + args : "")).trim();
return line || "-";
}
async function renderPlugins() {
document.querySelectorAll("[data-mcp-edit-modal]").forEach((n) => n.remove());
document.querySelectorAll("body > .row-actions__menu, body > .chat-sess-menu-pop.row-actions__menu").forEach((n) => n.remove());
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 mcpBinding = { available_specialists: ["generalist"], servers: [], mapping: {} };
try {
mcp = await apiGet("/admin/api/mcp/servers");
} catch (_) {}
try {
mcpBinding = await apiGet("/admin/api/mcp/binding");
} catch (_) {}
try {
toolPolicy = await apiGet("/admin/api/tool-policy");
} 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 bindingStatus = el("div", { class: "muted", text: "" });
const installStatus = el("div", { class: "muted", text: "" });
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"];
let 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();
};
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");
try {
const fresh = await apiGet("/admin/api/mcp/binding");
if (fresh && fresh.mapping && typeof fresh.mapping === "object") {
bindingDraft = { ...fresh.mapping };
}
if (Array.isArray(fresh && fresh.servers)) {
bindingServers = fresh.servers.filter((x) => x && String(x.server_id || "").trim());
}
} catch (_) {}
renderBindingList();
renderBindingReverse();
},
});
renderBindingList();
renderBindingReverse();
const jsonInstallInput = el("textarea", {
class: "input",
placeholder:
'{\n "mcpServers": {\n "my-server": { "command": "npx", "args": ["-y", "mcp-fetch-server"] }\n }\n}',
rows: "10",
});
const installBtn = el("button", {
class: "btn",
text: "Install",
onclick: async () => {
const raw = String(jsonInstallInput.value || "").trim();
if (!raw) {
installStatus.textContent = "[install] empty JSON";
return;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
installStatus.textContent = `[install] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
installStatus.textContent = "[install] body must be a JSON object containing mcpServers";
return;
}
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
installStatus.textContent = "[install] mcpServers required";
return;
}
installStatus.textContent = "[install] installing...";
try {
const res = await apiPost("/admin/api/mcp/install", parsed);
installStatus.textContent = JSON.stringify(res);
if (res && res.ok) {
markPrewarmReminder("mcp_installed");
await softReloadMcpServers();
}
} catch (err) {
installStatus.textContent = `[install] failed: ${String((err && err.message) || err || "install_failed")}`;
}
},
});
const mcpExportJsonBtn = el("button", {
class: "btn",
text: "Export JSON",
title: "Download Cursor mcpServers snapshot for reinstall / migration",
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 : "");
},
});
let 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();
let editingServer = null;
const editModal = el("div", { class: "session-monitor-modal u-hidden", "data-mcp-edit-modal": "1" });
const editTitle = el("div", { class: "card__title", text: "Edit MCP server" });
const editServerIdLab = el("div", { class: "muted", text: "" });
const editCursorInput = el("textarea", {
class: "input",
rows: "12",
placeholder: '{ "command": "npx", "args": ["-y", "..."] }',
});
const editEnabledCb = el("input", { type: "checkbox" });
const editTimeoutInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "600",
value: "30",
});
const editStatus = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingServer = null;
editStatus.textContent = "";
editModal.classList.add("u-hidden");
editModal.style.display = "";
};
const openEditModal = (row) => {
if (!row) return;
editingServer = row;
const sid = String(row.server_id || "");
editTitle.textContent = `Edit MCP: ${sid}`;
editServerIdLab.textContent = `server_id: ${sid}`;
const cursor = row.cursor && typeof row.cursor === "object" ? row.cursor : {};
editCursorInput.value = JSON.stringify(cursor, null, 2);
editEnabledCb.checked = !!row.enabled;
editTimeoutInput.value = String(Number(row.timeout_s || 30));
editStatus.textContent = "";
if (editModal.parentNode !== document.body) document.body.appendChild(editModal);
editModal.classList.remove("u-hidden");
editModal.style.display = "";
setTimeout(() => editCursorInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: "Save",
onclick: async () => {
if (!editingServer) return;
const sid = String(editingServer.server_id || "").trim();
if (!sid) return;
let parsed;
try {
parsed = JSON.parse(String(editCursorInput.value || "").trim() || "{}");
} catch (err) {
editStatus.textContent = `[edit] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
editStatus.textContent = "[edit] Cursor config must be a JSON object";
return;
}
editStatus.textContent = "[edit] saving...";
try {
const r = await apiPost("/admin/api/mcp/config", {
server_id: sid,
server: parsed,
enabled: !!editEnabledCb.checked,
timeout_s: Number(editTimeoutInput.value || 30),
});
editStatus.textContent = JSON.stringify(r);
if (r && r.ok) {
closeEditModal();
markPrewarmReminder("mcp_config_updated");
await softReloadMcpServers();
repaintExpertBindingDashboard();
}
} catch (err) {
editStatus.textContent = `[edit] failed: ${String((err && err.message) || err || "save_failed")}`;
}
},
});
editModal.appendChild(
el("div", { class: "card session-monitor-modal__card", style: "width:min(720px,96vw);" }, [
editTitle,
editServerIdLab,
el("div", { class: "muted", text: "Cursor server config (single mcpServers value)" }),
editCursorInput,
el("div", { class: "row", style: "align-items:center;gap:10px;flex-wrap:wrap;" }, [
el("label", { class: "kv" }, [editEnabledCb, document.createTextNode(" Enabled")]),
el("label", { text: "timeout_s" }),
editTimeoutInput,
]),
editStatus,
el("div", { class: "row u-row-end", style: "gap:8px;margin-top:10px;" }, [
el("button", { class: "btn", text: "Cancel", onclick: closeEditModal }),
editSaveBtn,
]),
]),
);
const mcpInstalledPageRef = { value: 1 };
const mcpInstalledTotalHolder = { value: mcpServerList.length };
const mcpInstalledTbody = el("tbody");
let foldInstalledSummary = null;
const softReloadMcpServers = async () => {
const resp = await apiGet("/admin/api/mcp/servers");
mcpServerList = Array.isArray(resp.servers) ? resp.servers : [];
bindingServers = mcpServerList.filter((x) => x && String(x.server_id || "").trim());
mcpInstalledTotalHolder.value = mcpServerList.length;
if (foldInstalledSummary) {
foldInstalledSummary.textContent = tf("plugins.fold.installed", { n: mcpServerList.length });
}
repaintMcpInstalled();
mcpInstalledPager.sync();
renderBindingList();
renderBindingReverse();
};
const buildMcpInstalledRow = (x) => {
const sid = String(x.server_id || "");
const cursor = x.cursor && typeof x.cursor === "object" ? x.cursor : {};
const actions = rowActions("⋯", [
{
label: "Edit",
onClick: () => openEditModal(x),
},
{
label: x.enabled ? "Disable" : "Enable",
onClick: async () => {
await apiPost("/admin/api/mcp/toggle", { server_id: sid, enabled: !x.enabled });
markPrewarmReminder("mcp_toggled");
installStatus.textContent = `[toggle:${sid}] enabled=${!x.enabled ? 1 : 0}`;
await softReloadMcpServers();
},
},
{
label: "Health",
onClick: async () => {
const r = await apiPost("/admin/api/mcp/healthcheck", { server_id: sid });
installStatus.textContent = `[health:${sid}] ` + JSON.stringify(r);
await softReloadMcpServers();
},
},
{
label: "Sync Tools",
onClick: async () => {
const r = await apiPost("/admin/api/mcp/tools/sync", { server_id: sid });
installStatus.textContent = `[sync:${sid}] ` + JSON.stringify(r);
await softReloadMcpServers();
},
},
{
label: "Delete",
danger: true,
onClick: async () => {
if (!window.confirm(`Delete ${sid} from MCP registry?`)) return;
const r = await apiPost("/admin/api/mcp/delete", { server_id: sid });
installStatus.textContent = `[delete:${sid}] ` + JSON.stringify(r);
markPrewarmReminder("mcp_deleted");
await softReloadMcpServers();
},
},
]);
const tools = Array.isArray(x.tools) ? x.tools.map((t0) => String(t0.tool_name || "")).join(", ") : "";
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(cursorCommandSummary(cursor), 56),
tdCell(tools || "-", 48),
tdCell(String(x.enabled ? 1 : 0), 8),
el("td", { text: healthText, title: healthTitle }),
el("td", { class: "table__cell--actions", "data-copy-disabled": "1" }, [
el("div", { class: "table__cell-actions" }, [actions]),
]),
]);
};
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 foldToolPolicy = pluginsFold(tf("plugins.fold.toolPolicy", { n: pluginCatalog.length }), [
el("div", { class: "muted", text: t("plugins.fold.toolPolicyHint") }),
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 foldMcpInstall = pluginsFold(t("plugins.fold.installCursor"), [
el("div", { class: "muted", text: t("plugins.fold.installCursorHint") }),
jsonInstallInput,
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
installBtn,
mcpExportJsonBtn,
]),
el("div", { class: "muted", text: t("plugins.fold.migrateHint") }),
]);
const foldMcpInstalled = pluginsFold(tf("plugins.fold.installed", { n: mcpServerList.length }), [
installStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "server_id" }),
el("th", { text: "command / url" }),
el("th", { text: "tools" }),
el("th", { text: "enabled" }),
el("th", { text: "health" }),
el("th", { class: "table__cell--actions", text: t("tenants.rowActions") }),
]),
]),
mcpInstalledTbody,
]),
]),
mcpInstalledPager.wrap,
]);
foldMcpInstalled.open = true;
foldInstalledSummary = foldMcpInstalled.querySelector("summary");
const foldExpertBindingDash = pluginsFold(t("plugins.fold.bindingDash"), [
el("div", {
class: "muted",
text: t("plugins.fold.bindingDashHint"),
}),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("plugins.colSpecialist") }),
el("th", { text: t("plugins.colBoundMcp") }),
el("th", { text: t("plugins.colTools") }),
]),
]),
expertBindingDashTbody,
]),
]),
]);
const foldMcpBinding = pluginsFold(t("plugins.fold.bindingEdit"), [
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: tf("plugins.subtitle", { n: PLUGINS_PAGE_SIZE }),
sections: [
{ id: "plugins-tool-policy", label: t("plugins.toc.policy") },
{ id: "plugins-install", label: t("plugins.toc.install") },
{ id: "plugins-instances", label: t("plugins.toc.instances") },
{ id: "plugins-binding", label: t("plugins.toc.binding") },
],
},
[
el("div", { class: "page-grid page-grid--single" }, [
el("div", { id: "plugins-tool-policy" }, [foldToolPolicy]),
el("div", { id: "plugins-install" }, [foldMcpInstall]),
el("div", { id: "plugins-instances" }, [foldMcpInstalled]),
foldExpertBindingDash,
el("div", { id: "plugins-binding" }, [foldMcpBinding]),
]),
],
);
}
export { pluginsFold, pluginsPagerBar, renderPlugins };

View file

@ -0,0 +1,151 @@
import { state, t, el, apiGet, apiRequest, apiPostFormData, apiDeleteJson, resolveAdminApiUrl, getStoredAuthToken, authStoreGet, authStoreSet, AUTH_SESSION_KEY } from "../core.js";
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";
}
/** <img> 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));
state.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]);
}
export { renderProfile };

View file

@ -0,0 +1,517 @@
import { t, tf, el, tdCell, apiGet, apiPost, apiRequest, resolveAdminApiUrl, getStoredAuthToken, formatSystemLocalDateTime } from "../core.js";
import { hasPermission } from "./authz.js";
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 runsTbody = el("tbody", {});
const runsEmpty = el("div", { class: "muted", text: t("scheduledJobs.noRuns") });
const runsTable = el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colJob") }),
el("th", { text: t("scheduledJobs.colRunStatus") }),
el("th", { text: t("scheduledJobs.colFailClass") }),
el("th", { text: t("scheduledJobs.colFinished") }),
el("th", { text: t("scheduledJobs.colError") }),
]),
]),
runsTbody,
]),
]);
const runsBox = el("div", {}, [runsEmpty, runsTable]);
runsTable.style.display = "none";
const statsBox = el("div", { class: "muted", text: "" });
const attentionBox = el("div", { class: "muted", text: "" });
const msg = el("div", { class: "muted", text: "" });
const weixinInfo = el("div", { class: "muted", text: "" });
const failClassLabel = (fc) => {
const key = `scheduledJobs.failClass.${String(fc || "").trim()}`;
const labeled = t(key);
return labeled === key ? String(fc || "—") : labeled;
};
const renderRunsTable = (items, jobNameFallback) => {
const rows = Array.isArray(items) ? items : [];
runsTbody.replaceChildren();
if (!rows.length) {
runsEmpty.style.display = "";
runsTable.style.display = "none";
runsEmpty.textContent = t("scheduledJobs.noRuns");
return;
}
runsEmpty.style.display = "none";
runsTable.style.display = "";
for (const run of rows.slice(0, 30)) {
const st = String(run.status || "");
const fc = String(run.failure_class || "");
const err = String(run.error || "").trim();
const name = String(run.job_name || jobNameFallback || run.job_id || "—");
const finished = run.finished_at
? formatSystemLocalDateTime(String(run.finished_at))
: (run.created_at ? formatSystemLocalDateTime(String(run.created_at)) : "—");
const statusCell = el("td", { text: st || "—" });
if (st.toLowerCase() === "failed" || st.toLowerCase() === "error") {
statusCell.style.color = "var(--danger, #c44)";
} else if (st.toLowerCase() === "success") {
statusCell.style.color = "var(--success, #2a7)";
}
runsTbody.appendChild(
el("tr", {}, [
el("td", { text: name.slice(0, 40) }),
statusCell,
el("td", { text: fc ? failClassLabel(fc) : "—" }),
el("td", { text: finished }),
el("td", { text: err ? err.slice(0, 120) : "—" }),
])
);
}
};
const loadStats = async () => {
try {
const stats = await apiGet("/admin/api/scheduled-jobs/stats?recent_limit=80");
const finished = Number(stats.recent_success || 0) + Number(stats.recent_failed || 0);
const pct = Number(stats.recent_fail_rate_pct || 0);
const classes = stats.recent_fail_classes || {};
const classParts = Object.keys(classes).map((k) => `${failClassLabel(k)} ${classes[k]}`);
statsBox.textContent = [
tf("scheduledJobs.statsFailRate", {
pct: String(pct),
failed: String(stats.recent_failed || 0),
finished: String(finished),
}),
classParts.length
? `${t("scheduledJobs.statsClasses")}: ${classParts.join(" · ")}`
: t("scheduledJobs.statsNone"),
tf("scheduledJobs.statsJobsFailed", { n: String((stats.jobs_last_failed || []).length) }),
].join(" | ");
const failedJobs = stats.jobs_last_failed || [];
if (!failedJobs.length) {
attentionBox.textContent = "";
} else {
attentionBox.textContent =
`${t("scheduledJobs.attentionTitle")}: ` +
failedJobs
.slice(0, 8)
.map((j) => `${j.name || j.id}${j.last_run_at ? ` @ ${formatSystemLocalDateTime(String(j.last_run_at))}` : ""}`)
.join(" · ");
}
const fails = stats.recent_failures || [];
if (fails.length) {
renderRunsTable(fails, "");
return true;
}
return false;
} catch (e) {
statsBox.textContent = String(e && e.message ? e.message : e);
return false;
}
};
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 = t("scheduledJobs.requiredFields");
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) {
renderRunsTable([], "");
return;
}
const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
const target = withLast.length ? withLast[0] : jobs[0];
if (!target || !target.id) {
renderRunsTable([], "");
return;
}
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: target.name || target.id }));
renderRunsTable(runItems, String(target.name || target.id || ""));
}
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`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: job.name || jobId }));
renderRunsTable(runItems, String(job.name || jobId));
},
}),
...(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 lastStatus = String(job.last_run_status || "").trim();
const lastRun = job.last_run_at
? `${formatSystemLocalDateTime(String(job.last_run_at))}${lastStatus ? ` (${lastStatus})` : ""}`
: "—";
const lastCell = el("td", { text: lastRun });
if (lastStatus.toLowerCase() === "failed") {
lastCell.style.color = "var(--danger, #c44)";
} else if (lastStatus.toLowerCase() === "success") {
lastCell.style.color = "var(--success, #2a7)";
}
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),
lastCell,
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) });
const showedFails = await loadStats();
if (!showedFails) {
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") });
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;flex-wrap:wrap;" }, [
status,
el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
msg,
]),
el("div", { class: "card__title", style: "margin-top:10px;font-size:13px;", text: t("scheduledJobs.statsTitle") }),
statsBox,
attentionBox,
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,
]),
]);
}
export { formatScheduledJobDelivery, formatScheduledJobPlaybook, buildScheduledJobDeliveryPayload, renderScheduledJobs };

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,784 @@
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf } from "../core.js";
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: t("common.notLoggedIn") }),
]);
}
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: t("common.auto"), selected: curLang === "auto" ? "selected" : undefined }),
el("option", { value: "zh", text: t("common.chinese"), selected: curLang === "zh" ? "selected" : undefined }),
el("option", { value: "en", text: t("common.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: t("wa.bindSpecialist"),
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: t("wa.channelExpertOnly"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.specialist") }),
specialistSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.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: t("wa.selectGroup") }),
...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)}`
: (t("wa.noAlertGroup")),
});
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: t("wa.blacklistMode"),
selected: String(waAccessCfg.access_mode || "blacklist") === "blacklist" ? "selected" : undefined,
}),
el("option", {
value: "whitelist",
text: t("wa.whitelistMode"),
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: t("common.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: t("common.all") }),
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.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(t("common.selectType"));
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) || (t("common.updateFailed"))));
return;
}
navigateAdmin();
} 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: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
typeSel.value = lt || "whitelist";
const actionBtns = [
el("button", {
class: "btn",
text: t("common.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: t("wa.addWhitelist"),
onclick: async () => {
await waSaveContact(phone, name, "whitelist");
},
}));
}
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--danger",
text: t("common.remove"),
onclick: async () => {
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/contacts?phone=${encodeURIComponent(phone)}`,
);
if (resp && resp.deleted === false) {
window.alert(t("wa.contactNotFound"));
return;
}
navigateAdmin();
} 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: t("wa.noContacts") })])]),
);
};
waContactFilterSel.addEventListener("change", () => {
renderWaContactRows(String(waContactFilterSel.value || "all"));
});
renderWaContactRows("all");
const waContactPhoneInput = el("input", { class: "input", placeholder: t("wa.phonePlaceholder") });
const waContactNameInput = el("input", { class: "input", placeholder: t("wa.namePlaceholder") });
const waContactTypeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
const waPendingPickSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: t("wa.pickPending") }),
...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) || (t("common.requestFailed"))));
return;
}
navigateAdmin();
} 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) || (t("common.deleteFailed"))));
return;
}
navigateAdmin();
} 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: t("wa.addWhitelist"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.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: t("wa.approve"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn",
text: t("wa.deny"),
onclick: async () => { await waResolvePending(pendingId, "deny"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.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: t("wa.accessTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.accessHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("common.mode") }),
waAccessModeSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.messageLang") }),
waAccessLangSel,
]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn btn--primary",
text: t("wa.saveConfig"),
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: t("wa.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: t("wa.addContact"),
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) || (t("wa.addFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
},
}),
]),
]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colType") }),
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colAction") }),
])]),
waContactsTbody,
]),
]),
el("div", { class: "card__title", text: t("wa.pendingTitle") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: "JID" }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colTime") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: t("wa.nonePending") })])]),
]),
]),
el("div", { class: "card__title", text: t("wa.deniedTitle") }),
el("div", { class: "card__hint", text: t("wa.deniedHint") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colDeniedAt") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: t("wa.noneDenied") })])]),
]),
]),
]);
const whatsappAlertBindingCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.alertGroupTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.alertGroupHint"),
}),
]),
]),
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: t("wa.saveBinding"),
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)}`
: (t("wa.bindFailed"));
},
}),
el("button", {
class: "btn",
text: t("wa.testPush"),
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: t("stack.clearScanCache"),
onclick: async () => {
const resp = await apiPost("/admin/api/runtime/scan-artifacts/cleanup", {});
scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)}`;
navigateAdmin();
},
});
const btnPruneScan = el("button", {
class: "btn",
text: t("stack.clearByPolicy"),
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)}`;
navigateAdmin();
},
});
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: t("stack.prewarmNow"),
onclick: async () => {
prewarmStatus.textContent = t("stack.prewarmSubmitting");
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 =
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
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 navigateAdmin();
},
});
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: t("stack.allRoles") }),
]);
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: t("stack.reloadPrompts"),
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: t("stack.prewarmTitle") }),
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:
t("auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme"),
},
),
prewarmStatus,
].filter(Boolean)),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("stack.prewarmHistory") }),
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: t("stack.prewarmedPrompts") }),
el("div", {
class: "card__subtitle",
text: t("stack.prewarmedPromptsHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("stack.roleFilter") }),
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: t("stack.scanCacheTitle") }),
el("div", { class: "card__subtitle", text: tf("stack.scanCacheSubtitle", { dir: scanDir || "-" }) }),
]),
]),
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),
]),
]),
]),
]);
}
export { renderStack };

View file

@ -0,0 +1,691 @@
import { state, t, tf, el, tdCell, apiGet, apiPost, formatUserLabel, formatSystemLocalDateTime, navigateAdmin } from "../core.js";
import { hasPermission } from "./authz.js";
async function renderUserManagement() {
const sessionTid = String((state.authSession && state.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 navigateAdmin();
} 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 =
tf("users.accountModeExpert", { 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: t("wa.bindSpecialist"),
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 navigateAdmin();
}});
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: t("wa.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: t("common.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,
]),
]),
]);
}
export { renderUserManagement };

View file

@ -0,0 +1,210 @@
import { state, t, el, apiGet, apiPost, formatUserLabel } from "../core.js";
import { hasPermission } from "./authz.js";
async function renderWorkspacePaths() {
const sessionTid = String((state.authSession && state.authSession.tenant_id) || "").trim();
const sessionUid = String((state.authSession && state.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: state.authSession && state.authSession.username,
display_name: state.authSession && state.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);
}
export { renderWorkspacePaths };

View file

@ -0,0 +1,126 @@
import {
state, t, el, mount, setActive, applyI18nStatic, getRoute,
authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, getStoredAuthToken, apiPost,
} from "./core.js";
import { hasPermission, canManageApiGrants, isAdministratorUsername } from "./pages/authz.js";
import { renderStack } from "./pages/stack.js";
import { renderScheduledJobs } from "./pages/scheduled-jobs.js";
import { renderUserManagement } from "./pages/users.js";
import { renderMemory } from "./pages/memory.js";
import { renderModels } from "./pages/models.js";
import { renderAudit } from "./pages/audit.js";
import { renderPlugins } from "./pages/plugins.js";
import { renderSkills } from "./pages/skills.js";
import { renderAttachments } from "./pages/attachments.js";
import { renderWorkspacePaths } from "./pages/workspace-paths.js";
import { renderProfile } from "./pages/profile.js";
import { renderLogin } from "./pages/login.js";
async function router() {
const route = getRoute();
const page = route.page;
const tok = getStoredAuthToken();
if (!state.authSession || !tok) {
if (state.authSession || tok) {
authStoreRemove(AUTH_TOKEN_KEY);
authStoreRemove(AUTH_SESSION_KEY);
state.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((state.authSession && (state.authSession.display_name || state.authSession.username || state.authSession.user_id)) || "");
const role = String((state.authSession && state.authSession.role) || "");
user.textContent = name ? `${name} (${role})` : "";
}
const forbiddenCard = () =>
el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]);
const mountPageLoading = (titleKey) => {
mount(
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t(titleKey) }),
el("div", { class: "muted", text: t("chat.loading") }),
]),
);
};
/** Let the browser paint the loading card before heavy page fetches/DOM work. */
const yieldForPaint = () =>
new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
let view;
if (page === "stack") {
mountPageLoading("title.stack");
await yieldForPaint();
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: t("common.pageRemoved") }),
]);
} 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") {
if (!hasPermission("admin:user:write")) {
view = forbiddenCard();
} else {
mountPageLoading("title.plugins");
await yieldForPaint();
view = await renderPlugins();
}
} else if (page === "skills") {
if (!hasPermission("admin:read")) {
view = forbiddenCard();
} else {
mountPageLoading("title.skills");
await yieldForPaint();
view = await renderSkills();
}
} 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);
}
export { router };

View file

@ -0,0 +1,17 @@
const LANG_KEY = "ops_admin_lang";
let lang = "zh";
try {
lang = String(localStorage.getItem(LANG_KEY) || "zh").toLowerCase();
} catch (_) {
lang = "zh";
}
if (lang !== "zh" && lang !== "en") lang = "zh";
export const state = {
currentLang: lang,
authSession: null,
/** @type {null | (() => any)} */
reauthHandler: null,
/** @type {null | (() => any)} */
navigate: null,
};

View file

@ -781,17 +781,52 @@ input[type="checkbox"]:checked::before,
}
.table--compact { table-layout: fixed; min-width: 860px; }
.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table--compact td.table__cell--actions,
.table--compact td.table__cell--form {
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
.table--compact th.table__cell--actions,
.table--compact td.table__cell--actions {
overflow: visible;
text-overflow: clip;
white-space: nowrap;
width: 4rem;
min-width: 4rem;
}
.table__cell-actions {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
justify-content: center;
position: relative;
}
.row-actions__btn {
opacity: 1 !important;
pointer-events: auto !important;
}
.row-actions__btn:focus,
.row-actions__btn:focus-visible {
outline: none;
border-color: transparent;
box-shadow: none;
}
.row-actions__menu {
z-index: 1200;
}
.row-actions__item--danger {
color: #fca5a5;
}
.row-actions__item--danger:hover {
background: rgba(239, 68, 68, 0.14);
}
/* Multi-button action cells (users / market): don't force ⋯ width. */
.table--compact td.table__cell--actions:has(.btn),
.table--users-mgmt td.table__cell--actions {
width: auto;
min-width: 0;
max-width: none;
}
@media (max-width: 1200px) {
@ -814,6 +849,14 @@ input[type="checkbox"]:checked::before,
justify-content: center;
padding: 16px;
}
.session-monitor-modal:not(.u-hidden) {
display: flex;
}
.session-monitor-modal.u-hidden {
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
}
.session-monitor-modal__card {
width: min(1200px, 96vw);
max-height: 92vh;
@ -1127,7 +1170,7 @@ body.theme-ds-body[data-admin-theme="light"] .content {
.u-w-12p { width: 12%; }
.u-w-full-mt-8 { width: 100%; margin-top: 8px; }
.u-w-full-mt-10 { width: 100%; margin-top: 10px; }
.u-overlay-fixed { position: fixed; z-index: 300; }
.u-overlay-fixed { position: fixed; z-index: 400; }
.u-overlay-scrim { position: fixed; inset: 0; z-index: 250; background: transparent; }
.u-z-modal { z-index: 9998; }
.u-z-modal-top { z-index: 9999; }