From daf71930e58f160676e5c1114957b2c4ac16f4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:02:47 +0800 Subject: [PATCH 1/5] Add scoped model mappings and refine the glass dashboard Separate public and upstream model IDs, enforce account-or-region routing, and preserve policy ownership through protocol conversion. Reuse persistent hourly aggregates for selectable overview granularity without inventing missing history. Add icon-only navigation, structured diagnostics, translucent glass surfaces and scroll-safe dismissible drawers. Complete OAuth enrollment by closing the management drawer and refreshing credentials while retaining isolation of the official tab. Synchronize bilingual usage and rollback guidance. --- app/admin_api.py | 77 +++- app/audit_store.py | 29 +- app/control_store.py | 28 +- app/gateway_management.py | 26 +- app/model_policy.py | 83 +++-- app/runtime_management.py | 2 +- converter.py | 4 +- docs/webui.md | 17 +- docs/webui.zh-CN.md | 17 +- tests/test_admin_api.py | 21 +- tests/test_control_store.py | 16 + tests/test_dashboard_granularity.py | 72 ++++ tests/test_webui_integration.py | 107 +++++- web/e2e/backend.integration.ts | 19 + web/e2e/experience.spec.ts | 255 +++++++++++++ web/src/App.tsx | 57 ++- web/src/OAuth.tsx | 145 ++++++++ web/src/api.ts | 14 +- web/src/components.test.tsx | 3 + web/src/components.tsx | 60 +-- web/src/dashboard.test.tsx | 68 +++- web/src/interaction.test.tsx | 168 +++++++++ web/src/modal.ts | 40 ++ web/src/models.test.tsx | 140 +++++++ web/src/oauth.test.tsx | 103 ++++++ web/src/pages/Credentials.tsx | 171 ++------- web/src/pages/Dashboard.tsx | 119 ++++-- web/src/pages/Models.tsx | 551 +++++++++++++++++++++------- web/src/test-setup.ts | 4 +- web/src/ui.module.scss | 506 +++++++++++++++++++++---- web/src/values.tsx | 258 +++++++++++++ 31 files changed, 2671 insertions(+), 509 deletions(-) create mode 100644 tests/test_dashboard_granularity.py create mode 100644 web/e2e/experience.spec.ts create mode 100644 web/src/OAuth.tsx create mode 100644 web/src/interaction.test.tsx create mode 100644 web/src/modal.ts create mode 100644 web/src/models.test.tsx create mode 100644 web/src/oauth.test.tsx create mode 100644 web/src/values.tsx diff --git a/app/admin_api.py b/app/admin_api.py index 1910e45..0914f2e 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -8,6 +8,7 @@ import threading import time from urllib.parse import quote, urlsplit +import uuid import zipfile from fastapi import HTTPException, Request @@ -250,18 +251,29 @@ def apply(): @route("GET", "/admin/models") async def models_get(request): - snapshot = control.snapshot() - models = [] - for item in await run_in_threadpool(gateway.admin_model_inventory): - item = {"id": item} if isinstance(item, str) else dict(item) - source = item["id"] - rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, - "region": None, "profile": None, "credential_ids": []}) - models.append({**item, **rule}) - return JSONResponse({"revision": snapshot["revision"], "models": models}) - - def checked_rule(source, data): - rule = validate_model(source, data, control.snapshot()["models"], known_models()) + def build_models(): + with mutation_lock: + inventory = gateway.admin_model_inventory() + snapshot = control.snapshot() # 扫描可能同步账号身份,随后读取对应的规则版本。 + models = [] + for item in inventory: + item = {"id": item} if isinstance(item, str) else dict(item) + source = item["id"] + rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, + "region": None, "profile": None, "credential_ids": []}) + models.append({**item, **rule}) + return JSONResponse({"revision": snapshot["revision"], "models": models}) + return await run_in_threadpool(build_models) + + def checked_rule(source, data, *, creating=False): + if "custom" in data: + raise ValueError("custom 是只读字段") + existing = control.snapshot()["models"].get(source, {}) + if creating and (not data.get("public_id") or not data.get("upstream_id")): + raise ValueError("对外 ID 和上游模型 ID 均不能为空") + values = {"upstream_id": existing.get("upstream_id", source), **data, + "custom": True if creating else existing.get("custom", False)} + rule = validate_model(source, values, control.snapshot()["models"], known_models()) for identity in rule["credential_ids"]: item = selected(identity) if item is None: @@ -272,6 +284,42 @@ def checked_rule(source, data): raise ValueError("绑定凭证与区域或产品规则冲突") return rule + @route("POST", "/admin/models") + async def models_create(request): + data = await _body(request) + revision = data.pop("revision", None) + source = "custom:" + uuid.uuid4().hex + def apply(): + with mutation_lock: + rule = checked_rule(source, data, creating=True) + snapshot = control.update_model(source, rule, revision, known_models()) + event("model.created", {"model": rule["public_id"]}) + return JSONResponse({"revision": snapshot["revision"], "model": {"id": source, **rule}}, status_code=201) + return await run_in_threadpool(apply) + + @route("POST", "/admin/models/preview") + async def models_create_preview(request): + data = await _body(request) + data.pop("revision", None) + source = "custom:" + uuid.uuid4().hex + def preview(): + return JSONResponse(gateway.admin_model_preview(source, checked_rule(source, data, creating=True))) + return await run_in_threadpool(preview) + + @route("DELETE", "/admin/models/{id:path}") + async def models_delete(request): + data = await _body(request) + if set(data) != {"revision"}: + raise ValueError("删除模型只接受 revision") + source = request.path_params["id"] + def remove(): + with mutation_lock: + snapshot = control.delete_model(source, data["revision"]) + event("model.deleted", {"model": source}) + return JSONResponse({"revision": snapshot["revision"], "ok": True}) + return await run_in_threadpool(remove) + + @route("PUT", "/admin/models/{id:path}") async def models_put(request): data = await _body(request) @@ -442,8 +490,11 @@ async def dashboard_get(request): raise ValueError("days 必须为 1、7、30 或 90") from None if days not in (1, 7, 30, 90): raise ValueError("days 必须为 1、7、30 或 90") + granularity = request.query_params.get("granularity", "auto") + if granularity not in ("auto", "hour", "day"): + raise ValueError("granularity 必须为 auto、hour 或 day") def build_dashboard(): - result = audit.dashboard(days) + result = audit.dashboard(days, granularity=granularity) if result.get("degraded"): return error_response(503, "统计暂时无法读取,不能确认当前数值;请检查审计存储状态") rows = [_public_credential(item) for item in inventory()] diff --git a/app/audit_store.py b/app/audit_store.py index 9cff907..5ea9c77 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -475,12 +475,16 @@ def fetch(): return json.loads(row[0]) if row else None return self._run(fetch, write=True) - def dashboard(self, days=30): + def dashboard(self, days=30, granularity="auto"): days = int(days) - if not 1 <= days <= 36500: - raise ValueError("invalid days") + if not 1 <= days <= 36500 or granularity not in ("auto", "hour", "day"): + raise ValueError("invalid dashboard range or granularity") + grain = ("hour" if days == 1 else "day") if granularity == "auto" else granularity + if grain == "hour" and days > 90: + raise ValueError("hourly range exceeds 90 days") now = time.time() start = int(now // 86400) * 86400 - (days - 1) * 86400 + period = {"days": days, "start": start, "end": now, "timezone": "UTC", "granularity": grain} def fetch(): summary = self._empty_stats() series, models, profiles = [], {}, {} @@ -489,17 +493,28 @@ def fetch(): stats = json.loads(row["payload"]) if row["dimension"] == "global": self._merge(summary, stats) - series.append({"bucket": row["bucket"], "date": time.strftime("%Y-%m-%d", time.gmtime(row["bucket"])), **stats}) + if grain == "day": + series.append({"bucket": row["bucket"], **stats}) elif row["dimension"] in ("model", "profile"): target = models if row["dimension"] == "model" else profiles self._merge(target.setdefault(row["dimension_key"], self._empty_stats()), stats) + if grain == "hour": + rows = self._db.execute("SELECT bucket,payload FROM stats_hourly WHERE dimension='global' AND bucket>=? AND bucket<=? ORDER BY bucket", (start, now)).fetchall() + series = [{"bucket": row["bucket"], **json.loads(row["payload"])} for row in rows] + partial = grain == "hour" and sum(row["requests"] for row in series) != summary["requests"] + step = 3600 if grain == "hour" else 86400 + if days <= 90 and not partial: + recorded = {row["bucket"]: row for row in series} + series = [recorded.get(bucket, {"bucket": bucket, **self._empty_stats()}) + for bucket in range(start, int(now // step) * step + 1, step)] + for row in series: + row["date"] = time.strftime("%Y-%m-%d %H:00" if grain == "hour" else "%Y-%m-%d", time.gmtime(row["bucket"])) summary["success_rate"] = summary["success"] / summary["requests"] if summary["requests"] else None return {"summary": summary, "series": series, "models": [{"model": key, **value} for key, value in models.items()], "profiles": [{"profile": key, **value} for key, value in profiles.items()], - "generated_at": now, "range": {"days": days, "start": start, "end": now, "timezone": "UTC"}} - return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": {"days": days, "start": start, "end": now}, "degraded": True}) - + "generated_at": now, "range": {**period, "partial": partial}} + return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": period, "degraded": True}) def storage(self): def fetch(): self._prune() diff --git a/app/control_store.py b/app/control_store.py index b3be8cf..b8ddd08 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -21,13 +21,19 @@ def _identifier(value, label): return value -def validate_model(source, rule, models=None, known_models=()): - _identifier(source, "上游模型 ID") - if not isinstance(rule, dict) or set(rule) - {"public_id", "enabled", "keep_original", "region", "profile", "credential_ids"}: +def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes=False): + _identifier(source, "模型规则 ID") + if not isinstance(rule, dict) or set(rule) - {"public_id", "upstream_id", "custom", "enabled", "keep_original", "region", "profile", "credential_ids"}: raise ValueError("模型规则字段无效") - clean = {"public_id": source, "enabled": True, "keep_original": False, + clean = {"public_id": source, "upstream_id": source, "custom": False, + "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": [], **rule} + _identifier(clean["upstream_id"], "上游模型 ID") + if type(clean["custom"]) is not bool or (clean["custom"] and clean["keep_original"]): + raise ValueError("自建模型不能公开内部规则标识") _identifier(clean["public_id"], "公开模型 ID") + if clean["custom"] and clean["public_id"] == source: + raise ValueError("自建模型必须使用独立的对外 ID") if type(clean["enabled"]) is not bool or type(clean["keep_original"]) is not bool: raise ValueError("enabled/keep_original 必须为布尔值") if clean["region"] not in (None, "cn", "intl") or clean["profile"] not in (None, "cn-cli", "cn-work", "intl-cli", "intl-work"): @@ -41,6 +47,8 @@ def validate_model(source, rule, models=None, known_models=()): _identifier(identity, "账号指纹") if len(set(ids)) != len(ids): raise ValueError("账号指纹重复") + if ids and (clean["region"] or clean["profile"]) and not legacy_scopes: + raise ValueError("指定账号与区域/产品范围只能选择一种") all_rules = {**(models or {}), source: clean} real_ids = set(known_models) | set(all_rules) | {"auto"} aliases = {} @@ -94,7 +102,7 @@ def _load(self): if not isinstance(data["models"], dict) or not isinstance(data["credentials"], dict): raise ValueError("管理数据库策略无效") for source, rule in data["models"].items(): - validate_model(source, rule, data["models"]) + validate_model(source, rule, data["models"], legacy_scopes=True) # 旧联合范围保持原有交集,编辑时再显式转换。 for identity, metadata in data["credentials"].items(): _identifier(identity, "账号指纹") if not isinstance(metadata, dict) or set(metadata) - {"enabled", "label"} or type(metadata.get("enabled")) is not bool: @@ -138,6 +146,16 @@ def change(state): state["models"][source] = validate_model(source, rule, state["models"], known_models) return self._update(revision, change) + def delete_model(self, source, revision): + if type(revision) is not int: + raise ValueError("revision 必须为整数") + def change(state): + if not state["models"].get(source, {}).get("custom"): + raise ValueError("只能删除自建模型,目录模型请停用") + del state["models"][source] + return self._update(revision, change) + + def set_credential(self, account_key, enabled): _identifier(account_key, "账号指纹") if type(enabled) is not bool: diff --git a/app/gateway_management.py b/app/gateway_management.py index e9646cf..e753c57 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -52,7 +52,7 @@ def admin_credential_inventory(self): cooldowns=cooldowns, credits=balance.get("credits") or None, sync_pending=path in pool._sync_pending or path in pool._syncing or path in pool._sync_retry, catalog_ready=(self.CONFIG.get("account_catalogs") or {}).get(identity, {}).get("models") is not None, - bindings=[source for source, rule in model_policy.snapshot(self.CONFIG)["models"].items() + bindings=[rule.get("public_id", source) for source, rule in model_policy.snapshot(self.CONFIG)["models"].items() if identity in rule.get("credential_ids", [])]) result.append(row) return result @@ -106,11 +106,13 @@ def admin_model_inventory(self): for source, row in facts.items(): rule = model_policy.rule_for(self.CONFIG, source) preview = self.admin_model_preview(source, rule) - result.append({**row, **rule, "available_credentials": len(preview["candidates"]), + target = facts.get(rule["upstream_id"], row) + result.append({**target, **rule, "id": source, "available_credentials": len(preview["candidates"]), "available": bool(preview["candidates"])}) return sorted(result, key=lambda row: row["id"]) def admin_model_preview(self, source, rule): + upstream = rule.get("upstream_id", source) pool = self.CONFIG.get("cred_pool") accepted, rejected = [], [] if pool is None: @@ -128,20 +130,20 @@ def admin_model_preview(self, source, rule): reason = "不在绑定范围" elif not pool._healthy(entry): reason = "认证熔断" - elif not pool._model_healthy(entry, source): + elif not pool._model_healthy(entry, upstream): reason = "模型额度冷却" - else: + elif not pool._model_servable(entry, upstream): + reason = "后端模型暂时不可用" + elif not pool._eligible(entry, upstream, rule=rule): account = (self.CONFIG.get("account_catalogs") or {}).get(identity, {}) models = self.gateway._account_scope(account, "serves") - if self.CONFIG.get("account_catalogs") is not None: - if models is None: - reason = "目录尚未就绪" - elif not any(item["id"] == self.gateway._upstream_model(source, profile) - for item in self.gateway._usable_models(models)) and not ( - source == "auto" and profile == "cn-cli" and self.gateway._usable_models(models)): - reason = "账号自身目录不支持模型" - if reason is None and not (pool._has_credit(entry, profile) or pool._model_free(entry, source)): + if (self.CONFIG.get("account_catalogs") is not None or self.CONFIG.get("model_cache") is not None) and ( + models is None or account.get("profile") != profile): + reason = "目录尚未就绪" + elif not (pool._has_credit(entry, profile) or pool._model_free(entry, upstream)): reason = "额度不足或未知" + else: + reason = "账号自身目录不支持模型" item = {"id": identity, "name": Path(entry["id"]).name, "profile": profile} if reason: rejected.append({**item, "reason": reason}) diff --git a/app/model_policy.py b/app/model_policy.py index bbd4251..6366892 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -1,10 +1,8 @@ """Local model policies layered over account-owned upstream capabilities.""" - from contextvars import ContextVar from fastapi import HTTPException - _request_policy = ContextVar("gateway_model_policy", default=None) @@ -32,7 +30,8 @@ def credential_enabled(config, entry): def default_rule(source): - return {"public_id": source, "enabled": True, "keep_original": False, + return {"public_id": source, "upstream_id": source, "custom": False, + "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": []} @@ -45,46 +44,54 @@ def _deny(message, code="model_disabled"): "message": message, "type": "invalid_request_error", "param": "model", "code": code}}) +def _active(config, upstream): + state = _request_policy.get() + if state and state.get("upstream") == upstream: + return rule_for(config, state["source"]), state + return rule_for(config, upstream), None + + def resolve(config, public_model): - """Resolve once per request, keeping the public name separate from wire data.""" + """Resolve one public name to a literal upstream ID while retaining its own policy.""" if not isinstance(public_model, str) or not public_model.strip(): raise HTTPException(status_code=400, detail={"error": {"message": "model must be a non-empty string"}}) state = _request_policy.get() - if state and state.get("source") == public_model: - source, rule = state["source"], state["rule"] - if rule_for(config, source) != rule: + if state and public_model in (state.get("upstream"), state.get("public_model")): + if rule_for(config, state["source"]) != state["rule"]: _deny("模型策略已变更,请重新请求", "model_policy_changed") - return source + return state["upstream"] data = snapshot(config) source = next((key for key, value in data["models"].items() if value.get("public_id", key) == public_model), public_model) rule = {**default_rule(source), **data["models"].get(source, {})} if not rule["enabled"]: _deny("模型已停用") - if source == public_model and rule["public_id"] != source and not rule["keep_original"]: + if source == public_model and (rule["custom"] or (rule["public_id"] != source and not rule["keep_original"])): _deny("原模型 ID 已停用,请使用已配置的对外 ID", "model_renamed") + if state is None and rule["upstream_id"] != source: + raise HTTPException(status_code=503, detail={"error": {"message": "模型路由上下文未初始化", + "type": "server_error", "code": "model_policy_context_missing"}}) if state is not None: - state.update(source=source, public_model=public_model, rule=rule) - return source + state.update(source=source, public_model=public_model, upstream=rule["upstream_id"], rule=rule) + return rule["upstream_id"] -def check_resolved(config, source): - if not rule_for(config, source)["enabled"]: +def check_resolved(config, upstream): + rule, state = _active(config, upstream) + if state and state["rule"] != rule: + _deny("模型策略已变更,请重新请求", "model_policy_changed") + if not rule["enabled"]: _deny("模型已停用") def route_allowed(config, entry, model, *, rule=None): if not credential_enabled(config, entry): return False - active = rule_for(config, model) if model else default_rule("") if rule is None: - state = _request_policy.get() - if state and state.get("source") == model: - if state["rule"] != active: - return False - rule = state["rule"] - else: - rule = active + active, state = _active(config, model) if model else (default_rule(""), None) + if state and state["rule"] != active: + return False + rule = active if not rule.get("enabled", True): return False profile = entry.get("profile", "") @@ -98,7 +105,8 @@ def route_allowed(config, entry, model, *, rule=None): def sticky_scope(config, key, model): if key and config.get("control_store") is not None: - return f"policy:{snapshot(config)['revision']}:{model}:{key}" + state = _request_policy.get() or {} + return f"policy:{snapshot(config)['revision']}:{state.get('source', model)}:{key}" return key @@ -107,21 +115,30 @@ def public_name(fallback): def public_details(gateway, region=None): - """Only advertise capabilities and prices from credentials inside the rule.""" + """Publish each route using only its upstream capabilities and allowed accounts.""" config = gateway.CONFIG raw = gateway.current_model_details(region) if config.get("control_store") is None: return raw + known = {item["id"]: item for item in raw} + policies = snapshot(config)["models"] + owners = {rule.get("public_id", source): source for source, rule in policies.items()} pool = config.get("cred_pool") entries = pool.entries() if pool is not None else [] out = [] - for item in raw: - source = item["id"] + for source in dict.fromkeys([*known, *policies]): rule = rule_for(config, source) - if not rule["enabled"]: + upstream = rule["upstream_id"] + item = known.get(upstream) + if not rule["enabled"] or item is None: continue - candidates = [entry for entry in entries if route_allowed(config, entry, source, rule=rule) - and pool._eligible(entry, source, region=region)] + token = _request_policy.set({"source": source, "upstream": upstream, + "public_model": rule["public_id"], "rule": rule}) + try: + candidates = [entry for entry in entries if route_allowed(config, entry, upstream, rule=rule) + and pool._eligible(entry, upstream, region=region)] + finally: + _request_policy.reset(token) if pool is not None and not candidates: continue prices = {} @@ -129,9 +146,8 @@ def public_details(gateway, region=None): if accounts is not None: for entry in candidates: profile = entry.get("profile") - for model in gateway._account_scope( - accounts.get(entry.get("account_key")) or {}, "serves") or []: - if model.get("id") == gateway._upstream_model(source, profile): + for model in gateway._account_scope(accounts.get(entry.get("account_key")) or {}, "serves") or []: + if model.get("id") == gateway._upstream_model(upstream, profile): price = gateway._multiplier_value(model.get("credits")) if price is not None: prices[profile] = min(prices.get(profile, price), price) @@ -140,9 +156,12 @@ def public_details(gateway, region=None): prices = {profile: price for profile, price in item["credits_by_profile"].items() if not pool or profile in allowed_profiles} names = [rule["public_id"]] - if rule["keep_original"] and source not in names: + if rule["keep_original"] and not rule["custom"] and source not in names: names.append(source) for name in names: + # A later catalog refresh must not overwrite an explicitly configured alias. + if name in owners and owners[name] != source: + continue out.append({"id": name, "credits": min(prices.values(), default=None), "credits_by_profile": prices}) return out diff --git a/app/runtime_management.py b/app/runtime_management.py index cf68197..0011238 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -38,7 +38,7 @@ def storage(self): return {"available": False, "degraded": True, "last_error": self.code, "dropped": self.dropped, "warning": "日志存储不可用;保留原文件,未自动重建。统计可能不完整。"} - def dashboard(self, days=30): + def dashboard(self, days=30, granularity="auto"): return {"summary": None, "series": [], "models": [], "profiles": [], "degraded": True, "storage": self.storage()} def list_records(self, *args, **kwargs): diff --git a/converter.py b/converter.py index a61becc..3e7ecfc 100644 --- a/converter.py +++ b/converter.py @@ -716,8 +716,8 @@ def _has_credit(self, entry, profile): except (TypeError, ValueError): return False - def _eligible(self, entry, model, *, region=None, profile=None): - if not model_policy.route_allowed(CONFIG, entry, model): + def _eligible(self, entry, model, *, region=None, profile=None, rule=None): + if not model_policy.route_allowed(CONFIG, entry, model, rule=rule): return False actual = self._entry_profile(entry) profile = profile or actual diff --git a/docs/webui.md b/docs/webui.md index 605c08d..f4c932a 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -10,15 +10,26 @@ Management is locked without a key. After changing it, sign in again and restart ## Common tasks -- **Overview:** view requests, usage and credential health. Official account balances may include usage from other clients. +- **Overview:** select 1/7/30/90 days and automatic/hourly/daily granularity. Automatic uses hours for one day and days otherwise; ranges still follow UTC calendar days. Missing hourly history is marked, never reconstructed from daily totals, and detail cleanup preserves hourly aggregates. Official balances may include usage from other clients. - **Credentials:** select Mainland China (CN), International WorkBuddy or International CodeBuddy for browser login, or import `.info`/ZIP files. Distinguish manual disabling, credential-level authentication circuits and model-level 429 cooldowns. Disabling keeps files; deletion removes them and requires removing model bindings first. Exports preserve safe UTF-8 filenames (otherwise `credential.info`/`credentials.zip`) and contain plaintext credentials; do not share them. -- **Models:** enable models, set public IDs, regions/products and specific accounts. Unavailable bindings never fall back to unselected accounts. Clients use the IDs published here. + - Successful OAuth enrollment closes the drawer and refreshes the list. The official tab keeps `noopener` isolation and must be closed manually. Failures stay visible; closing the drawer stops polling. +- **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. - **Logs:** filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. - **Settings:** edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md). - “Keep tool descriptions” is off by default and works across all three protocols, independently of prompt compaction; see [tool metadata retention](advanced.md#tool-metadata-retention) for configuration and limits. Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current API key. This does not delete credentials or gateway settings. +The sidebar remembers its icon-only mode. Drawers lock background scrolling, close on backdrop clicks or Esc, and restore focus; saves, imports and deletions prevent accidental dismissal while pending. Details use labeled fields and status groups with folded raw diagnostics. Glass surfaces fall back to solid colors when transparency is reduced or blur is unsupported. + +## Model mappings and statistics API + +Multiple independent mappings may share an upstream model. Custom mapping IDs are management-only; clients use `public_id`. Existing account capability, balance and avoidance checks still apply. Legacy combined account/region scopes keep their original intersection until an explicit mode is selected during editing. + +- Create: `POST /admin/models` with `public_id`, `upstream_id` and scope; edit: `PUT /admin/models/{id}`; delete: `DELETE /admin/models/{id}` (custom mappings only). Writes require the current `revision`. +- Preview: `POST /admin/models/preview` or `POST /admin/models/{id}/preview`. `credential_ids` is mutually exclusive with `region`/`profile`. +- Statistics: `GET /admin/dashboard?days=1&granularity=auto`, with `auto`, `hour` or `day`. Responses identify `range.granularity`, `range.partial` and UTC buckets without inventing missing hourly history. + ## Data and backups Data defaults to `auth/`, or `/data/auth` inside Docker. Local installs can set `CODEBUDDY_AUTH_DIR`; Compose uses `CODEBUDDY2API_AUTH_PATH` for the host directory. @@ -35,4 +46,6 @@ Mount the whole data directory on writable local storage, not just a single data Stop the gateway before copying the entire directory, including databases, any WAL/SHM files, credentials and catalog/credit state files; do not back up only `.info` files. Keep this private data secure. +Back up control metadata before using the new model rules. When reverting to older code, restore the matching control-database snapshot, including its WAL/SHM state without mixing old and new files; older readers reject the new rule fields. + See [client configuration](clients.md) for API keys and URLs. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 4363b1a..f4fd4b1 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -10,15 +10,26 @@ ## 常用操作 -- **运行概览**:查看请求、用量和凭证健康状态。官方账号余额可能包含其他客户端的使用。 +- **运行概览**:切换 1/7/30/90 天范围与自动/小时/天粒度;自动模式下 1 天按小时,其余按天,范围仍按 UTC 自然日计算。缺少小时历史时明确提示,不拆分日统计伪造数据;明细清理不删除小时聚合。官方账号余额可能包含其他客户端的使用。 - **凭证管理**:选择“中国大陆 · CN”“国际 · WorkBuddy”或“国际 · CodeBuddy”扫码添加账号,也可导入 `.info`、ZIP 文件。区分人工停用、凭证级认证熔断和模型级 429 冷却;停用保留文件,删除会移除文件,仍有模型绑定时需先解除。导出保留安全的 UTF-8 文件名,否则使用 `credential.info`/`credentials.zip`;文件含明文凭证,请勿分享。 -- **模型路由**:设置模型启停、对外 ID、地域/产品和指定账号;绑定不可用时不会回退到未选账号。客户端使用这里发布的 ID。 + - OAuth 入库成功后自动关闭添加抽屉并刷新列表;官方标签页保留 `noopener` 安全隔离,需手动关闭。失败保留错误,关闭抽屉会停止查询。 +- **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 - **日志审计**:筛选请求、查看失败详情。关闭详情或切换日志类型会取消未完成的详情加载。清空明细会保留历史统计。 - **系统设置**:修改可编辑项;热更新项立即生效,标记为重启生效的设置需手动重启。锁定项在启动配置中修改,优先级见 [进阶参考](advanced.zh-CN.md)。 - “保留工具描述”默认关闭,可为三协议保留工具说明;独立于提示词压缩,配置与限制见 [工具描述保留](advanced.zh-CN.md#工具描述保留)。 **全部清空日志与统计不可撤销**,需输入弹窗中的确认文字并复核当前 API key;不会删除凭证或网关配置。 +侧栏可折叠为图标并记住偏好;抽屉锁定背景滚动,支持点击遮罩、Esc 关闭和焦点恢复,保存/导入/删除等操作提交中防止误关。详情使用字段与状态分组,原始诊断折叠展示;玻璃层在低透明度偏好或不支持模糊时回退为实色。 + +## 模型映射与统计接口 + +同一上游可设置多条独立映射;自建映射的内部 `id` 仅用于管理,客户端使用 `public_id`。新增映射仍遵守既有账号能力、余额和避让检查。旧版账号/区域联合规则保留原交集,编辑时须明确选择一种绑定。 + +- 创建:`POST /admin/models`,设置 `public_id`、`upstream_id` 及路由范围;编辑:`PUT /admin/models/{id}`;删除:`DELETE /admin/models/{id}`(仅自建映射)。写请求携带当前 `revision`。 +- 预览:`POST /admin/models/preview` 或 `POST /admin/models/{id}/preview`;账号使用 `credential_ids`,区域使用 `region`(可附 `profile`),两组互斥。 +- 统计:`GET /admin/dashboard?days=1&granularity=auto`,粒度为 `auto`、`hour` 或 `day`;返回 `range.granularity`、`range.partial` 和 UTC 时段,不补造缺失小时记录。 + ## 数据与备份 数据默认保存在 `auth/`,Docker 内为 `/data/auth`;本地可通过 `CODEBUDDY_AUTH_DIR` 指定目录,Compose 宿主机目录由 `CODEBUDDY2API_AUTH_PATH` 控制。 @@ -35,4 +46,6 @@ 备份前先停止网关,再复制整个目录,包含数据库、可能存在的 WAL/SHM 文件、凭证及目录/积分状态文件;不要只备份 `.info`。备份包含私有数据,请妥善保管。 +使用新版模型规则前请保留配置快照;回滚旧代码时成套恢复对应的控制库备份(含其 WAL/SHM 状态,不混用新旧文件),旧版不识别新增规则字段。 + 客户端密钥和地址的填写方式见 [客户端配置](clients.zh-CN.md)。 diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 2317f15..3d2b4f1 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -353,9 +353,28 @@ def test_settings_revision_locked_sources_and_secret_redaction(self): stale = self.client.patch("/admin/settings", headers=self.headers, json={"revision": 0, "values": {"max_images": 6}}) self.assertEqual(stale.status_code, 409) + def test_model_inventory_uses_revision_after_identity_sync(self): + def inventory(): + self.store.update_model("custom:synced", {"public_id": "synced-model", "upstream_id": "upstream", "custom": True}, 0) + return [{"id": "custom:synced", "public_id": "synced-model", "upstream_id": "upstream", "custom": True}] + self.gateway.admin_model_inventory.side_effect = inventory + response = self.client.get("/admin/models", headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["revision"], 1) + self.assertEqual(response.json()["models"][0]["public_id"], "synced-model") + + + def test_dashboard_granularity_is_validated_and_forwarded(self): + response = self.client.get("/admin/dashboard?days=1&granularity=hour", headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + self.audit.dashboard.assert_called_with(1, granularity="hour") + for value in ("week", "minute", "unknown"): + self.assertEqual(self.client.get("/admin/dashboard?days=1&granularity=" + value, headers=self.headers).status_code, 400) + + def test_model_rules_preview_and_credential_constraints(self): response = self.client.put("/admin/models/upstream", headers=self.headers, - json={"revision": 0, "public_id": "public", "credential_ids": ["fingerprint"], "region": "cn"}) + json={"revision": 0, "public_id": "public", "credential_ids": ["fingerprint"]}) self.assertEqual(response.status_code, 200, response.text) models = self.client.get("/admin/models", headers=self.headers).json() self.assertEqual(models["models"][0]["public_id"], "public") diff --git a/tests/test_control_store.py b/tests/test_control_store.py index 310fe9e..b16ae07 100644 --- a/tests/test_control_store.py +++ b/tests/test_control_store.py @@ -66,6 +66,22 @@ def test_alias_collision_and_chains(self): self.store.update_model(source, rule, 1) self.assertEqual(self.store.snapshot()["revision"], 1) + def test_legacy_combined_scopes_load_without_widening_and_require_explicit_edit(self): + from app import model_policy + legacy = {"public_id": "legacy", "enabled": True, "keep_original": False, + "region": "cn", "profile": None, "credential_ids": ["cn-account", "intl-account"]} + self.store._update(0, lambda state: state["models"].update({"real": legacy})) + reopened = ControlStore(self.path) + self.addCleanup(reopened.close) + self.assertEqual(reopened.snapshot()["models"]["real"], legacy) + config = {"control_store": reopened} + self.assertTrue(model_policy.route_allowed(config, {"profile": "cn-cli", "account_key": "cn-account"}, "real")) + self.assertFalse(model_policy.route_allowed(config, {"profile": "intl-cli", "account_key": "intl-account"}, "real")) + with self.assertRaises(ValueError): + reopened.update_model("real", legacy, 1) + self.assertEqual(reopened.snapshot()["revision"], 1) + + def test_credential_metadata_and_no_secret_settings(self): self.store.set_credential("account-fingerprint", False) self.assertEqual(self.store.snapshot()["credentials"], {"account-fingerprint": {"enabled": False}}) diff --git a/tests/test_dashboard_granularity.py b/tests/test_dashboard_granularity.py new file mode 100644 index 0000000..a7c9b3c --- /dev/null +++ b/tests/test_dashboard_granularity.py @@ -0,0 +1,72 @@ +"""Dashboard bucket regressions using isolated persistent aggregates.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from datetime import datetime, timezone +import tempfile +import unittest +from unittest.mock import patch + +from app.audit_store import AuditStore + + +class GranularityTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.start = datetime(2026, 9, 14, tzinfo=timezone.utc).timestamp() + self.enterContext(patch("app.audit_store.time.time", return_value=self.start + 12.5 * 3600)) + self.store = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(self.store.close) + for hour in (2, 9): + result = self.store.record_request({"id": f"hour-{hour}", **self.store.ticket(), + "started_at": self.start + hour * 3600, "public_model": "synthetic-model", + "profile": "cn-cli", "protocol": "chat", "status_code": 200, "outcome": "success"}) + self.assertTrue(result["ok"]) + + def test_one_day_auto_uses_real_hours_without_changing_summary(self): + hourly = self.store.dashboard(1) + daily = self.store.dashboard(1, "day") + self.assertEqual(hourly["range"]["granularity"], "hour") + self.assertEqual(hourly["range"]["timezone"], "UTC") + self.assertEqual(len(hourly["series"]), 13) + self.assertEqual(len(daily["series"]), 1) + self.assertEqual(hourly["summary"], daily["summary"]) + self.assertEqual([row["bucket"] for row in hourly["series"] if row["requests"]], + [self.start + 2 * 3600, self.start + 9 * 3600]) + self.assertEqual(sum(row["requests"] for row in hourly["series"]), 2) + self.assertIsNone(hourly["series"][0]["credit"]) + self.assertEqual(hourly["series"][0]["requests"], 0) + self.assertLessEqual(hourly["series"][-1]["bucket"], hourly["range"]["end"]) + + def test_granularity_is_explicit_and_bounded(self): + self.assertEqual(self.store.dashboard(7)["range"]["granularity"], "day") + self.assertEqual(self.store.dashboard(7, "hour")["range"]["granularity"], "hour") + self.assertLessEqual(len(self.store.dashboard(90, "hour")["series"]), 90 * 24) + for grain in ("minute", "stats_hourly; DROP TABLE requests", None): + with self.subTest(grain=grain), self.assertRaises(ValueError): + self.store.dashboard(1, grain) + with self.assertRaises(ValueError): + self.store.dashboard(91, "hour") + + def test_detail_clear_and_restart_retain_hourly_stats(self): + before = self.store.dashboard(1) + self.store.clear("details") + reopened = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(reopened.close) + self.assertEqual(reopened.dashboard(1)["series"], before["series"]) + self.assertEqual(reopened.list_records()["items"], []) + reopened.clear("all") + self.assertEqual(sum(row["requests"] for row in reopened.dashboard(1)["series"]), 0) + + def test_missing_hourly_history_is_marked_incomplete_not_split_from_daily(self): + self.store._db.execute("DELETE FROM stats_hourly") + result = self.store.dashboard(1, "hour") + self.assertTrue(result["range"]["partial"]) + self.assertEqual(result["series"], []) + self.assertEqual(result["summary"]["requests"], 2) + self.assertFalse(self.store.dashboard(1, "day")["range"]["partial"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index e97f6e7..88282a5 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -8,7 +8,7 @@ import unittest from unittest.mock import patch -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient import converter @@ -51,7 +51,7 @@ def policy(self, source="shared-model", **kwargs): def test_managed_alias_all_protocols_strict_binding_and_response_names(self): identity = self.entries["intl-work"]["account_key"] - self.policy(public_id="garden-fast", region="intl", profile="intl-work", credential_ids=[identity]) + self.policy(public_id="garden-fast", credential_ids=[identity]) published = self.client.get("/v1/models").json()["data"] self.assertIn("garden-fast", [model["id"] for model in published]) self.assertNotIn("shared-model", [model["id"] for model in published]) @@ -66,6 +66,109 @@ def test_managed_alias_all_protocols_strict_binding_and_response_names(self): self.management.admin_set_credential_enabled(identity, False) self.post_rejected("chat/completions", self.payload(selected_model="garden-fast"), (503,)) + def test_custom_models_keep_upstream_and_binding_independent(self): + created = [] + for label, profile in (("studio-cn", "cn-work"), ("studio-intl", "intl-work")): + response = self.client.post("/admin/models", json={ + "revision": self.control.snapshot()["revision"], "public_id": label, + "upstream_id": "shared-model", "credential_ids": [self.entries[profile]["account_key"]]}) + self.assertEqual(response.status_code, 201, response.text) + rule = response.json()["model"] + self.assertTrue(rule["custom"]) + self.assertNotEqual(rule["id"], label) + created.append(rule) + for endpoint in fixtures.GENERATIONS: + for stream in (False, True): + _, wire = self.post_ok(endpoint, self.payload(endpoint, label, stream=stream), {profile}) + self.assertEqual(wire["model"], "shared-model") + published = {row["id"] for row in self.client.get("/v1/models").json()["data"]} + self.assertTrue({"studio-cn", "studio-intl", "shared-model"} <= published) + self.assertTrue(all(row["id"] not in published for row in created)) + self.post_rejected("chat/completions", self.payload(selected_model=created[0]["id"]), (404,)) + bindings = {row["id"]: row["bindings"] for row in self.management.admin_credential_inventory()} + self.assertEqual(bindings[self.entries["cn-work"]["account_key"]], ["studio-cn"]) + self.policy(enabled=False) # 停用目录名称不改变独立自建路由的启停与范围。 + published = {row["id"] for row in self.client.get("/v1/models").json()["data"]} + self.assertNotIn("shared-model", published) + self.assertTrue({"studio-cn", "studio-intl"} <= published) + self.management.admin_set_credential_enabled(self.entries["cn-work"]["account_key"], False) + self.post_rejected("chat/completions", self.payload(selected_model="studio-cn"), (503,)) + self.post_ok("chat/completions", self.payload(selected_model="studio-intl"), {"intl-work"}) + + def test_custom_model_crud_preview_unknown_and_collisions(self): + body = {"revision": 0, "public_id": "my-model", "upstream_id": "shared-model", "region": "cn"} + preview = self.client.post("/admin/models/preview", json=body) + self.assertEqual(preview.status_code, 200, preview.text) + self.assertTrue(preview.json()["candidates"]) + self.assertEqual(self.control.snapshot()["revision"], 0) + created = self.client.post("/admin/models", json=body) + self.assertEqual(created.status_code, 201, created.text) + identifier = created.json()["model"]["id"] + self.assertEqual(self.client.post("/admin/models", json={**body, "revision": 1}).status_code, 400) + conflict = self.client.put("/admin/models/" + identifier, json={**body, "upstream_id": "missing"}) + self.assertEqual(conflict.status_code, 409) + updated = self.client.put("/admin/models/" + identifier, json={**body, "revision": 1, "upstream_id": "cn-cli-only"}) + self.assertEqual(updated.status_code, 200, updated.text) + _, wire = self.post_ok("chat/completions", self.payload(selected_model="my-model"), {"cn-cli"}) + self.assertEqual(wire["model"], "cn-cli-only") + preview = self.client.post("/admin/models/preview", json={"public_id": "unready-model", "upstream_id": "missing"}) + self.assertEqual(preview.status_code, 200, preview.text) + self.assertEqual(preview.json()["candidates"], []) + missing = self.client.post("/admin/models", json={"revision": 2, "public_id": "unready-model", "upstream_id": "missing"}) + self.assertEqual(missing.status_code, 201, missing.text) + self.post_rejected("chat/completions", self.payload(selected_model="unready-model"), (404,)) + self.assertNotIn("unready-model", [row["id"] for row in self.client.get("/v1/models").json()["data"]]) + deleted = self.client.request("DELETE", "/admin/models/" + identifier, json={"revision": 3}) + self.assertEqual(deleted.status_code, 200, deleted.text) + self.assertEqual(len(self.pool.entries()), 4) + self.post_rejected("chat/completions", self.payload(selected_model="my-model"), (404,)) + + def test_custom_auto_keeps_account_scope_through_backend_model_rewrite(self): + tables = fixtures.catalogs() + tables["intl-work"].append(fixtures.model("default-model", "x1")) + self.account_catalogs(tables) + identity = self.entries["intl-work"]["account_key"] + response = self.client.post("/admin/models", json={"revision": 0, "public_id": "my-auto", + "upstream_id": "auto", "credential_ids": [identity]}) + self.assertEqual(response.status_code, 201, response.text) + self.assertIn("my-auto", {row["id"] for row in self.client.get("/v1/models").json()["data"]}) + for endpoint in fixtures.GENERATIONS: + with self.subTest(endpoint=endpoint): + request, wire = self.post_ok(endpoint, self.payload(endpoint, "my-auto", stream=False), {"intl-work"}) + self.assertEqual(wire["model"], "default-model") + self.assertEqual(request.headers["X-Domain"], "www.workbuddy.ai") + self.assertEqual(request.headers["X-User-Id"], "intl-work") + + def test_custom_scope_conflicts_and_policy_changes_fail_closed(self): + from app import model_policy + identity = self.entries["intl-work"]["account_key"] + for scope in ({"region": "intl"}, {"profile": "intl-work"}): + response = self.client.post("/admin/models", json={ + "revision": 0, "public_id": "invalid-scope", "upstream_id": "shared-model", + "credential_ids": [identity], **scope}) + self.assertEqual(response.status_code, 400) + created = self.client.post("/admin/models", json={ + "revision": 0, "public_id": "frozen-policy", "upstream_id": "shared-model"}).json()["model"] + missing_context = model_policy._request_policy.set(None) + try: + with self.assertRaises(HTTPException) as error: + model_policy.resolve(converter.CONFIG, "frozen-policy") + self.assertEqual(error.exception.status_code, 503) + finally: + model_policy._request_policy.reset(missing_context) + token = model_policy._request_policy.set({}) + try: + self.assertEqual(model_policy.resolve(converter.CONFIG, "frozen-policy"), "shared-model") + self.control.update_model(created["id"], {**{k: v for k, v in created.items() if k != "id"}, + "upstream_id": "cn-cli-only"}, 1) + with self.assertRaises(HTTPException) as changed: + model_policy.check_resolved(converter.CONFIG, "shared-model") + self.assertEqual(changed.exception.detail["error"]["code"], "model_policy_changed") + self.assertFalse(model_policy.route_allowed(converter.CONFIG, self.entries["cn-cli"], "shared-model")) + finally: + model_policy._request_policy.reset(token) + + def test_managed_disabled_cannot_bypass_guard_or_alias(self): self.policy(public_id="garden-fast", enabled=False, keep_original=True) converter.CONFIG["model_guard"] = False diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index 4443843..d1c1c29 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -30,6 +30,25 @@ test("real management API: session, model alias, audit, clear, and file credenti }); expect(generated.status()).toBe(200); expect((await generated.json()).model).toBe("garden-fixture"); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByLabel("对外 ID").fill("mapped-fixture"); + await page.getByLabel("上游 ID").fill("fixture-model"); + await page.getByRole("radio", { name: /指定账号/ }).check(); + await page.getByRole("checkbox", { name: /fixture.info/ }).check(); + await page.getByRole("button", { name: "创建模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const mapped = await page.request.post("/v1/chat/completions", { + headers: { Authorization: "Bearer synthetic-e2e-key" }, + data: { + model: "mapped-fixture", + stream: false, + messages: [{ role: "user", content: "synthetic mapping" }], + }, + }); + expect(mapped.status()).toBe(200); + expect((await mapped.json()).model).toBe("mapped-fixture"); + const credentials = (await (await page.request.get("/admin/credentials")).json()).credentials; + expect(credentials[0].bindings).toContain("mapped-fixture"); await page.getByRole("link", { name: "日志审计" }).click(); await expect(page.getByText("garden-fixture", { exact: true }).first()).toBeVisible(); await page.screenshot({ path: "test-results/backend-real-audit.png", fullPage: true }); diff --git a/web/e2e/experience.spec.ts b/web/e2e/experience.spec.ts new file mode 100644 index 0000000..127d43f --- /dev/null +++ b/web/e2e/experience.spec.ts @@ -0,0 +1,255 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function experienceAPI(page: Page) { + let revision = 1, + enrolled = false; + const calls: { method: string; path: string; body: Record | null }[] = []; + const credentials = [ + { + id: "cn-account", + name: "mainland.info", + profile: "cn-cli", + enabled: true, + health: "ready", + nickname: "隔离测试 · 大陆", + credits: { remaining: 200 }, + cooldowns: [], + }, + { + id: "intl-account", + name: "international.info", + profile: "intl-work", + enabled: true, + health: "ready", + nickname: "隔离测试 · 国际", + credits: { remaining: 120 }, + cooldowns: [], + }, + ]; + const models: Record[] = Array.from({ length: 48 }, (_, i) => ({ + id: `fixture-model-${i}`, + public_id: `fixture-model-${i}`, + upstream_id: `fixture-model-${i}`, + enabled: true, + keep_original: false, + custom: false, + region: null, + profile: null, + credential_ids: [], + available: true, + credits: 0, + credits_by_profile: { "cn-cli": 0, "intl-work": 0.5 }, + })); + await page.route("**/admin/**", async (route) => { + const request = route.request(), + url = new URL(request.url()), + method = request.method(); + const path = decodeURIComponent(url.pathname); + const body = request.postData() ? (request.postDataJSON() as Record) : null; + calls.push({ method, path: path + url.search, body }); + const send = (json: unknown, status = 200) => route.fulfill({ json, status }); + if (path === "/admin/session") + return send({ authenticated: true, csrf_token: "isolated-csrf" }); + if (method !== "GET" || path.endsWith("/poll")) + expect(request.headers()["x-csrf-token"]).toBe("isolated-csrf"); + if (path === "/admin/credentials") + return send({ + credentials: enrolled + ? [...credentials, { ...credentials[1], id: "new-account", name: "newly-enrolled.info" }] + : credentials, + }); + if (path === "/admin/oauth/start") + return send({ + login_id: "isolated", + verification_uri: "https://www.codebuddy.cn/isolated-oauth", + expires_in: 60, + }); + if (path === "/admin/oauth/poll") { + enrolled = true; + return send({ done: true }); + } + if (path.endsWith("/preview")) { + const ids = body?.credential_ids as string[]; + const allowed = credentials.filter( + (c) => + (!ids.length || ids.includes(c.id)) && + (!body?.region || (typeof body.region === "string" && c.profile.startsWith(body.region))), + ); + return send({ + candidates: allowed, + excluded: credentials + .filter((c) => !allowed.includes(c)) + .map((c) => ({ ...c, reason: "不在绑定范围" })), + }); + } + if (path === "/admin/models") { + if (method === "POST") { + expect(body?.revision).toBe(revision); + const created = { ...body, id: "custom:isolated", custom: true, available: true }; + models.push(created); + revision++; + return send({ revision, model: created }, 201); + } + return send({ revision, models }); + } + if (path === "/admin/models/custom:isolated" && method === "DELETE") { + expect(body?.revision).toBe(revision); + models.splice( + models.findIndex((m) => m.id === "custom:isolated"), + 1, + ); + revision++; + return send({ revision, ok: true }); + } + if (path === "/admin/dashboard") { + const days = Number(url.searchParams.get("days") ?? 7); + const grain = url.searchParams.get("granularity"); + const hourly = grain === "hour" || (grain === "auto" && days === 1); + const endDay = Date.UTC(2026, 8, 14) / 1000, + start = endDay - (days - 1) * 86400; + const hours: Record = { 9: 8, 12: 17, 16: 11, 19: 4, 22: 6 }; + const series = Array.from({ length: days * (hourly ? 24 : 1) }, (_, i) => { + const bucket = start + i * (hourly ? 3600 : 86400); + const count = bucket < endDay ? 0 : hourly ? (hours[(bucket - endDay) / 3600] ?? 0) : 46; + return { + bucket, + date: new Date(bucket * 1000) + .toISOString() + .slice(0, hourly ? 16 : 10) + .replace("T", " "), + requests: count, + success: count, + error: 0, + }; + }); + return send({ + summary: { requests: 46, success_rate: 1, total_tokens: 8400, credit: 0 }, + series, + models: [{ model: "fixture-model-0", requests: 46, total_tokens: 8400, credit: 0 }], + profiles: [ + { profile: "cn-cli", requests: 23 }, + { profile: "intl-work", requests: 23 }, + ], + health: { credentials }, + storage: { + degraded: false, + logical_bytes: 4096, + max_bytes: 1048576, + db_bytes: 32768, + retention_days: 30, + pending_cleanup: false, + }, + range: { days, granularity: hourly ? "hour" : "day", timezone: "UTC", partial: false }, + generated_at: endDay + 23 * 3600, + }); + } + return send({ error: { message: `Unmocked ${method} ${path}` } }, 404); + }); + return calls; +} + +test("glass workspace folds, creates a scoped model and keeps dialogs isolated", async ({ + page, +}) => { + const calls = await experienceAPI(page); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/dashboard/models"); + const opener = page.getByRole("button", { name: "编辑规则" }).nth(24); + await opener.scrollIntoViewIfNeeded(); + const before = await page.evaluate(() => scrollY); + await opener.click(); + await expect(page.locator("body")).toHaveCSS("position", "fixed"); + await expect(page.locator("html")).toHaveCSS("overflow", "hidden"); + await expect(page.locator("body")).toHaveCSS("top", `${-before}px`); + await page.mouse.move(100, 400); + await page.mouse.wheel(0, 600); + await expect(page.locator("body")).toHaveCSS("top", `${-before}px`); + await page.getByRole("dialog").click({ position: { x: 100, y: 400 } }); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await page.evaluate(() => scrollY)).toBe(before); + await expect(opener).toBeFocused(); + await page.getByRole("button", { name: "收起侧栏" }).click(); + await expect(page.locator("aside")).toHaveCSS("width", "76px"); + await page.reload(); + await expect(page.getByRole("button", { name: "展开侧栏" })).toBeVisible(); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByLabel("对外 ID").fill("team-coding"); + await page.getByLabel("上游 ID").fill("fixture-model-0"); + await page.getByRole("radio", { name: /指定区域/ }).check(); + await page.getByRole("combobox", { name: "区域", exact: true }).selectOption("intl"); + await page.getByRole("radio", { name: /指定账号/ }).check(); + await expect(page.getByRole("combobox", { name: "区域", exact: true })).toHaveCount(0); + await page.getByRole("checkbox", { name: /international.info/ }).check(); + await page.getByRole("button", { name: "预览候选路由" }).click(); + await expect(page.getByRole("heading", { name: "路由预览" })).toBeVisible(); + await page.screenshot({ path: "test-results/glass-model-editor.png" }); + await page.getByRole("button", { name: "创建模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const created = calls.find((call) => call.path === "/admin/models" && call.method === "POST")!; + expect(created.body).toMatchObject({ + public_id: "team-coding", + upstream_id: "fixture-model-0", + credential_ids: ["intl-account"], + region: null, + profile: null, + }); + await page.getByLabel("搜索模型").fill("team-coding"); + await expect(page.getByText("team-coding", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "删除", exact: true }).click(); + await page.getByRole("button", { name: "确认删除模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("没有匹配的模型")).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("daily range uses hourly points, with desktop and mobile glass layouts", async ({ page }) => { + await experienceAPI(page); + await page.goto("/dashboard"); + await page.getByLabel("统计时间范围").selectOption("1"); + await expect(page.getByRole("img", { name: "按小时请求量趋势" }).locator("circle")).toHaveCount( + 24, + ); + await page.getByRole("button", { name: "收起侧栏" }).click(); + await expect(page.locator("aside")).toHaveCSS("width", "76px"); + await page.screenshot({ path: "test-results/glass-overview-hourly.png" }); + await page.getByLabel("统计粒度").selectOption("day"); + await expect(page.getByRole("img", { name: "按日请求量趋势" }).locator("circle")).toHaveCount(1); + await page.getByLabel("统计粒度").selectOption("hour"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("img", { name: "按小时请求量趋势" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/glass-overview-mobile.png" }); + await page.getByRole("link", { name: "模型路由", exact: true }).click(); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByRole("radio", { name: /指定账号/ }).check(); + const dialog = page.getByRole("dialog"); + const bounds = await dialog.boundingBox(); + expect(bounds!.width).toBeLessThanOrEqual(390); + await expect(page.locator("body")).toHaveCSS("position", "fixed"); + await page.screenshot({ path: "test-results/glass-editor-mobile.png" }); + await dialog.click({ position: { x: 2, y: 2 } }); + await expect(dialog).toHaveCount(0); +}); + +test("OAuth enrollment closes the drawer and refreshes without exposing the opener", async ({ + page, + context, +}) => { + const calls = await experienceAPI(page); + await context.route("https://www.codebuddy.cn/**", (route) => + route.fulfill({ contentType: "text/html", body: "

Isolated authorization fixture

" }), + ); + await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "添加凭证" }).click(); + await page.getByRole("button", { name: "发起 OAuth 授权" }).click(); + const opened = page.waitForEvent("popup"); + await page.getByRole("link", { name: /打开官方授权页面/ }).click(); + const popup = await opened; + await popup.waitForURL("https://www.codebuddy.cn/isolated-oauth"); + expect(await popup.evaluate(() => window.opener === null)).toBe(true); + await expect(page.getByRole("dialog")).toHaveCount(0, { timeout: 8000 }); + await expect(page.getByText("newly-enrolled.info", { exact: true })).toBeVisible(); + expect(popup.isClosed()).toBe(false); // The external tab remains isolated, not controlled by the admin page. + expect(calls.filter((call) => call.path === "/admin/credentials")).toHaveLength(2); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index b7cbe34..c39e76e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -42,32 +42,65 @@ function Shell() { const location = useLocation(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem("codebuddy.sidebar.collapsed") === "true"; + } catch { + return false; + } + }); + const toggleSidebar = () => { + const next = !collapsed; + setCollapsed(next); + try { + localStorage.setItem("codebuddy.sidebar.collapsed", String(next)); + } catch { + /* Session-only preference when storage is unavailable. */ + } + }; return ( -
+
跳转到内容