mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 08:30:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
4
interfaces/admin/__init__.py
Normal file
4
interfaces/admin/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
1532
interfaces/admin/chat_api.py
Normal file
1532
interfaces/admin/chat_api.py
Normal file
File diff suppressed because it is too large
Load diff
184
interfaces/admin/mcp_e2e_probe.py
Normal file
184
interfaces/admin/mcp_e2e_probe.py
Normal 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",
|
||||
]
|
||||
|
||||
561
interfaces/admin/models_api.py
Normal file
561
interfaces/admin/models_api.py
Normal 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
3069
interfaces/admin/routes.py
Normal file
File diff suppressed because it is too large
Load diff
604
interfaces/admin/skills_api.py
Normal file
604
interfaces/admin/skills_api.py
Normal 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"]
|
||||
|
||||
7578
interfaces/admin/static/app.js
Normal file
7578
interfaces/admin/static/app.js
Normal file
File diff suppressed because it is too large
Load diff
455
interfaces/admin/static/chat.html
Normal file
455
interfaces/admin/static/chat.html
Normal 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>
|
||||
3404
interfaces/admin/static/chat.js
Normal file
3404
interfaces/admin/static/chat.js
Normal file
File diff suppressed because it is too large
Load diff
344
interfaces/admin/static/default-user-avatar.svg
Normal file
344
interfaces/admin/static/default-user-avatar.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 307 KiB |
59
interfaces/admin/static/index.html
Normal file
59
interfaces/admin/static/index.html
Normal 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>
|
||||
333
interfaces/admin/static/oliver.svg
Normal file
333
interfaces/admin/static/oliver.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 111 KiB |
315
interfaces/admin/static/styles.css
Normal file
315
interfaces/admin/static/styles.css
Normal 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; }
|
||||
775
interfaces/admin/static/theme-deepseek.css
Normal file
775
interfaces/admin/static/theme-deepseek.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue