重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

1532
interfaces/admin/chat_api.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id)."""
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
from oclaw.runtime.tools.tool_validation import validate_tool_arguments
def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None:
"""Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name)."""
if not full_name.startswith("mcp__"):
return None
rest = full_name[len("mcp__") :]
idx = rest.find("__")
if idx < 0:
return None
server_id = rest[:idx].strip()
tool = rest[idx + 2 :].strip()
if not server_id or not tool:
return None
return server_id, tool
def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]:
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return []
req = parameters.get("required")
if not isinstance(req, list):
return []
return [str(x).strip() for x in req if str(x).strip()]
def _fill_required_from_properties(
parameters: dict[str, Any],
*,
workspace_root: str,
) -> dict[str, Any] | None:
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
req = _json_schema_required_keys(parameters)
out: dict[str, Any] = {}
for key in req:
prop = props.get(key) if isinstance(props.get(key), dict) else {}
t = prop.get("type")
lk = key.lower()
if t == "string":
if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"):
out[key] = workspace_root
elif lk == "url":
out[key] = "https://example.com"
elif lk == "query":
out[key] = "model context protocol"
elif lk == "message":
out[key] = "mcp-e2e"
elif lk == "timezone":
out[key] = "UTC"
else:
out[key] = ""
elif t == "boolean":
out[key] = True if lk in ("includeuntracked", "includetracked") else False
elif t in ("number", "integer"):
out[key] = 0
elif t == "array":
out[key] = []
elif t == "object":
out[key] = {}
else:
return None
return out
# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values.
_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = {
"sequentialthinking": {
"thought": "e2e",
"nextThoughtNeeded": False,
"thoughtNumber": 1,
"totalThoughts": 1,
},
}
# Lower index = higher priority when multiple tools are callable.
_PROBE_PRIORITY: tuple[str, ...] = (
"browser_close",
"read_graph",
"db_info",
"list_pdfs",
"echo",
"list_directory",
"git_status",
"fetch_markdown",
"web_search",
"sequentialthinking",
)
def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None:
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
return None
_, mcp_tool = parsed
params = spec.parameters if isinstance(spec.parameters, dict) else {}
if mcp_tool in _KNOWN_MCP_TOOL_ARGS:
args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool])
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
req = _json_schema_required_keys(params)
if not req:
args: dict[str, Any] = {}
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
filled = _fill_required_from_properties(params, workspace_root=workspace_root)
if filled is None:
return None
ok, _ = validate_tool_arguments(params, filled)
return filled if ok else None
def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None:
candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = []
for sp in specs:
args = _probe_args_for_spec(sp, workspace_root=workspace_root)
if args is None:
continue
ok, _ = validate_tool_arguments(sp.parameters or {}, args)
if not ok:
continue
parsed = parse_mcp_bound_tool_name(sp.name)
mcp_tool = (parsed or ("", ""))[1]
try:
pri = _PROBE_PRIORITY.index(mcp_tool)
except ValueError:
pri = 900
candidates.append((pri, mcp_tool, sp, args))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1], x[2].name))
return candidates[0][2], candidates[0][3]
def build_mcp_e2e_probe_plans(
reg: ToolRegistry,
*,
workspace_root: str,
) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]:
"""
Returns (plans, skipped_server_ids).
``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``.
One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``.
``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe
could be constructed (no false positives from arbitrary ``tools/call``).
"""
by_server: dict[str, list[ToolSpec]] = {}
for spec in reg.list():
if "mcp" not in (spec.tags or frozenset()):
continue
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
continue
sid, _ = parsed
by_server.setdefault(sid, []).append(spec)
plans: list[tuple[str, str, dict[str, Any]]] = []
for sid in sorted(by_server.keys()):
picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root)
if picked is None:
continue
spec, args = picked
plans.append((sid, spec.name, args))
planned = {p[0] for p in plans}
skipped = [s for s in sorted(by_server.keys()) if s not in planned]
return plans, skipped
__all__ = [
"build_mcp_e2e_probe_plans",
"parse_mcp_bound_tool_name",
]

View file

@ -0,0 +1,561 @@
"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
from __future__ import annotations
import csv
import io
import json
import os
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException, Query
from fastapi.responses import Response
from oclaw.runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
from oclaw.runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
AGENT_ROLE_IDS,
dump_agent_profile_bindings,
parse_agent_profile_bindings,
)
from oclaw.platform.config.paths import db_path
from oclaw.runtime.orchestration.evaluation import eval_summary
from oclaw.platform.persistence.sqlite_store import (
LLM_BUILTIN_OLLAMA_PROFILE_ID,
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if permission in perms:
return
if str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
def _require_models_mutate(ctx: dict[str, Any]) -> None:
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
_require_permission(ctx, "admin:tenant:write")
else:
_require_permission(ctx, "admin:read")
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
tid = str(ctx.get("tenant_id") or "").strip()
if not uid:
return {}
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
if tid:
out["viewer_tenant_id"] = tid
return out
def _active_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return "active_llm_profile_id"
return active_llm_profile_setting_key(uid, uname or None)
def _bindings_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return AGENT_PROFILE_BINDINGS_KEY
return agent_profile_bindings_setting_key(uid, uname or None)
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
if not prof:
raise HTTPException(status_code=404, detail="profile_not_found")
if prof.get("is_builtin"):
return prof
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
return prof
if own == uid:
return prof
raise HTTPException(status_code=403, detail="profile_forbidden")
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
uname = str(ctx.get("username") or "").strip()
if not is_administrator_model_pool(uname):
return False
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms:
return True
return str(ctx.get("role") or "") == "owner"
def _require_grant_manager(ctx: dict[str, Any]) -> None:
if not _can_manage_llm_grants(ctx):
raise HTTPException(status_code=403, detail="grants_administrator_only")
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
if not prof or prof.get("is_builtin"):
return False
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
if not own:
return True
return bool(uid) and own == uid
def _normalize_active(
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
) -> str:
if not profile_ids:
return ""
key = _active_key(ctx)
active_id = str(store.get_setting(key) or "").strip()
if active_id not in profile_ids:
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
if active_id not in profile_ids:
active_id = profile_ids[0]
store.set_setting(key, active_id)
return active_id
def include_model_mgmt_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
@mg.get("")
def api_models_state(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
lk = _models_list_kwargs(ctx)
profiles = store.list_llm_profiles(visible_only=True, **lk)
profile_ids = [str(p["id"]) for p in profiles]
active_id = _normalize_active(store, profiles, profile_ids, ctx)
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
if ui_lang not in ("zh", "en"):
ui_lang = "zh"
secret = ""
if active_id and active_id in profile_ids:
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
secret = store.get_llm_profile_secret(active_id) or ""
out: dict[str, Any] = {
"ok": True,
"active_llm_profile_id": active_id,
"profiles": profiles,
"bindings": bindings,
"ui_lang": ui_lang,
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
"role_ids": list(AGENT_ROLE_IDS),
"profile_secret": secret,
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
"db_path": db_path(),
}
return out
@mg.post("/active")
def api_models_set_active(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
_require_permission(ctx, "admin:read")
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = [str(p["id"]) for p in profiles]
pid = str(payload.get("profile_id") or "").strip()
if pid not in profile_ids:
raise HTTPException(status_code=400, detail="invalid_profile_id")
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "active_llm_profile_id": pid}
@mg.post("/bindings")
def api_models_set_bindings(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = set(str(p["id"]) for p in profiles)
raw = payload.get("bindings")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="bindings_object_required")
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
for rid in AGENT_ROLE_IDS:
v = raw.get(rid)
if v is None:
continue
s = str(v).strip()
if s and s not in profile_ids:
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
cur[rid] = s
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
return {"ok": True, "bindings": cur}
@mg.post("/profiles")
def api_models_create_profile(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
name = str(payload.get("name") or "").strip() or "新配置"
mode = str(payload.get("mode") or "openai").strip().lower()
if mode not in _LLM_USER_CREATE_MODES:
raise HTTPException(status_code=400, detail="invalid_mode")
if mode == "openai":
new_model = "gpt-4o-mini"
new_bu = ""
else:
new_model = DEFAULT_OLLAMA_MODEL
new_bu = DEFAULT_OLLAMA_BASE_URL
own: str | None = None
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if uid and not is_administrator_model_pool(uname):
own = uid
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
store.set_setting(_active_key(ctx), pid)
prof = store.get_llm_profile(pid)
return {"ok": True, "profile_id": pid, "profile": prof}
@mg.patch("/profiles/{profile_id}")
def api_models_patch_profile(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
else:
if mode_raw not in _LLM_MODE_OPTIONS:
raise HTTPException(status_code=400, detail="invalid_mode")
mode_save = mode_raw
model = payload.get("model")
base_url = payload.get("base_url")
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
store.update_llm_profile(
profile_id=pid,
name=name,
mode=mode_save,
model=model_s or None,
base_url=bu_s or None,
)
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.post("/profiles/{profile_id}/secret")
def api_models_profile_secret(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
remember = bool(payload.get("remember"))
key_text = str(payload.get("secret") or "").strip()
mode_save = str(prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
if mode_save in ("openai", "ollama"):
if remember:
if key_text:
store.set_llm_profile_secret(pid, key_text)
elif mode_save == "openai":
raise HTTPException(status_code=400, detail="remember_key_empty")
else:
store.clear_llm_profile_secret(pid)
else:
store.clear_llm_profile_secret(pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.delete("/profiles/{profile_id}")
def api_models_delete_profile(
profile_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
try:
store.delete_llm_profile(pid)
except ValueError:
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
store.set_setting(_active_key(ctx), new_active)
return {"ok": True, "active_llm_profile_id": new_active}
@mg.get("/members")
def api_models_members(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
if not tid:
raise HTTPException(status_code=400, detail="tenant_required")
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
members: list[dict[str, Any]] = []
for u in users:
mid = str(u.get("id") or "").strip()
un = str(u.get("username") or "").strip()
if not mid:
continue
profs = store.list_llm_profiles(
visible_only=True,
viewer_user_id=mid,
viewer_username=un or None,
viewer_tenant_id=tid,
)
members.append(
{
"user_id": mid,
"username": un,
"display_name": str(u.get("display_name") or "").strip(),
"role": str(u.get("role") or "").strip(),
"profiles": profs,
}
)
return {"ok": True, "members": members}
@mg.get("/grants/tenant")
def api_models_grants_tenant_get(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
granted = store.tenant_has_llm_profile_grant(tid, pid)
return {"ok": True, "granted": granted}
@mg.post("/grants/tenant")
def api_models_grants_tenant_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_tenant(
tenant_id=tid, profile_id=pid, created_by_user_id=actor
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants/tenant")
def api_models_grants_tenant_revoke(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
return {"ok": True, "removed": int(n)}
@mg.get("/grants")
def api_models_grants_list(
profile_id: str = Query(..., description="llm_profile id"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
rows = store.list_llm_profile_grants_for_profile(tid, pid)
return {"ok": True, "grants": rows}
@mg.post("/grants")
def api_models_grants_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
uid = str(payload.get("user_id") or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_user(
tenant_id=tid,
profile_id=pid,
user_id=uid,
created_by_user_id=actor,
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
if code == "user_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants")
def api_models_grants_revoke(
profile_id: str = Query(...),
user_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
uid = str(user_id or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
return {"ok": True, "removed": int(n)}
@mg.get("/eval")
def api_models_eval(
authorization: str | None = Header(default=None),
limit_logs: int = Query(default=100, ge=1, le=500),
limit_summary: int = Query(default=500, ge=1, le=5000),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
summary = eval_summary(store, limit=limit_summary)
logs = store.list_agent_eval_logs(limit=limit_logs)
return {"ok": True, "summary": summary, "logs": logs}
_EVAL_EXPORT_FIELDS = (
"timestamp",
"session_id",
"specialist",
"task_kind",
"success",
"latency_ms",
"cost_hint",
"notes",
)
@mg.get("/eval/export")
def api_models_eval_export(
authorization: str | None = Header(default=None),
format: str = Query(default="csv", description="csv or json"),
limit: int = Query(default=100_000, ge=1, le=200_000),
) -> Response:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
fmt = str(format or "csv").strip().lower()
rows = store.list_agent_eval_logs(limit=limit)
if fmt == "json":
body = json.dumps(rows, ensure_ascii=False, indent=2)
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
},
)
if fmt != "csv":
raise HTTPException(status_code=400, detail="invalid_format")
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
w.writeheader()
for r in rows:
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
if "success" in row:
row["success"] = 1 if bool(row.get("success")) else 0
w.writerow(row)
payload = "\ufeff" + buf.getvalue()
return Response(
content=payload.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
)
router.include_router(mg)
__all__ = ["include_model_mgmt_routes"]

3069
interfaces/admin/routes.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,604 @@
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException
from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.runtime.skill_installer import (
auto_install_skill_from_payload,
create_skill_from_template,
install_skill_from_local_dir,
install_skill_from_registry_archive,
list_skills_with_status,
set_skill_enabled,
)
from oclaw.runtime.skill_role_binding import (
SKILL_ROLE_BINDING_ENABLED_SETTING,
SKILL_ROLE_BINDING_KEY,
load_skill_role_binding_dict,
normalize_skill_role_binding,
ordered_binding_roles,
skill_role_binding_enabled,
)
from oclaw.runtime.skills_prompt import collect_skill_catalog_entries
from oclaw.runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
def include_skill_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
sk = APIRouter(prefix="/admin/api/skills", tags=["skills"])
def _require_admin(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:read" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:read")
def _require_tenant_write(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write")
def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None:
try:
store.add_admin_audit_log(
actor_tenant_id=str(ctx.get("tenant_id") or ""),
actor_user_id=str(ctx.get("user_id") or ""),
action=action,
target_type="skill",
target_id=str(target_id or ""),
status=status,
detail=detail or {},
)
except Exception:
pass
def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]:
roles = ordered_binding_roles()
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
mapping = normalize_skill_role_binding(
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=valid,
available_roles=roles,
)
return roles, mapping, valid
@sk.get("")
def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
items = list_skills_with_status(store=store)
return {"ok": True, "items": items}
@sk.post("/install")
def api_skills_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source_dir = str(payload.get("source_dir") or "").strip()
if not source_dir:
raise HTTPException(status_code=400, detail="source_dir_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"})
out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or source_dir,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/install-registry")
def api_skills_install_registry(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
archive_url = str(payload.get("archive_url") or "").strip()
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"})
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/market/search")
def api_skills_market_search(
q: str | None = None,
limit: int | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
query = str(q or "").strip()
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
lim = max(1, min(lim, 200))
items = clawhub_search_skills(query, limit=lim)
return {"ok": True, "items": items}
@sk.get("/market/detail")
def api_skills_market_detail(
slug: str | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(slug or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
detail = clawhub_get_skill_detail(s)
return {"ok": True, "detail": detail}
@sk.post("/market/install")
def api_skills_market_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(payload.get("slug") or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
requested_version = str(payload.get("version") or "").strip()
overwrite = bool(payload.get("overwrite"))
detail = clawhub_get_skill_detail(s)
archive_url = str(detail.get("archiveUrl") or "").strip()
chosen_version = str(detail.get("latestVersion") or "").strip()
if requested_version:
chosen_version = requested_version
archive_url = ""
for v in (detail.get("versions") or []):
if not isinstance(v, dict):
continue
if str(v.get("version") or "").strip() == requested_version:
archive_url = str(v.get("archiveUrl") or "").strip()
break
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_unavailable")
_audit(
store,
ctx,
action="skill_install_started",
target_id=archive_url,
status="start",
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
)
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": "clawhub",
"slug": s,
"version": chosen_version,
"input_target": archive_url,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/binding")
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
items = list_skills_with_status(store=store)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"installed_skills": items,
"mapping": mapping,
}
@sk.post("/binding")
def api_skills_binding_save(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
if "enabled" in payload:
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
roles, _prev_mapping, valid = _normalized_skill_binding(store)
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
mapping = normalize_skill_role_binding(
mapping_raw=mapping_raw,
valid_skill_names=valid,
available_roles=roles,
)
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
_audit(
store,
ctx,
action="skill_binding_update",
target_id="skill_role_binding",
status="ok",
detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))},
)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"mapping": mapping,
}
@sk.get("/effective")
def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
role_rows: list[dict[str, Any]] = []
for role in roles:
specialist = "generalist" if role == "manager" else role
ex = build_gateway_executor(store=store, specialist=specialist)
tools = getattr(ex, "tools", None)
model = getattr(ex, "model", None)
if tools is None:
role_rows.append(
{
"role": role,
"total": 0,
"workspace_total": 0,
"workspace_direct": 0,
"workspace_inherited_manager": 0,
"mcp_total": 0,
"tool_total": 0,
"names_preview": [],
}
)
continue
base_url = str(getattr(model, "base_url", "") or "")
entries = collect_skill_catalog_entries(
store=store,
registry=tools,
base_url=base_url,
skill_binding_role=role,
)
allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy(
registry=tools,
store=store,
base_url=base_url,
)
direct_set = set(mapping.get(role) or [])
manager_set = set(mapping.get("manager") or [])
workspace_total = 0
workspace_direct = 0
workspace_inherited = 0
workspace_resolved_tool_match = 0
workspace_docs_only = 0
mcp_total = 0
tool_total = 0
names: list[str] = []
docs_only_names: list[str] = []
resolved_workspace_names: list[str] = []
for nm, _desc, loc in entries:
names.append(str(nm))
vloc = str(loc or "")
if vloc.endswith("SKILL.md"):
workspace_total += 1
if nm in allowed_tool_names:
workspace_resolved_tool_match += 1
resolved_workspace_names.append(str(nm))
else:
workspace_docs_only += 1
docs_only_names.append(str(nm))
if nm in direct_set:
workspace_direct += 1
elif role != "manager" and nm in manager_set:
workspace_inherited += 1
elif str(nm).startswith("mcp__"):
mcp_total += 1
else:
tool_total += 1
role_rows.append(
{
"role": role,
"total": len(entries),
"workspace_total": workspace_total,
"workspace_direct": workspace_direct,
"workspace_inherited_manager": workspace_inherited,
"workspace_resolved_tool_match": workspace_resolved_tool_match,
"workspace_docs_only": workspace_docs_only,
"mcp_total": mcp_total,
"tool_total": tool_total,
"names_preview": names[:20],
"docs_only_names_preview": docs_only_names[:20],
"resolved_workspace_names_preview": resolved_workspace_names[:20],
}
)
return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows}
@sk.post("/create")
def api_skills_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
desc = str(payload.get("description") or "").strip()
body = str(payload.get("body_markdown") or "").strip()
md = payload.get("metadata_oclaw")
md = dict(md) if isinstance(md, dict) else {}
overwrite = bool(payload.get("overwrite"))
out = create_skill_from_template(
store=store,
name=name,
description=desc,
body_markdown=body,
metadata_oclaw=md,
overwrite=overwrite,
)
_audit(
store,
ctx,
action="skill_create",
target_id=out.name or name,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/enable")
def api_skills_enable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=True)
_audit(store, ctx, action="skill_enable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/disable")
def api_skills_disable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=False)
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/auto-install")
def api_skills_auto_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
auto_name = str(payload.get("name") or "")
_audit(
store,
ctx,
action="skill_install_started",
target_id=auto_name,
status="start",
detail={"source": "auto", "input_target": auto_name},
)
out = auto_install_skill_from_payload(store=store, payload=payload)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or str(payload.get("name") or ""),
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/retry-install")
def api_skills_retry_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source = str(payload.get("source") or "").strip().lower()
target = str(payload.get("target") or "").strip()
if source not in {"local", "registry", "auto"}:
raise HTTPException(status_code=400, detail="invalid_source")
if not target:
raise HTTPException(status_code=400, detail="target_required")
_audit(
store,
ctx,
action="skill_install_started",
target_id=target,
status="start",
detail={"source": source, "retry": True, "input_target": target},
)
if source == "local":
out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True)
elif source == "registry":
out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True)
else:
out = auto_install_skill_from_payload(
store=store,
payload={
"name": str(payload.get("name") or "").strip() or target,
"description": str(payload.get("description") or "retry auto install"),
"body_markdown": str(payload.get("body_markdown") or ""),
"metadata_oclaw": dict(payload.get("metadata_oclaw") or {})
if isinstance(payload.get("metadata_oclaw"), dict)
else {},
},
)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or target,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": source,
"retry": True,
"input_target": target,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/test-run")
def api_skills_test_run(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
args = payload.get("args")
if not name:
raise HTTPException(status_code=400, detail="name_required")
if args is None:
args = {}
if not isinstance(args, dict):
raise HTTPException(status_code=400, detail="args_must_be_object")
ex = build_gateway_executor(store=store, specialist="generalist")
tools = getattr(ex, "tools", None)
if tools is None:
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
spec = tools.get(name)
if spec is None:
raise HTTPException(status_code=404, detail="tool_not_found")
try:
result = spec.handler(dict(args))
except Exception as exc:
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
_audit(
store,
ctx,
action="skill_test_run",
target_id=name,
status="ok" if bool((result or {}).get("ok")) else "fail",
detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))},
)
return {"ok": True, "name": name, "result": result}
router.include_router(sk)
__all__ = ["include_skill_routes"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,455 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<style>
body.theme-ds-body.chat-standalone-page {
margin: 0;
min-height: 100vh;
background: var(--ds-bg, #0d0d0d);
color: var(--ds-text, #e8e8e8);
display: flex;
flex-direction: column;
}
#app {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
}
.chat-app--login {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
#app .chat-layout {
flex: 1;
min-height: 0;
}
.chat-sess-row {
display: flex;
align-items: stretch;
gap: 0;
margin-bottom: 4px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 10px 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-user-row {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-user-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-user-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more:hover {
background: transparent;
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.2));
outline-offset: 1px;
}
.chat-sess-more--active {
background: transparent;
border-color: transparent;
}
.chat-sess-more--active:hover {
background: transparent;
}
.chat-sess-menu-pop {
z-index: 200;
background: var(--ds-panel, #222);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__md {
line-height: 1.45;
word-break: break-word;
}
.chat-msg .chat-msg__md p {
margin: 0.28em 0;
}
.chat-msg .chat-msg__md > :first-child {
margin-top: 0;
}
.chat-msg .chat-msg__md > :last-child {
margin-bottom: 0;
}
.chat-msg__md pre {
overflow: auto;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.chat-msg__md code {
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
/* 父级 .chat-msg 的 pre-wrap 会继承到子节点,影响图片与 Markdown 排版 */
.chat-msg .chat-msg__md,
.chat-msg .chat-att-wrap {
white-space: normal;
}
.chat-msg__md img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
margin: 0.25rem 0;
vertical-align: middle;
cursor: zoom-in;
}
.chat-msg__plain {
white-space: pre-wrap;
}
.chat-msg__stream-status {
font-size: 12px;
line-height: 1.35;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.62));
margin-bottom: 6px;
}
.chat-att-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.chat-att-img {
max-width: 180px;
max-height: 180px;
border-radius: 8px;
object-fit: cover;
cursor: zoom-in;
}
.chat-img-lightbox {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.chat-img-lightbox__inner {
position: relative;
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__img {
max-width: min(96vw, 100%);
max-height: min(92vh, 100%);
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.chat-img-lightbox__close {
position: absolute;
top: -8px;
right: -8px;
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #eee;
font-size: 26px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__close:hover {
background: rgba(255, 255, 255, 0.22);
}
.chat-att-chip {
font-size: 12px;
opacity: 0.9;
}
.chat-composer-shell {
border-radius: 12px;
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
background: rgba(0, 0, 0, 0.35);
padding: 6px 8px 6px 6px;
}
.chat-composer-shell--busy .chat-composer__field {
opacity: 0.72;
}
.chat-pending-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 4.5rem;
overflow-y: auto;
padding: 2px 4px 6px;
margin: 0 -2px;
}
.chat-pending-files:empty {
display: none;
}
.chat-pending-row {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
}
.chat-pending-row .muted {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-pending-row .chat-pending-remove {
padding: 0 4px;
min-height: 22px;
min-width: 22px;
font-size: 14px;
line-height: 1;
border: none;
background: transparent;
color: inherit;
opacity: 0.75;
border-radius: 6px;
cursor: pointer;
}
.chat-pending-row .chat-pending-remove:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.12);
}
.chat-composer-row {
display: flex;
align-items: flex-end;
gap: 4px;
}
.chat-composer-iconbtn {
flex: 0 0 40px;
width: 40px;
height: 40px;
position: relative;
padding: 0;
margin: 0 0 2px 2px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.75));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.chat-composer-iconbtn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--ds-text, #e8e8e8);
}
.chat-composer-iconbtn:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25));
outline-offset: 1px;
}
.chat-composer-iconbtn svg {
display: block;
}
.chat-composer__field {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 200px;
resize: none;
border: none;
border-radius: 0;
padding: 10px 8px 12px 4px;
margin-bottom: 2px;
background: transparent;
color: inherit;
line-height: 1.45;
}
.chat-composer__field:focus {
outline: none;
}
.chat-composer__field::placeholder {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.45));
}
.chat-composer-actions {
position: relative;
flex: 0 0 40px;
width: 40px;
height: 40px;
margin: 0 2px 2px 0;
}
.chat-composer-send,
.chat-composer-stop {
position: absolute;
right: 0;
bottom: 0;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 999px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.12s ease;
}
.chat-composer-send {
background: rgba(94, 179, 255, 0.92);
color: #0a0a0c;
}
.chat-composer-send:hover:not(:disabled) {
background: rgba(120, 195, 255, 1);
}
.chat-composer-send:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chat-composer-stop {
background: rgba(255, 255, 255, 0.12);
color: #f3f3f3;
opacity: 0;
pointer-events: none;
}
.chat-composer-stop:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.35);
color: #fff;
}
.chat-composer-stop:disabled {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-send {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-stop:not(:disabled) {
opacity: 1;
pointer-events: auto;
}
.chat-msg-cap {
padding: 6px 0;
font-size: 12px;
}
#app .chat-status {
flex-shrink: 0;
min-height: 1.25rem;
line-height: 1.35;
}
.chat-confirm-backdrop {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(0, 0, 0, 0.58);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.chat-confirm-card {
width: min(420px, 92vw);
background: linear-gradient(180deg, rgba(11, 18, 30, 0.98), rgba(7, 12, 22, 0.98));
border: 1px solid rgba(94, 179, 255, 0.35);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
color: var(--ds-text, #e8e8e8);
padding: 14px;
}
.chat-confirm-text {
font-size: 14px;
line-height: 1.45;
color: var(--ds-text, #e8e8e8);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
</head>
<body class="theme-ds-body chat-standalone-page">
<div id="app"></div>
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
<script src="/admin/assets/chat.js?v=20260421-1"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 307 KiB

View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
</head>
<body class="theme-ds-body">
<div class="layout">
<aside class="sidebar">
<div class="brand">
<div class="brand__logoWrap">
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
</div>
</div>
<nav class="nav">
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
<a class="nav__item" data-page="api-grants" href="#/api-grants" data-i18n="nav.apiGrants">API 使用授权</a>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<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>
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
<a class="nav__item" data-page="admin-audit" href="#/admin-audit" data-i18n="nav.adminAudit">Admin Audit</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>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
</nav>
<div class="sidebar__footer">
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div id="topTitle" class="topbar__title">Runtime</div>
<div class="topbar__actions">
<span id="authUser" class="muted"></span>
<a id="btnBackChat" class="btn" data-i18n="nav.chat" href="chat">对话</a>
<button id="btnLogout" class="btn" data-i18n="auth.logout">退出登录</button>
<button id="btnLang" class="btn">中文</button>
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
</div>
</header>
<section id="content" class="content"></section>
</main>
</div>
<script src="/admin/assets/app.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -0,0 +1,315 @@
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
/* Prefer theme variables when enabled (e.g. theme-deepseek.css). */
color: var(--ds-text, #e2e8f0);
background: var(--ds-bg, #0b1020);
}
.layout { display: flex; height: 100vh; }
.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); }
.brand { padding: 16px 16px 8px; }
.brand__logoWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 10px;
}
.brand__logo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.brand__title {
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
}
.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; }
.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; }
.nav__item:hover { background: rgba(148,163,184,0.12); }
.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; }
.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); }
.muted { font-size: 12px; color: rgba(226,232,240,0.65); }
.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); }
.topbar__title { color: #e2e8f0; font-weight: 650; }
.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; }
.btn:hover { background: rgba(148,163,184,0.18); }
.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); }
.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); }
.content { padding: 18px; overflow: auto; color: #e2e8f0; }
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.card__title { font-weight: 650; margin-bottom: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chat-login-fields {
display: flex;
flex-direction: column;
gap: 12px;
margin: 6px 0 10px;
}
.chat-login-fields .input {
width: 100%;
min-width: 0;
}
.chat-login-actions {
margin-bottom: 8px;
}
.row--wecom-form {
flex-wrap: nowrap;
align-items: flex-end;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 4px;
}
.row--wecom-form__field {
flex: 1 1 140px;
min-width: 0;
max-width: 280px;
}
.row--wecom-form__field .input {
min-width: 0;
width: 100%;
max-width: 100%;
}
.row--wecom-form__chk {
flex: 0 0 auto;
white-space: nowrap;
}
.row--wecom-form .btn {
flex: 0 0 auto;
}
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
.input--compact {
min-width: 0;
padding: 6px 8px;
}
.input--readonly {
max-width: min(100%, 720px);
cursor: default;
color: rgba(226,232,240,0.82);
border-color: rgba(148,163,184,0.18);
background: rgba(2,6,23,0.45);
}
.input--readonly::placeholder {
color: rgba(226,232,240,0.45);
}
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; }
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.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;
}
.table__cell-actions {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
align-items: center;
justify-content: flex-end;
}
.session-monitor-row--active {
background: rgba(94, 179, 255, 0.12);
}
.session-monitor-row--active td {
border-bottom-color: rgba(94, 179, 255, 0.35);
}
.session-monitor-modal {
position: fixed;
inset: 0;
z-index: 320;
background: rgba(0, 0, 0, 0.58);
align-items: center;
justify-content: center;
padding: 16px;
}
.session-monitor-modal__card {
width: min(1200px, 96vw);
max-height: 92vh;
overflow: auto;
}
.session-monitor-session-table th:nth-child(1),
.session-monitor-session-table td:nth-child(1) { width: 18%; }
.session-monitor-session-table th:nth-child(2),
.session-monitor-session-table td:nth-child(2) { width: 30%; }
.session-monitor-session-table th:nth-child(3),
.session-monitor-session-table td:nth-child(3) { width: 12%; }
.session-monitor-session-table th:nth-child(4),
.session-monitor-session-table td:nth-child(4) { width: 9%; }
.session-monitor-session-table th:nth-child(5),
.session-monitor-session-table td:nth-child(5) { width: 15%; }
.session-monitor-session-table th:nth-child(6),
.session-monitor-session-table td:nth-child(6) { width: 8%; }
.session-monitor-session-table th:nth-child(7),
.session-monitor-session-table td:nth-child(7) {
width: 8%;
min-width: 4rem;
}
.session-monitor-detail-table th:nth-child(1),
.session-monitor-detail-table td:nth-child(1) { width: 8%; }
.session-monitor-detail-table th:nth-child(2),
.session-monitor-detail-table td:nth-child(2) { width: 8%; }
.session-monitor-detail-table th:nth-child(3),
.session-monitor-detail-table td:nth-child(3) {
width: 62%;
white-space: normal;
word-break: break-word;
}
.session-monitor-detail-table th:nth-child(4),
.session-monitor-detail-table td:nth-child(4) { width: 22%; }
/* Match chat-page "three dots" action style */
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 6px 4px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-sess-more:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid rgba(94, 179, 255, 0.35);
outline-offset: 1px;
}
.chat-sess-more--active {
background: rgba(94, 179, 255, 0.18);
border-color: rgba(94, 179, 255, 0.3);
}
.chat-sess-more--active:hover {
background: rgba(94, 179, 255, 0.22);
}
.chat-sess-menu-pop {
z-index: 300;
background: #222;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.table--resizable thead th {
position: relative;
}
.table-col-resizer {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
}
.table-col-resizer:hover {
background: rgba(94, 179, 255, 0.18);
}
body.col-resize-active {
cursor: col-resize;
}
/* User management: fill row with % columns + roomier padding */
.table--compact.table--users-mgmt {
width: 100%;
min-width: 100%;
}
.table--users-mgmt th,
.table--users-mgmt td { padding: 12px 14px; }
.table--users-mgmt th:nth-child(1),
.table--users-mgmt td:nth-child(1) { width: 15%; }
.table--users-mgmt th:nth-child(2),
.table--users-mgmt td:nth-child(2) { width: 18%; }
.table--users-mgmt th:nth-child(3),
.table--users-mgmt td:nth-child(3) { width: 8%; }
.table--users-mgmt th:nth-child(4),
.table--users-mgmt td:nth-child(4) { width: 6%; }
.table--users-mgmt th:nth-child(5),
.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; }
.table--users-mgmt th:nth-child(6),
.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; }
.table--users-mgmt th:nth-child(7),
.table--users-mgmt td:nth-child(7) {
width: 25%;
min-width: 15rem;
text-align: left;
}
.table--users-mgmt .table__cell-actions {
flex-wrap: nowrap;
gap: 8px 10px;
justify-content: flex-start;
}
.table--users-mgmt .table__cell--form .input {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.cell-copyable { cursor: copy; position: relative; }
.cell-copyable:hover { background: rgba(59,130,246,0.12); }
.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); }
.cell-copyable.cell-copied::after {
content: "Copied";
position: absolute;
right: 6px;
top: 4px;
font-size: 11px;
color: #93c5fd;
}
.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); }
.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; }
.plugins-fold { margin-bottom: 12px; }
.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); }
.plugins-pager .btn.btn--small { min-width: 72px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); }
.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); }
.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); }
.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); }
.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); }
.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; }
.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; }
.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; }
.alert-list li { margin: 4px 0; }

View file

@ -0,0 +1,775 @@
/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */
body.theme-ds-body {
--ds-bg: #0d0d0d;
/* 右侧对话主区:比侧栏/页面略深的黑 */
--ds-chat-main: #050505;
--ds-surface: #1a1a1c;
--ds-surface-2: #222224;
--ds-border: rgba(255, 255, 255, 0.08);
--ds-text: #e8e8e8;
--ds-text-muted: rgba(232, 232, 232, 0.55);
--ds-accent: #5eb3ff;
--ds-accent-soft: rgba(94, 179, 255, 0.18);
/* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */
--chat-sess-hover-bg: rgba(255, 255, 255, 0.05);
--chat-sess-active-bg: rgba(255, 255, 255, 0.062);
--chat-sess-border: rgba(255, 255, 255, 0.09);
/* 用户消息气泡(微信式绿底深字) */
--chat-user-bubble-bg: #42b983;
--chat-user-bubble-fg: #111111;
}
body.theme-ds-body .layout .sidebar {
background: #161618;
border-right-color: var(--ds-border);
}
body.theme-ds-body .brand__title,
body.theme-ds-body .nav__item {
color: var(--ds-text);
}
body.theme-ds-body .nav__item:hover {
background: rgba(255, 255, 255, 0.06);
}
body.theme-ds-body .nav__item--active {
background: var(--ds-accent-soft);
border: 1px solid rgba(94, 179, 255, 0.25);
color: var(--ds-text);
}
body.theme-ds-body .sidebar__footer {
border-top-color: var(--ds-border);
}
body.theme-ds-body .main {
background: var(--ds-bg);
}
body.theme-ds-body .topbar {
background: rgba(22, 22, 24, 0.92);
border-bottom-color: var(--ds-border);
}
body.theme-ds-body .topbar__title {
color: var(--ds-text);
}
body.theme-ds-body .content {
background: var(--ds-bg);
color: var(--ds-text);
}
body.theme-ds-body .muted {
color: var(--ds-text-muted);
}
body.theme-ds-body .btn {
background: rgba(255, 255, 255, 0.06);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
body.theme-ds-body .btn--primary {
background: var(--ds-accent-soft);
border-color: rgba(94, 179, 255, 0.45);
color: var(--ds-text);
}
body.theme-ds-body .input {
background: var(--ds-surface-2);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .card {
background: var(--ds-surface);
border-color: var(--ds-border);
color: var(--ds-text);
}
/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */
.chat-layout {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
}
.chat-nav {
width: 252px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-bg);
overflow: hidden;
}
.chat-nav__top {
flex-shrink: 0;
display: block;
padding: 12px 0 6px;
border-bottom: none;
}
.chat-nav__toolbar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
border-bottom: none;
margin-top: -14px;
position: relative;
z-index: 1;
}
.chat-nav__new {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 40px;
padding: 8px 0 8px 8px;
font-size: 13px;
font-family: inherit;
border-radius: 0;
border: none;
/* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */
background: var(--ds-bg);
color: inherit;
box-shadow: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
cursor: pointer;
text-align: left;
-webkit-appearance: none;
appearance: none;
}
.chat-nav__new:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
}
.chat-nav__newGlyph {
flex-shrink: 0;
position: relative;
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.chat-nav__new:hover .chat-nav__newGlyph {
background-color: rgba(255, 255, 255, 0.22);
transform: scale(1.05);
}
.chat-nav__newGlyph::before,
.chat-nav__newGlyph::after {
content: "";
position: absolute;
background-color: #fff;
border-radius: 2px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.chat-nav__newGlyph::before {
width: 14px;
height: 2px;
}
.chat-nav__newGlyph::after {
width: 2px;
height: 14px;
}
.chat-nav__newLabel,
.chat-nav__newText {
font-size: 13px;
line-height: 1.2;
white-space: nowrap;
}
.chat-nav__brand {
flex: 1;
min-width: 0;
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
line-height: 1.25;
}
.chat-nav__brandWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 0;
}
.chat-nav__brandLogo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* 负水平偏移:在 cover 裁切下再向左露出一点画面 */
object-position: -10px center;
}
.chat-nav__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 6px;
}
.chat-sessions__list {
padding: 0;
}
.chat-nav__footer {
flex-shrink: 0;
border-top: none;
padding: 10px 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: transparent;
}
.chat-nav__user {
min-height: 0;
}
.chat-nav__user-name {
font-size: 13px;
font-weight: 500;
line-height: 1.35;
word-break: break-word;
}
.chat-nav__user-role {
font-size: 11px;
margin-top: 2px;
line-height: 1.3;
}
.chat-nav__link {
display: inline-block;
font-size: 13px;
color: rgba(94, 179, 255, 0.95);
text-decoration: none;
}
.chat-nav__link:hover {
text-decoration: underline;
}
.chat-nav__footer-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chat-sess-btn {
display: block;
width: 100%;
text-align: left;
padding: 10px 0 10px 8px;
margin-bottom: 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 13px;
}
.chat-sess-btn:hover {
background: transparent;
}
.chat-sess-row {
border: 1px solid transparent;
border-radius: 0;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row--active .chat-sess-btn {
color: var(--ds-text, #e8e8e8);
}
.chat-sess-row .chat-sess-more {
opacity: 0;
pointer-events: none;
}
.chat-sess-row:hover .chat-sess-more,
.chat-sess-row:focus-within .chat-sess-more {
opacity: 1;
pointer-events: auto;
}
.chat-sess-row .chat-sess-more--active {
opacity: 1;
pointer-events: auto;
}
.chat-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-chat-main, #050505);
overflow: hidden;
}
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */
.chat-msg__reasoning-pre,
.chat-pending-files,
.table-wrap,
.session-monitor-modal__card {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.chat-msg__reasoning-pre:hover,
.chat-pending-files:hover,
.table-wrap:hover,
.session-monitor-modal__card:hover {
scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre::-webkit-scrollbar,
.chat-pending-files::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar,
.session-monitor-modal__card::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar,
.chat-pending-files:hover::-webkit-scrollbar,
.table-wrap:hover::-webkit-scrollbar,
.session-monitor-modal__card:hover::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-track,
.chat-pending-files::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track,
.session-monitor-modal__card::-webkit-scrollbar-track {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb,
.chat-pending-files::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb,
.session-monitor-modal__card::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track,
.chat-pending-files:hover::-webkit-scrollbar-track,
.table-wrap:hover::-webkit-scrollbar-track,
.session-monitor-modal__card:hover::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb,
.chat-pending-files:hover::-webkit-scrollbar-thumb,
.table-wrap:hover::-webkit-scrollbar-thumb,
.session-monitor-modal__card:hover::-webkit-scrollbar-thumb {
background: rgba(94, 179, 255, 0.45);
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover,
.chat-pending-files::-webkit-scrollbar-thumb:hover,
.table-wrap::-webkit-scrollbar-thumb:hover,
.session-monitor-modal__card::-webkit-scrollbar-thumb:hover {
background: rgba(94, 179, 255, 0.65);
}
.chat-msg {
max-width: 92%;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.chat-row {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.chat-row--assistant {
justify-content: flex-start;
}
.chat-row--user {
justify-content: flex-end;
}
/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */
.chat-msg-col {
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */
.chat-msg-col--assistant {
max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%);
}
.chat-msg-col--user {
align-items: flex-end;
max-width: min(max(0px, calc(50% - 58px)), 100%);
}
.chat-msg-col .chat-msg {
max-width: 100%;
}
.chat-msg-col--user .chat-msg--user {
align-self: flex-end;
}
.chat-avatar-slot {
flex: 0 0 48px;
width: 48px;
height: 48px;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 999px;
}
.chat-avatar {
display: block;
width: 48px;
height: 48px;
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
border-radius: 999px;
object-fit: cover;
flex-shrink: 0;
box-sizing: border-box;
}
/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */
.chat-avatar--bot {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userBuiltin {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userPhoto {
object-fit: cover;
padding: 0;
background: transparent;
}
.chat-row--meta {
padding-left: 58px;
max-width: min(calc(50% + 2cm), 100%);
box-sizing: border-box;
}
.chat-msg__time {
font-size: 11px;
line-height: 1.3;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.5));
margin-bottom: 6px;
white-space: nowrap;
user-select: none;
}
.chat-msg--user {
align-self: flex-end;
background: var(--chat-user-bubble-bg, #42b983);
color: var(--chat-user-bubble-fg, #111);
border: 1px solid rgba(0, 0, 0, 0.12);
}
.chat-msg--user .chat-msg__md,
.chat-msg--user .chat-msg__plain {
color: inherit;
}
.chat-msg--user .chat-msg__md a {
color: #0b4d8c;
}
.chat-msg--user .chat-msg__md pre,
.chat-msg--user .chat-msg__md code {
background: rgba(0, 0, 0, 0.12);
color: #111;
border-color: rgba(0, 0, 0, 0.12);
}
.chat-msg--assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08));
}
.chat-msg--tool {
align-self: flex-start;
font-family: ui-monospace, monospace;
font-size: 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px dashed rgba(255, 255, 255, 0.12);
}
/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */
.chat-msg--thinking {
align-self: flex-start;
max-width: 95%;
font-size: 12px;
line-height: 1.45;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.14);
white-space: pre-wrap;
word-break: break-word;
}
.chat-task-stage {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: default;
}
.chat-task-stage__toggle {
margin-left: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
border-radius: 8px;
padding: 1px 6px;
line-height: 1.2;
cursor: pointer;
}
.chat-task-stage__toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */
.chat-msg--rich {
display: flex;
flex-direction: column;
gap: 6px;
white-space: normal;
}
.chat-msg__text {
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg__reasoning {
align-self: stretch;
margin: 4px 0 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 4px 10px 6px;
}
.chat-msg__reasoning:not([open]) {
padding-bottom: 2px;
}
.chat-msg__reasoning > summary {
cursor: pointer;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.7));
padding: 2px 2px 4px;
user-select: none;
list-style-position: outside;
}
.chat-msg__reasoning[open] > summary {
color: var(--ds-text, #e8e8e8);
margin-bottom: 6px;
}
.chat-msg__reasoning-pre {
margin: 0;
padding: 8px 10px;
max-height: 260px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.chat-msg__reasoning-block {
margin-bottom: 8px;
}
.chat-msg__timeline-logs {
display: flex;
flex-direction: column;
gap: 6px;
margin: 6px 0 2px;
}
.chat-msg__timeline-detail {
margin-top: 0;
}
.chat-msg__process-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.chat-msg__process-summary::-webkit-details-marker {
display: none;
}
.chat-msg__process-summary::marker {
content: "";
}
.chat-msg__process-status {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.78));
}
.chat-msg__process-caret {
color: rgba(232, 232, 232, 0.6);
font-size: 12px;
line-height: 1;
}
.chat-msg__reasoning-title {
font-size: 11px;
opacity: 0.78;
margin-bottom: 4px;
}
.chat-composer {
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 10px 12px 12px;
}
.chat-composer textarea {
font-family: inherit;
font-size: 14px;
}
.chat-status {
padding: 6px 12px;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.55));
}
/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */
body.chat-standalone-page .chat-nav__scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
body.chat-standalone-page .chat-messages {
scrollbar-width: thin;
scrollbar-color: #7a7a7a #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-track {
background: #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb {
background: #7a7a7a;
border-radius: 0;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover {
background: #909090;
}