From 5b65b7c422e3ec7ce1dd84b11615ba3742904501 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:11:10 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(p2):=20=E5=BD=92=E5=9B=A0=E5=9C=B0?= =?UTF-8?q?=E5=9F=BA=20user=5Fkey=20+=20client=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=20|=20attribution=20foundation=20&=20version?= =?UTF-8?q?=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-2.1(D5): trajectories.user_key 列,canonical=user_name。team_client 桶 discover 入库时从 watch_dirs.label 写入;存量 scripts/backfill_user_key.py 一次性回填(幂等);metrics._traj_client_map 弃 label JOIN 改读新列。 P2-2.10: RegisterRequest.client_version + sync X-Xskill-Version header; clients.client_version 列(touch 时 upsert,未上报不清既有值)。 Co-Authored-By: Claude Fable 5 --- scripts/backfill_user_key.py | 55 ++++++++++++++++++++ src/xskill/dashboard/metrics.py | 12 +++-- src/xskill/pipeline/registry.py | 20 ++++++-- src/xskill/team/client/daemon.py | 8 ++- src/xskill/team/server/api.py | 14 +++-- src/xskill/team/server/client_registry.py | 62 +++++++++++++++-------- src/xskill/team/shared/protocol.py | 3 ++ 7 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 scripts/backfill_user_key.py diff --git a/scripts/backfill_user_key.py b/scripts/backfill_user_key.py new file mode 100644 index 00000000..03ae49a7 --- /dev/null +++ b/scripts/backfill_user_key.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""backfill_user_key.py — P2-2.1 存量归因一次性回填(D5,跑完即弃) + +把 ``trajectories.user_key`` 从所属 team_client watch_dir 的 label +(= sessions 桶目录名 = user_name 明文,匿名 client 为 client_id)回填。 +非 team 目录的轨迹 user_key 留空('(local)' 语义由聚合层给出)。 + +用法(server 机器上跑一次):: + + python scripts/backfill_user_key.py # 默认 ~/.xskill 注册库 + python scripts/backfill_user_key.py /path/to/registry.db + +幂等:只回填 user_key 为空的行,重复跑无副作用。 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from xskill.pipeline.registry import get_connection # noqa: E402 + + +def backfill(db_path: Path | None = None) -> dict: + conn = get_connection(db_path) # get_connection 自带 user_key 列迁移 + try: + before = conn.execute( + "SELECT COUNT(*) FROM trajectories" + " WHERE COALESCE(user_key,'')=''" + ).fetchone()[0] + conn.execute( + "UPDATE trajectories SET user_key = (" + " SELECT w.label FROM watch_dirs w WHERE w.id = trajectories.watch_dir_id" + " AND w.ecosystem = 'team_client' AND COALESCE(w.label,'') != ''" + ") WHERE COALESCE(user_key,'')=''" + " AND watch_dir_id IN (" + " SELECT id FROM watch_dirs" + " WHERE ecosystem='team_client' AND COALESCE(label,'')!='')" + ) + conn.commit() + after = conn.execute( + "SELECT COUNT(*) FROM trajectories" + " WHERE COALESCE(user_key,'')=''" + ).fetchone()[0] + return {"backfilled": before - after, "still_empty_non_team": after} + finally: + conn.close() + + +if __name__ == "__main__": + db = Path(sys.argv[1]) if len(sys.argv) > 1 else None + result = backfill(db) + print(f"回填 {result['backfilled']} 行;" + f" 剩余 user_key 为空(非 team 轨迹,预期): {result['still_empty_non_team']}") diff --git a/src/xskill/dashboard/metrics.py b/src/xskill/dashboard/metrics.py index dc4f4647..161b5e61 100644 --- a/src/xskill/dashboard/metrics.py +++ b/src/xskill/dashboard/metrics.py @@ -187,13 +187,15 @@ def _usage(self) -> list[dict]: return load_usage_records(self._skill_dir) def _traj_client_map(self) -> dict[str, str]: - """traj_id → 用户键。team_client 目录用 label(=user_name/client_id), - 其余目录归 '(local)'。""" + """traj_id → 用户键(canonical=user_name,D5)。 + + P2-2.1 起直接读 ``trajectories.user_key``(team 桶入库时写、存量由 + scripts/backfill_user_key.py 回填),不再 JOIN ``watch_dirs.label``—— + source 唯一,不留两条归因链路。非 team 轨迹 user_key 为空 → '(local)'。""" conn = get_connection(self._db) try: rows = conn.execute( - "SELECT t.filename fn, w.label label, w.ecosystem eco" - " FROM trajectories t JOIN watch_dirs w ON t.watch_dir_id=w.id" + "SELECT filename fn, user_key uk FROM trajectories" ).fetchall() finally: conn.close() @@ -201,7 +203,7 @@ def _traj_client_map(self) -> dict[str, str]: for r in rows: fn = r["fn"] or "" stem = fn[:-3] if fn.endswith(".md") else fn - out[stem] = r["label"] or "(local)" + out[stem] = r["uk"] or "(local)" return out def overview(self) -> dict: diff --git a/src/xskill/pipeline/registry.py b/src/xskill/pipeline/registry.py index 46beec84..ad891af7 100644 --- a/src/xskill/pipeline/registry.py +++ b/src/xskill/pipeline/registry.py @@ -74,6 +74,7 @@ class ProcessAction(str, Enum): canary_side TEXT, source_model TEXT, source_harness TEXT, + user_key TEXT DEFAULT '', ux_score REAL, error_msg TEXT, retry_count INTEGER DEFAULT 0, @@ -186,6 +187,10 @@ def _migrate(conn: sqlite3.Connection) -> None: # 用户 coding agent(harness):discover 时从 .json sidecar 的 harness 写入。 # team server 据此按真实 coding agent 分组,替代把所有上传一律标 team_client。 ("source_harness", "TEXT"), + # P2-2.1 归因地基(D5):canonical 身份键=user_name。team_client 桶 + # discover 时从 watch_dirs.label(=sessions 桶目录名)写入;存量用 + # scripts/backfill_user_key.py 一次性回填。非 team 目录留空。 + ("user_key", "TEXT DEFAULT ''"), ] for col, typedef in migrations: if col not in cols: @@ -620,6 +625,15 @@ def discover_trajectories( conn = get_connection(db_path) new_files: list[str] = [] try: + # P2-2.1 归因(D5):team_client 桶的 label = sessions 桶目录名 = + # user_name(匿名 client 为 client_id)。入库时写进 user_key, + # 聚合层不再 JOIN watch_dirs.label。非 team 目录 user_key 留空。 + wd = conn.execute( + "SELECT label, ecosystem FROM watch_dirs WHERE id=?", + (watch_dir_id,), + ).fetchone() + user_key = (wd["label"] or "") if (wd and wd["ecosystem"] == "team_client") else "" + existing = { row["filename"]: row for row in conn.execute( @@ -638,10 +652,10 @@ def discover_trajectories( conn.execute( "INSERT INTO trajectories" " (watch_dir_id, filename, file_mtime, source_model," - " source_harness)" - " VALUES (?, ?, ?, ?, ?)", + " source_harness, user_key)" + " VALUES (?, ?, ?, ?, ?, ?)", (watch_dir_id, md.name, mtime, _sidecar_model(md), - _sidecar_field(md, "harness")), + _sidecar_field(md, "harness"), user_key), ) new_files.append(md.name) continue diff --git a/src/xskill/team/client/daemon.py b/src/xskill/team/client/daemon.py index bc006e4a..41dbb828 100644 --- a/src/xskill/team/client/daemon.py +++ b/src/xskill/team/client/daemon.py @@ -45,11 +45,14 @@ def register_with_server( ``user_name`` 即 ``--name <工号/userid>``:非空时 server 派生确定性 client_id (跨设备同 name 共享画像),优先于 claimed/fingerprint。 """ + from xskill import __version__ as _xskill_version body = { "token": token, "client_label": label, "hostname": hostname, "claimed_client_id": existing_client_id, + # P2-2.10:server 写 clients.client_version,连接状态看板"落后"标注用 + "client_version": _xskill_version, } if user_name: body["user_name"] = user_name @@ -98,8 +101,11 @@ def __init__( # ── HTTP 鉴权头 ────────────────────────────────────────────── def _hdr(self, extra: dict | None = None) -> dict: + from xskill import __version__ as _xskill_version h = {"X-Xskill-Token": self.state.join_token, - "X-Xskill-Client": self.state.client_id} + "X-Xskill-Client": self.state.client_id, + # P2-2.10:每次 sync 携带版本,server touch 时 upsert 进 clients 表 + "X-Xskill-Version": _xskill_version} if extra: h.update(extra) return h diff --git a/src/xskill/team/server/api.py b/src/xskill/team/server/api.py index b50570c3..d897cc2d 100644 --- a/src/xskill/team/server/api.py +++ b/src/xskill/team/server/api.py @@ -85,15 +85,19 @@ def init_team_context( _ctx.skillhub = skillhub -def _auth(token: str | None, client_id: str | None) -> str: - """校验 token + client_id,返回 client_id。失败抛 HTTPException。""" +def _auth(token: str | None, client_id: str | None, + version: str | None = None) -> str: + """校验 token + client_id,返回 client_id。失败抛 HTTPException。 + + ``version``(P2-2.10)= 请求的 ``X-Xskill-Version`` header,非空时随 + touch 一并 upsert 进 clients.client_version。""" if _ctx.client_registry is None: raise HTTPException(status_code=503, detail="team context not initialized") if not token or token != _ctx.join_token: raise HTTPException(status_code=401, detail="invalid join token") if not client_id or not _ctx.client_registry.exists(client_id): raise HTTPException(status_code=403, detail="unknown client_id") - _ctx.client_registry.touch(client_id) + _ctx.client_registry.touch(client_id, version=version) return client_id @@ -362,6 +366,7 @@ async def team_register(req: RegisterRequest) -> RegisterResponse: hostname=req.hostname, claimed_client_id=req.claimed_client_id, user_name=user_name, + client_version=req.client_version, ) logger.info("team client registered: %s (label=%s, name=%s)", client_id, req.client_label, user_name or "") @@ -470,12 +475,13 @@ async def team_ingest_db( def team_sync( x_xskill_token: str | None = Header(default=None), x_xskill_client: str | None = Header(default=None), + x_xskill_version: str | None = Header(default=None), ): """同步 def(而非 async def):update_user_interest → encode_batch 是同步 httpx(最长 60s)。每个 client 周期性打这个端点,冷启动期 atom 集频繁 变化、画像指纹屡屡失效——写成 async 会让 embedding 后端一慢就把整个 事件循环冻住,所有端点"连不上"。def 路由 FastAPI 自动丢线程池。""" - client_id = _auth(x_xskill_token, x_xskill_client) + client_id = _auth(x_xskill_token, x_xskill_client, version=x_xskill_version) # §5 sync 前刷新该 client 的用户画像(atom 集变化时重算,未变则指纹命中跳过)。 # 画像由 build_manifest → _pick_recommended → engine.get_skill_for_client 消费。 eng = None diff --git a/src/xskill/team/server/client_registry.py b/src/xskill/team/server/client_registry.py index 3bdd0848..8767a808 100644 --- a/src/xskill/team/server/client_registry.py +++ b/src/xskill/team/server/client_registry.py @@ -68,12 +68,13 @@ def safe_dir_name(user_name: str | None, client_id: str) -> str: _SCHEMA = """ CREATE TABLE IF NOT EXISTS clients ( - client_id TEXT PRIMARY KEY, - label TEXT DEFAULT '', - hostname TEXT DEFAULT '', - user_name TEXT DEFAULT '', - joined_at TEXT NOT NULL, - last_seen TEXT NOT NULL + client_id TEXT PRIMARY KEY, + label TEXT DEFAULT '', + hostname TEXT DEFAULT '', + user_name TEXT DEFAULT '', + client_version TEXT DEFAULT '', + joined_at TEXT NOT NULL, + last_seen TEXT NOT NULL ); """ @@ -104,6 +105,10 @@ def _init_schema(self) -> None: cols = {row["name"] for row in conn.execute("PRAGMA table_info(clients)")} if "user_name" not in cols: conn.execute("ALTER TABLE clients ADD COLUMN user_name TEXT DEFAULT ''") + if "client_version" not in cols: + # P2-2.10:client 上报的 xskill 版本,register/sync 时 upsert + conn.execute( + "ALTER TABLE clients ADD COLUMN client_version TEXT DEFAULT ''") conn.commit() finally: conn.close() @@ -114,6 +119,7 @@ def register( hostname: str = "", claimed_client_id: str | None = None, user_name: str | None = None, + client_version: str = "", ) -> str: """注册或续用 client_id。 @@ -144,15 +150,18 @@ def register( ).fetchone() if existing: conn.execute( - "UPDATE clients SET last_seen=?, hostname=?, user_name=?" + "UPDATE clients SET last_seen=?, hostname=?, user_name=?," + " client_version=?" " WHERE client_id=?", - (_now(), hostname or "", norm, client_id), + (_now(), hostname or "", norm, client_version or "", client_id), ) else: conn.execute( "INSERT INTO clients (client_id, label, hostname, user_name," - " joined_at, last_seen) VALUES (?, ?, ?, ?, ?, ?)", - (client_id, label or norm, hostname or "", norm, now, now), + " client_version, joined_at, last_seen)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + (client_id, label or norm, hostname or "", norm, + client_version or "", now, now), ) conn.commit() finally: @@ -160,12 +169,12 @@ def register( return client_id # 优先级 ② — claimed_client_id 命中 if claimed_client_id and self.exists(claimed_client_id): - self.touch(claimed_client_id) + self.touch(claimed_client_id, version=client_version or None) return claimed_client_id # 优先级 ③ — (hostname, label) 指纹回查 existing = self._find_by_fingerprint(hostname=hostname, label=label) if existing: - self.touch(existing) + self.touch(existing, version=client_version or None) return existing # 优先级 ④ — 发新 uuid client_id = uuid.uuid4().hex @@ -173,9 +182,10 @@ def register( conn = self._conn() try: conn.execute( - "INSERT INTO clients (client_id, label, hostname, joined_at, last_seen)" - " VALUES (?, ?, ?, ?, ?)", - (client_id, label, hostname, now, now), + "INSERT INTO clients (client_id, label, hostname, client_version," + " joined_at, last_seen)" + " VALUES (?, ?, ?, ?, ?, ?)", + (client_id, label, hostname, client_version or "", now, now), ) conn.commit() finally: @@ -236,14 +246,24 @@ def exists(self, client_id: str) -> bool: finally: conn.close() - def touch(self, client_id: str) -> None: - """更新 last_seen。client_id 不存在则静默 no-op。""" + def touch(self, client_id: str, *, version: str | None = None) -> None: + """更新 last_seen(P2-2.10:携带 version 时顺带 upsert client_version)。 + + client_id 不存在则静默 no-op。version=None(旧 client 未上报)不清 + 既有值。""" conn = self._conn() try: - conn.execute( - "UPDATE clients SET last_seen=? WHERE client_id=?", - (_now(), client_id), - ) + if version: + conn.execute( + "UPDATE clients SET last_seen=?, client_version=?" + " WHERE client_id=?", + (_now(), version, client_id), + ) + else: + conn.execute( + "UPDATE clients SET last_seen=? WHERE client_id=?", + (_now(), client_id), + ) conn.commit() finally: conn.close() diff --git a/src/xskill/team/shared/protocol.py b/src/xskill/team/shared/protocol.py index 6e91580c..e17225e9 100644 --- a/src/xskill/team/shared/protocol.py +++ b/src/xskill/team/shared/protocol.py @@ -35,6 +35,9 @@ class RegisterRequest(BaseModel): # 同 name 共享画像),优先于 claimed_client_id / 指纹回查。None = 匿名 # (回退 hashid/uuid 逻辑;受 server allow_anonymous_user 闸门)。 user_name: str | None = None + # P2-2.10:client 自报 xskill 版本。server 写 clients.client_version, + # 连接状态看板据此标注"落后"。空串=旧 client 未上报。 + client_version: str = "" class RegisterResponse(BaseModel): From 8c90a8631b79975aeb1470dfb60b8dfb40bc1e7f Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:17:38 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(p2):=20dashboard=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E8=A7=92=E8=89=B2(D2/Q2a)=20|=20dashboard=20?= =?UTF-8?q?login=20&=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clients.dashboard_token:--name 注册时发放(幂等),RegisterResponse 回传, connect 成功后 client 侧打印一次 - dashboard/auth.py:HMAC 签名 cookie 会话(secret 落 ~/.xskill/ dashboard_secret.json 0600);登录=user_name+token;admin=dashboard.admins 名单+admin_password 强口令(为空=admin 登录关闭);require_user/require_admin 依赖(匿名 401/越权 403) - mount_dashboard 增 serve_builtin 开关(D4):独立只读实例登录/写路由物理不挂载 Co-Authored-By: Claude Fable 5 --- src/xskill/cli.py | 11 +- src/xskill/config.py | 7 + src/xskill/dashboard/auth.py | 217 ++++++++++++++++++++++ src/xskill/dashboard/mount.py | 30 ++- src/xskill/team/client/daemon.py | 16 +- src/xskill/team/server/api.py | 8 +- src/xskill/team/server/client_registry.py | 45 +++++ src/xskill/team/shared/protocol.py | 3 + 8 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 src/xskill/dashboard/auth.py diff --git a/src/xskill/cli.py b/src/xskill/cli.py index a314b8e7..237683eb 100644 --- a/src/xskill/cli.py +++ b/src/xskill/cli.py @@ -231,7 +231,7 @@ def _connect_handshake(args, state_path): from xskill.team.client.state import ( ClientState, load_client_state, save_client_state, ) - from xskill.team.client.daemon import register_with_server + from xskill.team.client.daemon import register_with_server_full if not args.token: print("error: 首次 connect 必须带 --token(server 启动时打印的 join token)", @@ -258,13 +258,14 @@ def _connect_handshake(args, state_path): http = httpx.Client(base_url=server_url, timeout=30.0, trust_env=args.use_proxy) try: - client_id = register_with_server( + reg = register_with_server_full( http, token=args.token, label=args.label or _socket.gethostname(), hostname=_socket.gethostname(), existing_client_id=existing_client_id, user_name=args.name or None, ) + client_id = reg["client_id"] except Exception as e: print(f"error: 注册失败: {e}", file=sys.stderr) return None @@ -273,6 +274,12 @@ def _connect_handshake(args, state_path): save_client_state(state, state_path) name_hint = f" (--name={args.name})" if args.name else "" print(f"connected: client_id={client_id} server={server_url}{name_hint}") + # P2-2.2(Q2a):server 为命名用户发放 dashboard 登录 token,这里打印一次。 + # token 幂等(重连拿到同一个),丢了重新 connect 即可再看到。 + dash_token = reg.get("dashboard_token") + if dash_token: + print(f"dashboard 登录: 用户名 {args.name} + token {dash_token}" + f" (server 看板地址 {server_url}/)") return state diff --git a/src/xskill/config.py b/src/xskill/config.py index 6a32fc52..aea165e1 100644 --- a/src/xskill/config.py +++ b/src/xskill/config.py @@ -340,10 +340,17 @@ def dashboard_config(cfg: dict) -> dict: """从已加载 config 取 dashboard 段,缺字段用显式默认(非 fallback 兼容)。""" d = cfg.get("dashboard") or {} attr = _resolve_attribution(d) + admins = d.get("admins") or [] + if not isinstance(admins, list): + raise ValueError("dashboard.admins 必须是 user_name 列表") return { "enabled": bool(d.get("enabled", False)), "public": bool(d.get("public", False)), "password": str(d.get("password", "") or ""), + # P2-2.2(D2/Q2a):admin 名单(user_name) + admin 单独强口令。 + # admin_password 为空 = admin 登录关闭(显式缺省,非默认开)。 + "admins": [str(a).strip() for a in admins if str(a).strip()], + "admin_password": str(d.get("admin_password", "") or ""), "default_harness": attr["harness"], "default_model": attr["model"], } diff --git a/src/xskill/dashboard/auth.py b/src/xskill/dashboard/auth.py new file mode 100644 index 00000000..3a52d896 --- /dev/null +++ b/src/xskill/dashboard/auth.py @@ -0,0 +1,217 @@ +"""dashboard/auth.py — P2-2.2 登录与角色(D2 + Q2a) + +身份复用不另造: +- 普通用户登录 = ``user_name`` + **dashboard token**。token 在 ``xskill connect + --name`` 注册成功时由 server 发放(存 clients.dashboard_token,client 侧打印 + 一次),跨设备同 name 同 token。 +- admin = ``dashboard.admins`` 名单(user_name 列表)+ 单独强口令 + ``dashboard.admin_password``。口令为空 = admin 登录关闭(显式,非默认开)。 + +会话 = HMAC-SHA256 签名 cookie(无服务端 session 表): +``base64url(json{u,role,exp}) . base64url(hmac)``。secret 随机生成落 +``~/.xskill/dashboard_secret.json``(0600),server 重启会话仍有效。 + +角色enforcement走 FastAPI dependency(``require_user`` / ``require_admin``), +写路由本身只在 serve 内置形态挂载(D4)——依赖是第二道闸,不是唯一闸。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import secrets +import time +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel + +logger = logging.getLogger("xskill.dashboard.auth") + +SESSION_COOKIE = "xskill_session" +SESSION_TTL_SECONDS = 7 * 24 * 3600 + + +# --------------------------------------------------------------------------- +# secret 持久化 +# --------------------------------------------------------------------------- + +def ensure_dashboard_secret(path: Path | str) -> str: + """读取(或生成并 0600 落盘)dashboard 会话签名 secret。""" + path = Path(path) + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + sec = data.get("secret") + if isinstance(sec, str) and sec: + return sec + except (json.JSONDecodeError, OSError): + logger.warning("dashboard secret 文件损坏,重新生成: %s", path) + sec = secrets.token_hex(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"secret": sec}), encoding="utf-8") + path.chmod(0o600) + return sec + + +# --------------------------------------------------------------------------- +# 会话签名 +# --------------------------------------------------------------------------- + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _b64d(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +class SessionSigner: + def __init__(self, secret: str): + self._key = secret.encode() + + def sign(self, user: str, role: str, *, ttl: int = SESSION_TTL_SECONDS) -> str: + payload = json.dumps( + {"u": user, "r": role, "exp": int(time.time()) + ttl}, + separators=(",", ":"), + ).encode() + sig = hmac.new(self._key, payload, hashlib.sha256).digest() + return f"{_b64e(payload)}.{_b64e(sig)}" + + def verify(self, token: str) -> Optional[dict]: + """合法且未过期 → {"user":…, "role":…};否则 None。""" + try: + p64, s64 = token.split(".", 1) + payload = _b64d(p64) + sig = _b64d(s64) + except (ValueError, TypeError): + return None + want = hmac.new(self._key, payload, hashlib.sha256).digest() + if not hmac.compare_digest(sig, want): + return None + try: + data = json.loads(payload) + except json.JSONDecodeError: + return None + if int(data.get("exp", 0)) < time.time(): + return None + return {"user": data.get("u", ""), "role": data.get("r", "")} + + +# --------------------------------------------------------------------------- +# 模块级 auth 上下文(mount 时注入,端点依赖读取) +# --------------------------------------------------------------------------- + +class _AuthContext: + def __init__(self): + self.signer: SessionSigner | None = None + self.admins: list[str] = [] + self.admin_password: str = "" + # 零参 callable → ClientRegistry | None。用 provider 而非实例: + # mount_dashboard 在 create_app 时跑,而 ClientRegistry 在 app startup + # 才创建(team ctx 初始化),登录时才解引用。 + self.registry_provider = None + + +_ctx = _AuthContext() + + +def configure_auth(*, secret: str, admins: list[str], admin_password: str, + registry_provider=None) -> None: + """serve 启动时调用一次。registry_provider 返回 None → 普通用户登录 + 不可用(standalone 无 connect 身份,仅 admin 口令登录)。""" + _ctx.signer = SessionSigner(secret) + _ctx.admins = [a.strip() for a in admins if a and a.strip()] + _ctx.admin_password = admin_password or "" + _ctx.registry_provider = registry_provider + + +def _identity_from_request(request: Request) -> Optional[dict]: + if _ctx.signer is None: + return None + raw = request.cookies.get(SESSION_COOKIE, "") + if not raw: + return None + return _ctx.signer.verify(raw) + + +def require_user(request: Request) -> dict: + """登录即可(user 或 admin)。未登录 401。""" + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="login required") + return ident + + +def require_admin(request: Request) -> dict: + """仅 admin。未登录 401,非 admin 403。""" + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="login required") + if ident.get("role") != "admin": + raise HTTPException(status_code=403, detail="admin only") + return ident + + +# --------------------------------------------------------------------------- +# 登录端点 +# --------------------------------------------------------------------------- + +class LoginRequest(BaseModel): + user_name: str + secret: str # 普通用户=dashboard token;admin=dashboard.admin_password + + +def build_auth_router() -> APIRouter: + router = APIRouter(prefix="/api/v1/dashboard") + + @router.post("/login") + def login(req: LoginRequest, response: Response): + if _ctx.signer is None: + raise HTTPException(status_code=503, detail="auth not configured") + user = req.user_name.strip() + if not user or not req.secret: + raise HTTPException(status_code=400, detail="user_name/secret required") + role = _resolve_role(user, req.secret) + if role is None: + raise HTTPException(status_code=401, detail="invalid credentials") + response.set_cookie( + SESSION_COOKIE, _ctx.signer.sign(user, role), + max_age=SESSION_TTL_SECONDS, httponly=True, samesite="lax", + ) + return {"user": user, "role": role} + + @router.post("/logout") + def logout(response: Response): + response.delete_cookie(SESSION_COOKIE) + return {"ok": True} + + @router.get("/me") + def me(request: Request): + ident = _identity_from_request(request) + if ident is None: + raise HTTPException(status_code=401, detail="not logged in") + return ident + + return router + + +def _resolve_role(user: str, secret: str) -> Optional[str]: + """显式两分支判定(非 fallback 链): + ① user ∈ admins 且 admin_password 非空且匹配 → admin + ② client_registry 里该 user_name 的 dashboard token 匹配 → user + """ + if (user in _ctx.admins and _ctx.admin_password + and hmac.compare_digest(secret, _ctx.admin_password)): + return "admin" + reg = _ctx.registry_provider() if _ctx.registry_provider else None + if reg is not None: + stored = reg.dashboard_token_for(user) + if stored and hmac.compare_digest(secret, stored): + # 命名用户也可能在 admins 名单里,但走 token 登录只给 user 角色 + # ——admin 角色必须出示 admin_password(Q2a"admin 单独强口令") + return "user" + return None diff --git a/src/xskill/dashboard/mount.py b/src/xskill/dashboard/mount.py index 599b839c..815bac0d 100644 --- a/src/xskill/dashboard/mount.py +++ b/src/xskill/dashboard/mount.py @@ -4,18 +4,42 @@ from pathlib import Path from typing import Optional -from xskill.config import dashboard_config +from xskill.config import XSKILL_HOME, dashboard_config from xskill.dashboard.router import build_dashboard_router from xskill.dashboard.security import DashboardAccessMiddleware -def mount_dashboard(app, cfg: dict, *, db_path: Optional[Path] = None) -> None: +def _team_registry_provider(): + """登录时解引用 team ctx 的 ClientRegistry(app startup 后才存在)。 + + 非 team 模式 / ctx 未初始化 → None(普通用户登录不可用,仅 admin 口令)。 + """ + from xskill.team.server.api import _ctx as team_ctx + return getattr(team_ctx, "client_registry", None) + + +def mount_dashboard(app, cfg: dict, *, db_path: Optional[Path] = None, + serve_builtin: bool = True) -> None: + """``serve_builtin=False`` = 独立只读实例(D4):登录/写路由物理不挂载。""" dc = dashboard_config(cfg) if not dc["enabled"]: return app.include_router(build_dashboard_router( db_path=db_path, default_harness=dc["default_harness"], - default_model=dc["default_model"])) + default_model=dc["default_model"], + expose_sensitive=serve_builtin)) + if serve_builtin: + # P2-2.2 登录与角色:仅 serve 内置形态挂载(D4)。 + from xskill.dashboard.auth import ( + build_auth_router, configure_auth, ensure_dashboard_secret, + ) + configure_auth( + secret=ensure_dashboard_secret(XSKILL_HOME / "dashboard_secret.json"), + admins=dc["admins"], + admin_password=dc["admin_password"], + registry_provider=_team_registry_provider, + ) + app.include_router(build_auth_router()) app.add_middleware(DashboardAccessMiddleware, public=dc["public"], password=dc["password"]) diff --git a/src/xskill/team/client/daemon.py b/src/xskill/team/client/daemon.py index 41dbb828..9115b01b 100644 --- a/src/xskill/team/client/daemon.py +++ b/src/xskill/team/client/daemon.py @@ -45,6 +45,20 @@ def register_with_server( ``user_name`` 即 ``--name <工号/userid>``:非空时 server 派生确定性 client_id (跨设备同 name 共享画像),优先于 claimed/fingerprint。 """ + return register_with_server_full( + http, token=token, label=label, hostname=hostname, + existing_client_id=existing_client_id, user_name=user_name, + )["client_id"] + + +def register_with_server_full( + http, *, + token: str, label: str, hostname: str, + existing_client_id: str | None = None, + user_name: str | None = None, +) -> dict: + """同 ``register_with_server``,但返回完整响应 dict——CLI 用它拿 + ``dashboard_token``(P2-2.2)在 connect 成功时打印一次。""" from xskill import __version__ as _xskill_version body = { "token": token, @@ -61,7 +75,7 @@ def register_with_server( raise RuntimeError( f"register failed: HTTP {resp.status_code} — {resp.text}" ) - return resp.json()["client_id"] + return resp.json() class TeamClient: diff --git a/src/xskill/team/server/api.py b/src/xskill/team/server/api.py index d897cc2d..0773ae7b 100644 --- a/src/xskill/team/server/api.py +++ b/src/xskill/team/server/api.py @@ -370,7 +370,13 @@ async def team_register(req: RegisterRequest) -> RegisterResponse: ) logger.info("team client registered: %s (label=%s, name=%s)", client_id, req.client_label, user_name or "") - return RegisterResponse(client_id=client_id) + # P2-2.2(Q2a):命名用户发放 dashboard 登录 token(幂等,已有则原样返回)。 + # 匿名用户无 user_name 身份键,dashboard 登录不适用 → None。 + dashboard_token = ( + _ctx.client_registry.ensure_dashboard_token(client_id) + if user_name else None + ) + return RegisterResponse(client_id=client_id, dashboard_token=dashboard_token) @router.post("/upload", response_model=UploadResponse) diff --git a/src/xskill/team/server/client_registry.py b/src/xskill/team/server/client_registry.py index 8767a808..e3069f9c 100644 --- a/src/xskill/team/server/client_registry.py +++ b/src/xskill/team/server/client_registry.py @@ -11,6 +11,7 @@ import hashlib import re +import secrets import sqlite3 import uuid from datetime import datetime, timezone @@ -109,6 +110,10 @@ def _init_schema(self) -> None: # P2-2.10:client 上报的 xskill 版本,register/sync 时 upsert conn.execute( "ALTER TABLE clients ADD COLUMN client_version TEXT DEFAULT ''") + if "dashboard_token" not in cols: + # P2-2.2(Q2a):dashboard 登录凭证,--name 注册时发放并回传打印 + conn.execute( + "ALTER TABLE clients ADD COLUMN dashboard_token TEXT DEFAULT ''") conn.commit() finally: conn.close() @@ -217,6 +222,46 @@ def _find_by_fingerprint( finally: conn.close() + def ensure_dashboard_token(self, client_id: str) -> str: + """确保该 client 行有 dashboard token(无则生成),返回 token。 + + P2-2.2(Q2a):``--name`` 注册路径调用。token 与 user_name 1:1 + (命名用户的 client_id 由 name 确定性派生)。""" + conn = self._conn() + try: + row = conn.execute( + "SELECT dashboard_token FROM clients WHERE client_id=?", + (client_id,), + ).fetchone() + if row is None: + raise ValueError(f"unknown client_id: {client_id}") + existing = row["dashboard_token"] or "" + if existing: + return existing + token = secrets.token_hex(16) + conn.execute( + "UPDATE clients SET dashboard_token=? WHERE client_id=?", + (token, client_id), + ) + conn.commit() + return token + finally: + conn.close() + + def dashboard_token_for(self, user_name: str) -> str | None: + """按 user_name 取 dashboard token(登录校验用)。未注册/无 token → None。""" + norm = _normalize_user_name(user_name) + conn = self._conn() + try: + row = conn.execute( + "SELECT dashboard_token FROM clients WHERE user_name=?" + " ORDER BY last_seen DESC LIMIT 1", + (norm,), + ).fetchone() + return (row["dashboard_token"] or None) if row else None + finally: + conn.close() + def find_by_user_name(self, user_name: str) -> str | None: """按明文 user_name 反查 client_id("按名找人")。 diff --git a/src/xskill/team/shared/protocol.py b/src/xskill/team/shared/protocol.py index e17225e9..3dfe9057 100644 --- a/src/xskill/team/shared/protocol.py +++ b/src/xskill/team/shared/protocol.py @@ -42,6 +42,9 @@ class RegisterRequest(BaseModel): class RegisterResponse(BaseModel): client_id: str + # P2-2.2(Q2a):--name 注册时发放的 dashboard 登录 token,client 侧打印一次。 + # 匿名注册为 None(dashboard 登录依赖 user_name 身份)。 + dashboard_token: str | None = None class UploadTrajectory(BaseModel): From b14aa2aaacd8e99378287560253120467408634c Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:26:58 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(p2):=20skill=5Fprefs=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2=E2=80=94=E2=80=94pin/=E5=B1=8F=E8=94=BD/mani?= =?UTF-8?q?fest=20=E6=B3=A8=E5=85=A5/=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=20|=20control=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry:skill_prefs(user_key|*global*,skill,pref,set_by) + skill_lifecycle (retired);写入侧 pin 配额校验(D8,全局 pin 对全员合计),PinQuotaExceeded→409 - build_manifest 注入顺序(2.4):blocked 排除→pinned 占位→ranked→recommended 回填;retired 无条件不分发(即便被 pin);bucket 枚举加 'pinned';推荐引擎 exclude 集合并入 blocked/retired;sync 读路径 best-effort 绝不 500 - canary.check_and_decide:retired skill 返回 action='retired',不再晋升/翻牌 - dashboard/console.py(仅 serve 内置形态挂载,D4): /my/manifest /my/prefs /my/contributions /my/reco-trigger /admin/users-matrix /admin/user/{k}/prefs /admin/prefs /admin/skills /admin/skill/{n}/retire|unretire DELETE /admin/skill/{n}(confirm_name 二次 确认+skill_repo_lock+prefs 清理,删后同名再生从零开始) - admin 设置的条目普通用户不可取消/屏蔽(403);用户级推荐×触发口径:曝光= 去重 recommendation_log(client→user_name 经 ClientRegistry 翻译,D5), 触发=.ux_scores.jsonl 按用户轨迹归因;结论列阈值注释写死 Co-Authored-By: Claude Fable 5 --- src/xskill/canary.py | 6 + src/xskill/dashboard/console.py | 466 +++++++++++++++++++++++ src/xskill/dashboard/mount.py | 3 + src/xskill/pipeline/registry.py | 206 ++++++++++ src/xskill/team/server/api.py | 16 + src/xskill/team/server/skill_manifest.py | 67 +++- src/xskill/team/shared/protocol.py | 2 +- 7 files changed, 748 insertions(+), 18 deletions(-) create mode 100644 src/xskill/dashboard/console.py diff --git a/src/xskill/canary.py b/src/xskill/canary.py index e13bfa58..b51db6d3 100644 --- a/src/xskill/canary.py +++ b/src/xskill/canary.py @@ -517,6 +517,12 @@ def check_and_decide(skill_dir: Path, config: CanaryConfig | None = None, cfg = config or CanaryConfig() skill_dir = Path(skill_dir) + # P2-2.4c:已下线 skill 不再参与灰度判定——不晋升、不翻牌,staging 原样 + # 冻结(数据保留;恢复在役后按剩余样本继续)。 + from xskill.pipeline.registry import retired_skills + if skill_dir.name in retired_skills(): + return {"action": "retired"} + if not has_staging(skill_dir): return {"action": "no_staging"} diff --git a/src/xskill/dashboard/console.py b/src/xskill/dashboard/console.py new file mode 100644 index 00000000..f85d6d97 --- /dev/null +++ b/src/xskill/dashboard/console.py @@ -0,0 +1,466 @@ +"""dashboard/console.py — P2 控制面端点(§2.4 我的 / §2.5 管理 / §2.6 归因) + +只在 **serve 内置形态**挂载(D4):独立只读实例物理上拿不到这些路由。 +角色由 ``auth.require_user`` / ``auth.require_admin`` 把守(第二道闸)。 + +team 上下文(skill_dir / slot 配置 / ClientRegistry)在 app startup 才就绪, +这里全部经 provider 延迟解引用——与 ``dashboard.auth`` 同一模式。 +""" +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from xskill.dashboard.auth import require_admin, require_user +from xskill.pipeline.registry import ( + GLOBAL_PREF_KEY, + PinQuotaExceeded, + clear_skill_pref, + effective_prefs, + get_connection, + prefs_for, + purge_skill_records, + retire_skill, + retired_skills, + set_skill_pref, + unretire_skill, +) + +logger = logging.getLogger("xskill.dashboard.console") + + +def _team_ctx(): + from xskill.team.server.api import _ctx + return _ctx + + +def _require_team_ctx(): + ctx = _team_ctx() + if getattr(ctx, "client_registry", None) is None: + raise HTTPException( + status_code=503, + detail="team server 未启用——控制面依赖 connect 身份与 skill 分发上下文") + return ctx + + +def _total_slots() -> int: + ctx = _team_ctx() + return int(getattr(ctx, "total_slots", 100) or 100) + + +def _traj_user_map(db_path: Optional[Path]) -> dict[str, str]: + """traj_id(stem) → user_key。直接读 trajectories.user_key(P2-2.1)。""" + conn = get_connection(db_path) + try: + rows = conn.execute( + "SELECT filename fn, user_key uk FROM trajectories").fetchall() + finally: + conn.close() + out: dict[str, str] = {} + for r in rows: + fn = r["fn"] or "" + out[fn[:-3] if fn.endswith(".md") else fn] = r["uk"] or "" + return out + + +def _client_to_user(registry) -> dict[str, str]: + """client_id → user_name 翻译表(D5:凡存 client_id 的表聚合时统一翻译)。 + 匿名 client 无 user_name → 保留 client_id 原文(诚实标注,不伪装成用户名)。""" + out: dict[str, str] = {} + for row in registry.list(): + out[row["client_id"]] = row.get("user_name") or row["client_id"] + return out + + +# --------------------------------------------------------------------------- +# 推荐 × 触发(2.6 用户级精确口径) +# --------------------------------------------------------------------------- + +def reco_trigger_for_users(*, db_path: Optional[Path], skill_dir: Path, + registry) -> dict[str, list[dict]]: + """全量算 user_key → [{skill, exposures, triggers, rate, last_trigger, + verdict}]。 + + 口径(与 docs/dashboard-metrics.md 同源原则): + - 曝光 = recommendation_log 去重行(已按 (client,skill,side,sha) 唯一), + client_id 经 ClientRegistry 译成 user_name(D5)。 + - 触发 = 该用户轨迹产生的 .ux_scores.jsonl 打分记录(atom used_skills + 命中事实源,PR#74 审计口径),按 (skill,traj) 计数。 + - 结论列(阈值写死并在此注明,不是可调玄学): + exposures>=3 且 triggers==0 → 零触发→建议停推 + exposures>=5 且 rate<0.1 → 常推不用→建议停推 + rate>=0.5 且 triggers>=2 → 高价值 + 其余 → 正常 + """ + from xskill.dashboard.metrics import load_usage_records + + c2u = _client_to_user(registry) + conn = get_connection(db_path) + try: + reco_rows = conn.execute( + "SELECT client_id, skill, ts FROM recommendation_log").fetchall() + finally: + conn.close() + + exposures: dict[tuple, int] = {} + for r in reco_rows: + user = c2u.get(r["client_id"] or "", r["client_id"] or "") + key = (user, r["skill"] or "") + exposures[key] = exposures.get(key, 0) + 1 + + traj_user = _traj_user_map(db_path) + triggers: dict[tuple, int] = {} + last_trigger: dict[tuple, str] = {} + for rec in load_usage_records(skill_dir): + user = traj_user.get(rec.get("traj_id") or "", "") + if not user: + continue + key = (user, rec.get("skill") or "") + triggers[key] = triggers.get(key, 0) + 1 + ts = rec.get("scored_at") or "" + if ts > last_trigger.get(key, ""): + last_trigger[key] = ts + + out: dict[str, list[dict]] = {} + for (user, skill), n_exp in exposures.items(): + n_trig = triggers.get((user, skill), 0) + rate = n_trig / n_exp if n_exp else 0.0 + if n_exp >= 3 and n_trig == 0: + verdict = "零触发→建议停推" + elif n_exp >= 5 and rate < 0.1: + verdict = "常推不用→建议停推" + elif rate >= 0.5 and n_trig >= 2: + verdict = "高价值" + else: + verdict = "正常" + out.setdefault(user, []).append({ + "skill": skill, "exposures": n_exp, "triggers": n_trig, + "rate": round(rate, 3), + "last_trigger": last_trigger.get((user, skill), ""), + "verdict": verdict, + }) + for rows in out.values(): + rows.sort(key=lambda r: (-r["exposures"], r["skill"])) + return out + + +# --------------------------------------------------------------------------- +# 请求模型 +# --------------------------------------------------------------------------- + +PrefAction = Literal["pin", "block", "clear"] + + +class MyPrefRequest(BaseModel): + skill_name: str + action: PrefAction + + +class AdminPrefRequest(BaseModel): + user_key: str # user_name 或 '*global*' + skill_name: str + action: PrefAction + + +class DeleteSkillRequest(BaseModel): + confirm_name: str # 二次确认:必须输入 skill 名 + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + +def build_console_router(db_path: Optional[Path] = None) -> APIRouter: + router = APIRouter(prefix="/api/v1/dashboard") + + # ── 我的(普通用户,2.3/2.4/2.5) ───────────────────────────────── + + @router.get("/my/manifest") + def my_manifest(ident=Depends(require_user)): + """登录用户视角的 slot 清单 + 已屏蔽组。槽位 chip 标注注入类型。""" + ctx = _require_team_ctx() + from xskill.team.server.skill_manifest import build_manifest + user = ident["user"] + prefs = effective_prefs(user, db_path=db_path) + client_id = ctx.client_registry.find_by_user_name(user) or user + resp = build_manifest( + client_id=client_id, + skill_dir=ctx.skill_dir, + probability=ctx.probability, + ranked_slots=ctx.ranked_slots, + total_slots=ctx.total_slots, + traj_root=ctx.traj_root, + prefs=prefs, + retired=retired_skills(db_path=db_path), + ) + slots = [] + for s in resp.slots: + meta = prefs["pin_meta"].get(s.skill_name, {}) + slots.append({ + "skill_name": s.skill_name, "side": s.side, "sha": s.sha, + "bucket": s.bucket, "source": s.source, + # pinned 细分:自己 pin / admin 代 pin / 全局 pin——前端置灰依据 + "pin_scope": meta.get("scope", ""), + "pin_set_by": meta.get("set_by", ""), + "user_removable": ( + s.bucket != "pinned" or meta.get("set_by") == user), + }) + blocked_rows = [r for r in prefs_for(user, db_path=db_path) + if r["pref"] == "blocked"] + return {"user": user, "slots": slots, + "blocked": [{"skill_name": r["skill_name"], + "set_by": r["set_by"], "ts": r["ts"]} + for r in blocked_rows], + "total_slots": ctx.total_slots} + + @router.post("/my/prefs") + def my_prefs(req: MyPrefRequest, ident=Depends(require_user)): + """用户自 pin / 屏蔽 / 恢复。admin 设的条目不可动(403,前端置灰)。""" + _require_team_ctx() + user = ident["user"] + existing = {r["skill_name"]: r for r in prefs_for(user, db_path=db_path)} + row = existing.get(req.skill_name) + if row is not None and row["set_by"] != user: + raise HTTPException( + status_code=403, + detail=f"该条目由 admin({row['set_by']}) 设置,普通用户不可更改") + gprefs = {r["skill_name"]: r for r in + prefs_for(GLOBAL_PREF_KEY, db_path=db_path)} + if req.skill_name in gprefs and gprefs[req.skill_name]["pref"] == "pinned" \ + and req.action in ("block", "clear"): + raise HTTPException( + status_code=403, detail="全局 pin 的条目普通用户不可取消/屏蔽") + if req.action == "clear": + if not clear_skill_pref(user_key=user, skill_name=req.skill_name, + db_path=db_path): + raise HTTPException(status_code=404, detail="没有这条偏好") + return {"ok": True} + pref = "pinned" if req.action == "pin" else "blocked" + try: + set_skill_pref(user_key=user, skill_name=req.skill_name, pref=pref, + set_by=user, + max_pinned=_total_slots(), db_path=db_path) + except PinQuotaExceeded as e: + # D8/2.4d:超量在写入侧拒绝——409 冲突,永不进 sync 路径 + raise HTTPException(status_code=409, detail=str(e)) from e + return {"ok": True} + + @router.get("/my/contributions") + def my_contributions(ident=Depends(require_user)): + """我的贡献去向(2.5):四级步进 trajs→atoms→采纳→skills + 使用者。""" + ctx = _require_team_ctx() + from xskill.dashboard.metrics import load_usage_records + user = ident["user"] + conn = get_connection(db_path) + try: + row = conn.execute( + "SELECT COUNT(*) n, COALESCE(SUM(tasks_extracted),0) atoms" + " FROM trajectories WHERE user_key=?", (user,)).fetchone() + my_stems = { + (r["filename"][:-3] if r["filename"].endswith(".md") + else r["filename"]) + for r in conn.execute( + "SELECT filename FROM trajectories WHERE user_key=?", + (user,)).fetchall() + } + adoption = conn.execute( + "SELECT atom_id, skill, weightscore FROM atom_adoption" + ).fetchall() + finally: + conn.close() + # atom_id 内嵌 traj_id(atom__NNNN)——按包含判定归属 + my_adopted = [dict(a) for a in adoption + if any(stem in (a["atom_id"] or "") for stem in my_stems)] + my_skills = sorted({a["skill"] for a in my_adopted if a["skill"]}) + + traj_user = _traj_user_map(db_path) + usage_by_skill: dict[str, dict] = {} + for rec in load_usage_records(ctx.skill_dir): + skill = rec.get("skill") or "" + if skill not in my_skills: + continue + u = traj_user.get(rec.get("traj_id") or "", "") or "(unknown)" + entry = usage_by_skill.setdefault( + skill, {"skill": skill, "users": {}, "scores": []}) + entry["users"][u] = entry["users"].get(u, 0) + 1 + if rec.get("score") is not None: + entry["scores"].append(rec["score"]) + usage = [] + for skill, e in sorted(usage_by_skill.items()): + scores = e.pop("scores") + e["avg_score"] = round(sum(scores) / len(scores), 2) if scores else None + e["users"] = [{"user": u, "count": c} + for u, c in sorted(e["users"].items(), + key=lambda kv: -kv[1])] + usage.append(e) + return { + "user": user, + "steps": {"trajs": row["n"], "atoms": row["atoms"], + "adopted_atoms": len(my_adopted), + "skills": len(my_skills)}, + "skills": my_skills, + "usage": usage, + } + + @router.get("/my/reco-trigger") + def my_reco_trigger(ident=Depends(require_user)): + """推给我的 × 我触发的(2.6 用户级精确口径,结论列可执行)。""" + ctx = _require_team_ctx() + table = reco_trigger_for_users( + db_path=db_path, skill_dir=Path(ctx.skill_dir), + registry=ctx.client_registry) + return {"user": ident["user"], "rows": table.get(ident["user"], [])} + + # ── 管理(admin,2.5/2.4c) ─────────────────────────────────────── + + @router.get("/admin/users-matrix") + def admin_users_matrix(_=Depends(require_admin)): + """用户 × 推送/配置矩阵:被推荐数/pinned·blocked 计数/触发率。""" + ctx = _require_team_ctx() + table = reco_trigger_for_users( + db_path=db_path, skill_dir=Path(ctx.skill_dir), + registry=ctx.client_registry) + rows = [] + for client in ctx.client_registry.list(): + user = client.get("user_name") or client["client_id"] + prefs = {r["skill_name"]: r["pref"] + for r in prefs_for(user, db_path=db_path)} + rt = table.get(user, []) + n_exp = sum(r["exposures"] for r in rt) + n_trig = sum(r["triggers"] for r in rt) + rows.append({ + "user": user, + "client_version": client.get("client_version") or "", + "last_seen": client.get("last_seen") or "", + "exposures": n_exp, + "triggers": n_trig, + "rate": round(n_trig / n_exp, 3) if n_exp else None, + "pinned": sum(1 for p in prefs.values() if p == "pinned"), + "blocked": sum(1 for p in prefs.values() if p == "blocked"), + "stale_advice": [r for r in rt if "停推" in r["verdict"]][:5], + }) + rows.sort(key=lambda r: r["user"]) + global_pins = [r["skill_name"] for r in + prefs_for(GLOBAL_PREF_KEY, db_path=db_path) + if r["pref"] == "pinned"] + return {"users": rows, "global_pinned": global_pins} + + @router.get("/admin/user/{user_key}/prefs") + def admin_user_prefs(user_key: str, _=Depends(require_admin)): + return {"user_key": user_key, + "prefs": prefs_for(user_key, db_path=db_path), + "effective": { + **effective_prefs(user_key, db_path=db_path), + "blocked": sorted( + effective_prefs(user_key, db_path=db_path)["blocked"]), + }} + + @router.post("/admin/prefs") + def admin_prefs(req: AdminPrefRequest, ident=Depends(require_admin)): + """admin 代 pin / 代屏蔽 / 全局 pin(user_key='*global*')。""" + _require_team_ctx() + if not req.user_key: + raise HTTPException(status_code=400, detail="user_key required") + if req.action == "clear": + if not clear_skill_pref(user_key=req.user_key, + skill_name=req.skill_name, db_path=db_path): + raise HTTPException(status_code=404, detail="没有这条偏好") + return {"ok": True} + pref = "pinned" if req.action == "pin" else "blocked" + try: + set_skill_pref(user_key=req.user_key, skill_name=req.skill_name, + pref=pref, set_by=ident["user"], + max_pinned=_total_slots(), db_path=db_path) + except PinQuotaExceeded as e: + raise HTTPException(status_code=409, detail=str(e)) from e + return {"ok": True} + + @router.get("/admin/skills") + def admin_skills(_=Depends(require_admin)): + """技能生命周期表:状态徽章 + 近 30 日使用数。""" + ctx = _require_team_ctx() + from xskill.canary import has_staging + from xskill.dashboard.metrics import load_usage_records + from xskill.skill.repo import SkillRepo + import datetime as _dt + retired = retired_skills(db_path=db_path) + cutoff = (_dt.datetime.now(_dt.timezone.utc) + - _dt.timedelta(days=30)).isoformat() + usage30: dict[str, int] = {} + for rec in load_usage_records(Path(ctx.skill_dir)): + if (rec.get("scored_at") or "") >= cutoff: + usage30[rec.get("skill") or ""] = \ + usage30.get(rec.get("skill") or "", 0) + 1 + out = [] + for s in SkillRepo(Path(ctx.skill_dir)): + if s.name in retired: + state = "retired" + elif has_staging(s.path): + state = "canary" + else: + state = "active" + out.append({"name": s.name, "state": state, + "usage_30d": usage30.get(s.name, 0)}) + out.sort(key=lambda r: r["name"]) + return {"skills": out} + + @router.post("/admin/skill/{name}/retire") + def admin_retire(name: str, ident=Depends(require_admin)): + ctx = _require_team_ctx() + if not (Path(ctx.skill_dir) / name).is_dir(): + raise HTTPException(status_code=404, detail=f"skill not found: {name}") + retire_skill(skill_name=name, set_by=ident["user"], db_path=db_path) + _invalidate_engine_cache() + return {"ok": True, "state": "retired"} + + @router.post("/admin/skill/{name}/unretire") + def admin_unretire(name: str, _=Depends(require_admin)): + _require_team_ctx() + if not unretire_skill(skill_name=name, db_path=db_path): + raise HTTPException(status_code=404, detail=f"{name} 不在下线状态") + _invalidate_engine_cache() + return {"ok": True, "state": "active"} + + @router.delete("/admin/skill/{name}") + def admin_delete(name: str, req: DeleteSkillRequest, + ident=Depends(require_admin)): + """删除(两段式第二段):需 confirm_name 输入 skill 名。 + + 抢 ``skill_repo_lock``(与 watcher/canary 同一把锁)防并发;删目录 + + 清 prefs/lifecycle 行——删后同名 skill 再生从零开始(2.4c 语义)。 + """ + ctx = _require_team_ctx() + if req.confirm_name != name: + raise HTTPException( + status_code=400, + detail=f"二次确认失败:请输入 skill 名 {name!r}") + skill_dir = Path(ctx.skill_dir) + if not (skill_dir / name).is_dir(): + raise HTTPException(status_code=404, detail=f"skill not found: {name}") + from xskill.skill.git import skill_repo_lock + from xskill.skill.skill import delete_skill + with skill_repo_lock(skill_dir / name): + ok = delete_skill(skill_dir, name) + if not ok: + raise HTTPException(status_code=500, detail="delete commit failed") + purge_skill_records(skill_name=name, db_path=db_path) + _invalidate_engine_cache() + logger.info("admin %s deleted skill %s", ident["user"], name) + return {"ok": True} + + return router + + +def _invalidate_engine_cache() -> None: + """retire/delete 后失效推荐引擎缓存——否则引擎继续按旧候选池推荐。""" + try: + from xskill.team.server.skill_manifest import get_recommend_engine + eng = get_recommend_engine() + if eng is not None: + eng.invalidate_cache() + except Exception: # pylint: disable=broad-exception-caught + logger.debug("engine cache invalidation skipped", exc_info=True) diff --git a/src/xskill/dashboard/mount.py b/src/xskill/dashboard/mount.py index 815bac0d..5c99fdaf 100644 --- a/src/xskill/dashboard/mount.py +++ b/src/xskill/dashboard/mount.py @@ -41,5 +41,8 @@ def mount_dashboard(app, cfg: dict, *, db_path: Optional[Path] = None, registry_provider=_team_registry_provider, ) app.include_router(build_auth_router()) + # P2 控制面(我的/管理):同样只在 serve 内置形态 + from xskill.dashboard.console import build_console_router + app.include_router(build_console_router(db_path=db_path)) app.add_middleware(DashboardAccessMiddleware, public=dc["public"], password=dc["password"]) diff --git a/src/xskill/pipeline/registry.py b/src/xskill/pipeline/registry.py index ad891af7..077716c4 100644 --- a/src/xskill/pipeline/registry.py +++ b/src/xskill/pipeline/registry.py @@ -131,6 +131,27 @@ class ProcessAction(str, Enum): age_days REAL ); +-- P2-2.4 控制面:用户/全局 skill 偏好(pinned|blocked)。 +-- user_key='*global*' 为全局 pin/屏蔽(admin 设);其余为 user_name(D5)。 +-- 超量校验在写入侧(D8),sync 读路径永不因此报错。 +CREATE TABLE IF NOT EXISTS skill_prefs ( + user_key TEXT NOT NULL, + skill_name TEXT NOT NULL, + pref TEXT NOT NULL CHECK(pref IN ('pinned','blocked')), + set_by TEXT DEFAULT '', + ts TEXT DEFAULT (datetime('now')), + PRIMARY KEY (user_key, skill_name) +); + +-- P2-2.4c skill 生命周期:retired=下线(停止分发/推荐,数据与 git 历史保留)。 +-- 删除是物理动作不入此表;"在役"=无行。 +CREATE TABLE IF NOT EXISTS skill_lifecycle ( + skill_name TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK(state IN ('retired')), + set_by TEXT DEFAULT '', + ts TEXT DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS skill_trigger_eval ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT (datetime('now')), @@ -277,6 +298,191 @@ def record_usage(*, step: str, model: str, prompt: int, completion: int, # 记录函数走旁路 telemetry——调用点用 try/except 包,记录失败绝不阻断管线。 # --------------------------------------------------------------------------- +GLOBAL_PREF_KEY = "*global*" + + +class PinQuotaExceeded(ValueError): + """pinned 总量(用户+全局)超 slot 上限——写入侧拒绝(D8)。""" + + +def set_skill_pref(*, user_key: str, skill_name: str, pref: str, set_by: str, + max_pinned: Optional[int] = None, + db_path: Optional[Path] = None) -> None: + """写入/覆盖一条偏好(pinned|blocked)。 + + D8:``max_pinned`` 给定且 pref='pinned' 时在**写入侧**校验——该用户 + pinned + 全局 pinned 合计(去重,含本次)不得超过 max_pinned,超量抛 + ``PinQuotaExceeded``,超量状态根本不可能入库;全局 pin 则对**全员** + 逐一校验合计。sync 读路径永远不需要处理该错误。 + """ + if pref not in ("pinned", "blocked"): + raise ValueError(f"pref 必须是 pinned|blocked,得到 {pref!r}") + if not user_key or not skill_name: + raise ValueError("user_key/skill_name 不能为空") + conn = get_connection(db_path) + try: + if pref == "pinned" and max_pinned is not None: + _check_pin_quota(conn, user_key=user_key, skill_name=skill_name, + max_pinned=max_pinned) + conn.execute( + "INSERT INTO skill_prefs(user_key,skill_name,pref,set_by)" + " VALUES(?,?,?,?)" + " ON CONFLICT(user_key,skill_name)" + " DO UPDATE SET pref=excluded.pref, set_by=excluded.set_by," + " ts=datetime('now')", + (user_key, skill_name, pref, set_by), + ) + conn.commit() + finally: + conn.close() + + +def _check_pin_quota(conn, *, user_key: str, skill_name: str, + max_pinned: int) -> None: + """pinned 配额校验(含本次写入)。全局 pin 对全员合计逐一校验。""" + def pinned_set(key: str) -> set: + return {r["skill_name"] for r in conn.execute( + "SELECT skill_name FROM skill_prefs WHERE user_key=? AND pref='pinned'", + (key,), + ).fetchall()} + + global_pinned = pinned_set(GLOBAL_PREF_KEY) + if user_key == GLOBAL_PREF_KEY: + global_pinned = global_pinned | {skill_name} + user_keys = [r["user_key"] for r in conn.execute( + "SELECT DISTINCT user_key FROM skill_prefs WHERE user_key!=?", + (GLOBAL_PREF_KEY,), + ).fetchall()] + for uk in user_keys + [None]: # None=只有全局 pin 的裸用户基线 + merged = global_pinned | (pinned_set(uk) if uk else set()) + if len(merged) > max_pinned: + raise PinQuotaExceeded( + f"全局 pin {skill_name!r} 会使用户 {uk or '(baseline)'} 的" + f" pinned 合计 {len(merged)} 超过 slot 上限 {max_pinned}") + else: + merged = global_pinned | pinned_set(user_key) | {skill_name} + if len(merged) > max_pinned: + raise PinQuotaExceeded( + f"pin {skill_name!r} 会使 pinned 合计(含全局) {len(merged)}" + f" 超过 slot 上限 {max_pinned}") + + +def clear_skill_pref(*, user_key: str, skill_name: str, + db_path: Optional[Path] = None) -> bool: + """删除一条偏好(pin 取消/屏蔽恢复)。返回是否真的删了行。""" + conn = get_connection(db_path) + try: + cur = conn.execute( + "DELETE FROM skill_prefs WHERE user_key=? AND skill_name=?", + (user_key, skill_name), + ) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def prefs_for(user_key: str, *, db_path: Optional[Path] = None) -> list[dict]: + """某 user_key(或 '*global*')的全部偏好行。""" + conn = get_connection(db_path) + try: + return [dict(r) for r in conn.execute( + "SELECT user_key, skill_name, pref, set_by, ts FROM skill_prefs" + " WHERE user_key=? ORDER BY ts", + (user_key,), + ).fetchall()] + finally: + conn.close() + + +def effective_prefs(user_key: str, *, db_path: Optional[Path] = None) -> dict: + """manifest 注入用的合并视图:{'pinned': [...有序...], 'blocked': set, + 'pin_meta': {skill: set_by}}。 + + 合并规则:全局行先于用户行(admin 全局 pin 排最前);同一 skill 用户行 + 与全局行冲突时,**blocked 优先**(任何一侧屏蔽即不分发——全局屏蔽用户 + 不能自行恢复;用户自己屏蔽的全局推荐仅对自己生效)。 + """ + conn = get_connection(db_path) + try: + rows = [dict(r) for r in conn.execute( + "SELECT user_key, skill_name, pref, set_by, ts FROM skill_prefs" + " WHERE user_key IN (?, ?) ORDER BY CASE user_key WHEN ? THEN 0" + " ELSE 1 END, ts", + (GLOBAL_PREF_KEY, user_key, GLOBAL_PREF_KEY), + ).fetchall()] + finally: + conn.close() + blocked = {r["skill_name"] for r in rows if r["pref"] == "blocked"} + pinned: list[str] = [] + pin_meta: dict = {} + for r in rows: + if r["pref"] == "pinned" and r["skill_name"] not in blocked \ + and r["skill_name"] not in pinned: + pinned.append(r["skill_name"]) + pin_meta[r["skill_name"]] = { + "set_by": r["set_by"], + "scope": "global" if r["user_key"] == GLOBAL_PREF_KEY else "user", + } + return {"pinned": pinned, "blocked": blocked, "pin_meta": pin_meta} + + +# --------------------------------------------------------------------------- +# P2-2.4c skill 生命周期 +# --------------------------------------------------------------------------- + +def retire_skill(*, skill_name: str, set_by: str, + db_path: Optional[Path] = None) -> None: + """下线:停止分发与推荐,数据与 git 历史保留。幂等。""" + conn = get_connection(db_path) + try: + conn.execute( + "INSERT INTO skill_lifecycle(skill_name,state,set_by) VALUES(?,?,?)" + " ON CONFLICT(skill_name) DO UPDATE SET state='retired'," + " set_by=excluded.set_by, ts=datetime('now')", + (skill_name, "retired", set_by), + ) + conn.commit() + finally: + conn.close() + + +def unretire_skill(*, skill_name: str, db_path: Optional[Path] = None) -> bool: + """恢复在役。返回是否真的有行被删。""" + conn = get_connection(db_path) + try: + cur = conn.execute( + "DELETE FROM skill_lifecycle WHERE skill_name=?", (skill_name,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def retired_skills(*, db_path: Optional[Path] = None) -> set: + conn = get_connection(db_path) + try: + return {r["skill_name"] for r in conn.execute( + "SELECT skill_name FROM skill_lifecycle WHERE state='retired'" + ).fetchall()} + finally: + conn.close() + + +def purge_skill_records(*, skill_name: str, + db_path: Optional[Path] = None) -> None: + """删除 skill 时清掉它的 prefs/lifecycle 行——"删后同名 skill 再生"从 + 零开始,不继承旧 pin/屏蔽/下线状态(语义拍板,见 tasks.md 2.4c)。 + 评分/推荐等历史埋点(recommendation_log 等)保留作审计。""" + conn = get_connection(db_path) + try: + conn.execute("DELETE FROM skill_prefs WHERE skill_name=?", (skill_name,)) + conn.execute("DELETE FROM skill_lifecycle WHERE skill_name=?", (skill_name,)) + conn.commit() + finally: + conn.close() + + def record_recommendation(*, client_id: str, skill: str, side: str, bucket: str, sha: str = "", db_path: Optional[Path] = None) -> None: """记一次"把 skill(某版本)推荐给某用户"——曝光事件。 diff --git a/src/xskill/team/server/api.py b/src/xskill/team/server/api.py index 0773ae7b..1c56873c 100644 --- a/src/xskill/team/server/api.py +++ b/src/xskill/team/server/api.py @@ -502,6 +502,20 @@ def team_sync( eng.update_user_interest(ClientInterest(client_id)) except Exception: # pylint: disable=broad-exception-caught logger.debug("profile refresh for %s skipped", client_id, exc_info=True) + # P2-2.4 控制面注入:blocked 排除→pinned 占位→ranked→recommended。 + # best-effort 读取(D8:超量在写入侧拒绝,这里读挂了退回无 prefs 分发, + # 后台链路绝不因控制面阻塞)。user_key=user_name(D5),匿名 client 只吃全局。 + prefs = None + retired = None + try: + from xskill.pipeline.registry import effective_prefs, retired_skills + row = _ctx.client_registry.get(client_id) or {} + user_key = row.get("user_name") or "" + prefs = effective_prefs(user_key) + retired = retired_skills() + except Exception: # pylint: disable=broad-exception-caught + logger.warning("skill prefs lookup failed, serving without control-plane", + exc_info=True) resp = build_manifest( client_id=client_id, skill_dir=_ctx.skill_dir, @@ -509,6 +523,8 @@ def team_sync( ranked_slots=_ctx.ranked_slots, total_slots=_ctx.total_slots, traj_root=_ctx.traj_root, + prefs=prefs, + retired=retired, ) return resp.model_dump() diff --git a/src/xskill/team/server/skill_manifest.py b/src/xskill/team/server/skill_manifest.py index fdf30ad3..26df1740 100644 --- a/src/xskill/team/server/skill_manifest.py +++ b/src/xskill/team/server/skill_manifest.py @@ -90,6 +90,8 @@ def build_manifest( ranked_slots: int = 80, total_slots: int = 100, traj_root: Path | str | None = None, + prefs: dict | None = None, + retired: set | None = None, ) -> SyncResponse: """为 ``client_id`` 现算 manifest。skill 总数不足 total_slots 时全发。 @@ -98,36 +100,67 @@ def build_manifest( 就不该下发给 client——这里直接过滤掉,不是 fallback 而是正确的可分发 集合判定。 - slot 分两段: - - 前 ``ranked_slots`` 个 ``ranked`` —— 按 ux 滑窗均分降序取高分。 - - 其余 ``recommended`` —— SP3 画像推荐位:基于该 client 用过的 skill 的 - 质心,从「distributable 且不在 ranked、且 client 没用过」的候选里取 - cosine 最近邻。``traj_root`` 为 None(非 team server 调用)或该 client - 没有任何带 used_skills 的 atom(冷启动、无画像)时,``recommended`` - 退回 ux 排序往下接着取——这不是 fallback,是画像不存在时的正确定义。 + P2-2.4 注入顺序(含 ``prefs``/``retired`` 时):**blocked 先排除 → + pinned 占位 → ranked → recommended 回填**。 + - ``prefs`` = ``registry.effective_prefs`` 的合并视图(调用方现查, + None = 无控制面语义,纯 ranked+recommended)。 + - ``retired`` = 下线 skill 集合,无条件不分发(即便被 pin)。 + - pinned 超量在**写入侧**拒绝(D8),这里对"pin 的 skill 已不可分发" + 只跳过不报错——sync 路径绝不 500。 + + slot 分段: + - pinned —— prefs 里的钉住 skill(全局在前),占位不参与排名。 + - ranked —— 按 ux 滑窗均分降序取高分(配额 = min(ranked_slots, 余量))。 + - recommended —— SP3 画像推荐位:基于该 client 用过的 skill 的 + 质心,从「distributable 且不在 pinned/ranked、且 client 没用过」的候选 + 里取 cosine 最近邻。``traj_root`` 为 None(非 team server 调用)或该 + client 没有任何带 used_skills 的 atom(冷启动、无画像)时,退回 ux + 排序往下接着取——这不是 fallback,是画像不存在时的正确定义。 """ skill_dir = Path(skill_dir) + prefs = prefs or {"pinned": [], "blocked": set()} + retired = retired or set() + excluded = set(prefs.get("blocked") or set()) | retired + repo = SkillRepo(skill_dir) - distributable = [s for s in repo if main_sha(s.path)] + distributable = [ + s for s in repo if main_sha(s.path) and s.name not in excluded + ] skills = sorted(distributable, key=_rank_key, reverse=True) - - reco_slots = max(total_slots - ranked_slots, 0) - ranked = skills[:ranked_slots] - ranked_names = {s.name for s in ranked} - - chosen = ranked + _pick_recommended( + by_name = {s.name: s for s in distributable} + + # pinned 占位:不存在/尚不可分发(无 main)/已下线的 pin 跳过——读路径 + # 绝不 throw(D8),写入侧校验已把守常规超量。 + pinned = [by_name[n] for n in (prefs.get("pinned") or []) if n in by_name] + pinned = pinned[:total_slots] + pinned_names = {s.name for s in pinned} + + remaining = total_slots - len(pinned) + non_pinned = [s for s in skills if s.name not in pinned_names] + ranked = non_pinned[:min(ranked_slots, remaining)] + taken_names = pinned_names | {s.name for s in ranked} + reco_slots = max(remaining - len(ranked), 0) + + # exclude 集合并入 blocked/retired:推荐引擎有自己的候选索引(skillhub 等), + # 不经 distributable 过滤,必须显式排除 + chosen = pinned + ranked + _pick_recommended( client_id=client_id, skill_dir=skill_dir, ranked=ranked, - ranked_names=ranked_names, - ux_ordered=skills, + ranked_names=taken_names | excluded, + ux_ordered=non_pinned, reco_slots=reco_slots, traj_root=traj_root, ) slots: list[SkillSlot] = [] for idx, skill in enumerate(chosen): - bucket = "ranked" if idx < ranked_slots else "recommended" + if idx < len(pinned): + bucket = "pinned" + elif idx < len(pinned) + len(ranked): + bucket = "ranked" + else: + bucket = "recommended" slot = _resolve_slot(skill, client_id, probability, bucket) if slot is not None: slots.append(slot) diff --git a/src/xskill/team/shared/protocol.py b/src/xskill/team/shared/protocol.py index 3dfe9057..7e1be79e 100644 --- a/src/xskill/team/shared/protocol.py +++ b/src/xskill/team/shared/protocol.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field Side = Literal["main", "staging"] -Bucket = Literal["ranked", "recommended"] +Bucket = Literal["pinned", "ranked", "recommended"] # P2-2.4:pinned=用户/admin 钉住 SkillSource = Literal["repo", "skillhub"] From 320b2f39dc2cf854feae70c3f5b18f7217480273 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:31:17 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(p2):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=20API=20+=20P2=20=E6=8E=A7=E5=88=B6=E9=9D=A2=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=2015=20=E4=BE=8B=20|=20settings=20API=20&=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/config GET/validate/reload:校验失败不落盘不生效(no-fallback); 原子落盘(tmp+rename);热加载=原地更新 _config(watcher/canary 持同一引用 每轮现取)+推荐引擎缓存失效;llm/watch_dirs 段显式标注需重启 - tests/test_dashboard_console_p2.py:登录角色(匿名 401/越权 403/登出), 注入顺序,全局 pin 排序,写入侧超量 409(含全局全员合计),admin 条目用户 不可动,retire 断分发断裁决,delete 二次确认+prefs 清理,users-matrix 版本列,config validate/reload Co-Authored-By: Claude Fable 5 --- src/xskill/dashboard/console.py | 95 ++++++++++ tests/test_dashboard_console_p2.py | 295 +++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 tests/test_dashboard_console_p2.py diff --git a/src/xskill/dashboard/console.py b/src/xskill/dashboard/console.py index f85d6d97..e6b6f341 100644 --- a/src/xskill/dashboard/console.py +++ b/src/xskill/dashboard/console.py @@ -170,6 +170,35 @@ class DeleteSkillRequest(BaseModel): confirm_name: str # 二次确认:必须输入 skill 名 +class ConfigPayload(BaseModel): + raw: str # config.yaml 全文(原文编辑器提交) + + +# 热加载范围显式声明(2.9):这些段改完即生效(读方每次现取); +# llm/watch_dirs 涉及进程级资源(client 连接池/watcher 注册),改动需重启 serve。 +HOT_RELOAD_SECTIONS = ("dashboard", "canary", "recommend", "skillhub") +RESTART_SECTIONS = ("llm", "llm_skill", "embedding", "watcher", "team") + + +def _validate_config_text(raw: str) -> dict: + """解析并逐段校验。失败抛 ValueError(带原因),绝不返回半合法配置。""" + import yaml + try: + cfg = yaml.safe_load(raw) + except yaml.YAMLError as e: + raise ValueError(f"YAML 解析失败: {e}") from e + if not isinstance(cfg, dict): + raise ValueError("config.yaml 顶层必须是 mapping") + from xskill.config import dashboard_config + dashboard_config(cfg) # admins 类型等 + from xskill.canary import CanaryConfig + CanaryConfig.from_dict(cfg.get("canary", {}) or {}) + llm = cfg.get("llm", {}) or {} + if llm and not llm.get("base_url"): + raise ValueError("llm.base_url 不能为空") + return cfg + + # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- @@ -452,6 +481,72 @@ def admin_delete(name: str, req: DeleteSkillRequest, logger.info("admin %s deleted skill %s", ident["user"], name) return {"ok": True} + # ── 设置页(admin,2.9) ───────────────────────────────────────── + + @router.get("/admin/config") + def admin_config(_=Depends(require_admin)): + from xskill.config import CONFIG_PATH + if not CONFIG_PATH.is_file(): + raise HTTPException(status_code=404, + detail=f"config 不存在: {CONFIG_PATH}") + return {"path": str(CONFIG_PATH), + "raw": CONFIG_PATH.read_text(encoding="utf-8"), + "hot_reload_sections": list(HOT_RELOAD_SECTIONS), + "restart_sections": list(RESTART_SECTIONS)} + + @router.post("/admin/config/validate") + def admin_config_validate(req: ConfigPayload, _=Depends(require_admin)): + try: + _validate_config_text(req.raw) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True} + + @router.post("/admin/config/reload") + def admin_config_reload(req: ConfigPayload, ident=Depends(require_admin)): + """校验并热加载:校验失败不落盘、不生效、直接报错(no-fallback, + 不存在"部分生效")。 + + 热加载实现:原地更新 serve 进程的 ``_config`` dict——watcher/canary/ + recommend 等读方都持同一引用且每次现取相应段;推荐引擎缓存失效重建。 + llm/watch_dirs 段的改动响应里显式标注需重启,不做静默半生效。 + """ + try: + new_cfg = _validate_config_text(req.raw) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + from xskill.config import CONFIG_PATH + import os + import tempfile as _tf + from xskill.api import app as app_mod + + old_cfg = dict(app_mod._config or {}) + changed = sorted( + k for k in set(old_cfg) | set(new_cfg) + if old_cfg.get(k) != new_cfg.get(k)) + # 原子落盘:同目录 tmp + rename,写一半断电不会留半个 config + fd, tmp = _tf.mkstemp(dir=str(CONFIG_PATH.parent), suffix=".yaml.tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(req.raw) + os.replace(tmp, str(CONFIG_PATH)) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + # 原地更新:所有持引用的读方(watcher self.config 等)即刻看到新值 + if app_mod._config is not None: + app_mod._config.clear() + app_mod._config.update(new_cfg) + _invalidate_engine_cache() + needs_restart = [k for k in changed if k in RESTART_SECTIONS] + hot = [k for k in changed if k not in RESTART_SECTIONS] + logger.info("admin %s reloaded config (hot=%s, needs_restart=%s)", + ident["user"], hot, needs_restart) + return {"ok": True, "hot_reloaded": hot, "needs_restart": needs_restart} + return router diff --git a/tests/test_dashboard_console_p2.py b/tests/test_dashboard_console_p2.py new file mode 100644 index 00000000..fa112389 --- /dev/null +++ b/tests/test_dashboard_console_p2.py @@ -0,0 +1,295 @@ +"""test_dashboard_console_p2.py —— P2 控制面(登录/角色/prefs/manifest 注入/生命周期/设置页) + +覆盖 openspec dashboard-console-redesign P2 的 SHALL 条款: +- 2.2 登录:匿名 401 / 普通用户写 admin 端点 403 / admin 口令空=关闭 +- 2.4 注入顺序:blocked 排除 → pinned 占位 → ranked → recommended 回填 +- 2.4d 超量写入侧拒绝(409),含全局 pin 全员合计 +- 2.4c retire 不分发不裁决;delete 需二次确认,prefs 清理 +- 2.9 校验失败不落盘不生效 +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from xskill.dashboard.auth import ( + build_auth_router, configure_auth, ensure_dashboard_secret, +) +from xskill.dashboard.console import build_console_router +from xskill.pipeline import registry as R +from xskill.team.server.api import init_team_context +from xskill.team.server.client_registry import ClientRegistry +from xskill.team.server.skill_manifest import build_manifest + + +def _git(args, cwd): + subprocess.run(["git"] + args, cwd=str(cwd), + capture_output=True, text=True, check=True) + + +def _make_skill(root: Path, name: str) -> Path: + d = root / name + d.mkdir(parents=True) + _git(["init", "-q"], d) + _git(["checkout", "-q", "-b", "main"], d) + _git(["config", "user.email", "t@t"], d) + _git(["config", "user.name", "t"], d) + (d / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + _git(["add", "."], d) + _git(["commit", "-q", "-m", "v1"], d) + return d + + +@pytest.fixture() +def console_env(tmp_path): + """team ctx + auth + console app。alice=普通用户,boss=admin。""" + skills = tmp_path / "skills" + skills.mkdir() + _git(["init", "-q"], skills) + _git(["checkout", "-q", "-b", "main"], skills) + _git(["config", "user.email", "t@t"], skills) + _git(["config", "user.name", "t"], skills) + for n in ("alpha", "beta", "gamma"): + _make_skill(skills, n) + db = tmp_path / "r.db" + reg = ClientRegistry(tmp_path / "c.db") + cid = reg.register(user_name="alice") + token = reg.ensure_dashboard_token(cid) + init_team_context( + join_token="jt", client_registry=reg, skill_dir=skills, + traj_root=tmp_path / "traj", probability=0.2, ranked_slots=2, + total_slots=3, register_dir=lambda p, l: None) + configure_auth( + secret=ensure_dashboard_secret(tmp_path / "sec.json"), + admins=["boss"], admin_password="pw", + registry_provider=lambda: reg) + app = FastAPI() + app.include_router(build_auth_router()) + app.include_router(build_console_router(db_path=db)) + + alice = TestClient(app) + r = alice.post("/api/v1/dashboard/login", + json={"user_name": "alice", "secret": token}) + assert r.status_code == 200 and r.json()["role"] == "user" + boss = TestClient(app) + r = boss.post("/api/v1/dashboard/login", + json={"user_name": "boss", "secret": "pw"}) + assert r.status_code == 200 and r.json()["role"] == "admin" + return {"app": app, "alice": alice, "boss": boss, "db": db, + "skills": skills, "token": token, "registry": reg} + + +# ── 2.2 登录/角色 ───────────────────────────────────────────────────── + +def test_anonymous_gets_401(console_env): + anon = TestClient(console_env["app"]) + assert anon.get("/api/v1/dashboard/my/manifest").status_code == 401 + assert anon.get("/api/v1/dashboard/admin/skills").status_code == 401 + + +def test_wrong_credentials_401(console_env): + anon = TestClient(console_env["app"]) + assert anon.post("/api/v1/dashboard/login", + json={"user_name": "alice", "secret": "bad"} + ).status_code == 401 + # admin 名单里的人用错口令也 401(token 路径不给 admin 角色) + assert anon.post("/api/v1/dashboard/login", + json={"user_name": "boss", "secret": "bad"} + ).status_code == 401 + + +def test_user_hitting_admin_endpoint_403(console_env): + alice = console_env["alice"] + assert alice.get("/api/v1/dashboard/admin/skills").status_code == 403 + assert alice.post( + "/api/v1/dashboard/admin/skill/alpha/retire").status_code == 403 + assert alice.get("/api/v1/dashboard/admin/config").status_code == 403 + + +def test_logout_invalidates_session(console_env): + boss = console_env["boss"] + assert boss.get("/api/v1/dashboard/admin/skills").status_code == 200 + boss.post("/api/v1/dashboard/logout") + assert boss.get("/api/v1/dashboard/admin/skills").status_code == 401 + + +def test_dashboard_token_issued_and_idempotent(console_env): + reg = console_env["registry"] + cid = reg.find_by_user_name("alice") + assert reg.ensure_dashboard_token(cid) == console_env["token"] + assert reg.dashboard_token_for("alice") == console_env["token"] + + +# ── 2.4 manifest 注入顺序 ───────────────────────────────────────────── + +def test_injection_order_blocked_pinned_ranked_recommended(console_env): + db, skills = console_env["db"], console_env["skills"] + R.set_skill_pref(user_key="alice", skill_name="gamma", pref="pinned", + set_by="alice", db_path=db) + R.set_skill_pref(user_key="alice", skill_name="alpha", pref="blocked", + set_by="alice", db_path=db) + prefs = R.effective_prefs("alice", db_path=db) + resp = build_manifest(client_id="c", skill_dir=skills, probability=0.2, + ranked_slots=2, total_slots=3, + prefs=prefs, retired=set()) + got = [(s.skill_name, s.bucket) for s in resp.slots] + assert got[0] == ("gamma", "pinned") + assert all(n != "alpha" for n, _ in got) + + +def test_global_pin_ordered_before_user_pin(console_env): + db = console_env["db"] + R.set_skill_pref(user_key="alice", skill_name="beta", pref="pinned", + set_by="alice", db_path=db) + R.set_skill_pref(user_key=R.GLOBAL_PREF_KEY, skill_name="gamma", + pref="pinned", set_by="boss", db_path=db) + prefs = R.effective_prefs("alice", db_path=db) + assert prefs["pinned"] == ["gamma", "beta"] + assert prefs["pin_meta"]["gamma"]["scope"] == "global" + + +def test_retired_not_distributed_even_if_pinned(console_env): + db, skills = console_env["db"], console_env["skills"] + R.set_skill_pref(user_key="alice", skill_name="gamma", pref="pinned", + set_by="alice", db_path=db) + resp = build_manifest(client_id="c", skill_dir=skills, probability=0.2, + ranked_slots=2, total_slots=3, + prefs=R.effective_prefs("alice", db_path=db), + retired={"gamma"}) + assert all(s.skill_name != "gamma" for s in resp.slots) + + +# ── 2.4d 写入侧超量拒绝 ─────────────────────────────────────────────── + +def test_pin_quota_rejected_at_write_side(console_env): + alice, boss = console_env["alice"], console_env["boss"] + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "alpha", "action": "pin"} + ).status_code == 200 + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "beta", "action": "pin"} + ).status_code == 200 + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "gamma", "action": "pin"} + ).status_code == 200 + # 第 4 个超 total_slots=3 → 409 + r = alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "s4", "action": "pin"}) + assert r.status_code == 409 + # 全局 pin 会把 alice 顶爆 → 对全员合计校验后 409 + r = boss.post("/api/v1/dashboard/admin/prefs", + json={"user_key": R.GLOBAL_PREF_KEY, + "skill_name": "s5", "action": "pin"}) + assert r.status_code == 409 + + +def test_admin_set_pref_immutable_by_user(console_env): + alice, boss = console_env["alice"], console_env["boss"] + assert boss.post("/api/v1/dashboard/admin/prefs", + json={"user_key": "alice", "skill_name": "beta", + "action": "pin"}).status_code == 200 + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "beta", "action": "clear"} + ).status_code == 403 + # 全局 pin 的条目不可屏蔽 + assert boss.post("/api/v1/dashboard/admin/prefs", + json={"user_key": R.GLOBAL_PREF_KEY, + "skill_name": "alpha", "action": "pin"} + ).status_code == 200 + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "alpha", "action": "block"} + ).status_code == 403 + + +# ── 2.4c 生命周期 ──────────────────────────────────────────────────── + +def test_retire_stops_distribution_and_canary(console_env, tmp_path): + alice, boss = console_env["alice"], console_env["boss"] + assert boss.post( + "/api/v1/dashboard/admin/skill/alpha/retire").status_code == 200 + m = alice.get("/api/v1/dashboard/my/manifest").json() + assert all(s["skill_name"] != "alpha" for s in m["slots"]) + # canary 判定直接短路——check_and_decide 读默认注册库(生产=同一个库; + # 本 fixture 的 console 用独立 tmp 库,故这里在默认库上单独验证) + from xskill.canary import check_and_decide + R.retire_skill(skill_name="alpha", set_by="t") + try: + assert check_and_decide( + console_env["skills"] / "alpha")["action"] == "retired" + finally: + R.unretire_skill(skill_name="alpha") + # 恢复在役 + assert boss.post( + "/api/v1/dashboard/admin/skill/alpha/unretire").status_code == 200 + m = alice.get("/api/v1/dashboard/my/manifest").json() + assert any(s["skill_name"] == "alpha" for s in m["slots"]) + + +def test_delete_requires_confirm_and_purges_prefs(console_env): + alice, boss, db = (console_env["alice"], console_env["boss"], + console_env["db"]) + assert alice.post("/api/v1/dashboard/my/prefs", + json={"skill_name": "gamma", "action": "pin"} + ).status_code == 200 + r = boss.request("DELETE", "/api/v1/dashboard/admin/skill/gamma", + json={"confirm_name": "not-gamma"}) + assert r.status_code == 400 + r = boss.request("DELETE", "/api/v1/dashboard/admin/skill/gamma", + json={"confirm_name": "gamma"}) + assert r.status_code == 200 + assert not (console_env["skills"] / "gamma").exists() + assert all(p["skill_name"] != "gamma" + for p in R.prefs_for("alice", db_path=db)) + + +# ── 2.3/2.5/2.6 读视图 ────────────────────────────────────────────── + +def test_my_views_empty_db_do_not_crash(console_env): + alice = console_env["alice"] + c = alice.get("/api/v1/dashboard/my/contributions") + assert c.status_code == 200 + assert c.json()["steps"]["trajs"] == 0 + assert alice.get("/api/v1/dashboard/my/reco-trigger").json()["rows"] == [] + + +def test_users_matrix_lists_clients_with_version(console_env): + boss = console_env["boss"] + reg = console_env["registry"] + cid = reg.find_by_user_name("alice") + reg.touch(cid, version="0.9.9") + um = boss.get("/api/v1/dashboard/admin/users-matrix").json() + row = next(u for u in um["users"] if u["user"] == "alice") + assert row["client_version"] == "0.9.9" + + +# ── 2.9 设置页 ────────────────────────────────────────────────────── + +def test_config_validate_and_reload(console_env, tmp_path, monkeypatch): + import xskill.config as C + from xskill.api import app as app_mod + cfgp = tmp_path / "config.yaml" + cfgp.write_text("skill_dir: /tmp/s\nllm:\n base_url: http://x/v1\n", + encoding="utf-8") + monkeypatch.setattr(C, "CONFIG_PATH", cfgp) + monkeypatch.setattr(app_mod, "_config", + {"skill_dir": "/tmp/s", + "llm": {"base_url": "http://x/v1"}}) + boss = console_env["boss"] + # 坏 YAML → 400,不落盘不生效 + before = cfgp.read_text() + r = boss.post("/api/v1/dashboard/admin/config/reload", + json={"raw": "llm: [broken"}) + assert r.status_code == 400 and cfgp.read_text() == before + # canary 段热生效,llm 段标注需重启 + new = ("skill_dir: /tmp/s\nllm:\n base_url: http://y/v1\n" + "canary:\n probability: 0.5\n") + r = boss.post("/api/v1/dashboard/admin/config/reload", json={"raw": new}) + body = r.json() + assert r.status_code == 200 + assert "canary" in body["hot_reloaded"] + assert "llm" in body["needs_restart"] + assert app_mod._config["canary"]["probability"] == 0.5 From fb837890a32e42d2eabd4cfc6bc8ef5714c32a95 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:33:13 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(p2):=20users=5Fstatus=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=88=97=E7=82=B9=E4=BA=AE(2.10=20=E8=AF=BB=E4=BE=A7)?= =?UTF-8?q?=20|=20version=20column=20in=20users=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client_version 空=未上报;packaging.Version 比对 server 版本标 version_stale, 解析失败(dev 版)按不落后处理。旧 team_clients.db 无列时兼容读取。 Co-Authored-By: Claude Fable 5 --- src/xskill/dashboard/explore.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/xskill/dashboard/explore.py b/src/xskill/dashboard/explore.py index 88af52de..4260ddad 100644 --- a/src/xskill/dashboard/explore.py +++ b/src/xskill/dashboard/explore.py @@ -306,9 +306,14 @@ def users_status(db_path: Optional[Path], *, cconn = sqlite3.connect(str(clients_db)) cconn.row_factory = sqlite3.Row try: + ccols = {r[1] for r in cconn.execute("PRAGMA table_info(clients)")} + # P2-2.10:client_version 列点亮版本列;旧库无列时按未上报处理 + ver_expr = ("COALESCE(client_version,'')" if "client_version" in ccols + else "''") crows = cconn.execute( "SELECT client_id, COALESCE(user_name,'') user_name," - " COALESCE(label,'') label, last_seen, joined_at FROM clients" + f" COALESCE(label,'') label, {ver_expr} client_version," + " last_seen, joined_at FROM clients" ).fetchall() finally: cconn.close() @@ -366,10 +371,14 @@ def users_status(db_path: Optional[Path], *, online += 1 total_h = sum(h["n"] for h in hs) or 1 total_m = sum(m["n"] for m in ms) or 1 + cver = c["client_version"] or "" users.append({ "user": c["user_name"] or c["client_id"], "online": is_online, "last_seen": last_seen, + # 版本列(P2-2.10):空=未上报(旧 client);低于 server 版本标落后 + "client_version": cver, + "version_stale": bool(cver) and _version_lt(cver, _server_version()), "trajs": st["trajs"], "atoms": st["atoms"], "harness": sorted(({**h, "pct": round(h["n"] / total_h * 100)} for h in hs), key=lambda x: -x["n"]), @@ -381,6 +390,20 @@ def users_status(db_path: Optional[Path], *, return {"users": users, "online": online, "reason": ""} +def _server_version() -> str: + from xskill import __version__ + return __version__ + + +def _version_lt(a: str, b: str) -> bool: + """a < b?解析失败(dev 版本等)按不落后处理——不给误导性标注。""" + try: + from packaging.version import Version + return Version(a) < Version(b) + except Exception: # pylint: disable=broad-exception-caught + return False + + def _ts_key(ts: str) -> float: try: t = datetime.fromisoformat(ts.replace(" ", "T")) From 54f7d713e49e7051c63e50db072e0e5b47a07793 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Fri, 10 Jul 2026 14:37:38 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(p2):=20=E5=89=8D=E7=AB=AF=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=99=BB=E5=BD=95/=E6=88=91=E7=9A=84/=E7=AE=A1?= =?UTF-8?q?=E7=90=86/=E8=AE=BE=E7=BD=AE=E9=A1=B5=20|=20frontend:=20login,?= =?UTF-8?q?=20my=20console,=20admin,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏登录区(HMAC cookie 会话,/me 恢复);我的/管理/设置导航按角色显隐 - 我的(图④):槽位清单(pinned·自己/admin/全局 chip,admin 项置灰锁定), pin/✕屏蔽/恢复,贡献去向四级步进+使用者列表,推荐×触发结论列 - 管理(图⑤):用户矩阵(版本/触发率/停推建议)+右侧抽屉代 pin/代屏蔽, 全局 pin chips,技能生命周期表(下线/恢复/删除输名确认) - 设置(图⑦):config.yaml 原文编辑,仅校验/校验并热加载,热生效与需重启 分色提示;用户连接状态表点亮版本列(落后=amber) - Tailwind 重编译产物内联(BUILD.md 流程,零 CDN) Co-Authored-By: Claude Fable 5 --- src/xskill/dashboard/static/app.js | 243 ++++++++++++++++++++++++- src/xskill/dashboard/static/index.html | 105 ++++++++++- src/xskill/dashboard/static/tw.css | 2 +- 3 files changed, 341 insertions(+), 9 deletions(-) diff --git a/src/xskill/dashboard/static/app.js b/src/xskill/dashboard/static/app.js index e5b0f3d4..7adb7fe1 100644 --- a/src/xskill/dashboard/static/app.js +++ b/src/xskill/dashboard/static/app.js @@ -696,6 +696,9 @@ async function loadUsersStatus() { ? '在线' : '离线'} ${fdate(u.last_seen)} + ${u.client_version + ? `${esc(u.client_version)}${u.version_stale ? ' 落后' : ''}` + : '未上报'} ${u.trajs} · ${u.atoms} ${hs} ${model}`; @@ -750,7 +753,7 @@ async function loadCanary() { } // ── SPA-lite 路由(hash)───────────────────────────────────────── -const NAMES = { overview: '总览', skills: '技能库', traj: '轨迹 & 原子', users: '用户 & 画像', canary: '灰度 Canary' }; +const NAMES = { overview: '总览', skills: '技能库', traj: '轨迹 & 原子', users: '用户 & 画像', canary: '灰度 Canary', my: '我的', admin: '管理', settings: '设置' }; function showPage(pg) { if (!document.getElementById('pg-' + pg)) pg = 'overview'; document.querySelectorAll('.sec-page').forEach(s => s.classList.remove('on')); @@ -868,3 +871,241 @@ for (const f of [loadOverview, loadRates, loadPipeline, loadDomain, loadCost, loadSkills, loadDirs, loadUsersStatus, loadTags, loadCanary]) { f().catch(e => console.error(e)); } + +// ═════════════ P2:登录/角色 + 我的/管理/设置 ═════════════ +let IDENT = null; // {user, role} | null + +async function jpost(u, body, method) { + const r = await fetch(u, { method: method || 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || (u + ' ' + r.status)); + return data; +} + +function applyIdent() { + const logged = !!IDENT, admin = logged && IDENT.role === 'admin'; + document.querySelectorAll('.auth-user').forEach(e => e.classList.toggle('hidden', !logged)); + document.querySelectorAll('.auth-admin').forEach(e => e.classList.toggle('hidden', !admin)); + document.getElementById('who-anon').classList.toggle('hidden', logged); + document.getElementById('who-user').classList.toggle('hidden', !logged); + if (logged) { + document.getElementById('who-name').textContent = IDENT.user; + document.getElementById('who-role').textContent = IDENT.role; + } + document.getElementById('my-guard').classList.toggle('hidden', logged); + document.getElementById('my-body').classList.toggle('hidden', !logged); + document.getElementById('admin-guard').classList.toggle('hidden', admin); + document.getElementById('admin-body').classList.toggle('hidden', !admin); + document.getElementById('settings-guard').classList.toggle('hidden', admin); + document.getElementById('settings-body').classList.toggle('hidden', !admin); +} + +async function initIdent() { + try { IDENT = await j('/api/v1/dashboard/me'); } catch { IDENT = null; } + applyIdent(); + if (IDENT) loadMy().catch(console.error); + if (IDENT && IDENT.role === 'admin') { loadAdmin().catch(console.error); loadSettings().catch(console.error); } +} + +// 登录弹窗 +const _lm = document.getElementById('login-modal'); +document.getElementById('btn-login').addEventListener('click', () => { _lm.classList.remove('hidden'); document.getElementById('login-user').focus(); }); +document.getElementById('login-cancel').addEventListener('click', () => _lm.classList.add('hidden')); +document.getElementById('login-submit').addEventListener('click', async () => { + const user = document.getElementById('login-user').value.trim(); + const sec = document.getElementById('login-secret').value; + const err = document.getElementById('login-err'); + err.textContent = ''; + try { + IDENT = await jpost('/api/v1/dashboard/login', { user_name: user, secret: sec }); + _lm.classList.add('hidden'); + applyIdent(); + loadMy().catch(console.error); + if (IDENT.role === 'admin') { loadAdmin().catch(console.error); loadSettings().catch(console.error); } + } catch (e) { err.textContent = e.message; } +}); +document.getElementById('login-secret').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-submit').click(); }); +document.getElementById('btn-logout').addEventListener('click', async () => { + await jpost('/api/v1/dashboard/logout').catch(() => {}); + IDENT = null; applyIdent(); location.hash = '#overview'; +}); + +// ── 我的 ──────────────────────────────────────────────────────── +const BUCKET_CHIP = { + pinned: 'bg-violet-100 text-violet-700', + ranked: 'bg-teal-50 text-teal-700 ring-1 ring-teal-100', + recommended: 'bg-sky-100 text-sky-700', +}; +function bucketLabel(s) { + if (s.bucket !== 'pinned') return s.bucket; + return s.pin_scope === 'global' ? 'pinned·全局' : (s.user_removable ? 'pinned·自己' : 'pinned·admin'); +} +async function loadMy() { + if (!IDENT) return; + const [m, ct, rt] = await Promise.all([ + j('/api/v1/dashboard/my/manifest'), + j('/api/v1/dashboard/my/contributions'), + j('/api/v1/dashboard/my/reco-trigger'), + ]); + document.getElementById('my-slot-sum').textContent = `${m.slots.length}/${m.total_slots} 槽位`; + document.getElementById('my-slots').innerHTML = m.slots.map(s => ` +
+ ${s.skill_name} + ${bucketLabel(s)} + ${s.side} + + ${s.bucket === 'pinned' + ? (s.user_removable ? `` + : `锁定`) + : ` + `} +
`).join('') || '暂无槽位'; + document.getElementById('my-blocked').innerHTML = m.blocked.map(b => ` + ${b.skill_name} + `).join('') + || ''; + const st = ct.steps; + document.getElementById('my-steps').innerHTML = + [['轨迹', st.trajs], ['原子', st.atoms], ['被采纳', st.adopted_atoms], ['进入 skill', st.skills]] + .map(([k, v], i) => `${i ? '' : ''} +
+
${v}
${k}
`).join(''); + document.getElementById('my-usage').innerHTML = ct.usage.map(u => ` +
${u.skill} + 均分 ${u.avg_score ?? '—'} + ${u.users.map(x => `${x.user}×${x.count}`).join('')}
`).join('') + || '还没有被他人使用的记录'; + const VC = { '高价值': 'bg-emerald-100 text-emerald-700', '正常': 'bg-slate-100 text-slate-600' }; + rows('my-rt-body', rt.rows.map(r => ` + ${r.skill} + ${r.exposures}${r.triggers} + ${pctf(r.rate)} + ${r.verdict}`).join(''), + '暂无推荐记录'); +} +document.addEventListener('click', async e => { + const b = e.target.closest('.my-pref'); + if (!b) return; + try { await jpost('/api/v1/dashboard/my/prefs', { skill_name: b.dataset.skill, action: b.dataset.act }); await loadMy(); } + catch (err) { alert(err.message); } +}); + +// ── 管理 ──────────────────────────────────────────────────────── +async function loadAdmin() { + if (!IDENT || IDENT.role !== 'admin') return; + const [um, sk] = await Promise.all([ + j('/api/v1/dashboard/admin/users-matrix'), + j('/api/v1/dashboard/admin/skills'), + ]); + document.getElementById('admin-gpins').innerHTML = um.global_pinned.map(g => ` + ${g} + `).join('') || ''; + rows('admin-users-body', um.users.map(u => ` + ${u.user} + ${u.client_version || '未上报'} + ${u.exposures} + ${u.rate === null ? '—' : pctf(u.rate)} + ${u.pinned} · ${u.blocked} + ${u.stale_advice.map(a => `${a.skill}`).join('') || ''} + `).join(''), + '暂无 client'); + const ST = { active: ['在役', 'bg-emerald-100 text-emerald-700'], canary: ['灰度中', 'bg-amber-100 text-amber-700'], retired: ['已下线', 'bg-rose-100 text-rose-700'] }; + rows('admin-skills-body', sk.skills.map(s => { + const [label, cls] = ST[s.state]; + return `${s.name} + ${label} + ${s.usage_30d} + + ${s.state === 'retired' + ? ` + ` + : ``} + `; + }).join(''), '暂无 skill'); +} +async function openAdminDrawer(user) { + const d = document.getElementById('admin-drawer'); + const p = await j('/api/v1/dashboard/admin/user/' + encodeURIComponent(user) + '/prefs'); + d.classList.remove('hidden'); + d.innerHTML = `
+

${user} 的偏好 pinned=${p.effective.pinned.length} blocked=${p.effective.blocked.length}

+
+
${p.prefs.map(r => ` + + ${r.skill_name} ${r.pref}·${r.set_by} + `).join('') || ''}
+
+ + + +
`; +} +document.addEventListener('click', async e => { + const cfg = e.target.closest('.adm-cfg'); + if (cfg) { openAdminDrawer(cfg.dataset.user).catch(err => alert(err.message)); return; } + if (e.target.id === 'adm-drawer-x') { document.getElementById('admin-drawer').classList.add('hidden'); return; } + const ap = e.target.closest('.adm-pref'); + if (ap) { + const skill = ap.dataset.skill || (document.getElementById('adm-skill-in') || {}).value; + if (!skill) return; + try { + await jpost('/api/v1/dashboard/admin/prefs', { user_key: ap.dataset.user, skill_name: skill.trim(), action: ap.dataset.act }); + await openAdminDrawer(ap.dataset.user); await loadAdmin(); + } catch (err) { alert(err.message); } + return; + } + const gd = e.target.closest('.gpin-del'); + if (gd) { + try { await jpost('/api/v1/dashboard/admin/prefs', { user_key: '*global*', skill_name: gd.dataset.skill, action: 'clear' }); await loadAdmin(); } + catch (err) { alert(err.message); } + return; + } + const lf = e.target.closest('.adm-life'); + if (lf) { + const name = lf.dataset.skill, act = lf.dataset.act; + try { + if (act === 'delete') { + const typed = prompt(`删除不可逆:skill 目录与 git 历史将被移除。\n请输入 skill 名确认: ${name}`); + if (typed === null) return; + await jpost('/api/v1/dashboard/admin/skill/' + encodeURIComponent(name), { confirm_name: typed }, 'DELETE'); + } else { + await jpost(`/api/v1/dashboard/admin/skill/${encodeURIComponent(name)}/${act}`); + } + await loadAdmin(); + } catch (err) { alert(err.message); } + } +}); +document.getElementById('gpin-add').addEventListener('click', async () => { + const v = document.getElementById('gpin-input').value.trim(); + if (!v) return; + try { await jpost('/api/v1/dashboard/admin/prefs', { user_key: '*global*', skill_name: v, action: 'pin' }); document.getElementById('gpin-input').value = ''; await loadAdmin(); } + catch (err) { alert(err.message); } +}); + +// ── 设置 ──────────────────────────────────────────────────────── +async function loadSettings() { + if (!IDENT || IDENT.role !== 'admin') return; + const c = await j('/api/v1/dashboard/admin/config'); + document.getElementById('cfg-path').textContent = c.path; + document.getElementById('cfg-editor').value = c.raw; +} +async function cfgAction(endpoint) { + const res = document.getElementById('cfg-result'); + res.className = 'text-[12.5px] text-slate-500'; res.textContent = '…'; + try { + const r = await jpost('/api/v1/dashboard/admin/config/' + endpoint, { raw: document.getElementById('cfg-editor').value }); + if (endpoint === 'validate') { res.className = 'text-[12.5px] text-emerald-700'; res.textContent = '✓ 校验通过'; } + else { + res.className = 'text-[12.5px] text-emerald-700'; + res.textContent = `✓ 已生效 ${r.hot_reloaded.join('/') || '无变更'}` + + (r.needs_restart.length ? `;⚠ ${r.needs_restart.join('/')} 段需重启 serve` : ''); + if (r.needs_restart.length) res.className = 'text-[12.5px] text-amber-700'; + } + } catch (err) { res.className = 'text-[12.5px] text-rose-600'; res.textContent = '✗ ' + err.message; } +} +document.getElementById('cfg-validate').addEventListener('click', () => cfgAction('validate')); +document.getElementById('cfg-reload').addEventListener('click', () => cfgAction('reload')); + +initIdent().catch(console.error); diff --git a/src/xskill/dashboard/static/index.html b/src/xskill/dashboard/static/index.html index f6e293e5..d0c6de5f 100644 --- a/src/xskill/dashboard/static/index.html +++ b/src/xskill/dashboard/static/index.html @@ -4,7 +4,7 @@ xskill 控制台 +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-6{left:1.5rem}.right-6{right:1.5rem}.top-0{top:0}.top-\[17px\]{top:17px}.z-10{z-index:10}.z-50{z-index:50}.col-span-12{grid-column:span 12/span 12}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.mt-0{margin-top:0}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0{height:0}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-36{max-height:9rem}.max-h-44{max-height:11rem}.max-h-72{max-height:18rem}.max-h-80{max-height:20rem}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[60px\]{min-width:60px}.min-w-\[92px\]{min-width:92px}.min-w-max{min-width:-moz-max-content;min-width:max-content}.max-w-\[200px\]{max-width:200px}.max-w-\[280px\]{max-width:280px}.max-w-\[360px\]{max-width:360px}.max-w-\[480px\]{max-width:480px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(241 245 249/var(--tw-divide-opacity,1))}.divide-slate-50>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(248 250 252/var(--tw-divide-opacity,1))}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/60{background-color:rgba(255,251,235,.6)}.bg-amber-50\/70{background-color:rgba(255,251,235,.7)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-rose-100{--tw-bg-opacity:1;background-color:rgb(255 228 230/var(--tw-bg-opacity,1))}.bg-rose-50{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.bg-rose-50\/50{background-color:rgba(255,241,242,.5)}.bg-rose-500{--tw-bg-opacity:1;background-color:rgb(244 63 94/var(--tw-bg-opacity,1))}.bg-sky-100{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity,1))}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/40{background-color:rgba(15,23,42,.4)}.bg-teal-50{--tw-bg-opacity:1;background-color:rgb(240 253 250/var(--tw-bg-opacity,1))}.bg-teal-50\/40{background-color:rgba(240,253,250,.4)}.bg-teal-50\/50{background-color:rgba(240,253,250,.5)}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-600{--tw-bg-opacity:1;background-color:rgb(13 148 136/var(--tw-bg-opacity,1))}.bg-violet-100{--tw-bg-opacity:1;background-color:rgb(237 233 254/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-teal-500{--tw-gradient-from:#14b8a6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(20,184,166,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-cyan-700{--tw-gradient-to:#0e7490 var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pt-3{padding-top:.75rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[26px\]{font-size:26px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-4{line-height:1rem}.leading-\[2\.4\]{line-height:2.4}.leading-normal{line-height:1.5}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-rose-600{--tw-text-opacity:1;color:rgb(225 29 72/var(--tw-text-opacity,1))}.text-rose-700{--tw-text-opacity:1;color:rgb(190 18 60/var(--tw-text-opacity,1))}.text-rose-800{--tw-text-opacity:1;color:rgb(159 18 57/var(--tw-text-opacity,1))}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-teal-600{--tw-text-opacity:1;color:rgb(13 148 136/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-teal-800{--tw-text-opacity:1;color:rgb(17 94 89/var(--tw-text-opacity,1))}.text-violet-600{--tw-text-opacity:1;color:rgb(124 58 237/var(--tw-text-opacity,1))}.text-violet-700{--tw-text-opacity:1;color:rgb(109 40 217/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-teal-200{text-decoration-color:#99f6e4}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-1,.ring-2{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-amber-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 243 199/var(--tw-ring-opacity,1))}.ring-amber-200{--tw-ring-opacity:1;--tw-ring-color:rgb(253 230 138/var(--tw-ring-opacity,1))}.ring-emerald-200{--tw-ring-opacity:1;--tw-ring-color:rgb(167 243 208/var(--tw-ring-opacity,1))}.ring-rose-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 205 211/var(--tw-ring-opacity,1))}.ring-sky-200{--tw-ring-opacity:1;--tw-ring-color:rgb(186 230 253/var(--tw-ring-opacity,1))}.ring-slate-100{--tw-ring-opacity:1;--tw-ring-color:rgb(241 245 249/var(--tw-ring-opacity,1))}.ring-slate-200{--tw-ring-opacity:1;--tw-ring-color:rgb(226 232 240/var(--tw-ring-opacity,1))}.ring-slate-300{--tw-ring-opacity:1;--tw-ring-color:rgb(203 213 225/var(--tw-ring-opacity,1))}.ring-teal-100{--tw-ring-opacity:1;--tw-ring-color:rgb(204 251 241/var(--tw-ring-opacity,1))}.ring-teal-200{--tw-ring-opacity:1;--tw-ring-color:rgb(153 246 228/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:bg-rose-50:hover{--tw-bg-opacity:1;background-color:rgb(255 241 242/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-50:hover{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.hover\:bg-teal-700:hover{--tw-bg-opacity:1;background-color:rgb(15 118 110/var(--tw-bg-opacity,1))}.focus\:ring-teal-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(20 184 166/var(--tw-ring-opacity,1))}@media (min-width:768px){.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-7{grid-column:span 7/span 7}.md\:col-span-8{grid-column:span 8/span 8}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-5{grid-column:span 5/span 5}.lg\:col-span-7{grid-column:span 7/span 7}}@media (min-width:1280px){.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-7{grid-column:span 7/span 7}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}