feat(hub): A34 восстановление подключения аккаунтов, OpenRouter, NVIDIA, удаление десктопа
1. P0-1: Восстановлены все веб-обработчики в app.js (openAddAccountWizard, handleNodeAccountChange, handleNodeModelChange, handleRefreshProviderModels, checkUpdates), связаны с потоками startDeviceAuth и startRedirectAuth, выбор слота обязателен и понятен пользователю. 2. P0-2: Добавлены адаптеры OpenRouter и NVIDIA NIM с поддержкой динамического base_url, множественных аккаунтов без ограничений, GET /models и честным отображением квот/«Н/Д». 3. P0-3: В мастере подключения локального провайдера реализована кнопка автопоиска (discover_local_models), отображение серверов, ошибок портов и автозаполнение. 4. P0-4: Полностью удален устаревший десктопный интерфейс CustomTkinter (router/ui/** 20 файлов, hermes_hub_app.py), зависимости customtkinter и pillow убраны из pyproject.toml и инсталляторов, оставлен единый ярлык «Hermes Hub». 5. 418 passed, 1 skipped, 4 deselected, ruff чисто.
This commit is contained in:
parent
ab2ee12db4
commit
c7539b36d4
52 changed files with 2418 additions and 10042 deletions
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
|
|
@ -137,7 +137,7 @@ namespace HermesHubSetup
|
|||
{
|
||||
ProcessStartInfo checkPsi = new ProcessStartInfo();
|
||||
checkPsi.FileName = pythonExe;
|
||||
checkPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\"";
|
||||
checkPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_OK')\"";
|
||||
checkPsi.UseShellExecute = false;
|
||||
checkPsi.RedirectStandardOutput = true;
|
||||
checkPsi.RedirectStandardError = true;
|
||||
|
|
@ -159,7 +159,7 @@ namespace HermesHubSetup
|
|||
|
||||
if (needsInstall)
|
||||
{
|
||||
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (customtkinter, Pillow, PyYAML, psutil, FastAPI, uvicorn)...", 40);
|
||||
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (PyYAML, psutil, FastAPI, uvicorn)...", 40);
|
||||
|
||||
// 1. Ensure pip is installed/bootstrapped if needed
|
||||
try
|
||||
|
|
@ -185,7 +185,7 @@ namespace HermesHubSetup
|
|||
{
|
||||
ProcessStartInfo pipPsi = new ProcessStartInfo();
|
||||
pipPsi.FileName = pythonExe;
|
||||
pipPsi.Arguments = "-m pip install --no-warn-script-location customtkinter pillow pyyaml psutil fastapi uvicorn";
|
||||
pipPsi.Arguments = "-m pip install --no-warn-script-location pyyaml psutil fastapi uvicorn";
|
||||
pipPsi.UseShellExecute = false;
|
||||
pipPsi.RedirectStandardOutput = true;
|
||||
pipPsi.RedirectStandardError = true;
|
||||
|
|
@ -209,7 +209,7 @@ namespace HermesHubSetup
|
|||
{
|
||||
ProcessStartInfo uvPsi = new ProcessStartInfo();
|
||||
uvPsi.FileName = "uv";
|
||||
uvPsi.Arguments = string.Format("pip install --python \"{0}\" customtkinter pillow pyyaml psutil fastapi uvicorn", pythonExe);
|
||||
uvPsi.Arguments = string.Format("pip install --python \"{0}\" pyyaml psutil fastapi uvicorn", pythonExe);
|
||||
uvPsi.UseShellExecute = false;
|
||||
uvPsi.RedirectStandardOutput = true;
|
||||
uvPsi.RedirectStandardError = true;
|
||||
|
|
@ -237,7 +237,7 @@ namespace HermesHubSetup
|
|||
{
|
||||
ProcessStartInfo recheckPsi = new ProcessStartInfo();
|
||||
recheckPsi.FileName = pythonExe;
|
||||
recheckPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\"";
|
||||
recheckPsi.Arguments = "-c \"import yaml, psutil, fastapi, uvicorn; print('DEPS_VERIFIED')\"";
|
||||
recheckPsi.UseShellExecute = false;
|
||||
recheckPsi.RedirectStandardOutput = true;
|
||||
recheckPsi.RedirectStandardError = true;
|
||||
|
|
@ -309,7 +309,7 @@ namespace HermesHubSetup
|
|||
}
|
||||
|
||||
// 2. Install UI & System Dependencies into Hermes Python Environment
|
||||
if (progressCallback != null) progressCallback("Checking Python dependencies (customtkinter, Pillow, FastAPI, uvicorn)...", 35);
|
||||
if (progressCallback != null) progressCallback("Checking Python dependencies (FastAPI, uvicorn, PyYAML, psutil)...", 35);
|
||||
if (!EnsurePythonDependencies(HermesPython, progressCallback))
|
||||
{
|
||||
return 13; // Dependency install failed
|
||||
|
|
@ -376,7 +376,7 @@ namespace HermesHubSetup
|
|||
// 8. Post-install Verification & Import Smoke Test
|
||||
if (progressCallback != null) progressCallback("Running post-install import validation...", 90);
|
||||
string pluginSrcDir = Path.Combine(HermesHome, @"plugins\antigravity-provider\src");
|
||||
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import customtkinter; from PIL import Image; import antigravity_provider.router.hermes_hub_app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
|
||||
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import fastapi, uvicorn; import antigravity_provider.router.web.server; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
|
||||
ProcessStartInfo smokePsi = new ProcessStartInfo();
|
||||
smokePsi.FileName = HermesPython;
|
||||
smokePsi.Arguments = smokeCmd;
|
||||
|
|
@ -552,36 +552,25 @@ namespace HermesHubSetup
|
|||
string targetDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
|
||||
if (!File.Exists(targetDesktopExe)) targetDesktopExe = Path.Combine(HermesHome, "HermesHub.exe");
|
||||
|
||||
// Clean up legacy shortcut names upon upgrade
|
||||
string[] oldShortcuts = new string[]
|
||||
{
|
||||
"Hermes Hub (Web).lnk",
|
||||
"Hermes Hub (Desktop).lnk",
|
||||
"Hermes Hub Web.lnk"
|
||||
};
|
||||
foreach (string sc in oldShortcuts)
|
||||
{
|
||||
string p = Path.Combine(startMenu, sc);
|
||||
if (File.Exists(p)) try { File.Delete(p); } catch { }
|
||||
}
|
||||
|
||||
Type shellType = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (shellType != null)
|
||||
{
|
||||
dynamic shell = Activator.CreateInstance(shellType);
|
||||
|
||||
// 1. Web Application Window Shortcut (Primary Web App mode)
|
||||
if (File.Exists(targetWebExe))
|
||||
{
|
||||
string webShortcutPath = Path.Combine(startMenu, "Hermes Hub (Web).lnk");
|
||||
dynamic webShortcut = shell.CreateShortcut(webShortcutPath);
|
||||
webShortcut.TargetPath = targetWebExe;
|
||||
webShortcut.WorkingDirectory = TargetInstallDir;
|
||||
webShortcut.Description = "Hermes Hub — Web Application Window";
|
||||
webShortcut.IconLocation = targetWebExe + ",0";
|
||||
webShortcut.Save();
|
||||
}
|
||||
|
||||
// 2. Native Desktop Shortcut (CustomTkinter GUI)
|
||||
if (File.Exists(targetDesktopExe))
|
||||
{
|
||||
string desktopShortcutPath = Path.Combine(startMenu, "Hermes Hub (Desktop).lnk");
|
||||
dynamic desktopShortcut = shell.CreateShortcut(desktopShortcutPath);
|
||||
desktopShortcut.TargetPath = targetDesktopExe;
|
||||
desktopShortcut.WorkingDirectory = TargetInstallDir;
|
||||
desktopShortcut.Description = "Hermes Hub — Native Desktop App";
|
||||
desktopShortcut.IconLocation = targetDesktopExe + ",0";
|
||||
desktopShortcut.Save();
|
||||
}
|
||||
|
||||
// 3. Standard "Hermes Hub.lnk" shortcut
|
||||
// Single standard "Hermes Hub.lnk" shortcut for Web Interface
|
||||
string standardTarget = File.Exists(targetWebExe) ? targetWebExe : targetDesktopExe;
|
||||
if (File.Exists(standardTarget))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ def check_hermes_agent_installed() -> bool:
|
|||
|
||||
|
||||
def verify_dependencies(venv_python: Path) -> bool:
|
||||
"""Verify that required UI packages can be imported without error."""
|
||||
code = "import customtkinter; from PIL import Image; import yaml; import psutil; print('OK')"
|
||||
"""Verify that required packages can be imported without error."""
|
||||
code = "import yaml; import psutil; import fastapi; import uvicorn; print('OK')"
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(venv_python), "-c", code],
|
||||
|
|
@ -48,8 +48,8 @@ def verify_dependencies(venv_python: Path) -> bool:
|
|||
|
||||
|
||||
def install_dependencies(venv_python: Path) -> bool:
|
||||
"""Install required UI packages into Hermes venv."""
|
||||
packages = ["customtkinter>=6.0.0", "pillow>=10.0.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"]
|
||||
"""Install required packages into Hermes venv."""
|
||||
packages = ["fastapi>=0.110.0", "uvicorn>=0.28.0", "psutil>=5.9.0", "pyyaml>=6.0.1", "requests>=2.31.0"]
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(venv_python), "-m", "pip", "install", "--upgrade"] + packages,
|
||||
|
|
@ -95,13 +95,13 @@ def run_installation(silent: bool = False):
|
|||
print(f" ✓ Hermes Agent найден ({venv_python})")
|
||||
|
||||
# 2. Dependency verification and install
|
||||
print("\n[2/5] Проверка и установка зависимостей UI (customtkinter, Pillow)...")
|
||||
print("\n[2/5] Проверка и установка зависимостей (FastAPI, uvicorn, psutil, pyyaml)...")
|
||||
if not verify_dependencies(venv_python):
|
||||
print(" Установка недостающих пакетов в окружение Hermes...")
|
||||
ok = install_dependencies(venv_python)
|
||||
if not ok or not verify_dependencies(venv_python):
|
||||
print("\n" + "!" * 60)
|
||||
print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости GUI (customtkinter / Pillow)!")
|
||||
print(" [ОШИБКА УСТАНОВКИ] Не удалось установить зависимости (FastAPI / uvicorn / psutil / pyyaml)!")
|
||||
print(" Пожалуйста, проверьте подключение к сети и права доступа к venv.")
|
||||
print("!" * 60 + "\n")
|
||||
if not silent:
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ dependencies = [
|
|||
"pydantic>=2.6.0",
|
||||
"requests>=2.31.0",
|
||||
"httpx>=0.27.0",
|
||||
"customtkinter>=6.0.0",
|
||||
"pillow>=12.3.0",
|
||||
"psutil>=5.9.0",
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn>=0.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
@ -50,10 +50,6 @@ dev = [
|
|||
"anyio>=4.0.0",
|
||||
"ruff>=0.3.0",
|
||||
]
|
||||
web = [
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn>=0.28.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hermes-hub = "antigravity_provider.router.cli_commands:main"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ from .codex_adapter import CodexAdapter
|
|||
from .deepseek_adapter import DeepSeekResponsesAdapter
|
||||
from .grok_adapter import GrokAdapter
|
||||
from .local_adapter import LocalLLMAdapter
|
||||
from .nvidia_adapter import NvidiaAdapter
|
||||
from .opencode_adapter import OpenCodeGoAdapter
|
||||
from .openrouter_adapter import OpenRouterAdapter
|
||||
|
||||
_ADAPTERS: dict[str, BaseProviderAdapter] = {
|
||||
"antigravity": AntigravityAdapter(),
|
||||
|
|
@ -30,6 +32,9 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = {
|
|||
"llama.cpp": LocalLLMAdapter(),
|
||||
"ollama": LocalLLMAdapter(),
|
||||
"vllm": LocalLLMAdapter(),
|
||||
"openrouter": OpenRouterAdapter(),
|
||||
"nvidia": NvidiaAdapter(),
|
||||
"nvidia-nim": NvidiaAdapter(),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
223
src/antigravity_provider/router/adapters/nvidia_adapter.py
Normal file
223
src/antigravity_provider/router/adapters/nvidia_adapter.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""NVIDIA NIM (integrate.api.nvidia.com) OpenAI-compatible provider adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..router_config import RouterProfileConfig
|
||||
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message
|
||||
|
||||
logger = logging.getLogger("hermes.router.adapter.nvidia")
|
||||
|
||||
DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
|
||||
class NvidiaAdapter(BaseProviderAdapter):
|
||||
"""Adapter for NVIDIA NIM's OpenAI-compatible chat completions API."""
|
||||
|
||||
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
|
||||
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
|
||||
url = (
|
||||
profile.custom_base_url
|
||||
or profile.auth_config.get("base_url")
|
||||
or os.environ.get("NVIDIA_BASE_URL")
|
||||
or DEFAULT_NVIDIA_BASE_URL
|
||||
)
|
||||
url_str = str(url).strip().rstrip("/")
|
||||
if not url_str.startswith(("http://", "https://")):
|
||||
url_str = f"https://{url_str}"
|
||||
return url_str
|
||||
|
||||
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
|
||||
"""Resolve API key from profile auth_config or environment."""
|
||||
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
|
||||
val = os.environ.get(candidate, "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
model = request.get("model", "")
|
||||
if not model or model == "default":
|
||||
model = profile.preferred_models[0] if profile.preferred_models else "default"
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": list(request.get("messages", [])),
|
||||
"temperature": request.get("temperature", 0.7),
|
||||
}
|
||||
if "tools" in request and request["tools"]:
|
||||
payload["tools"] = request["tools"]
|
||||
if "tool_choice" in request:
|
||||
payload["tool_choice"] = request["tool_choice"]
|
||||
if "response_format" in request:
|
||||
payload["response_format"] = request["response_format"]
|
||||
if "max_tokens" in request:
|
||||
payload["max_tokens"] = request["max_tokens"]
|
||||
if "stream" in request:
|
||||
payload["stream"] = request["stream"]
|
||||
if "stop" in request:
|
||||
payload["stop"] = request["stop"]
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/chat/completions",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as http_err:
|
||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
err_msg = extract_api_error_message(raw_err)
|
||||
except Exception:
|
||||
err_msg = raw_err
|
||||
raise RuntimeError(f"NVIDIA API Error ({http_err.code}): {err_msg}") from http_err
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"NVIDIA Transport Error: {exc}") from exc
|
||||
|
||||
self._reject_empty_answer(data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _reject_empty_answer(data: Dict[str, Any]) -> None:
|
||||
"""Пустой ответ — это отказ, а не успех."""
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise RuntimeError("NVIDIA вернул ответ без choices")
|
||||
|
||||
message = choices[0].get("message") or {}
|
||||
content = (message.get("content") or "").strip()
|
||||
if content:
|
||||
return
|
||||
|
||||
finish = choices[0].get("finish_reason")
|
||||
raise RuntimeError(f"NVIDIA вернул пустой ответ (finish_reason={finish})")
|
||||
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
"""Request GET {base_url}/models and return the server's model list.
|
||||
|
||||
No invented/hardcoded model list: on error, fall back to
|
||||
profile.preferred_models only.
|
||||
"""
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
items = data.get("data") or data.get("models") or []
|
||||
if isinstance(items, list):
|
||||
models = [
|
||||
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
|
||||
for m in items
|
||||
if m
|
||||
]
|
||||
if models:
|
||||
return sorted(models)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to discover models for nvidia profile %s: %s", profile.profile_id, exc)
|
||||
|
||||
return list(profile.preferred_models or [])
|
||||
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status in (200, 204)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def classify_error(
|
||||
self,
|
||||
exc: Exception,
|
||||
response_data: Optional[Dict[str, Any]] = None,
|
||||
) -> ErrorClassification:
|
||||
"""Classify execution failure into structured error category."""
|
||||
err_msg = str(exc)
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=30,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.QUOTA_EXHAUSTED,
|
||||
message=err_msg,
|
||||
reset_duration_seconds=1800,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in (
|
||||
"connection refused", "connection error", "connect", "refused",
|
||||
"timeout", "timed out", "502", "503", "504", "gateway",
|
||||
"econnrefused", "econnreset", "transport error", "urlerror",
|
||||
"winerror 10061", "nodename nor servname provided",
|
||||
)):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=2,
|
||||
)
|
||||
|
||||
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)
|
||||
223
src/antigravity_provider/router/adapters/openrouter_adapter.py
Normal file
223
src/antigravity_provider/router/adapters/openrouter_adapter.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""OpenRouter OpenAI-compatible provider adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..router_config import RouterProfileConfig
|
||||
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification, extract_api_error_message
|
||||
|
||||
logger = logging.getLogger("hermes.router.adapter.openrouter")
|
||||
|
||||
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
class OpenRouterAdapter(BaseProviderAdapter):
|
||||
"""Adapter for OpenRouter's OpenAI-compatible chat completions API."""
|
||||
|
||||
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
|
||||
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
|
||||
url = (
|
||||
profile.custom_base_url
|
||||
or profile.auth_config.get("base_url")
|
||||
or os.environ.get("OPENROUTER_BASE_URL")
|
||||
or DEFAULT_OPENROUTER_BASE_URL
|
||||
)
|
||||
url_str = str(url).strip().rstrip("/")
|
||||
if not url_str.startswith(("http://", "https://")):
|
||||
url_str = f"https://{url_str}"
|
||||
return url_str
|
||||
|
||||
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
|
||||
"""Resolve API key from profile auth_config or environment."""
|
||||
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
|
||||
val = os.environ.get(candidate, "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
model = request.get("model", "")
|
||||
if not model or model == "default":
|
||||
model = profile.preferred_models[0] if profile.preferred_models else "default"
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": list(request.get("messages", [])),
|
||||
"temperature": request.get("temperature", 0.7),
|
||||
}
|
||||
if "tools" in request and request["tools"]:
|
||||
payload["tools"] = request["tools"]
|
||||
if "tool_choice" in request:
|
||||
payload["tool_choice"] = request["tool_choice"]
|
||||
if "response_format" in request:
|
||||
payload["response_format"] = request["response_format"]
|
||||
if "max_tokens" in request:
|
||||
payload["max_tokens"] = request["max_tokens"]
|
||||
if "stream" in request:
|
||||
payload["stream"] = request["stream"]
|
||||
if "stop" in request:
|
||||
payload["stop"] = request["stop"]
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/chat/completions",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as http_err:
|
||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
err_msg = extract_api_error_message(raw_err)
|
||||
except Exception:
|
||||
err_msg = raw_err
|
||||
raise RuntimeError(f"OpenRouter API Error ({http_err.code}): {err_msg}") from http_err
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"OpenRouter Transport Error: {exc}") from exc
|
||||
|
||||
self._reject_empty_answer(data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _reject_empty_answer(data: Dict[str, Any]) -> None:
|
||||
"""Пустой ответ — это отказ, а не успех."""
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise RuntimeError("OpenRouter вернул ответ без choices")
|
||||
|
||||
message = choices[0].get("message") or {}
|
||||
content = (message.get("content") or "").strip()
|
||||
if content:
|
||||
return
|
||||
|
||||
finish = choices[0].get("finish_reason")
|
||||
raise RuntimeError(f"OpenRouter вернул пустой ответ (finish_reason={finish})")
|
||||
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
"""Request GET {base_url}/models and return the server's model list.
|
||||
|
||||
No invented/hardcoded model list: on error, fall back to
|
||||
profile.preferred_models only.
|
||||
"""
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
items = data.get("data") or data.get("models") or []
|
||||
if isinstance(items, list):
|
||||
models = [
|
||||
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
|
||||
for m in items
|
||||
if m
|
||||
]
|
||||
if models:
|
||||
return sorted(models)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to discover models for openrouter profile %s: %s", profile.profile_id, exc)
|
||||
|
||||
return list(profile.preferred_models or [])
|
||||
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status in (200, 204)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def classify_error(
|
||||
self,
|
||||
exc: Exception,
|
||||
response_data: Optional[Dict[str, Any]] = None,
|
||||
) -> ErrorClassification:
|
||||
"""Classify execution failure into structured error category."""
|
||||
err_msg = str(exc)
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=30,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.QUOTA_EXHAUSTED,
|
||||
message=err_msg,
|
||||
reset_duration_seconds=1800,
|
||||
)
|
||||
|
||||
if any(k in err_lower for k in (
|
||||
"connection refused", "connection error", "connect", "refused",
|
||||
"timeout", "timed out", "502", "503", "504", "gateway",
|
||||
"econnrefused", "econnreset", "transport error", "urlerror",
|
||||
"winerror 10061", "nodename nor servname provided",
|
||||
)):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=2,
|
||||
)
|
||||
|
||||
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)
|
||||
|
|
@ -254,7 +254,7 @@ def print_diagnostics_cli() -> int:
|
|||
reasons.append(f"Отсутствуют зависимости: {', '.join(missing_deps)}")
|
||||
has_fatal_error = True
|
||||
else:
|
||||
print("[PASS] Зависимости venv: все необходимые пакеты установлены (customtkinter, Pillow, psutil, pyyaml)")
|
||||
print("[PASS] Зависимости venv: все необходимые пакеты установлены (fastapi, uvicorn, psutil, pyyaml)")
|
||||
|
||||
# 2. Deployed Plugin Freshness Check
|
||||
hermes_home = paths.get_hermes_home()
|
||||
|
|
@ -478,8 +478,10 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||
args = parser.parse_args(argv)
|
||||
|
||||
if args.subcommand in ("hub", "cockpit", "gui"):
|
||||
from antigravity_provider.router.hermes_hub_app import launch_hub
|
||||
launch_hub()
|
||||
from antigravity_provider.router.web.server import run_web_server
|
||||
port = getattr(args, "port", None)
|
||||
no_browser = getattr(args, "no_browser", False)
|
||||
run_web_server(port=port, open_browser=not no_browser)
|
||||
return 0
|
||||
elif args.subcommand == "status":
|
||||
return print_router_status()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -58,15 +58,15 @@ def show_native_error(title: str, message: str) -> None:
|
|||
|
||||
|
||||
REQUIRED_PACKAGES: Dict[str, str] = {
|
||||
"customtkinter": "customtkinter>=6.0.0",
|
||||
"PIL": "pillow>=10.0.0",
|
||||
"fastapi": "fastapi>=0.110.0",
|
||||
"uvicorn": "uvicorn>=0.28.0",
|
||||
"psutil": "psutil>=5.9.0",
|
||||
"yaml": "pyyaml>=6.0.1",
|
||||
}
|
||||
|
||||
|
||||
def check_missing_dependencies() -> List[str]:
|
||||
"""Check which required UI / system packages are currently unimportable."""
|
||||
"""Check which required Web / system packages are currently unimportable."""
|
||||
missing = []
|
||||
for mod_name, pkg_spec in REQUIRED_PACKAGES.items():
|
||||
try:
|
||||
|
|
@ -118,7 +118,7 @@ def self_heal_dependencies(missing_packages: List[str]) -> Tuple[bool, str]:
|
|||
|
||||
|
||||
def bootstrap_and_launch() -> None:
|
||||
"""Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub GUI."""
|
||||
"""Bootstrap entry point: log startup, verify dependencies, and launch Hermes Hub Web."""
|
||||
log_startup("=== Hermes Hub Launcher Bootstrap initiated ===")
|
||||
log_startup(f"Python: {sys.executable} (version {sys.version.split()[0]})")
|
||||
log_startup(f"Working Directory: {os.getcwd()}")
|
||||
|
|
@ -132,22 +132,22 @@ def bootstrap_and_launch() -> None:
|
|||
log_path = get_startup_log_path()
|
||||
show_native_error(
|
||||
"Hermes Hub — Ошибка компонентов",
|
||||
"Не удалось автоматически установить необходимые компоненты графического интерфейса (customtkinter / Pillow / psutil).\n\n"
|
||||
"Не удалось автоматически установить необходимые компоненты веб-интерфейса (fastapi / uvicorn / psutil / pyyaml).\n\n"
|
||||
f"Детали ошибки: {detail}\n\n"
|
||||
f"Лог запуска: {log_path}\n\n"
|
||||
"Вы можете установить их вручную командой:\n"
|
||||
f"{sys.executable} -m pip install customtkinter pillow psutil pyyaml",
|
||||
f"{sys.executable} -m pip install fastapi uvicorn psutil pyyaml",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Launch GUI with full exception capture
|
||||
# 2. Launch Web GUI with full exception capture
|
||||
try:
|
||||
log_startup("Importing hermes_hub_app module...")
|
||||
from antigravity_provider.router.hermes_hub_app import launch_hub
|
||||
log_startup("Importing web.server module...")
|
||||
from antigravity_provider.router.web.server import run_web_server
|
||||
|
||||
log_startup("Executing launch_hub()...")
|
||||
launch_hub()
|
||||
log_startup("Hermes Hub GUI closed normally.")
|
||||
log_startup("Executing run_web_server()...")
|
||||
run_web_server(open_browser=True)
|
||||
log_startup("Hermes Hub Web closed normally.")
|
||||
except Exception as exc:
|
||||
tb = traceback.format_exc()
|
||||
log_startup(f"FATAL EXCEPTION during launch:\n{tb}")
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
"""Hermes Hub UI Package — Native Dark-First Design System."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
|
||||
__all__ = ["Theme", "AssetManager"]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,228 +0,0 @@
|
|||
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
Image = None
|
||||
ImageDraw = None
|
||||
|
||||
try:
|
||||
import customtkinter as ctk
|
||||
except ImportError:
|
||||
import unittest.mock as _mock
|
||||
|
||||
ctk = _mock.MagicMock()
|
||||
|
||||
|
||||
class AssetManager:
|
||||
_instance: Optional[AssetManager] = None
|
||||
_image_cache: Dict[str, ctk.CTkImage] = {}
|
||||
_pil_cache: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self):
|
||||
self.root_dir = self._find_repo_root()
|
||||
self.branding_dir = self.root_dir / "assets" / "branding"
|
||||
self.providers_dir = self.root_dir / "assets" / "providers"
|
||||
self.logo_dir = self.branding_dir / "logo"
|
||||
self.app_dir = self.branding_dir / "app"
|
||||
self.splash_dir = self.branding_dir / "splash"
|
||||
self.icons_dir = self.branding_dir / "icons"
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> AssetManager:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def _find_repo_root(self) -> Path:
|
||||
from antigravity_provider.paths import get_repo_root
|
||||
return get_repo_root()
|
||||
|
||||
def get_ico_path(self) -> str:
|
||||
ico = self.app_dir / "HermesHub.ico"
|
||||
if ico.exists():
|
||||
return str(ico)
|
||||
alt = self.root_dir / "launcher" / "HermesHub.ico"
|
||||
if alt.exists():
|
||||
return str(alt)
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls):
|
||||
cls._image_cache.clear()
|
||||
cls._pil_cache.clear()
|
||||
|
||||
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
|
||||
logo_path = self.logo_dir / "logo_approved.png"
|
||||
if not logo_path.exists():
|
||||
logo_path = self.logo_dir / "logo_256.png"
|
||||
if not logo_path.exists():
|
||||
logo_path = self.logo_dir / "logo_master.png"
|
||||
if not logo_path.exists():
|
||||
logo_path = self.branding_dir / "source" / "Hermes Hub.png"
|
||||
|
||||
if logo_path.exists():
|
||||
try:
|
||||
pil_img = Image.open(logo_path).convert("RGBA")
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
transparent = pil_img.copy()
|
||||
pixels = []
|
||||
tint_gold = Theme.current_scheme in {"dark", "hybrid"}
|
||||
dark_fill = tuple(int(Theme.BG_SIDEBAR[index : index + 2], 16) for index in (1, 3, 5))
|
||||
pixel_data = (
|
||||
transparent.get_flattened_data()
|
||||
if hasattr(transparent, "get_flattened_data")
|
||||
else transparent.getdata()
|
||||
)
|
||||
for red, green, blue, alpha in pixel_data:
|
||||
if red > 232 and green > 230 and blue > 224:
|
||||
pixels.append((red, green, blue, 0))
|
||||
elif tint_gold and max(red, green, blue) < 105:
|
||||
pixels.append((*dark_fill, alpha))
|
||||
else:
|
||||
pixels.append((red, green, blue, alpha))
|
||||
transparent.putdata(pixels)
|
||||
return ctk.CTkImage(light_image=transparent, dark_image=transparent, size=size)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_provider_image(self, provider: str, size: Tuple[int, int] = (20, 20)) -> Optional[ctk.CTkImage]:
|
||||
"""Fetch cached CTkImage for AI providers (antigravity, openai, opencode)."""
|
||||
prov_clean = provider.lower()
|
||||
if "antigravity" in prov_clean:
|
||||
key_name = "antigravity"
|
||||
elif "codex" in prov_clean or "openai" in prov_clean:
|
||||
key_name = "openai"
|
||||
elif "opencode" in prov_clean:
|
||||
key_name = "opencode"
|
||||
else:
|
||||
key_name = "antigravity"
|
||||
|
||||
cache_key = f"prov_{key_name}_{size[0]}x{size[1]}"
|
||||
if cache_key in self._image_cache:
|
||||
return self._image_cache[cache_key]
|
||||
|
||||
prov_folder = self.providers_dir / key_name
|
||||
# Find best size matching
|
||||
target_file = prov_folder / f"{key_name}_{size[0]}.png"
|
||||
if not target_file.exists():
|
||||
target_file = prov_folder / f"{key_name}_32.png"
|
||||
if not target_file.exists():
|
||||
target_file = prov_folder / f"{key_name}_master.png"
|
||||
|
||||
if target_file.exists():
|
||||
try:
|
||||
pil_img = Image.open(target_file).convert("RGBA")
|
||||
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_nav_icon(self, name: str, size: int = 20) -> Optional[ctk.CTkImage]:
|
||||
"""Return a consistent gold outline icon for the sidebar."""
|
||||
if Image is None or ImageDraw is None:
|
||||
return None
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
cache_key = f"nav_{Theme.current_scheme}_{name}_{size}"
|
||||
if cache_key in self._image_cache:
|
||||
return self._image_cache[cache_key]
|
||||
scale = 4
|
||||
canvas_size = size * scale
|
||||
image = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
color = Theme.TEXT_ACCENT
|
||||
width = 2 * scale
|
||||
|
||||
def points(values):
|
||||
return tuple(tuple(int(coord * scale) for coord in point) for point in values)
|
||||
|
||||
def line(values, **kwargs):
|
||||
draw.line(points(values), fill=color, width=width, joint="curve", **kwargs)
|
||||
|
||||
def ellipse(box):
|
||||
draw.ellipse(tuple(int(value * scale) for value in box), outline=color, width=width)
|
||||
|
||||
def rectangle(box, radius=0):
|
||||
scaled = tuple(int(value * scale) for value in box)
|
||||
if radius:
|
||||
draw.rounded_rectangle(scaled, radius=radius * scale, outline=color, width=width)
|
||||
else:
|
||||
draw.rectangle(scaled, outline=color, width=width)
|
||||
|
||||
if name == "overview":
|
||||
line(((3, 10), (10, 4), (17, 10)))
|
||||
line(((5, 9), (5, 17), (15, 17), (15, 9)))
|
||||
line(((8, 17), (8, 12), (12, 12), (12, 17)))
|
||||
elif name == "team":
|
||||
ellipse((3, 4, 9, 10))
|
||||
ellipse((11, 5, 16, 10))
|
||||
draw.arc((1 * scale, 9 * scale, 12 * scale, 19 * scale), 190, 350, fill=color, width=width)
|
||||
draw.arc((8 * scale, 10 * scale, 19 * scale, 19 * scale), 190, 350, fill=color, width=width)
|
||||
elif name == "accounts":
|
||||
draw.ellipse((3 * scale, 3 * scale, 17 * scale, 8 * scale), outline=color, width=width)
|
||||
line(((3, 5.5), (3, 15), (5, 17), (15, 17), (17, 15), (17, 5.5)))
|
||||
draw.arc((3 * scale, 8 * scale, 17 * scale, 13 * scale), 0, 180, fill=color, width=width)
|
||||
elif name == "routing":
|
||||
ellipse((2, 8, 6, 12))
|
||||
ellipse((8, 2, 12, 6))
|
||||
ellipse((14, 8, 18, 12))
|
||||
ellipse((8, 14, 12, 18))
|
||||
line(((6, 10), (14, 10)))
|
||||
line(((10, 6), (10, 14)))
|
||||
elif name == "orchestrator":
|
||||
ellipse((6, 6, 14, 14))
|
||||
for box in ((2, 2, 5, 5), (15, 2, 18, 5), (2, 15, 5, 18), (15, 15, 18, 18)):
|
||||
ellipse(box)
|
||||
line(((5, 5), (7, 7)))
|
||||
line(((15, 5), (13, 7)))
|
||||
line(((5, 15), (7, 13)))
|
||||
line(((15, 15), (13, 13)))
|
||||
elif name == "providers":
|
||||
draw.arc((2 * scale, 7 * scale, 18 * scale, 17 * scale), 175, 365, fill=color, width=width)
|
||||
draw.arc((5 * scale, 2 * scale, 14 * scale, 13 * scale), 190, 350, fill=color, width=width)
|
||||
line(((4, 16), (16, 16)))
|
||||
elif name == "quotas":
|
||||
line(((10, 2), (17, 5), (16, 13), (10, 18), (4, 13), (3, 5), (10, 2)))
|
||||
line(((7, 10), (9, 12), (13, 7)))
|
||||
elif name == "analytics":
|
||||
rectangle((3, 11, 6, 17))
|
||||
rectangle((8.5, 7, 11.5, 17))
|
||||
rectangle((14, 3, 17, 17))
|
||||
line(((2, 18), (18, 18)))
|
||||
elif name == "health":
|
||||
line(((2, 11), (6, 11), (8, 5), (11, 16), (13, 9), (18, 9)))
|
||||
elif name == "logs":
|
||||
rectangle((4, 2, 16, 18), radius=1)
|
||||
line(((7, 7), (13, 7)))
|
||||
line(((7, 11), (13, 11)))
|
||||
line(((7, 15), (11, 15)))
|
||||
elif name == "incidents":
|
||||
line(((10, 2), (18, 17), (2, 17), (10, 2)))
|
||||
line(((10, 7), (10, 12)))
|
||||
ellipse((9.3, 14, 10.7, 15.4))
|
||||
elif name == "settings":
|
||||
ellipse((7, 7, 13, 13))
|
||||
for angle in range(0, 360, 45):
|
||||
import math
|
||||
|
||||
start = (10 + 5 * math.cos(math.radians(angle)), 10 + 5 * math.sin(math.radians(angle)))
|
||||
end = (10 + 8 * math.cos(math.radians(angle)), 10 + 8 * math.sin(math.radians(angle)))
|
||||
line((start, end))
|
||||
else:
|
||||
ellipse((3, 3, 17, 17))
|
||||
line(((10, 8), (10, 15)))
|
||||
ellipse((9.3, 5, 10.7, 6.4))
|
||||
|
||||
image = image.resize((size, size), Image.Resampling.LANCZOS)
|
||||
result = ctk.CTkImage(light_image=image, dark_image=image, size=(size, size))
|
||||
self._image_cache[cache_key] = result
|
||||
return result
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,106 +0,0 @@
|
|||
"""Non-blocking UI adapter for the backend model-discovery cache.
|
||||
|
||||
The UI never discovers models synchronously and never invents model IDs. The
|
||||
adapter deliberately tolerates the small naming differences used by backend
|
||||
revisions so the presentation layer can be merged independently from A9.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CachedModels:
|
||||
provider: str
|
||||
models: tuple[str, ...] = ()
|
||||
fetched_at: str = ""
|
||||
is_stale: bool = True
|
||||
unavailable_reason: str = "Список моделей ещё не получен"
|
||||
|
||||
|
||||
def _service() -> Any:
|
||||
try:
|
||||
from antigravity_provider.router.model_discovery import ModelDiscoveryService
|
||||
|
||||
getter = getattr(ModelDiscoveryService, "get", None)
|
||||
return getter() if callable(getter) else ModelDiscoveryService()
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
pass
|
||||
try:
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
|
||||
getter = getattr(ModelDiscoveryService, "get", None)
|
||||
return getter() if callable(getter) else ModelDiscoveryService()
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalise(provider: str, raw: Any) -> CachedModels:
|
||||
if raw is None:
|
||||
return CachedModels(provider=provider)
|
||||
if isinstance(raw, dict):
|
||||
models = raw.get("models") or raw.get("discovered_models") or []
|
||||
fetched_at = raw.get("fetched_at") or raw.get("discovered_at") or raw.get("updated_at") or raw.get("last_refresh_at") or ""
|
||||
stale = raw.get("is_stale", raw.get("stale", False))
|
||||
reason = raw.get("unavailable_reason") or raw.get("error") or ""
|
||||
else:
|
||||
models = getattr(raw, "models", None) or getattr(raw, "discovered_models", None) or []
|
||||
fetched_at = (
|
||||
getattr(raw, "fetched_at", None)
|
||||
or getattr(raw, "updated_at", None)
|
||||
or getattr(raw, "last_refresh_at", None)
|
||||
or ""
|
||||
)
|
||||
stale = getattr(raw, "is_stale", getattr(raw, "stale", False))
|
||||
reason = getattr(raw, "unavailable_reason", None) or getattr(raw, "error", None) or ""
|
||||
clean = tuple(dict.fromkeys(str(model).strip() for model in models if str(model).strip()))
|
||||
return CachedModels(
|
||||
provider=provider,
|
||||
models=clean,
|
||||
fetched_at=str(fetched_at),
|
||||
is_stale=bool(stale),
|
||||
unavailable_reason=str(reason or ("" if clean else "Список моделей ещё не получен")),
|
||||
)
|
||||
|
||||
|
||||
def get_cached_models(provider: str) -> CachedModels:
|
||||
"""Return cached models immediately; never performs provider I/O."""
|
||||
service = _service()
|
||||
if service is None:
|
||||
return CachedModels(provider=provider, unavailable_reason="Служба обнаружения моделей ещё не подключена")
|
||||
for name in ("get_cached", "get_cached_models", "get_snapshot", "get_provider_models"):
|
||||
method = getattr(service, name, None)
|
||||
if not callable(method):
|
||||
continue
|
||||
try:
|
||||
return _normalise(provider, method(provider))
|
||||
except Exception as exc:
|
||||
return CachedModels(provider=provider, unavailable_reason=f"Кэш моделей недоступен: {exc}")
|
||||
return CachedModels(provider=provider, unavailable_reason="Служба не предоставляет чтение кэша моделей")
|
||||
|
||||
|
||||
def refresh_models_async(provider: str, on_complete: Callable[[CachedModels], None]) -> bool:
|
||||
"""Request a backend background refresh without blocking the Tk thread."""
|
||||
service = _service()
|
||||
if service is None:
|
||||
on_complete(get_cached_models(provider))
|
||||
return False
|
||||
for name in ("refresh_models_async", "refresh_models", "refresh_provider_async", "refresh_async", "discover_async"):
|
||||
method = getattr(service, name, None)
|
||||
if not callable(method):
|
||||
continue
|
||||
try:
|
||||
result: Optional[Any] = method(provider, on_complete=lambda *_args: on_complete(get_cached_models(provider)))
|
||||
return result is not False
|
||||
except TypeError:
|
||||
try:
|
||||
result = method(provider)
|
||||
return result is not False
|
||||
except Exception:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
on_complete(get_cached_models(provider))
|
||||
return False
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
"""Versioned presentation model for the Team routing graph.
|
||||
|
||||
The router YAML remains the source of truth for profiles and role chains. This
|
||||
module stores only topology/layout metadata next to it and applies profile
|
||||
assignments through :class:`AutoAssigner`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.router_config import RouterConfig, load_router_config
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EDGE_TYPES = ("PRIMARY", "FALLBACK", "DELEGATE")
|
||||
CANONICAL_ROLES = ("orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast")
|
||||
ROLE_LABELS = {
|
||||
"orchestrator": "Оркестратор",
|
||||
"coder-primary": "Основной кодер",
|
||||
"coder-secondary": "Резервный кодер",
|
||||
"reviewer": "Ревьюер",
|
||||
"research": "Исследователь",
|
||||
"fast": "Быстрый агент",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
role_id: str
|
||||
x: float
|
||||
y: float
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphEdge:
|
||||
source: str
|
||||
target: str
|
||||
edge_type: str = "DELEGATE"
|
||||
profile_id: str = ""
|
||||
edge_id: str = field(default_factory=lambda: uuid4().hex[:12])
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphIssue:
|
||||
code: str
|
||||
message: str
|
||||
node_id: str = ""
|
||||
edge_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingGraph:
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
nodes: list[GraphNode] = field(default_factory=list)
|
||||
edges: list[GraphEdge] = field(default_factory=list)
|
||||
zoom: float = 1.0
|
||||
viewport_x: float = 0.0
|
||||
viewport_y: float = 0.0
|
||||
|
||||
def clone(self) -> "RoutingGraph":
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "RoutingGraph":
|
||||
if int(value.get("schema_version", 0)) != SCHEMA_VERSION:
|
||||
raise ValueError("Unsupported routing graph schema")
|
||||
return cls(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
nodes=[GraphNode(**item) for item in value.get("nodes", [])],
|
||||
edges=[GraphEdge(**item) for item in value.get("edges", [])],
|
||||
zoom=float(value.get("zoom", 1.0)),
|
||||
viewport_x=float(value.get("viewport_x", 0.0)),
|
||||
viewport_y=float(value.get("viewport_y", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def default_graph(config: Optional[RouterConfig] = None) -> RoutingGraph:
|
||||
"""Migrate current configured roles without touching their chains."""
|
||||
config = config or load_router_config()
|
||||
roles = [role for role in CANONICAL_ROLES if role in config.roles]
|
||||
roles.extend(role for role in config.roles if role not in roles)
|
||||
nodes: list[GraphNode] = []
|
||||
for index, role_id in enumerate(roles):
|
||||
if role_id == "orchestrator":
|
||||
x, y = 90.0, 240.0
|
||||
else:
|
||||
slot = index - (1 if "orchestrator" in roles else 0)
|
||||
x, y = 390.0 + (slot // 3) * 300.0, 80.0 + (slot % 3) * 160.0
|
||||
nodes.append(GraphNode(role_id, x, y, ROLE_LABELS.get(role_id, role_id)))
|
||||
root = "orchestrator" if "orchestrator" in roles else (roles[0] if roles else "")
|
||||
edges = [GraphEdge(root, role, "DELEGATE") for role in roles if root and role != root]
|
||||
return RoutingGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
class RoutingGraphStore:
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
self.path = path or (paths.get_hermes_home() / "routing_graph.json")
|
||||
|
||||
def load(self, config: Optional[RouterConfig] = None) -> RoutingGraph:
|
||||
try:
|
||||
return RoutingGraph.from_dict(json.loads(self.path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return default_graph(config)
|
||||
|
||||
def save(self, graph: RoutingGraph) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(graph.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
|
||||
def validate_graph(graph: RoutingGraph, config: Optional[RouterConfig] = None) -> list[GraphIssue]:
|
||||
config = config or load_router_config()
|
||||
issues: list[GraphIssue] = []
|
||||
node_ids = [node.role_id for node in graph.nodes]
|
||||
node_set = set(node_ids)
|
||||
for role_id in sorted({item for item in node_ids if node_ids.count(item) > 1}):
|
||||
issues.append(GraphIssue("duplicate-node", f"Роль {role_id} добавлена дважды", role_id))
|
||||
orch_node = next((n for n in ("manager", "orchestrator") if n in node_set), None)
|
||||
if not orch_node:
|
||||
issues.append(GraphIssue("missing-orchestrator", "Отсутствует узел оркестратора"))
|
||||
for node in graph.nodes:
|
||||
policy = config.roles.get(node.role_id)
|
||||
if policy is None:
|
||||
issues.append(GraphIssue("missing-role", f"Роль {node.role_id} отсутствует в конфигурации", node.role_id))
|
||||
elif not policy.preferred_chain:
|
||||
issues.append(GraphIssue("empty-chain", f"У роли {node.label or node.role_id} нет профилей", node.role_id))
|
||||
else:
|
||||
for profile_id in policy.preferred_chain:
|
||||
if profile_id not in config.profiles:
|
||||
issues.append(
|
||||
GraphIssue("missing-profile", f"Профиль {profile_id} не существует", node.role_id)
|
||||
)
|
||||
seen_edges: set[tuple[str, str, str, str]] = set()
|
||||
adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_set}
|
||||
for edge in graph.edges:
|
||||
key = (edge.source, edge.target, edge.edge_type, edge.profile_id)
|
||||
if key in seen_edges:
|
||||
issues.append(GraphIssue("duplicate-edge", "Дублирующая связь", edge_id=edge.edge_id))
|
||||
seen_edges.add(key)
|
||||
if edge.edge_type not in EDGE_TYPES:
|
||||
issues.append(GraphIssue("edge-type", f"Неизвестный тип {edge.edge_type}", edge_id=edge.edge_id))
|
||||
if edge.source not in node_set or edge.target not in node_set:
|
||||
issues.append(GraphIssue("dangling-edge", "Связь ведёт к отсутствующей роли", edge_id=edge.edge_id))
|
||||
continue
|
||||
adjacency[edge.source].append(edge.target)
|
||||
if edge.profile_id and edge.profile_id not in config.profiles:
|
||||
issues.append(GraphIssue("missing-profile", f"Профиль {edge.profile_id} не существует", edge.target, edge.edge_id))
|
||||
|
||||
visited: set[str] = set()
|
||||
active: set[str] = set()
|
||||
|
||||
def visit(role_id: str) -> None:
|
||||
if role_id in active:
|
||||
issues.append(GraphIssue("cycle", f"Цикл маршрутизации через {role_id}", role_id))
|
||||
return
|
||||
if role_id in visited:
|
||||
return
|
||||
visited.add(role_id)
|
||||
active.add(role_id)
|
||||
for target in adjacency.get(role_id, []):
|
||||
visit(target)
|
||||
active.remove(role_id)
|
||||
|
||||
if orch_node:
|
||||
visit(orch_node)
|
||||
for role_id in sorted(node_set - visited):
|
||||
issues.append(GraphIssue("unreachable", f"Роль {role_id} недостижима от оркестратора", role_id))
|
||||
return issues
|
||||
|
||||
|
||||
class RoutingGraphController:
|
||||
"""Undoable graph editor whose assignments go through AutoAssigner."""
|
||||
|
||||
def __init__(self, store: Optional[RoutingGraphStore] = None):
|
||||
self.store = store or RoutingGraphStore()
|
||||
self.graph = self.store.load()
|
||||
self._undo: list[RoutingGraph] = []
|
||||
self._redo: list[RoutingGraph] = []
|
||||
self.dirty = False
|
||||
|
||||
def _checkpoint(self) -> None:
|
||||
self._undo.append(self.graph.clone())
|
||||
self._undo = self._undo[-50:]
|
||||
self._redo.clear()
|
||||
self.dirty = True
|
||||
|
||||
def move_node(self, role_id: str, x: float, y: float) -> None:
|
||||
node = next((item for item in self.graph.nodes if item.role_id == role_id), None)
|
||||
if node and (node.x, node.y) != (x, y):
|
||||
self._checkpoint()
|
||||
node.x, node.y = x, y
|
||||
|
||||
def add_edge(self, source: str, target: str, edge_type: str, profile_id: str = "") -> tuple[bool, str]:
|
||||
if edge_type not in EDGE_TYPES:
|
||||
return False, "Неизвестный тип связи"
|
||||
self._checkpoint()
|
||||
self.graph.edges.append(GraphEdge(source, target, edge_type, profile_id))
|
||||
if profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target, edge_type == "PRIMARY")
|
||||
if not ok:
|
||||
self.undo()
|
||||
return False, message
|
||||
return True, "Связь добавлена"
|
||||
|
||||
def delete_edge(self, edge_id: str) -> None:
|
||||
if any(edge.edge_id == edge_id for edge in self.graph.edges):
|
||||
self._checkpoint()
|
||||
self.graph.edges = [edge for edge in self.graph.edges if edge.edge_id != edge_id]
|
||||
|
||||
def set_edge_type(
|
||||
self, edge_id: str, edge_type: str, profile_id: Optional[str] = None
|
||||
) -> tuple[bool, str]:
|
||||
edge = next((item for item in self.graph.edges if item.edge_id == edge_id), None)
|
||||
if edge is None or edge_type not in EDGE_TYPES:
|
||||
return False, "Связь не найдена"
|
||||
self._checkpoint()
|
||||
edge.edge_type = edge_type
|
||||
if profile_id is not None:
|
||||
edge.profile_id = profile_id
|
||||
if edge.profile_id and edge_type in {"PRIMARY", "FALLBACK"}:
|
||||
ok, message = AutoAssigner.assign_profile_to_role(edge.profile_id, edge.target, edge_type == "PRIMARY")
|
||||
if not ok:
|
||||
self.undo()
|
||||
return ok, message
|
||||
return True, "Тип связи изменён"
|
||||
|
||||
def auto_layout(self) -> None:
|
||||
self._checkpoint()
|
||||
root = next((node for node in self.graph.nodes if node.role_id == "orchestrator"), None)
|
||||
if root:
|
||||
root.x, root.y = 80.0, 240.0
|
||||
others = [node for node in self.graph.nodes if node.role_id != "orchestrator"]
|
||||
for index, node in enumerate(others):
|
||||
node.x = 390.0 + (index // 4) * 290.0
|
||||
node.y = 55.0 + (index % 4) * 135.0
|
||||
|
||||
def undo(self) -> bool:
|
||||
if not self._undo:
|
||||
return False
|
||||
self._redo.append(self.graph.clone())
|
||||
self.graph = self._undo.pop()
|
||||
self.dirty = True
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
if not self._redo:
|
||||
return False
|
||||
self._undo.append(self.graph.clone())
|
||||
self.graph = self._redo.pop()
|
||||
self.dirty = True
|
||||
return True
|
||||
|
||||
def save(self) -> list[GraphIssue]:
|
||||
issues = validate_graph(self.graph)
|
||||
if not issues:
|
||||
self.store.save(self.graph)
|
||||
self.dirty = False
|
||||
return issues
|
||||
|
||||
def role_chain(self, role_id: str) -> Iterable[str]:
|
||||
policy = load_router_config().roles.get(role_id)
|
||||
return tuple(policy.preferred_chain if policy else ())
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""Hermes Hub — Native Brand Splash Screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import Optional
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
|
||||
|
||||
class SplashScreen(ctk.CTkToplevel):
|
||||
"""Brand-compliant Splash Screen shown during initialization."""
|
||||
|
||||
def __init__(self, parent: ctk.CTk, width: int = 500, height: int = 300):
|
||||
super().__init__(parent)
|
||||
self.overrideredirect(True)
|
||||
self.configure(fg_color=Theme.BG_WINDOW)
|
||||
|
||||
# Center on screen
|
||||
self.update_idletasks()
|
||||
sw = self.winfo_screenwidth()
|
||||
sh = self.winfo_screenheight()
|
||||
x = (sw - width) // 2
|
||||
y = (sh - height) // 2
|
||||
self.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
# Container
|
||||
container = ctk.CTkFrame(
|
||||
self,
|
||||
corner_radius=Theme.RADIUS_LG,
|
||||
border_width=2,
|
||||
border_color=Theme.BORDER_ACCENT,
|
||||
fg_color=Theme.BG_WINDOW,
|
||||
)
|
||||
container.pack(fill="both", expand=True, padx=2, pady=2)
|
||||
|
||||
# Logo
|
||||
logo_img = AssetManager.get().get_splash_logo(size=(100, 100))
|
||||
if logo_img:
|
||||
ctk.CTkLabel(container, image=logo_img, text="").pack(pady=(28, 8))
|
||||
|
||||
ctk.CTkLabel(
|
||||
container,
|
||||
text="HERMES HUB",
|
||||
font=Theme.font_title_hero(),
|
||||
text_color=Theme.TEXT_ACCENT,
|
||||
).pack(pady=(0, 2))
|
||||
|
||||
ctk.CTkLabel(
|
||||
container,
|
||||
text="Multi-Agent & Multi-Provider Control Hub",
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(pady=(0, 16))
|
||||
|
||||
# Progress indicator bar
|
||||
self.progress = ctk.CTkProgressBar(
|
||||
container,
|
||||
width=300,
|
||||
height=4,
|
||||
corner_radius=2,
|
||||
fg_color=Theme.SURFACE,
|
||||
progress_color=Theme.ACCENT,
|
||||
mode="indeterminate",
|
||||
)
|
||||
self.progress.pack(pady=(0, 20))
|
||||
self.progress.start()
|
||||
|
||||
self.update()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.progress.stop()
|
||||
self.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
"""Hermes Hub — Centralized Design Tokens and Theme Definition (v3).
|
||||
|
||||
Source of Truth: Brand Guidelines & Brandbook
|
||||
Primary Palette:
|
||||
PRIMARY: #0F1510 (Obsidian Forest)
|
||||
DARK: #1A2A1F (Deep Pine)
|
||||
SECONDARY: #2F4A36 (Moss Slate)
|
||||
LIGHT: #F7F1E3 (Warm Ivory)
|
||||
ACCENT: #CDAA64 (Ancient Gold)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Theme:
|
||||
# ── Core Brand Palette ──
|
||||
PRIMARY = "#0F1510"
|
||||
DARK = "#1A2A1F"
|
||||
SECONDARY = "#2F4A36"
|
||||
LIGHT = "#F7F1E3"
|
||||
ACCENT = "#CDAA64"
|
||||
|
||||
# ── Surfaces & Backgrounds ──
|
||||
BG_WINDOW = "#0F1510"
|
||||
BG_SIDEBAR = "#142018"
|
||||
BG_HEADER = "#16241B"
|
||||
BG_STATUSBAR = "#121C15"
|
||||
BG_MODAL_BACKDROP = "#080B08"
|
||||
|
||||
SURFACE = "#1D3123"
|
||||
SURFACE_HOVER = "#274230"
|
||||
SURFACE_ACTIVE = "#31543D"
|
||||
SURFACE_SELECTED = "#284432"
|
||||
SURFACE_MUTED = "#16251A"
|
||||
|
||||
# ── Borders ──
|
||||
BORDER = "#2F4A36"
|
||||
BORDER_SUBTLE = "#1F3526"
|
||||
BORDER_ACCENT = "#CDAA64"
|
||||
BORDER_HOVER = "#40644B"
|
||||
|
||||
# ── Typography Colors ──
|
||||
TEXT_PRIMARY = "#F7F1E3"
|
||||
TEXT_SECONDARY = "#C5BEAF"
|
||||
TEXT_MUTED = "#8A9C8E"
|
||||
TEXT_ON_ACCENT = "#0F1510"
|
||||
TEXT_ACCENT = "#CDAA64"
|
||||
TEXT_DISABLED = "#546457"
|
||||
|
||||
# ── Accent States ──
|
||||
ACCENT_HOVER = "#DCBE7D"
|
||||
ACCENT_PRESSED = "#BA954E"
|
||||
ACCENT_DIM = "#3D3522"
|
||||
ACCENT_GLOW = "#CDAA64"
|
||||
|
||||
DANGER_SURFACE = "#5A1E1E"
|
||||
DANGER_SURFACE_HOVER = "#7A2828"
|
||||
DANGER_BORDER = "#8A3333"
|
||||
DANGER_TEXT = "#FFD6D6"
|
||||
|
||||
# ── Operational / Status Colors ──
|
||||
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
|
||||
STATUS_WARNING = "#D97706" # Amber Gold
|
||||
STATUS_ERROR = "#DC2626" # Ruby Crimson
|
||||
STATUS_INFO = "#2563EB" # Azure
|
||||
STATUS_AUTH_REQUIRED = "#D97706"
|
||||
STATUS_DISABLED = "#5A6B5D"
|
||||
STATUS_FAILOVER = "#3B82F6"
|
||||
STATUS_MAIN = "#CDAA64" # Gold Badge
|
||||
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
|
||||
|
||||
# Semantic roles. Brand gold is intentionally not a health colour.
|
||||
COLOR_POSITIVE = STATUS_HEALTHY
|
||||
COLOR_CAUTION = STATUS_WARNING
|
||||
COLOR_NEGATIVE = STATUS_ERROR
|
||||
COLOR_NEUTRAL = STATUS_DISABLED
|
||||
COLOR_BRAND = ACCENT
|
||||
|
||||
# ── Provider Specific Accent Colors ──
|
||||
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
|
||||
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
|
||||
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
|
||||
PROVIDER_CLAUDE = "#D97706"
|
||||
PROVIDER_GROK = "#3B82F6"
|
||||
PROVIDER_GENERIC = "#8B5CF6"
|
||||
|
||||
SCHEMES = ("dark", "hybrid", "light")
|
||||
SCHEME_LABELS = {
|
||||
"dark": "Тёмная",
|
||||
"hybrid": "Средняя (гибрид)",
|
||||
"light": "Светлая (бежевая)",
|
||||
}
|
||||
PALETTES = {
|
||||
"dark": {
|
||||
"PRIMARY": "#071A17",
|
||||
"DARK": "#0B211D",
|
||||
"SECONDARY": "#244B3C",
|
||||
"LIGHT": "#F4EAD4",
|
||||
"ACCENT": "#C89A2B",
|
||||
"BG_WINDOW": "#061916",
|
||||
"BG_SIDEBAR": "#08221E",
|
||||
"BG_HEADER": "#071B18",
|
||||
"BG_STATUSBAR": "#061512",
|
||||
"BG_MODAL_BACKDROP": "#020B09",
|
||||
"SURFACE": "#0B2520",
|
||||
"SURFACE_HOVER": "#12342C",
|
||||
"SURFACE_ACTIVE": "#194535",
|
||||
"SURFACE_SELECTED": "#173B2E",
|
||||
"SURFACE_MUTED": "#091E1A",
|
||||
"BORDER": "#36513B",
|
||||
"BORDER_SUBTLE": "#203A2D",
|
||||
"BORDER_ACCENT": "#B78525",
|
||||
"BORDER_HOVER": "#537456",
|
||||
"TEXT_PRIMARY": "#F8F0DC",
|
||||
"TEXT_SECONDARY": "#D1C7AE",
|
||||
"TEXT_MUTED": "#8FA395",
|
||||
"TEXT_ON_ACCENT": "#102018",
|
||||
"TEXT_ACCENT": "#E0B84E",
|
||||
"TEXT_DISABLED": "#617367",
|
||||
"ACCENT_HOVER": "#E0B84E",
|
||||
"ACCENT_PRESSED": "#A9781E",
|
||||
"ACCENT_DIM": "#3D3218",
|
||||
"ACCENT_GLOW": "#D9AA37",
|
||||
"DANGER_SURFACE": "#4A2020",
|
||||
"DANGER_SURFACE_HOVER": "#692A2A",
|
||||
"DANGER_BORDER": "#8A3B36",
|
||||
"DANGER_TEXT": "#FFD8D2",
|
||||
"STATUS_HEALTHY": "#72C943",
|
||||
"STATUS_WARNING": "#E1A62B",
|
||||
"STATUS_ERROR": "#E45C4F",
|
||||
"STATUS_INFO": "#4C8DD8",
|
||||
"STATUS_AUTH_REQUIRED": "#E1A62B",
|
||||
"STATUS_DISABLED": "#708078",
|
||||
"STATUS_FAILOVER": "#4C8DD8",
|
||||
"STATUS_MAIN": "#C89A2B",
|
||||
"STATUS_ORCHESTRATOR": "#E0B84E",
|
||||
"PROVIDER_ANTIGRAVITY": "#74A9FF",
|
||||
"PROVIDER_CODEX": "#46BE8A",
|
||||
"PROVIDER_OPENCODE": "#F39A50",
|
||||
"PROVIDER_CLAUDE": "#DF9C63",
|
||||
"PROVIDER_GROK": "#6DA6F2",
|
||||
"PROVIDER_GENERIC": "#A99DD8",
|
||||
"SIDEBAR_TEXT": "#F8F0DC",
|
||||
"SIDEBAR_MUTED": "#8FA395",
|
||||
"SIDEBAR_HOVER": "#12342C",
|
||||
"SIDEBAR_SELECTED": "#173B2E",
|
||||
},
|
||||
"hybrid": {
|
||||
"PRIMARY": "#F7F2E8",
|
||||
"DARK": "#0A2721",
|
||||
"SECONDARY": "#DCE6D7",
|
||||
"LIGHT": "#FCF8F0",
|
||||
"ACCENT": "#B98118",
|
||||
"BG_WINDOW": "#F5F0E7",
|
||||
"BG_SIDEBAR": "#08251F",
|
||||
"BG_HEADER": "#FBF8F1",
|
||||
"BG_STATUSBAR": "#EFE8DB",
|
||||
"BG_MODAL_BACKDROP": "#E8E0D3",
|
||||
"SURFACE": "#FBF8F1",
|
||||
"SURFACE_HOVER": "#EEE8DA",
|
||||
"SURFACE_ACTIVE": "#E1EAD9",
|
||||
"SURFACE_SELECTED": "#E4EBD8",
|
||||
"SURFACE_MUTED": "#F1EBE0",
|
||||
"BORDER": "#D8CCB8",
|
||||
"BORDER_SUBTLE": "#E7DDCE",
|
||||
"BORDER_ACCENT": "#BD8B2F",
|
||||
"BORDER_HOVER": "#AFA188",
|
||||
"TEXT_PRIMARY": "#17241D",
|
||||
"TEXT_SECONDARY": "#4C584F",
|
||||
"TEXT_MUTED": "#7B817B",
|
||||
"TEXT_ON_ACCENT": "#FFFFFF",
|
||||
"TEXT_ACCENT": "#9B6C13",
|
||||
"TEXT_DISABLED": "#A9AAA4",
|
||||
"ACCENT_HOVER": "#CF9C39",
|
||||
"ACCENT_PRESSED": "#936514",
|
||||
"ACCENT_DIM": "#F0E4C8",
|
||||
"ACCENT_GLOW": "#C59432",
|
||||
"DANGER_SURFACE": "#F8E2DE",
|
||||
"DANGER_SURFACE_HOVER": "#F1CBC5",
|
||||
"DANGER_BORDER": "#C86155",
|
||||
"DANGER_TEXT": "#812E28",
|
||||
"STATUS_HEALTHY": "#397B35",
|
||||
"STATUS_WARNING": "#BE7B13",
|
||||
"STATUS_ERROR": "#C6473D",
|
||||
"STATUS_INFO": "#326DAD",
|
||||
"STATUS_AUTH_REQUIRED": "#BE7B13",
|
||||
"STATUS_DISABLED": "#969B94",
|
||||
"STATUS_FAILOVER": "#326DAD",
|
||||
"STATUS_MAIN": "#B98118",
|
||||
"STATUS_ORCHESTRATOR": "#A87817",
|
||||
"PROVIDER_ANTIGRAVITY": "#326DAD",
|
||||
"PROVIDER_CODEX": "#247A50",
|
||||
"PROVIDER_OPENCODE": "#B96324",
|
||||
"PROVIDER_CLAUDE": "#A55C2C",
|
||||
"PROVIDER_GROK": "#326DAD",
|
||||
"PROVIDER_GENERIC": "#7365A5",
|
||||
"SIDEBAR_TEXT": "#F8F0DC",
|
||||
"SIDEBAR_MUTED": "#98A89D",
|
||||
"SIDEBAR_HOVER": "#12342C",
|
||||
"SIDEBAR_SELECTED": "#173B2E",
|
||||
},
|
||||
"light": {
|
||||
"PRIMARY": "#FFF9EE",
|
||||
"DARK": "#FFF8EC",
|
||||
"SECONDARY": "#E9DFC9",
|
||||
"LIGHT": "#FFFDF8",
|
||||
"ACCENT": "#A96F12",
|
||||
"BG_WINDOW": "#FFF9EF",
|
||||
"BG_SIDEBAR": "#FBF0DE",
|
||||
"BG_HEADER": "#FFFDF8",
|
||||
"BG_STATUSBAR": "#F7EDDE",
|
||||
"BG_MODAL_BACKDROP": "#EDE2D2",
|
||||
"SURFACE": "#FFFDF8",
|
||||
"SURFACE_HOVER": "#F4E9D9",
|
||||
"SURFACE_ACTIVE": "#DFE8D7",
|
||||
"SURFACE_SELECTED": "#E7EEDC",
|
||||
"SURFACE_MUTED": "#F8F0E5",
|
||||
"BORDER": "#DDCFB9",
|
||||
"BORDER_SUBTLE": "#EDE2D2",
|
||||
"BORDER_ACCENT": "#B57A1C",
|
||||
"BORDER_HOVER": "#B8A78D",
|
||||
"TEXT_PRIMARY": "#1E281F",
|
||||
"TEXT_SECONDARY": "#505A51",
|
||||
"TEXT_MUTED": "#7E837C",
|
||||
"TEXT_ON_ACCENT": "#FFFFFF",
|
||||
"TEXT_ACCENT": "#90600F",
|
||||
"TEXT_DISABLED": "#ADAEA7",
|
||||
"ACCENT_HOVER": "#C58B2D",
|
||||
"ACCENT_PRESSED": "#835609",
|
||||
"ACCENT_DIM": "#F2E3C4",
|
||||
"ACCENT_GLOW": "#BA8428",
|
||||
"DANGER_SURFACE": "#F9E5E0",
|
||||
"DANGER_SURFACE_HOVER": "#F3D0C9",
|
||||
"DANGER_BORDER": "#C85A4E",
|
||||
"DANGER_TEXT": "#812E28",
|
||||
"STATUS_HEALTHY": "#367B36",
|
||||
"STATUS_WARNING": "#B97612",
|
||||
"STATUS_ERROR": "#C6473D",
|
||||
"STATUS_INFO": "#326DAD",
|
||||
"STATUS_AUTH_REQUIRED": "#B97612",
|
||||
"STATUS_DISABLED": "#999D96",
|
||||
"STATUS_FAILOVER": "#326DAD",
|
||||
"STATUS_MAIN": "#A96F12",
|
||||
"STATUS_ORCHESTRATOR": "#9D6810",
|
||||
"PROVIDER_ANTIGRAVITY": "#326DAD",
|
||||
"PROVIDER_CODEX": "#247A50",
|
||||
"PROVIDER_OPENCODE": "#B96324",
|
||||
"PROVIDER_CLAUDE": "#A55C2C",
|
||||
"PROVIDER_GROK": "#326DAD",
|
||||
"PROVIDER_GENERIC": "#7365A5",
|
||||
"SIDEBAR_TEXT": "#283129",
|
||||
"SIDEBAR_MUTED": "#767D76",
|
||||
"SIDEBAR_HOVER": "#F0E3D0",
|
||||
"SIDEBAR_SELECTED": "#E6EEDB",
|
||||
},
|
||||
}
|
||||
current_scheme = "dark"
|
||||
|
||||
@classmethod
|
||||
def apply_scheme(cls, scheme: str) -> str:
|
||||
"""Activate a complete palette and return its normalized key."""
|
||||
normalized = scheme if scheme in cls.PALETTES else "dark"
|
||||
for token, value in cls.PALETTES[normalized].items():
|
||||
setattr(cls, token, value)
|
||||
cls.current_scheme = normalized
|
||||
cls.COLOR_POSITIVE = cls.STATUS_HEALTHY
|
||||
cls.COLOR_CAUTION = cls.STATUS_WARNING
|
||||
cls.COLOR_NEGATIVE = cls.STATUS_ERROR
|
||||
cls.COLOR_NEUTRAL = cls.STATUS_DISABLED
|
||||
cls.COLOR_BRAND = cls.ACCENT
|
||||
return normalized
|
||||
|
||||
# ── Spacing Scale (px) ──
|
||||
SPACE_XS = 4
|
||||
SPACE_SM = 8
|
||||
SPACE_MD = 12
|
||||
SPACE_LG = 16
|
||||
SPACE_XL = 24
|
||||
SPACE_2XL = 32
|
||||
|
||||
# Layout aliases used by views and reusable components.
|
||||
PAGE_PAD_X = SPACE_LG
|
||||
PAGE_PAD_Y = SPACE_MD
|
||||
SECTION_GAP = SPACE_MD
|
||||
CARD_PAD_X = SPACE_MD
|
||||
CARD_PAD_Y = SPACE_MD
|
||||
INLINE_GAP = SPACE_SM
|
||||
|
||||
# ── Corner Radius Scale (px) ──
|
||||
RADIUS_SM = 6
|
||||
RADIUS_MD = 10
|
||||
RADIUS_LG = 14
|
||||
RADIUS_PILL = 999
|
||||
|
||||
# ── Control Heights & Dimensions ──
|
||||
HEIGHT_BTN_SM = 30
|
||||
HEIGHT_BTN_MD = 38
|
||||
HEIGHT_BTN_LG = 44
|
||||
HEIGHT_NAV_ITEM = 34
|
||||
HEIGHT_HEADER = 56
|
||||
HEIGHT_STATUSBAR = 30
|
||||
WIDTH_SIDEBAR = 190
|
||||
HEIGHT_INPUT = 36
|
||||
HEIGHT_BADGE = 24
|
||||
ACCOUNT_CARD_MIN_HEIGHT = 188
|
||||
ACCOUNT_CARD_COLUMNS = 3
|
||||
|
||||
# ── Fonts ──
|
||||
FONT_FAMILY_TITLE = "Cinzel"
|
||||
FONT_FAMILY_UI = "Segoe UI"
|
||||
FONT_FAMILY_MONO = "Consolas"
|
||||
|
||||
@classmethod
|
||||
def font_title_hero(cls):
|
||||
return (cls.FONT_FAMILY_TITLE, 24, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_title_page(cls):
|
||||
return (cls.FONT_FAMILY_UI, 20, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_title(cls):
|
||||
"""Compatibility alias for the normalized page title."""
|
||||
return cls.font_title_page()
|
||||
|
||||
@classmethod
|
||||
def font_title_section(cls):
|
||||
return (cls.FONT_FAMILY_UI, 17, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_heading(cls):
|
||||
return (cls.FONT_FAMILY_UI, 15, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_subheading(cls):
|
||||
return (cls.FONT_FAMILY_UI, 14, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_body(cls):
|
||||
return (cls.FONT_FAMILY_UI, 13)
|
||||
|
||||
@classmethod
|
||||
def font_body_bold(cls):
|
||||
return (cls.FONT_FAMILY_UI, 13, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_caption(cls):
|
||||
return (cls.FONT_FAMILY_UI, 11)
|
||||
|
||||
@classmethod
|
||||
def font_micro(cls):
|
||||
return (cls.FONT_FAMILY_UI, 10)
|
||||
|
||||
@classmethod
|
||||
def font_mono(cls):
|
||||
return (cls.FONT_FAMILY_MONO, 13)
|
||||
|
||||
@classmethod
|
||||
def font_mono_sm(cls):
|
||||
return (cls.FONT_FAMILY_MONO, 12)
|
||||
|
||||
@classmethod
|
||||
def font_micro_bold(cls):
|
||||
return (cls.FONT_FAMILY_UI, 10, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_badge_bold(cls):
|
||||
return (cls.FONT_FAMILY_UI, 10, "bold")
|
||||
|
||||
@classmethod
|
||||
def font_icon(cls):
|
||||
return (cls.FONT_FAMILY_UI, 15)
|
||||
|
||||
@classmethod
|
||||
def font_metric(cls):
|
||||
return (cls.FONT_FAMILY_UI, 24, "bold")
|
||||
|
||||
|
||||
Theme.apply_scheme("dark")
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""Hermes Hub UI Views Package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"""Hermes Hub — About View (О программе)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
from antigravity_provider.router.ui.components import (
|
||||
HubButton,
|
||||
HubCard,
|
||||
HubSectionHeader,
|
||||
)
|
||||
|
||||
from antigravity_provider.version import __version__
|
||||
|
||||
|
||||
class AboutView(ctk.CTkFrame):
|
||||
def __init__(self, master: Any, **kwargs):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
header = HubSectionHeader(
|
||||
self,
|
||||
title="О программе",
|
||||
subtitle="Информация о версии, архитектуре и назначении Hermes Hub",
|
||||
)
|
||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
||||
|
||||
card = HubCard(self, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
||||
card.pack(fill="both", expand=True, padx=20, pady=(0, 15))
|
||||
|
||||
# Center logo
|
||||
logo_img = AssetManager.get().get_logo_image(size=(140, 140))
|
||||
if logo_img:
|
||||
logo_lbl = ctk.CTkLabel(card, image=logo_img, text="")
|
||||
logo_lbl.pack(pady=(24, 10))
|
||||
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text="HERMES HUB",
|
||||
font=Theme.font_title_hero(),
|
||||
text_color=Theme.TEXT_ACCENT,
|
||||
).pack(pady=(0, 4))
|
||||
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text="Multi-Agent & Multi-Provider Control Hub",
|
||||
font=Theme.font_subheading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(pady=(0, 2))
|
||||
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text=f"Версия {__version__} • Native Windows Application • Local-First",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
).pack(pady=(0, 16))
|
||||
|
||||
# Info Box
|
||||
info_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD)
|
||||
info_box.pack(fill="x", padx=32, pady=(0, 16))
|
||||
|
||||
desc_text = (
|
||||
"Hermes Hub — нативный центр управления мультиагентной системой Hermes, "
|
||||
"аккаунтами AI-провайдеров, моделями и отказоустойчивой маршрутизацией."
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
info_box,
|
||||
text=desc_text,
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
wraplength=640,
|
||||
justify="center",
|
||||
).pack(padx=16, pady=14)
|
||||
|
||||
# Pillars
|
||||
pillars_box = ctk.CTkFrame(card, fg_color="transparent")
|
||||
pillars_box.pack(fill="x", padx=32, pady=(0, 16))
|
||||
for i in range(3):
|
||||
pillars_box.grid_columnconfigure(i, weight=1)
|
||||
|
||||
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
p1.grid(row=0, column=0, padx=4, sticky="nsew")
|
||||
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
pady=(8, 2)
|
||||
)
|
||||
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
|
||||
pady=(0, 8)
|
||||
)
|
||||
|
||||
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
p2.grid(row=0, column=1, padx=4, sticky="nsew")
|
||||
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
pady=(8, 2)
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||
).pack(pady=(0, 8))
|
||||
|
||||
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
p3.grid(row=0, column=2, padx=4, sticky="nsew")
|
||||
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
pady=(8, 2)
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||
).pack(pady=(0, 8))
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
"""Accounts and quota view driven exclusively by a supplied HubSnapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Dict, Iterable, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import (
|
||||
AccountCardWidget,
|
||||
ActionButton,
|
||||
EmptyState,
|
||||
FilterButton,
|
||||
SearchField,
|
||||
SectionHeader,
|
||||
)
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
PROVIDER_LABELS = {
|
||||
"antigravity": "Google Antigravity",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"claude": "Claude",
|
||||
"grok": "Grok",
|
||||
}
|
||||
|
||||
|
||||
class ProviderGroup(ctk.CTkFrame):
|
||||
"""Fixed provider section with always-visible Cockpit-style cards."""
|
||||
|
||||
def __init__(self, master: Any, provider: str):
|
||||
super().__init__(master, fg_color="transparent")
|
||||
self.provider = provider
|
||||
self.collapsed = False
|
||||
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
|
||||
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
|
||||
self.title = ctk.CTkLabel(
|
||||
self.header,
|
||||
text=PROVIDER_LABELS.get(provider, provider),
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
self.title.pack(side="left", padx=Theme.SPACE_MD, pady=Theme.SPACE_SM)
|
||||
self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||
self.count.pack(side="right", padx=Theme.SPACE_MD)
|
||||
self.body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.body.pack(fill="x")
|
||||
for column in range(Theme.ACCOUNT_CARD_COLUMNS):
|
||||
self.body.grid_columnconfigure(column, weight=1)
|
||||
|
||||
def toggle_collapsed(self) -> None:
|
||||
self.collapsed = False
|
||||
self.body.pack(fill="x")
|
||||
|
||||
|
||||
class AccountsView(ctk.CTkFrame):
|
||||
"""Keyed accounts view; one account delta never reconstructs another card."""
|
||||
|
||||
def __init__(
|
||||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.on_action = on_action
|
||||
self._snapshot: Optional[HubSnapshot] = None
|
||||
self._cards: Dict[str, AccountCardWidget] = {}
|
||||
self._groups: Dict[str, ProviderGroup] = {}
|
||||
self._search = ""
|
||||
self._provider_filter = "Все провайдеры"
|
||||
self._health_filter = "Все состояния"
|
||||
self._role_filter = "Все роли"
|
||||
self.cards_created = 0
|
||||
self.cards_destroyed = 0
|
||||
self._build()
|
||||
|
||||
def _build(self) -> None:
|
||||
header = SectionHeader(
|
||||
self,
|
||||
title="Аккаунты и квоты",
|
||||
subtitle="Компактные карточки аккаунтов, реальные остатки и быстрые действия",
|
||||
action_text="+ Добавить аккаунт",
|
||||
action_cmd=lambda: self._emit("add_account", {}),
|
||||
)
|
||||
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
|
||||
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||||
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
|
||||
self.search = SearchField(toolbar, placeholder_text="Поиск по аккаунту, модели или роли…", width=300)
|
||||
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||
self.search.bind("<KeyRelease>", lambda _event: self._set_search(self.search.get()))
|
||||
self.provider_filter = FilterButton(
|
||||
toolbar,
|
||||
values=["Все провайдеры", *PROVIDER_LABELS.values()],
|
||||
command=self._set_provider_filter,
|
||||
width=170,
|
||||
)
|
||||
self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||
self.health_filter = FilterButton(
|
||||
toolbar,
|
||||
values=["Все состояния", "Работают", "Требуют входа", "Проблемные"],
|
||||
command=self._set_health_filter,
|
||||
width=150,
|
||||
)
|
||||
self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||
self.role_filter = FilterButton(
|
||||
toolbar,
|
||||
values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"],
|
||||
command=self._set_role_filter,
|
||||
width=135,
|
||||
)
|
||||
self.role_filter.pack(side="left")
|
||||
ActionButton(
|
||||
toolbar,
|
||||
text="Обновить все",
|
||||
variant="secondary",
|
||||
command=lambda: self._emit("refresh_all", {}),
|
||||
).pack(side="right")
|
||||
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
self.empty = EmptyState(
|
||||
self.scroll,
|
||||
title="Нет подходящих аккаунтов",
|
||||
message="Измените фильтры или подключите новый аккаунт.",
|
||||
action_text="Подключить аккаунт",
|
||||
action_cmd=lambda: self._emit("add_account", {}),
|
||||
)
|
||||
|
||||
def _emit(self, action: str, profile: Any) -> None:
|
||||
if not self.on_action:
|
||||
return
|
||||
if isinstance(profile, dict):
|
||||
payload = profile
|
||||
else:
|
||||
payload = {
|
||||
"profile_id": profile.profile_id,
|
||||
"provider": profile.provider,
|
||||
"display_name": profile.display_name,
|
||||
}
|
||||
self.on_action(action, payload)
|
||||
|
||||
def _set_search(self, value: str) -> None:
|
||||
self._search = value.strip().lower()
|
||||
self._render_visibility()
|
||||
|
||||
def _set_provider_filter(self, value: str) -> None:
|
||||
self._provider_filter = value
|
||||
self._render_visibility()
|
||||
|
||||
def _set_health_filter(self, value: str) -> None:
|
||||
self._health_filter = value
|
||||
self._render_visibility()
|
||||
|
||||
def _set_role_filter(self, value: str) -> None:
|
||||
self._role_filter = value
|
||||
self._render_visibility()
|
||||
|
||||
def _matches(self, profile: Any) -> bool:
|
||||
if self._provider_filter != "Все провайдеры":
|
||||
if PROVIDER_LABELS.get(profile.provider, profile.provider) != self._provider_filter:
|
||||
return False
|
||||
if self._health_filter == "Работают" and profile.health_state != "healthy":
|
||||
return False
|
||||
if self._health_filter == "Требуют входа" and profile.auth_state not in {"AUTH_REQUIRED", "AUTH_EXPIRED"}:
|
||||
return False
|
||||
if self._health_filter == "Проблемные" and profile.health_state in {"healthy", "not_configured", "cold_spare"}:
|
||||
return False
|
||||
roles = list(getattr(profile, "assigned_roles", []) or [])
|
||||
if self._role_filter != "Все роли" and not any(self._role_filter.lower() in role.lower() for role in roles):
|
||||
return False
|
||||
haystack = " ".join(
|
||||
[
|
||||
AccountCardWidget.resolve_identity(profile),
|
||||
profile.display_name,
|
||||
profile.provider_display_name,
|
||||
*roles,
|
||||
*(getattr(profile, "preferred_models", []) or []),
|
||||
]
|
||||
).lower()
|
||||
return not self._search or self._search in haystack
|
||||
|
||||
def _profiles(self) -> Iterable[Any]:
|
||||
if not self._snapshot:
|
||||
return []
|
||||
return (profile for profile in self._snapshot.all_profiles.values() if not profile.is_empty_slot)
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
self._snapshot = snapshot
|
||||
live_ids = {profile.profile_id for profile in self._profiles()}
|
||||
for profile_id in list(self._cards):
|
||||
if profile_id not in live_ids:
|
||||
self._cards.pop(profile_id).destroy()
|
||||
self.cards_destroyed += 1
|
||||
|
||||
for profile in self._profiles():
|
||||
group = self._groups.get(profile.provider)
|
||||
if group is None:
|
||||
group = ProviderGroup(self.scroll, profile.provider)
|
||||
self._groups[profile.provider] = group
|
||||
card = self._cards.get(profile.profile_id)
|
||||
if card is None:
|
||||
card = AccountCardWidget(
|
||||
group.body,
|
||||
profile.profile_id,
|
||||
AccountCardWidget.resolve_identity(profile),
|
||||
profile.provider_display_name,
|
||||
compact=True,
|
||||
on_action=self._emit,
|
||||
)
|
||||
self._cards[profile.profile_id] = card
|
||||
self.cards_created += 1
|
||||
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
|
||||
self._render_visibility()
|
||||
|
||||
def _render_visibility(self) -> None:
|
||||
if not self._snapshot:
|
||||
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||
return
|
||||
grouped: Dict[str, list[Any]] = defaultdict(list)
|
||||
for profile in self._profiles():
|
||||
if self._matches(profile):
|
||||
grouped[profile.provider].append(profile)
|
||||
visible_total = 0
|
||||
for provider, group in self._groups.items():
|
||||
profiles = grouped.get(provider, [])
|
||||
if not profiles:
|
||||
group.pack_forget()
|
||||
continue
|
||||
group.pack(fill="x")
|
||||
group.count.configure(text=f"{len(profiles)} аккаунт(а)")
|
||||
for card in self._cards.values():
|
||||
if card.profile_model and card.profile_model.provider == provider:
|
||||
card.grid_remove()
|
||||
for index, profile in enumerate(profiles):
|
||||
self._cards[profile.profile_id].grid(
|
||||
row=index // Theme.ACCOUNT_CARD_COLUMNS,
|
||||
column=index % Theme.ACCOUNT_CARD_COLUMNS,
|
||||
padx=Theme.SPACE_XS,
|
||||
pady=Theme.SPACE_XS,
|
||||
sticky="nsew",
|
||||
)
|
||||
visible_total += len(profiles)
|
||||
if visible_total:
|
||||
self.empty.pack_forget()
|
||||
else:
|
||||
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||
|
||||
def render_stats(self) -> Dict[str, int]:
|
||||
return {
|
||||
"cards_created": self.cards_created,
|
||||
"cards_destroyed": self.cards_destroyed,
|
||||
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
|
||||
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
|
||||
}
|
||||
|
||||
def show_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> bool:
|
||||
"""Keep account action feedback next to the originating controls."""
|
||||
card = self._cards.get(profile_id)
|
||||
if card is None:
|
||||
return False
|
||||
card.set_action_feedback(message, success)
|
||||
return True
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""Empirical router telemetry and local host measurements."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import HubMetricCard, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class AnalyticsView(ctk.CTkFrame):
|
||||
def __init__(self, master: Any, **kwargs):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Аналитика",
|
||||
subtitle="Только собственные измерения Hermes Router",
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
grid = ctk.CTkFrame(self, fg_color="transparent")
|
||||
grid.pack(fill="x", padx=Theme.PAGE_PAD_X)
|
||||
for column in range(3):
|
||||
grid.grid_columnconfigure(column, weight=1)
|
||||
definitions = (
|
||||
("calls", "Вызовы", "◎"),
|
||||
("latency", "P50 задержка", "⌁"),
|
||||
("errors", "Доля ошибок", "△"),
|
||||
("tokens", "Токены", "◇"),
|
||||
("failovers", "Переключения", "⇄"),
|
||||
("cost", "Стоимость", "$"),
|
||||
)
|
||||
self.cards = {}
|
||||
for index, (key, title, icon) in enumerate(definitions):
|
||||
card = HubMetricCard(grid, title, "Н/Д", "нет измерений", icon=icon)
|
||||
card.grid(row=index // 3, column=index % 3, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
|
||||
self.cards[key] = card
|
||||
self.note = ctk.CTkLabel(
|
||||
self,
|
||||
text="Вызовы измерены Hermes Router; аппаратные показатели — локально через psutil. Внешний SLA не заявляется.",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
)
|
||||
self.note.pack(anchor="w", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_LG)
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
telemetry = dict(snapshot.metrics.get("telemetry") or {})
|
||||
global_telemetry = dict(telemetry.get("global") or {})
|
||||
if not telemetry.get("has_data"):
|
||||
for card in self.cards.values():
|
||||
card.val_label.configure(text="Н/Д")
|
||||
card.sub_label.configure(text="нет измерений")
|
||||
return
|
||||
values = {
|
||||
"calls": str(global_telemetry.get("total_calls", "Н/Д")),
|
||||
"latency": f"{global_telemetry['latency_p50_ms']:.0f} мс"
|
||||
if global_telemetry.get("latency_p50_ms") is not None
|
||||
else "Н/Д",
|
||||
"errors": f"{global_telemetry['error_rate']:.1%}"
|
||||
if global_telemetry.get("error_rate") is not None
|
||||
else "Н/Д",
|
||||
"tokens": str(global_telemetry.get("total_tokens"))
|
||||
if global_telemetry.get("total_tokens") is not None
|
||||
else "Н/Д",
|
||||
"failovers": str(global_telemetry.get("failovers_count", 0)),
|
||||
"cost": f"${global_telemetry['total_cost_usd']:.4f}"
|
||||
if global_telemetry.get("total_cost_usd") is not None
|
||||
else "Н/Д",
|
||||
}
|
||||
for key, value in values.items():
|
||||
self.cards[key].val_label.configure(text=value)
|
||||
self.cards[key].sub_label.configure(text="own_measurement")
|
||||
|
|
@ -1,746 +0,0 @@
|
|||
"""Approved Hermes Hub overview, visually aligned with the B5 reference."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import Any, Callable, Dict, Iterable, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
from antigravity_provider.router.ui.components import HubButton, HubCard
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class _Sparkline(ctk.CTkFrame):
|
||||
"""Tiny chart built only from values observed during this UI session."""
|
||||
|
||||
def __init__(self, master: Any, width: int = 58, height: int = 18):
|
||||
super().__init__(master, width=width, height=height, fg_color="transparent")
|
||||
self.pack_propagate(False)
|
||||
self._width, self._height = width, height
|
||||
self._values: list[float] = []
|
||||
self.canvas = tk.Canvas(self, width=width, height=height, bg=Theme.SURFACE, highlightthickness=0, bd=0)
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
|
||||
def update_value(self, value: Optional[float]) -> None:
|
||||
if value is not None:
|
||||
self._values.append(float(value))
|
||||
self._values = self._values[-18:]
|
||||
self.canvas.delete("all")
|
||||
if not self._values:
|
||||
self.canvas.create_line(3, self._height - 4, self._width - 3, self._height - 4, fill=Theme.BORDER_SUBTLE)
|
||||
return
|
||||
values = self._values if len(self._values) > 1 else [self._values[0], self._values[0]]
|
||||
low, high = min(values), max(values)
|
||||
span = max(high - low, max(abs(high), 1.0) * 0.08)
|
||||
points: list[float] = []
|
||||
for index, current in enumerate(values):
|
||||
points.extend(
|
||||
(
|
||||
3 + index * (self._width - 6) / max(len(values) - 1, 1),
|
||||
self._height - 3 - ((current - low) / span) * (self._height - 7),
|
||||
)
|
||||
)
|
||||
self.canvas.create_line(*points, fill=Theme.STATUS_HEALTHY, width=1.4, smooth=True)
|
||||
|
||||
|
||||
class _KpiCard(HubCard):
|
||||
def __init__(self, master: Any, title: str, value: str, subtext: str):
|
||||
super().__init__(master, corner_radius=Theme.RADIUS_SM, height=62)
|
||||
self.grid_propagate(False)
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkLabel(self, text=title, font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY).grid(
|
||||
row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(7, 0)
|
||||
)
|
||||
self.val_label = ctk.CTkLabel(self, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.val_label.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 6))
|
||||
self.sub_label = ctk.CTkLabel(self, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.sub_label.grid(row=1, column=1, sticky="e", padx=(2, 5), pady=(0, 6))
|
||||
self.spark = _Sparkline(self, width=48, height=16)
|
||||
self.spark.grid(row=1, column=2, sticky="e", padx=(0, 7), pady=(0, 5))
|
||||
|
||||
def set_metric(self, value: str, subtext: str, numeric: Optional[float] = None) -> None:
|
||||
self.val_label.configure(text=value)
|
||||
self.sub_label.configure(text=subtext)
|
||||
self.spark.update_value(numeric)
|
||||
|
||||
|
||||
class _EndpointCard(HubCard):
|
||||
"""Compact provider/agent card with brand icon and a real quota bar."""
|
||||
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, corner_radius=Theme.RADIUS_MD, height=76)
|
||||
self.pack_propagate(False)
|
||||
self.icon = ctk.CTkLabel(self, text="◇", width=38, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT)
|
||||
self.icon.pack(side="left", padx=(8, 5))
|
||||
text = ctk.CTkFrame(self, fg_color="transparent")
|
||||
text.pack(side="left", fill="both", expand=True, pady=3)
|
||||
self.title = ctk.CTkLabel(text, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.title.pack(anchor="w")
|
||||
self.subtitle = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.subtitle.pack(anchor="w", pady=(0, 1))
|
||||
self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
|
||||
self.status.pack(anchor="w")
|
||||
quota = ctk.CTkFrame(self, fg_color="transparent", width=92)
|
||||
quota.pack(side="right", fill="y", padx=(3, 8), pady=8)
|
||||
quota.pack_propagate(False)
|
||||
self.quota_label = ctk.CTkLabel(quota, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.quota_label.pack(anchor="e")
|
||||
self.progress = ctk.CTkProgressBar(
|
||||
quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED
|
||||
)
|
||||
self.progress.pack(fill="x", pady=(5, 0))
|
||||
self.progress.set(0)
|
||||
self._click_action: Optional[Callable[[], None]] = None
|
||||
self._status = "unknown"
|
||||
self._bind_click_tree(self)
|
||||
try:
|
||||
self._canvas.configure(takefocus=1)
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
self.bind("<Return>", self._activate, add="+")
|
||||
self.bind("<space>", self._activate, add="+")
|
||||
self.bind("<Enter>", self._hover_on, add="+")
|
||||
self.bind("<Leave>", self._hover_off, add="+")
|
||||
|
||||
def _bind_click_tree(self, widget: Any) -> None:
|
||||
widget.configure(cursor="hand2")
|
||||
widget.bind("<Button-1>", self._activate, add="+")
|
||||
for child in widget.winfo_children():
|
||||
self._bind_click_tree(child)
|
||||
|
||||
def set_click_action(self, action: Optional[Callable[[], None]]) -> None:
|
||||
self._click_action = action
|
||||
self.configure(cursor="hand2" if action else "arrow")
|
||||
|
||||
def _activate(self, _event: Any = None) -> None:
|
||||
if self._click_action:
|
||||
self._click_action()
|
||||
|
||||
def _hover_on(self, _event: Any = None) -> None:
|
||||
if self._click_action:
|
||||
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
|
||||
|
||||
def _hover_off(self, _event: Any = None) -> None:
|
||||
self.configure(
|
||||
fg_color=Theme.SURFACE,
|
||||
border_color=Theme.BORDER_ACCENT if self._status == "healthy" else Theme.BORDER,
|
||||
)
|
||||
|
||||
def update_card(
|
||||
self,
|
||||
provider_key: str,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
status_text: str,
|
||||
quota_text: str,
|
||||
quota_percent: Optional[float],
|
||||
status: str,
|
||||
) -> None:
|
||||
self._status = status
|
||||
image = AssetManager.get().get_provider_image(provider_key, size=(30, 30))
|
||||
self.icon.configure(image=image, text="" if image else "◇")
|
||||
self.icon.image = image
|
||||
self.title.configure(text=title)
|
||||
self.subtitle.configure(text=subtitle)
|
||||
self.status.configure(text=f"● {status_text}")
|
||||
self.quota_label.configure(text=quota_text)
|
||||
color = {
|
||||
"healthy": Theme.STATUS_HEALTHY,
|
||||
"warning": Theme.STATUS_WARNING,
|
||||
"error": Theme.STATUS_ERROR,
|
||||
}.get(status, Theme.STATUS_DISABLED)
|
||||
self.status.configure(text_color=color)
|
||||
self.configure(border_color=Theme.BORDER_ACCENT if status == "healthy" else Theme.BORDER)
|
||||
self.progress.configure(progress_color=color)
|
||||
self.progress.set(max(0.0, min(1.0, quota_percent / 100.0)) if quota_percent is not None else 0)
|
||||
|
||||
|
||||
class _OrchestratorNode(ctk.CTkFrame):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, width=174, height=150, fg_color="transparent")
|
||||
self.pack_propagate(False)
|
||||
self.circle = ctk.CTkFrame(
|
||||
self,
|
||||
width=96,
|
||||
height=96,
|
||||
corner_radius=48,
|
||||
fg_color=Theme.BG_SIDEBAR if Theme.current_scheme != "light" else Theme.SURFACE,
|
||||
border_width=2,
|
||||
border_color=Theme.BORDER_ACCENT,
|
||||
)
|
||||
self.circle.pack(pady=(0, 3))
|
||||
self.circle.pack_propagate(False)
|
||||
logo = AssetManager.get().get_logo_image(size=(76, 76))
|
||||
self.logo = ctk.CTkLabel(
|
||||
self.circle, image=logo, text="H" if logo is None else "", text_color=Theme.TEXT_ACCENT
|
||||
)
|
||||
self.logo.image = logo
|
||||
self.logo.place(relx=0.5, rely=0.5, anchor="center")
|
||||
ctk.CTkLabel(
|
||||
self, text="Главный оркестратор", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack()
|
||||
self.subtitle = ctk.CTkLabel(self, text="Не назначен", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.subtitle.pack()
|
||||
self.status = ctk.CTkLabel(self, text="● Н/Д", font=Theme.font_micro(), text_color=Theme.STATUS_WARNING)
|
||||
self.status.pack()
|
||||
|
||||
def update_node(self, subtitle: str, status_text: str, active: bool) -> None:
|
||||
self.subtitle.configure(text=subtitle)
|
||||
self.status.configure(
|
||||
text=f"● {status_text}", text_color=Theme.STATUS_HEALTHY if active else Theme.STATUS_WARNING
|
||||
)
|
||||
|
||||
|
||||
class _RouteDiagram(ctk.CTkFrame):
|
||||
"""Responsive diagram with smooth connections behind native widgets."""
|
||||
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, height=440, fg_color="transparent")
|
||||
self.pack_propagate(False)
|
||||
self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0)
|
||||
self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
self.provider_slots: list[Any] = []
|
||||
self.agent_slots: list[Any] = []
|
||||
self.orchestrator = _OrchestratorNode(self)
|
||||
self.orchestrator.place(relx=0.51, rely=0.46, anchor="center")
|
||||
self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46)
|
||||
self.context.place(relx=0.51, rely=0.91, relwidth=0.25, anchor="center")
|
||||
ctk.CTkLabel(
|
||||
self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(pady=(6, 0))
|
||||
self.context_status = ctk.CTkLabel(
|
||||
self.context,
|
||||
text="● Нет телеметрии хранилища",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
)
|
||||
self.context_status.pack()
|
||||
self._left_labels: list[str] = []
|
||||
self._right_labels: list[str] = []
|
||||
self._redraw_timer_id: Any = None
|
||||
self.bind("<Configure>", self._schedule_redraw)
|
||||
|
||||
def _schedule_redraw(self, event: Any = None) -> None:
|
||||
"""Свести поток <Configure> к одной перерисовке.
|
||||
|
||||
При перетаскивании окна Tk шлёт <Configure> непрерывно, и раньше каждое
|
||||
событие вызывало полную перерисовку канвы: удаление и построение всех
|
||||
связей, подписей и узлов. Очередь не успевала разгребаться, и окно
|
||||
продолжало ползти ещё несколько секунд после того, как мышь отпущена.
|
||||
|
||||
Перерисовываем один раз, когда поток событий утих.
|
||||
"""
|
||||
if self._redraw_timer_id is not None:
|
||||
try:
|
||||
self.after_cancel(self._redraw_timer_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._redraw_timer_id = self.after(80, self._redraw_now)
|
||||
|
||||
def _redraw_now(self) -> None:
|
||||
self._redraw_timer_id = None
|
||||
try:
|
||||
self._redraw()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def sync_slots(self, provider_count: int, agent_count: int) -> None:
|
||||
"""Grow/shrink endpoint slots to match the snapshot without hard caps."""
|
||||
while len(self.provider_slots) < provider_count:
|
||||
self.provider_slots.append(ctk.CTkFrame(self, height=76, fg_color="transparent"))
|
||||
while len(self.agent_slots) < agent_count:
|
||||
self.agent_slots.append(ctk.CTkFrame(self, height=68, fg_color="transparent"))
|
||||
for index, slot in enumerate(self.provider_slots):
|
||||
if index >= provider_count:
|
||||
slot.place_forget()
|
||||
continue
|
||||
y = 0.02 + index * (0.78 / max(provider_count - 1, 1))
|
||||
slot.place(relx=0.012, rely=y, relwidth=0.30)
|
||||
for index, slot in enumerate(self.agent_slots):
|
||||
if index >= agent_count:
|
||||
slot.place_forget()
|
||||
continue
|
||||
y = 0.01 + index * (0.80 / max(agent_count - 1, 1))
|
||||
slot.place(relx=0.71, rely=y, relwidth=0.278)
|
||||
self._redraw()
|
||||
|
||||
def set_labels(self, left: list[str], right: list[str]) -> None:
|
||||
self._left_labels = list(left)
|
||||
self._right_labels = list(right)
|
||||
self._redraw()
|
||||
|
||||
def _redraw(self, _event: Any = None) -> None:
|
||||
width, height = max(self.winfo_width(), 600), max(self.winfo_height(), 300)
|
||||
self.canvas.delete("route")
|
||||
center_x, center_y = width * 0.51, height * 0.40
|
||||
left_x, right_x = width * 0.312, width * 0.71
|
||||
left_count = len(self._left_labels)
|
||||
left_ys = [
|
||||
height * (0.02 + index * (0.78 / max(left_count - 1, 1))) + 38 for index in range(left_count)
|
||||
]
|
||||
for index, y_pos in enumerate(left_ys):
|
||||
self.canvas.create_line(
|
||||
left_x,
|
||||
y_pos,
|
||||
left_x + 44,
|
||||
y_pos,
|
||||
center_x - 78,
|
||||
center_y,
|
||||
center_x - 48,
|
||||
center_y,
|
||||
fill=Theme.BORDER_ACCENT,
|
||||
width=1.35,
|
||||
smooth=True,
|
||||
arrow=tk.LAST,
|
||||
tags="route",
|
||||
)
|
||||
self.canvas.create_text(
|
||||
left_x + 50,
|
||||
y_pos - 8,
|
||||
text=self._left_labels[index],
|
||||
fill=Theme.TEXT_SECONDARY,
|
||||
font=(Theme.FONT_FAMILY_UI, 8),
|
||||
anchor="w",
|
||||
tags="route",
|
||||
)
|
||||
right_count = len(self._right_labels)
|
||||
right_ys = [
|
||||
height * (0.01 + index * (0.80 / max(right_count - 1, 1))) + 34 for index in range(right_count)
|
||||
]
|
||||
for index, y_pos in enumerate(right_ys):
|
||||
self.canvas.create_line(
|
||||
center_x + 48,
|
||||
center_y,
|
||||
right_x - 34,
|
||||
y_pos,
|
||||
right_x,
|
||||
y_pos,
|
||||
fill=Theme.BORDER_ACCENT,
|
||||
width=1.35,
|
||||
smooth=True,
|
||||
arrow=tk.LAST,
|
||||
tags="route",
|
||||
)
|
||||
self.canvas.create_text(
|
||||
right_x - 39,
|
||||
y_pos - 8,
|
||||
text=self._right_labels[index],
|
||||
fill=Theme.TEXT_SECONDARY,
|
||||
font=(Theme.FONT_FAMILY_UI, 8),
|
||||
anchor="e",
|
||||
tags="route",
|
||||
)
|
||||
self.canvas.create_line(
|
||||
center_x,
|
||||
center_y + 48,
|
||||
center_x,
|
||||
height * 0.83,
|
||||
fill=Theme.BORDER_ACCENT,
|
||||
width=1.2,
|
||||
dash=(3, 3),
|
||||
arrow=tk.LAST,
|
||||
tags="route",
|
||||
)
|
||||
|
||||
|
||||
class _StatusRow(ctk.CTkFrame):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, fg_color="transparent", height=24)
|
||||
self.pack_propagate(False)
|
||||
self.dot = ctk.CTkLabel(self, text="●", width=12, font=Theme.font_micro())
|
||||
self.dot.pack(side="left")
|
||||
self.title = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.title.pack(side="left", padx=4)
|
||||
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.detail.pack(side="right")
|
||||
|
||||
def update_row(self, title: str, detail: str, status: str) -> None:
|
||||
self.title.configure(text=title)
|
||||
self.detail.configure(text=detail)
|
||||
self.dot.configure(
|
||||
text_color={
|
||||
"healthy": Theme.STATUS_HEALTHY,
|
||||
"warning": Theme.STATUS_WARNING,
|
||||
"error": Theme.STATUS_ERROR,
|
||||
}.get(status, Theme.STATUS_DISABLED)
|
||||
)
|
||||
|
||||
|
||||
class _SystemRow(ctk.CTkFrame):
|
||||
def __init__(self, master: Any, title: str):
|
||||
super().__init__(master, fg_color="transparent", height=25)
|
||||
self.pack_propagate(False)
|
||||
ctk.CTkLabel(
|
||||
self, text=title, width=47, anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY
|
||||
).pack(side="left")
|
||||
self.value = ctk.CTkLabel(
|
||||
self, text="Н/Д", width=48, anchor="e", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
|
||||
)
|
||||
self.value.pack(side="left")
|
||||
self.spark = _Sparkline(self, width=54, height=16)
|
||||
self.spark.pack(side="right")
|
||||
|
||||
def update_metric(self, text: str, numeric: Optional[float]) -> None:
|
||||
self.value.configure(text=text)
|
||||
self.spark.update_value(numeric)
|
||||
|
||||
|
||||
class _EventRow(ctk.CTkFrame):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, fg_color="transparent", height=23)
|
||||
self.pack_propagate(False)
|
||||
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.time.pack(side="left")
|
||||
self.dot = ctk.CTkLabel(self, width=18, text="●", font=Theme.font_micro())
|
||||
self.dot.pack(side="left")
|
||||
self.category = ctk.CTkLabel(
|
||||
self, width=112, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
|
||||
)
|
||||
self.category.pack(side="left", padx=(0, 6))
|
||||
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.message.pack(side="left", fill="x", expand=True)
|
||||
self.tag = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
width=82,
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
corner_radius=Theme.RADIUS_PILL,
|
||||
)
|
||||
self.tag.pack(side="right")
|
||||
|
||||
def update_event(self, event: Any) -> None:
|
||||
level = str(getattr(event, "level", "info"))
|
||||
color = {
|
||||
"success": Theme.STATUS_HEALTHY,
|
||||
"warning": Theme.STATUS_WARNING,
|
||||
"error": Theme.STATUS_ERROR,
|
||||
}.get(level, Theme.STATUS_INFO)
|
||||
category = str(getattr(event, "category", "system"))
|
||||
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
|
||||
self.dot.configure(text_color=color)
|
||||
self.category.configure(text=category.replace("_", " ").title())
|
||||
self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
|
||||
self.tag.configure(text=category)
|
||||
|
||||
|
||||
class DashboardView(ctk.CTkFrame):
|
||||
"""Dense overview following the approved dashboard composition."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: Any,
|
||||
app_state: Optional[Dict[str, Any]] = None,
|
||||
on_navigate: Optional[Callable] = None,
|
||||
on_action: Optional[Callable] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.on_navigate = on_navigate
|
||||
self.on_action = on_action
|
||||
self._provider_cards: Dict[str, _EndpointCard] = {}
|
||||
self._agent_cards: Dict[str, _EndpointCard] = {}
|
||||
self._event_rows: list[_EventRow] = []
|
||||
self._build()
|
||||
|
||||
def _build(self) -> None:
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(8, 10))
|
||||
self.snapshot_freshness = ctk.CTkLabel(
|
||||
self.scroll,
|
||||
text="● Система загружается",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.STATUS_HEALTHY,
|
||||
anchor="w",
|
||||
)
|
||||
# Kept as a presentation-state probe for tests/accessibility; the same
|
||||
# status is rendered once in the global header, as in the approved mockup.
|
||||
|
||||
self.empty_state = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.ACCENT_DIM)
|
||||
empty_copy = ctk.CTkFrame(self.empty_state, fg_color="transparent")
|
||||
empty_copy.pack(side="left", fill="x", expand=True, padx=14, pady=10)
|
||||
ctk.CTkLabel(
|
||||
empty_copy,
|
||||
text="Подключите первый аккаунт",
|
||||
font=Theme.font_heading(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(anchor="w")
|
||||
ctk.CTkLabel(
|
||||
empty_copy,
|
||||
text="Hermes назначит профиль роли и покажет реальную квоту, модель и цепочку отказоустойчивости.",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
HubButton(
|
||||
self.empty_state,
|
||||
text="Добавить аккаунт",
|
||||
variant="primary",
|
||||
command=lambda: self.on_action("add_account", {}) if self.on_action else None,
|
||||
).pack(side="right", padx=12, pady=10)
|
||||
|
||||
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
self.metrics.pack(fill="x", pady=(0, 8))
|
||||
for column in range(6):
|
||||
self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
|
||||
self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н/Д", "нет измерения")
|
||||
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "активно: 0")
|
||||
self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы")
|
||||
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50")
|
||||
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover")
|
||||
self.host_metric = _KpiCard(self.metrics, "Нагрузка CPU", "Н/Д", "нет измерения хоста")
|
||||
for index, metric in enumerate(
|
||||
(
|
||||
self.quota_metric,
|
||||
self.calls_metric,
|
||||
self.agents_metric,
|
||||
self.latency_metric,
|
||||
self.failover_metric,
|
||||
self.host_metric,
|
||||
)
|
||||
):
|
||||
metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 5 else 3), sticky="nsew")
|
||||
|
||||
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
body.pack(fill="x")
|
||||
body.grid_columnconfigure(0, weight=1)
|
||||
route_card = HubCard(body, height=480)
|
||||
route_card.grid(row=0, column=0, sticky="nsew")
|
||||
route_card.grid_propagate(False)
|
||||
ctk.CTkLabel(
|
||||
route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(anchor="w", padx=10, pady=(8, 0))
|
||||
self.route_diagram = _RouteDiagram(route_card)
|
||||
self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
|
||||
|
||||
self.events_card = HubCard(self.scroll)
|
||||
self.events_card.pack(fill="x", pady=(8, 0))
|
||||
events_header = ctk.CTkFrame(self.events_card, fg_color="transparent")
|
||||
events_header.pack(fill="x", padx=10, pady=(7, 3))
|
||||
ctk.CTkLabel(
|
||||
events_header, text="Последние события", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(side="left")
|
||||
ctk.CTkButton(
|
||||
events_header,
|
||||
text="Все события →",
|
||||
width=82,
|
||||
height=22,
|
||||
fg_color="transparent",
|
||||
hover_color=Theme.SURFACE_HOVER,
|
||||
text_color=Theme.TEXT_ACCENT,
|
||||
font=Theme.font_micro(),
|
||||
command=lambda: self.on_navigate("logs") if self.on_navigate else None,
|
||||
).pack(side="right")
|
||||
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
|
||||
self.events_body.pack(fill="x", padx=10, pady=(0, 7))
|
||||
self.events_empty = ctk.CTkLabel(
|
||||
self.events_body, text="События: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.events_empty.pack(anchor="w", pady=5)
|
||||
|
||||
@staticmethod
|
||||
def _quota_measurement(snapshot: HubSnapshot, provider_id: Optional[str] = None) -> tuple[str, Optional[float]]:
|
||||
profiles = snapshot.profiles_by_provider.get(provider_id, []) if provider_id else snapshot.all_profiles.values()
|
||||
for profile in profiles:
|
||||
quota = snapshot.quotas.get(profile.profile_id)
|
||||
if not quota or getattr(quota, "is_estimated", True):
|
||||
continue
|
||||
for bucket in quota.buckets:
|
||||
if bucket.remaining_percent is not None:
|
||||
return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
|
||||
return "Нет данных API", None
|
||||
|
||||
@staticmethod
|
||||
def _agent_quota_measurement(snapshot: HubSnapshot, agent: Any) -> tuple[str, Optional[float]]:
|
||||
quota = snapshot.quotas.get(agent.assigned_profile_id)
|
||||
if quota and not getattr(quota, "is_estimated", True):
|
||||
bucket = quota.get_bucket_for_model(agent.model)
|
||||
if bucket and bucket.remaining_percent is not None:
|
||||
remaining = float(bucket.remaining_percent)
|
||||
return bucket.formatted_remaining(), remaining
|
||||
reason = getattr(quota, "unavailable_reason", None) if quota else None
|
||||
return reason or agent.active_quota_label or "Нет телеметрии: роль ещё не вызывалась", None
|
||||
|
||||
@staticmethod
|
||||
def _sync_endpoint_cards(
|
||||
slots: list[Any],
|
||||
cache: Dict[str, _EndpointCard],
|
||||
items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
|
||||
) -> None:
|
||||
prepared = list(items)[: len(slots)]
|
||||
live = {key for key, *_rest in prepared}
|
||||
for key in list(cache):
|
||||
if key not in live:
|
||||
cache.pop(key).destroy()
|
||||
for index, item in enumerate(prepared):
|
||||
key, provider_key, title, subtitle, status_text, quota_text, quota_percent, status = item
|
||||
card = cache.get(key)
|
||||
if card is None:
|
||||
card = _EndpointCard(slots[index])
|
||||
card.pack(fill="both", expand=True)
|
||||
cache[key] = card
|
||||
card.update_card(provider_key, title, subtitle, status_text, quota_text, quota_percent, status)
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
self.snapshot_freshness.configure(
|
||||
text=(
|
||||
f"⚠ Данные устарели • snapshot #{snapshot.seq}"
|
||||
if snapshot.is_stale
|
||||
else f"● Система работает штатно • snapshot #{snapshot.seq}"
|
||||
),
|
||||
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
|
||||
)
|
||||
readiness = snapshot.readiness
|
||||
if readiness.accounts_connected_count == 0:
|
||||
self.empty_state.pack(fill="x", pady=(0, 8), before=self.metrics)
|
||||
else:
|
||||
self.empty_state.pack_forget()
|
||||
telemetry = dict(snapshot.metrics.get("telemetry") or {})
|
||||
global_telemetry = dict(telemetry.get("global") or {})
|
||||
provider_telemetry = dict(telemetry.get("by_provider") or {})
|
||||
role_telemetry = dict(telemetry.get("by_role") or {})
|
||||
has_telemetry = bool(telemetry.get("has_data"))
|
||||
total_calls = global_telemetry.get("total_calls") if has_telemetry else None
|
||||
latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None
|
||||
failovers = global_telemetry.get("failovers_count") if has_telemetry else None
|
||||
active_calls = snapshot.metrics.get("active_calls_total")
|
||||
host = dict(snapshot.metrics.get("host") or {})
|
||||
cpu = host.get("cpu_percent") if host.get("has_data") else None
|
||||
quota_text, quota_percent = self._quota_measurement(snapshot)
|
||||
self.quota_metric.set_metric(
|
||||
quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent
|
||||
)
|
||||
self.calls_metric.set_metric(
|
||||
str(total_calls) if total_calls is not None else "Н/Д",
|
||||
f"активно: {active_calls}"
|
||||
if isinstance(active_calls, int)
|
||||
else "нет телеметрии: запросы ещё не выполнялись",
|
||||
float(total_calls) if total_calls is not None else None,
|
||||
)
|
||||
self.agents_metric.set_metric(
|
||||
str(readiness.accounts_connected_count),
|
||||
f"роли готовы: {readiness.roles_ready_count}/{readiness.total_roles}",
|
||||
float(readiness.accounts_connected_count),
|
||||
)
|
||||
self.latency_metric.set_metric(
|
||||
f"{latency:.0f} мс" if latency is not None else "Н/Д",
|
||||
"P50" if latency is not None else "нет телеметрии: запросы ещё не выполнялись",
|
||||
float(latency) if latency is not None else None,
|
||||
)
|
||||
self.failover_metric.set_metric(
|
||||
str(failovers) if failovers is not None else "Н/Д",
|
||||
"failover" if failovers is not None else "нет телеметрии переключений",
|
||||
float(failovers) if failovers is not None else None,
|
||||
)
|
||||
self.host_metric.set_metric(
|
||||
f"{cpu:.0f}%" if cpu is not None else "Н/Д",
|
||||
"host_measurement" if cpu is not None else "psutil не вернул измерение",
|
||||
float(cpu) if cpu is not None else None,
|
||||
)
|
||||
|
||||
providers = list(snapshot.providers)
|
||||
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator]
|
||||
self.route_diagram.sync_slots(len(providers), len(agents))
|
||||
self._sync_endpoint_cards(
|
||||
self.route_diagram.provider_slots,
|
||||
self._provider_cards,
|
||||
(
|
||||
(
|
||||
provider.provider_id,
|
||||
provider.provider_id,
|
||||
provider.provider_name,
|
||||
(
|
||||
f"{provider.connected_count} аккаунт(а) • "
|
||||
+ next(
|
||||
(
|
||||
profile.preferred_models[0]
|
||||
for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
|
||||
if profile.preferred_models
|
||||
),
|
||||
"модели не обнаружены",
|
||||
)
|
||||
),
|
||||
"Онлайн" if provider.online_count else "Недоступен",
|
||||
*self._quota_measurement(snapshot, provider.provider_id),
|
||||
"healthy" if provider.online_count else "warning",
|
||||
)
|
||||
for provider in providers
|
||||
),
|
||||
)
|
||||
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
|
||||
if orchestrator:
|
||||
self.route_diagram.orchestrator.update_node(
|
||||
f"{orchestrator.provider_display_name} • {orchestrator.model}",
|
||||
"Онлайн" if orchestrator.is_active else orchestrator.status_label_ru,
|
||||
orchestrator.is_active,
|
||||
)
|
||||
else:
|
||||
self.route_diagram.orchestrator.update_node("Аккаунт не подключён", "Роль не настроена", False)
|
||||
|
||||
agent_items = []
|
||||
for agent in agents:
|
||||
agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent)
|
||||
agent_items.append(
|
||||
(
|
||||
agent.role_id,
|
||||
agent.provider,
|
||||
agent.role_name_ru,
|
||||
f"{agent.provider_display_name} • {agent.model}",
|
||||
"Здорово" if agent.is_active else agent.status_label_ru,
|
||||
agent_quota_text,
|
||||
agent_quota_percent,
|
||||
"healthy" if agent.is_active else "warning",
|
||||
)
|
||||
)
|
||||
self._sync_endpoint_cards(
|
||||
self.route_diagram.agent_slots,
|
||||
self._agent_cards,
|
||||
agent_items,
|
||||
)
|
||||
for agent in agents:
|
||||
card = self._agent_cards.get(agent.role_id)
|
||||
if card is not None:
|
||||
card.set_click_action(
|
||||
lambda current=agent: self.on_action(
|
||||
"agent_settings",
|
||||
{
|
||||
"role_id": current.role_id,
|
||||
"profile_id": current.assigned_profile_id,
|
||||
"provider": current.provider,
|
||||
},
|
||||
)
|
||||
if self.on_action
|
||||
else None
|
||||
)
|
||||
left_labels: list[str] = []
|
||||
for provider in providers:
|
||||
share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share")
|
||||
left_labels.append(f"{share:.0%}" if share is not None else "нет телеметрии")
|
||||
right_labels: list[str] = []
|
||||
for agent in agents:
|
||||
measured = dict(role_telemetry.get(agent.role_id) or {})
|
||||
calls = measured.get("total_calls") if measured.get("has_data") else None
|
||||
right_labels.append(f"{calls} выз." if calls is not None else "нет вызовов")
|
||||
self.route_diagram.set_labels(left_labels, right_labels)
|
||||
|
||||
def update_events(self, events: Iterable[Any]) -> None:
|
||||
items = list(events)[:5]
|
||||
while len(self._event_rows) < len(items):
|
||||
row = _EventRow(self.events_body)
|
||||
row.pack(fill="x", pady=1)
|
||||
self._event_rows.append(row)
|
||||
for index, row in enumerate(self._event_rows):
|
||||
if index < len(items):
|
||||
row.update_event(items[index])
|
||||
row.pack(fill="x", pady=1)
|
||||
else:
|
||||
row.pack_forget()
|
||||
if items:
|
||||
self.events_empty.pack_forget()
|
||||
else:
|
||||
self.events_empty.pack(anchor="w", pady=5)
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""Keyed system-health view using only the supplied snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import HubCard, SectionHeader, StatusBadge
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class HealthProfileRow(HubCard):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
||||
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.identity.grid(row=0, column=0, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
|
||||
self.models = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||
self.models.grid(row=0, column=1, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
|
||||
self.status = StatusBadge(self, "not_tested")
|
||||
self.status.grid(row=0, column=2, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="e")
|
||||
self.grid_columnconfigure(0, weight=2)
|
||||
self.grid_columnconfigure(1, weight=3)
|
||||
self.grid_columnconfigure(2, weight=1)
|
||||
|
||||
def update_profile(self, profile: Any) -> None:
|
||||
identity = profile.email or profile.account_identity or profile.profile_id
|
||||
self.identity.configure(text=f"{identity}\n{profile.profile_id}")
|
||||
families = (
|
||||
", ".join(f"{family}: {state.status_label_ru}" for family, state in profile.model_states.items())
|
||||
or "Семейства моделей: Н/Д"
|
||||
)
|
||||
self.models.configure(text=families)
|
||||
self.status.set_status(profile.health_state, profile.health_label_ru)
|
||||
|
||||
|
||||
class HealthView(ctk.CTkFrame):
|
||||
def __init__(
|
||||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_refresh: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self._rows: Dict[str, HealthProfileRow] = {}
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Состояние системы",
|
||||
subtitle="Авторизация, локальное здоровье и наблюдаемые состояния моделей",
|
||||
action_text="Обновить аудит",
|
||||
action_cmd=on_refresh,
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
self.banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK)
|
||||
self.banner.pack(fill="x", pady=(0, Theme.SECTION_GAP))
|
||||
self.title = ctk.CTkLabel(
|
||||
self.banner, text="Ожидание snapshot", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT
|
||||
)
|
||||
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
||||
self.summary = ctk.CTkLabel(self.banner, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.summary.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||
self.rows = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
self.rows.pack(fill="x")
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
readiness = snapshot.readiness
|
||||
self.title.configure(text=readiness.title_ru)
|
||||
warnings = " • ".join(readiness.warnings) if readiness.warnings else "Предупреждений нет"
|
||||
self.summary.configure(
|
||||
text=(
|
||||
f"{readiness.summary_ru}\n"
|
||||
f"Аккаунты {readiness.accounts_connected_count}/{readiness.total_accounts} • "
|
||||
f"Роли {readiness.roles_ready_count}/{readiness.total_roles} • {warnings}"
|
||||
)
|
||||
)
|
||||
live = set(snapshot.all_profiles)
|
||||
for profile_id in list(self._rows):
|
||||
if profile_id not in live:
|
||||
self._rows.pop(profile_id).destroy()
|
||||
ordered = sorted(snapshot.all_profiles.values(), key=lambda profile: (profile.provider, profile.display_name))
|
||||
for index, profile in enumerate(ordered):
|
||||
row = self._rows.get(profile.profile_id)
|
||||
if row is None:
|
||||
row = HealthProfileRow(self.rows)
|
||||
self._rows[profile.profile_id] = row
|
||||
row.update_profile(profile)
|
||||
row.grid(row=index, column=0, sticky="ew", pady=Theme.SPACE_XS)
|
||||
self.rows.grid_columnconfigure(0, weight=1)
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
"""Keyed event timeline populated by the application action layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import FilterButton, HubCard, SearchField, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class _LogRow(HubCard):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
||||
self.time = ctk.CTkLabel(self, text="", width=70, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
|
||||
self.time.pack(side="left", padx=(Theme.CARD_PAD_X, 0), pady=Theme.SPACE_SM)
|
||||
self.dot = ctk.CTkLabel(self, text="●", width=18, font=Theme.font_micro())
|
||||
self.dot.pack(side="left")
|
||||
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_SM)
|
||||
self.category = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
corner_radius=Theme.RADIUS_PILL,
|
||||
)
|
||||
self.category.pack(side="right", padx=Theme.CARD_PAD_X)
|
||||
|
||||
def update_event(self, event: Any) -> None:
|
||||
level = str(getattr(event, "level", "info"))
|
||||
color = {
|
||||
"success": Theme.STATUS_HEALTHY,
|
||||
"warning": Theme.STATUS_WARNING,
|
||||
"error": Theme.STATUS_ERROR,
|
||||
}.get(level, Theme.STATUS_INFO)
|
||||
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
|
||||
self.dot.configure(text_color=color)
|
||||
self.message.configure(text=str(getattr(event, "message", "Н/Д")))
|
||||
self.category.configure(text=f" {getattr(event, 'category', 'system')} ")
|
||||
|
||||
|
||||
class LogsView(ctk.CTkFrame):
|
||||
def __init__(self, master: Any, **kwargs):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self._events: list[Any] = []
|
||||
self._rows: list[_LogRow] = []
|
||||
self._query = ""
|
||||
self._level = "Все уровни"
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Журнал событий",
|
||||
subtitle="Реальные события Hermes Hub с уровнями и категориями",
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||||
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
|
||||
self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320)
|
||||
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||
self.search.bind("<KeyRelease>", lambda _event: self._set_query(self.search.get()))
|
||||
self.level = FilterButton(
|
||||
toolbar,
|
||||
values=["Все уровни", "Информация", "Успех", "Предупреждение", "Ошибка"],
|
||||
command=self._set_level,
|
||||
width=170,
|
||||
)
|
||||
self.level.pack(side="left")
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
self.empty = ctk.CTkLabel(
|
||||
self.scroll,
|
||||
text="События: Н/Д",
|
||||
font=Theme.font_caption(),
|
||||
text_color=Theme.TEXT_MUTED,
|
||||
)
|
||||
|
||||
def _set_query(self, value: str) -> None:
|
||||
self._query = value.strip().lower()
|
||||
self._render()
|
||||
|
||||
def _set_level(self, value: str) -> None:
|
||||
self._level = value
|
||||
self._render()
|
||||
|
||||
def _filtered(self) -> list[Any]:
|
||||
levels = {
|
||||
"Информация": "info",
|
||||
"Успех": "success",
|
||||
"Предупреждение": "warning",
|
||||
"Ошибка": "error",
|
||||
}
|
||||
expected = levels.get(self._level)
|
||||
result = []
|
||||
for event in self._events:
|
||||
if expected and str(getattr(event, "level", "info")) != expected:
|
||||
continue
|
||||
haystack = f"{getattr(event, 'category', '')} {getattr(event, 'message', '')}".lower()
|
||||
if self._query and self._query not in haystack:
|
||||
continue
|
||||
result.append(event)
|
||||
return result
|
||||
|
||||
def _render(self) -> None:
|
||||
events = self._filtered()
|
||||
while len(self._rows) < len(events):
|
||||
row = _LogRow(self.scroll)
|
||||
self._rows.append(row)
|
||||
for index, row in enumerate(self._rows):
|
||||
if index < len(events):
|
||||
row.update_event(events[index])
|
||||
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||
else:
|
||||
row.pack_forget()
|
||||
if events:
|
||||
self.empty.pack_forget()
|
||||
else:
|
||||
self.empty.pack(anchor="w", pady=Theme.SPACE_XL)
|
||||
|
||||
def update_events(self, events: Iterable[Any]) -> None:
|
||||
self._events = list(events)
|
||||
self._render()
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"""Provider summaries updated by stable provider id."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import HubCard, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class ProviderCard(HubCard):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master)
|
||||
self.provider_id = ""
|
||||
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
|
||||
|
||||
self.icon = ctk.CTkLabel(top, text="", width=24, height=24)
|
||||
self.icon.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||
|
||||
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.title.pack(side="left")
|
||||
self.updated = ctk.CTkLabel(top, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.updated.pack(side="right")
|
||||
self.stats = ctk.CTkLabel(self, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.stats.pack(anchor="w", padx=Theme.CARD_PAD_X)
|
||||
self.models = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
font=Theme.font_mono_sm(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
wraplength=900,
|
||||
justify="left",
|
||||
)
|
||||
self.models.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
|
||||
|
||||
def update_provider(self, summary: Any) -> None:
|
||||
self.provider_id = summary.provider_id
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
img = AssetManager.get().get_provider_image(summary.provider_id, size=(24, 24))
|
||||
self.icon.configure(image=img, text="" if img else "◇")
|
||||
self.icon.image = img
|
||||
self.title.configure(text=summary.provider_name)
|
||||
self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д — обнаружение ещё не запускалось'}")
|
||||
self.stats.configure(
|
||||
text=(
|
||||
f"Онлайн {summary.online_count}/{summary.connected_count} • "
|
||||
f"требуют входа {summary.auth_required_count} • "
|
||||
f"квота исчерпана {summary.quota_exhausted_count} • "
|
||||
f"холодный резерв {summary.cold_spare_count}"
|
||||
)
|
||||
)
|
||||
self.models.configure(
|
||||
text="Модели: "
|
||||
+ (
|
||||
" • ".join(summary.discovered_models)
|
||||
if summary.discovered_models
|
||||
else "Н/Д — список моделей ещё не получен"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ProvidersView(ctk.CTkFrame):
|
||||
def __init__(
|
||||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.on_action = on_action
|
||||
self._cards: Dict[str, ProviderCard] = {}
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Провайдеры и модели",
|
||||
subtitle="Локальная доступность адаптеров и реально обнаруженные модели",
|
||||
action_text="Обновить",
|
||||
action_cmd=lambda: self.on_action and self.on_action("refresh_data", {}),
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
live = {summary.provider_id for summary in snapshot.providers}
|
||||
for provider_id in list(self._cards):
|
||||
if provider_id not in live:
|
||||
self._cards.pop(provider_id).destroy()
|
||||
for summary in snapshot.providers:
|
||||
card = self._cards.get(summary.provider_id)
|
||||
if card is None:
|
||||
card = ProviderCard(self.scroll)
|
||||
card.pack(fill="x", pady=Theme.SPACE_XS)
|
||||
self._cards[summary.provider_id] = card
|
||||
card.update_provider(summary)
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""Truthful multi-bucket quota overview driven by HubSnapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import EmptyState, HubCard, QuotaBar, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
class _QuotaRow(HubCard):
|
||||
def __init__(self, master: Any):
|
||||
super().__init__(master, corner_radius=Theme.RADIUS_SM)
|
||||
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
||||
self.title = ctk.CTkLabel(header, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.title.pack(side="left")
|
||||
self.source = ctk.CTkLabel(header, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.source.pack(side="right")
|
||||
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
|
||||
self.bar = QuotaBar(self, label="Остаток")
|
||||
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||
|
||||
def update_bucket(self, profile: Any, quota: Any, bucket: Any) -> None:
|
||||
remaining = getattr(bucket, "remaining_percent", None)
|
||||
ratio = float(remaining) / 100.0 if remaining is not None else None
|
||||
detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н/Д"
|
||||
reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None
|
||||
estimated = bool(getattr(quota, "is_estimated", True))
|
||||
self.title.configure(text=f"{bucket.display_name} • {profile.provider_display_name}")
|
||||
self.identity.configure(text=profile.account_identity or profile.display_name)
|
||||
self.source.configure(text="оценка" if estimated else str(getattr(quota, "source", "измерено")))
|
||||
self.bar.set_value(ratio, detail)
|
||||
reason = getattr(quota, "unavailable_reason", None)
|
||||
self.reset.configure(text=reason or reset or "Сброс: Н/Д")
|
||||
|
||||
|
||||
class QuotasView(ctk.CTkFrame):
|
||||
def __init__(self, master: Any, **kwargs):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self._rows: Dict[str, _QuotaRow] = {}
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Квоты и лимиты",
|
||||
subtitle="Независимые корзины провайдеров; отсутствие измерения не считается нулём",
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
self.empty = EmptyState(
|
||||
self.scroll,
|
||||
title="Квоты: Н/Д",
|
||||
message="Провайдеры ещё не опубликовали ни одной квотной корзины.",
|
||||
)
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
incoming: list[tuple[str, Any, Any, Any]] = []
|
||||
for profile in snapshot.all_profiles.values():
|
||||
quota = snapshot.quotas.get(profile.profile_id)
|
||||
for bucket in list(getattr(quota, "buckets", None) or []):
|
||||
incoming.append((f"{profile.profile_id}:{bucket.id}", profile, quota, bucket))
|
||||
live = {key for key, *_rest in incoming}
|
||||
for key in list(self._rows):
|
||||
if key not in live:
|
||||
self._rows.pop(key).destroy()
|
||||
for key, profile, quota, bucket in incoming:
|
||||
row = self._rows.get(key)
|
||||
if row is None:
|
||||
row = _QuotaRow(self.scroll)
|
||||
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||
self._rows[key] = row
|
||||
row.update_bucket(profile, quota, bucket)
|
||||
if incoming:
|
||||
self.empty.pack_forget()
|
||||
else:
|
||||
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""Keyed failover-chain view driven exclusively by RolePipeline objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
import tkinter as tk
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import ActionButton, HubCard, RouteTargetWidget, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.unified_health import RolePipeline
|
||||
|
||||
|
||||
class RoutingRoleWidget(HubCard):
|
||||
def __init__(self, master: Any, pipeline: RolePipeline, on_action: Optional[Callable] = None, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.pipeline = pipeline
|
||||
self.on_action = on_action
|
||||
self._nodes: Dict[str, RouteTargetWidget] = {}
|
||||
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
|
||||
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.title.pack(side="left")
|
||||
self.meta = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||
self.meta.pack(side="left", padx=Theme.SPACE_SM)
|
||||
ActionButton(
|
||||
top,
|
||||
text="Изменить цепочку →",
|
||||
variant="secondary",
|
||||
width=90,
|
||||
command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}),
|
||||
).pack(side="right")
|
||||
self.chain = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
self.chain.pack(fill="x", padx=Theme.CARD_PAD_X)
|
||||
self.footer = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.footer.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
|
||||
self.update_from_pipeline(pipeline)
|
||||
|
||||
def update_from_pipeline(self, pipeline: RolePipeline) -> None:
|
||||
self.pipeline = pipeline
|
||||
self.title.configure(text=pipeline.role_name_ru)
|
||||
affinity = "session affinity" if pipeline.session_affinity else "без affinity"
|
||||
self.meta.configure(text=f"{pipeline.default_model} • {affinity}")
|
||||
live = {node.profile_id for node in pipeline.nodes}
|
||||
for profile_id in list(self._nodes):
|
||||
if profile_id not in live:
|
||||
self._nodes.pop(profile_id).destroy()
|
||||
for index, node in enumerate(pipeline.nodes):
|
||||
rank = "Основной" if index == 0 else f"Резерв {index}"
|
||||
identity = f" • {node.account_identity}" if node.account_identity else ""
|
||||
subtitle = f"{node.provider} • {node.model}{identity}"
|
||||
widget = self._nodes.get(node.profile_id)
|
||||
if widget is None:
|
||||
widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle)
|
||||
self._nodes[node.profile_id] = widget
|
||||
widget.update_target(
|
||||
rank,
|
||||
node.display_name,
|
||||
subtitle,
|
||||
"active" if node.is_active else node.status,
|
||||
node.quota_status,
|
||||
node.failover_reason,
|
||||
)
|
||||
widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew")
|
||||
self.chain.grid_columnconfigure(index, weight=1)
|
||||
active = next((node for node in pipeline.nodes if node.is_active), None)
|
||||
reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason]
|
||||
self.footer.configure(
|
||||
text=(
|
||||
f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'переключений ещё не было'}"
|
||||
if active
|
||||
else "Активный узел: Н/Д — аккаунт не подключён либо все профили недоступны"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class RoutingView(ctk.CTkFrame):
|
||||
def __init__(
|
||||
self, master: Any, routing_data: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.on_action = on_action
|
||||
self._role_widgets: Dict[str, RoutingRoleWidget] = {}
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Маршрутизация",
|
||||
subtitle="Основной → резерв 1 → резерв 2 → резерв 3; без выдуманных причин failover",
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
|
||||
def focus_role(self, role_id: str) -> None:
|
||||
"""Focus the existing route editor/card selected in the graph inspector."""
|
||||
widget = self._role_widgets.get(role_id)
|
||||
if not widget:
|
||||
return
|
||||
for current in self._role_widgets.values():
|
||||
current.configure(border_color=Theme.BORDER)
|
||||
widget.configure(border_color=Theme.BORDER_ACCENT)
|
||||
try:
|
||||
self.scroll._parent_canvas.yview_moveto(max(0.0, widget.winfo_y() / max(1, self.scroll.winfo_height())))
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
live_roles = set(snapshot.routing)
|
||||
for role_id in list(self._role_widgets):
|
||||
if role_id not in live_roles:
|
||||
self._role_widgets.pop(role_id).destroy()
|
||||
for role_id, pipeline in snapshot.routing.items():
|
||||
widget = self._role_widgets.get(role_id)
|
||||
if widget is None:
|
||||
widget = RoutingRoleWidget(self.scroll, pipeline, self.on_action)
|
||||
widget.pack(fill="x", pady=Theme.SPACE_XS)
|
||||
self._role_widgets[role_id] = widget
|
||||
widget.update_from_pipeline(pipeline)
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"""Presentation-only settings screen; persistence is delegated to the app action layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.components import ActionButton, HubCard, SectionHeader
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.version import CHANNEL, __version__
|
||||
|
||||
|
||||
class SettingsView(ctk.CTkFrame):
|
||||
def __init__(
|
||||
self,
|
||||
master: Any,
|
||||
on_action: Optional[Callable] = None,
|
||||
theme_name: str = "dark",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.on_action = on_action
|
||||
SectionHeader(
|
||||
self,
|
||||
title="Настройки",
|
||||
subtitle="Маршрутизация, мониторинг и обновления",
|
||||
action_text="Сохранить",
|
||||
action_cmd=self._save,
|
||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
appearance = self._section(scroll, "Оформление")
|
||||
theme_row = ctk.CTkFrame(appearance, fg_color="transparent")
|
||||
theme_row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||
ctk.CTkLabel(
|
||||
theme_row,
|
||||
text="Цветовая схема",
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
).pack(side="left")
|
||||
self.theme = ctk.CTkOptionMenu(
|
||||
theme_row,
|
||||
values=list(Theme.SCHEME_LABELS.values()),
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.SECONDARY,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
self.theme.set(Theme.SCHEME_LABELS.get(theme_name, Theme.SCHEME_LABELS["dark"]))
|
||||
self.theme.pack(side="right")
|
||||
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
|
||||
self.affinity = self._switch(routing, "Сессионная привязка", True)
|
||||
self.failover = self._switch(routing, "Автоматический failover", True)
|
||||
self.same_account = self._switch(routing, "Сначала менять модель на том же аккаунте", True)
|
||||
refresh = self._section(scroll, "Мониторинг")
|
||||
row = ctk.CTkFrame(refresh, fg_color="transparent")
|
||||
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||
ctk.CTkLabel(row, text="Интервал обновления квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
side="left"
|
||||
)
|
||||
self.interval = ctk.CTkOptionMenu(
|
||||
row,
|
||||
values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"],
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.SECONDARY,
|
||||
)
|
||||
self.interval.set("5 мин")
|
||||
self.interval.pack(side="right")
|
||||
updates = self._section(scroll, "Обновления")
|
||||
self.update_status = ctk.CTkLabel(
|
||||
updates,
|
||||
text=f"Версия {__version__} • канал {CHANNEL} • состояние обновления: Н/Д",
|
||||
font=Theme.font_body(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
self.update_status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||
ActionButton(
|
||||
updates,
|
||||
text="Проверить обновления",
|
||||
variant="secondary",
|
||||
command=lambda: self.on_action and self.on_action("check_updates", {}),
|
||||
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||
|
||||
@staticmethod
|
||||
def _section(master: Any, title: str) -> HubCard:
|
||||
card = HubCard(master)
|
||||
card.pack(fill="x", pady=Theme.SPACE_XS)
|
||||
ctk.CTkLabel(card, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)
|
||||
)
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _switch(master: Any, label: str, selected: bool) -> ctk.CTkSwitch:
|
||||
row = ctk.CTkFrame(master, fg_color="transparent")
|
||||
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS)
|
||||
ctk.CTkLabel(row, text=label, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||
switch = ctk.CTkSwitch(row, text="", progress_color=Theme.ACCENT)
|
||||
switch.pack(side="right")
|
||||
if selected:
|
||||
switch.select()
|
||||
return switch
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.on_action:
|
||||
return
|
||||
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
|
||||
theme_key = next(
|
||||
(key for key, label in Theme.SCHEME_LABELS.items() if label == self.theme.get()),
|
||||
"dark",
|
||||
)
|
||||
self.on_action(
|
||||
"save_settings",
|
||||
{
|
||||
"session_affinity": bool(self.affinity.get()),
|
||||
"auto_failover": bool(self.failover.get()),
|
||||
"prefer_same_account_model_fallback": bool(self.same_account.get()),
|
||||
"quota_refresh_interval_label": self.interval.get(),
|
||||
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
|
||||
"theme": theme_key,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,996 +0,0 @@
|
|||
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.ui.assets import AssetManager
|
||||
from antigravity_provider.router.ui.components import (
|
||||
HubButton,
|
||||
HubCard,
|
||||
HubMetricCard,
|
||||
HubSectionHeader,
|
||||
HubStatusBadge,
|
||||
)
|
||||
from antigravity_provider.router.unified_health import (
|
||||
AgentViewModel,
|
||||
STATUS_HEALTHY,
|
||||
STATUS_QUOTA_LOW,
|
||||
STATUS_QUOTA_EXHAUSTED,
|
||||
STATUS_AUTH_REQUIRED,
|
||||
STATUS_NOT_CONFIGURED,
|
||||
STATUS_AUTH_EXPIRED,
|
||||
)
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.event_bus import (
|
||||
EVENT_ACCOUNT_ADDED,
|
||||
EVENT_ACCOUNT_AUTH_CHANGED,
|
||||
EVENT_ACCOUNT_REMOVED,
|
||||
EVENT_ACCOUNT_UPDATED,
|
||||
EVENT_QUOTA_UPDATED,
|
||||
EVENT_ROUTING_UPDATED,
|
||||
EventBus,
|
||||
)
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
|
||||
|
||||
|
||||
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
|
||||
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
|
||||
config = load_router_config()
|
||||
policy = config.roles.get(role_id)
|
||||
if policy is None:
|
||||
return False, f"Роль '{role_id}' не найдена"
|
||||
if len(desired_chain) != len(set(desired_chain)):
|
||||
return False, "Профиль не может повторяться в одной цепочке"
|
||||
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
|
||||
if missing:
|
||||
return False, f"Профиль '{missing[0]}' не найден"
|
||||
|
||||
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
|
||||
removed = set(original[role_id]) - set(desired_chain)
|
||||
affected = {role_id}
|
||||
for profile_id in removed:
|
||||
affected.update(key for key, chain in original.items() if profile_id in chain)
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
|
||||
if not ok:
|
||||
return False, message
|
||||
|
||||
chains = {key: original[key] for key in affected}
|
||||
chains[role_id] = list(desired_chain)
|
||||
for target_role, chain in chains.items():
|
||||
for profile_id in reversed(chain):
|
||||
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
|
||||
if not ok:
|
||||
return False, message
|
||||
return True, f"Цепочка '{role_id}' сохранена"
|
||||
|
||||
|
||||
class AgentCardWidget(HubCard):
|
||||
"""Reusable agent card that updates in-place without rebuilding widgets."""
|
||||
|
||||
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs):
|
||||
super().__init__(master=master, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs)
|
||||
self.on_action = on_action
|
||||
self.agent_data: Optional[AgentViewModel] = None
|
||||
|
||||
# ── Line 1: Role Title + Badges + Status Dot ──
|
||||
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line1.pack(fill="x", padx=14, pady=(12, 2))
|
||||
|
||||
self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||
self.role_lbl.pack(side="left")
|
||||
|
||||
self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
|
||||
self.orch_pill_lbl = ctk.CTkLabel(
|
||||
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
|
||||
)
|
||||
self.orch_pill_lbl.pack(padx=5, pady=1)
|
||||
|
||||
self.status_dot = ctk.CTkLabel(
|
||||
self.line1, text="●", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY
|
||||
)
|
||||
self.status_dot.pack(side="right")
|
||||
|
||||
# ── Line 2: Internal ID + Model ──
|
||||
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line2.pack(fill="x", padx=14, pady=(0, 4))
|
||||
self.id_model_lbl = ctk.CTkLabel(self.line2, text="—", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||
self.id_model_lbl.pack(anchor="w")
|
||||
|
||||
# ── Line 3: Provider with Real Logo ──
|
||||
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line3.pack(fill="x", padx=14, pady=(2, 2))
|
||||
|
||||
self.prov_icon = ctk.CTkLabel(self.line3, text="")
|
||||
self.prov_icon.pack(side="left", padx=(0, 6))
|
||||
|
||||
self.prov_lbl = ctk.CTkLabel(self.line3, text="—", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.prov_lbl.pack(side="left")
|
||||
|
||||
# ── Line 4: Identity / Account ──
|
||||
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line4.pack(fill="x", padx=14, pady=(2, 4))
|
||||
self.identity_lbl = ctk.CTkLabel(
|
||||
self.line4, text="—", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY
|
||||
)
|
||||
self.identity_lbl.pack(anchor="w")
|
||||
|
||||
self.quota_lbl = ctk.CTkLabel(
|
||||
self.line4, text="Квота: Н/Д", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0))
|
||||
|
||||
# ── Line 5: Role Tag Pills ──
|
||||
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line5.pack(fill="x", padx=14, pady=(4, 6))
|
||||
|
||||
self.pill1 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
self.pill1.pack(side="left", padx=(0, 6))
|
||||
self.pill1_lbl = ctk.CTkLabel(self.pill1, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.pill1_lbl.pack(padx=6, pady=2)
|
||||
|
||||
self.pill2 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||
self.pill2.pack(side="left", padx=(0, 6))
|
||||
self.pill2_lbl = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||
self.pill2_lbl.pack(padx=6, pady=2)
|
||||
|
||||
# ── Line 6: Status & Menu Action ──
|
||||
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.line6.pack(fill="x", padx=14, pady=(4, 12))
|
||||
|
||||
self.status_str_lbl = ctk.CTkLabel(
|
||||
self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY
|
||||
)
|
||||
self.status_str_lbl.pack(side="left")
|
||||
|
||||
self.menu_btn = ctk.CTkButton(
|
||||
self.line6,
|
||||
text="⋮",
|
||||
width=28,
|
||||
height=24,
|
||||
fg_color="transparent",
|
||||
hover_color=Theme.SURFACE_HOVER,
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
command=self._open_menu,
|
||||
)
|
||||
self.menu_btn.pack(side="right")
|
||||
self._bind_settings_click(self)
|
||||
try:
|
||||
self._canvas.configure(takefocus=1)
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
self.bind("<Return>", self._open_settings, add="+")
|
||||
self.bind("<space>", self._open_settings, add="+")
|
||||
self.bind("<Enter>", self._hover_on, add="+")
|
||||
self.bind("<Leave>", self._hover_off, add="+")
|
||||
|
||||
def _bind_settings_click(self, widget: Any) -> None:
|
||||
if isinstance(widget, ctk.CTkButton):
|
||||
return
|
||||
widget.configure(cursor="hand2")
|
||||
widget.bind("<Button-1>", self._open_settings, add="+")
|
||||
for child in widget.winfo_children():
|
||||
self._bind_settings_click(child)
|
||||
|
||||
def _open_settings(self, _event: Any = None) -> None:
|
||||
if not self.agent_data or not self.on_action:
|
||||
return
|
||||
agent = self.agent_data
|
||||
self.on_action(
|
||||
"agent_settings",
|
||||
{
|
||||
"role_id": agent.role_id,
|
||||
"profile_id": agent.assigned_profile_id or "",
|
||||
"provider": agent.provider,
|
||||
},
|
||||
)
|
||||
|
||||
def _hover_on(self, _event: Any = None) -> None:
|
||||
self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER)
|
||||
|
||||
def _hover_off(self, _event: Any = None) -> None:
|
||||
is_orchestrator = bool(self.agent_data and self.agent_data.is_main_orchestrator)
|
||||
self.configure(
|
||||
fg_color=Theme.SURFACE,
|
||||
border_color=Theme.BORDER_ACCENT if is_orchestrator else Theme.BORDER,
|
||||
)
|
||||
|
||||
def update_agent(self, a: AgentViewModel):
|
||||
self.agent_data = a
|
||||
is_orch = a.is_main_orchestrator
|
||||
self.configure(border_color=Theme.BORDER_ACCENT if is_orch else Theme.BORDER)
|
||||
|
||||
self.role_lbl.configure(text=a.role_name_ru)
|
||||
|
||||
if is_orch:
|
||||
self.orch_pill.pack(side="left", padx=(8, 0))
|
||||
else:
|
||||
self.orch_pill.pack_forget()
|
||||
|
||||
# Dot color
|
||||
dot_color = (
|
||||
Theme.STATUS_HEALTHY
|
||||
if a.status == STATUS_HEALTHY
|
||||
else (
|
||||
Theme.STATUS_WARNING
|
||||
if "quota" in a.status or "auth" in a.status or "not_configured" in a.status
|
||||
else Theme.STATUS_ERROR
|
||||
)
|
||||
)
|
||||
self.status_dot.configure(text_color=dot_color)
|
||||
|
||||
# ID + Model
|
||||
self.id_model_lbl.configure(text=f"{a.assigned_profile_id} • {a.model}")
|
||||
|
||||
# Provider Icon + Label
|
||||
p_img = AssetManager.get().get_provider_image(a.provider, size=(18, 18))
|
||||
if p_img:
|
||||
self.prov_icon.configure(image=p_img)
|
||||
self.prov_icon.pack(side="left", padx=(0, 6))
|
||||
else:
|
||||
self.prov_icon.pack_forget()
|
||||
|
||||
prov = a.provider.lower()
|
||||
if "antigravity" in prov:
|
||||
self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
|
||||
elif "codex" in prov:
|
||||
self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
|
||||
elif "opencode" in prov:
|
||||
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
|
||||
elif "claude" in prov or "anthropic" in prov:
|
||||
self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
|
||||
elif "grok" in prov or "xai" in prov:
|
||||
self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
|
||||
else:
|
||||
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
||||
|
||||
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н/Д")
|
||||
quota_color = (
|
||||
Theme.STATUS_HEALTHY
|
||||
if a.active_quota_status == "healthy"
|
||||
else (
|
||||
Theme.STATUS_WARNING
|
||||
if a.active_quota_status == "warning"
|
||||
else Theme.STATUS_ERROR
|
||||
if a.active_quota_status == "exhausted"
|
||||
else Theme.TEXT_MUTED
|
||||
)
|
||||
)
|
||||
quota_label = a.active_quota_label or "Н/Д — провайдер не отдал лимиты"
|
||||
session = f" • сессия {a.session_id}" if a.session_id else ""
|
||||
self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color)
|
||||
|
||||
# Pills
|
||||
self.pill1_lbl.configure(text=a.role_id)
|
||||
self.pill2_lbl.configure(text=a.routing_position)
|
||||
|
||||
# Status text
|
||||
cd_str = f" ({a.cooldown_remaining_sec}s)" if a.cooldown_remaining_sec > 0 else ""
|
||||
self.status_str_lbl.configure(text=f"● {a.status_label_ru.upper()}{cd_str}", text_color=dot_color)
|
||||
|
||||
def _open_menu(self):
|
||||
if not self.agent_data:
|
||||
return
|
||||
a = self.agent_data
|
||||
pid = a.assigned_profile_id or ""
|
||||
|
||||
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
||||
popup.title(f"Действия: {a.role_name_ru}")
|
||||
popup.geometry("320x260")
|
||||
popup.configure(fg_color=Theme.DARK)
|
||||
popup.resizable(False, False)
|
||||
popup.transient(self.winfo_toplevel())
|
||||
popup.grab_set()
|
||||
|
||||
popup.update_idletasks()
|
||||
px = self.winfo_toplevel().winfo_x() + 300
|
||||
py = self.winfo_toplevel().winfo_y() + 200
|
||||
popup.geometry(f"+{px}+{py}")
|
||||
|
||||
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
||||
c.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
||||
ctk.CTkLabel(
|
||||
c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED
|
||||
).pack(pady=(0, 10))
|
||||
|
||||
def _do(action_name: str):
|
||||
popup.destroy()
|
||||
if self.on_action:
|
||||
self.on_action(
|
||||
action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name}
|
||||
)
|
||||
|
||||
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(
|
||||
fill="x", padx=12, pady=2
|
||||
)
|
||||
HubButton(
|
||||
c,
|
||||
text="👑 Назначить главным оркестратором",
|
||||
variant="secondary",
|
||||
height=30,
|
||||
command=lambda: _do("set_orchestrator"),
|
||||
).pack(fill="x", padx=12, pady=2)
|
||||
HubButton(
|
||||
c,
|
||||
text="★ Сделать основным аккаунтом Hermes",
|
||||
variant="secondary",
|
||||
height=30,
|
||||
command=lambda: _do("set_main"),
|
||||
).pack(fill="x", padx=12, pady=2)
|
||||
HubButton(
|
||||
c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all")
|
||||
).pack(fill="x", padx=12, pady=(2, 0))
|
||||
|
||||
|
||||
class TeamView(ctk.CTkFrame):
|
||||
"""Interactive canvas over the existing router role/profile chains."""
|
||||
|
||||
NODE_W = 218
|
||||
NODE_H = 104
|
||||
|
||||
def __init__(
|
||||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||||
):
|
||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||
self.app_state = app_state or {}
|
||||
self.on_action = on_action
|
||||
self.controller = RoutingGraphController()
|
||||
self.snapshot: Optional[HubSnapshot] = None
|
||||
self._live_pipelines: Dict[str, Any] = {}
|
||||
self._quota_overrides: Dict[str, Any] = {}
|
||||
self.selected_role = "orchestrator"
|
||||
self.selected_edge = ""
|
||||
self._drag_role = ""
|
||||
self._drag_origin = (0.0, 0.0)
|
||||
self._drag_node_origin = (0.0, 0.0)
|
||||
self._drag_moved = False
|
||||
self._node_items: Dict[str, tuple[int, ...]] = {}
|
||||
self._edge_items: Dict[str, tuple[int, ...]] = {}
|
||||
self._event_bus = EventBus.get()
|
||||
self._subscribed = False
|
||||
self._build_static_layout()
|
||||
self._subscribe_runtime_events()
|
||||
self._draw_graph(rebuild=True)
|
||||
self.after(20, self._restore_viewport)
|
||||
|
||||
def destroy(self):
|
||||
if self._subscribed:
|
||||
for name in self._runtime_events():
|
||||
self._event_bus.unsubscribe(name, self._on_runtime_event)
|
||||
self._subscribed = False
|
||||
super().destroy()
|
||||
|
||||
@staticmethod
|
||||
def _runtime_events() -> tuple[str, ...]:
|
||||
return (
|
||||
EVENT_ROUTING_UPDATED,
|
||||
EVENT_QUOTA_UPDATED,
|
||||
EVENT_ACCOUNT_ADDED,
|
||||
EVENT_ACCOUNT_UPDATED,
|
||||
EVENT_ACCOUNT_REMOVED,
|
||||
EVENT_ACCOUNT_AUTH_CHANGED,
|
||||
)
|
||||
|
||||
def _subscribe_runtime_events(self) -> None:
|
||||
if self._subscribed:
|
||||
return
|
||||
for name in self._runtime_events():
|
||||
self._event_bus.subscribe(name, self._on_runtime_event)
|
||||
self._subscribed = True
|
||||
|
||||
def _build_static_layout(self) -> None:
|
||||
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8))
|
||||
titles = ctk.CTkFrame(header, fg_color="transparent")
|
||||
titles.pack(side="left")
|
||||
ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||
anchor="w"
|
||||
)
|
||||
self.state_label = ctk.CTkLabel(
|
||||
titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.state_label.pack(anchor="w")
|
||||
actions = ctk.CTkFrame(header, fg_color="transparent")
|
||||
actions.pack(side="right")
|
||||
for text, command in (
|
||||
("↶", self._undo),
|
||||
("↷", self._redo),
|
||||
("Авто", self._auto_layout),
|
||||
("Вписать", self.fit_to_screen),
|
||||
("Сохранить", self._save),
|
||||
):
|
||||
HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack(
|
||||
side="left", padx=3
|
||||
)
|
||||
|
||||
toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM)
|
||||
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7))
|
||||
self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250)
|
||||
self.search.pack(side="left", padx=8, pady=6)
|
||||
self.search.bind("<Return>", self._search)
|
||||
ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
|
||||
side="left", padx=(8, 4)
|
||||
)
|
||||
self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112)
|
||||
self.edge_type.set("DELEGATE")
|
||||
self.edge_type.pack(side="left", pady=6)
|
||||
roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"]
|
||||
self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135)
|
||||
self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0]))
|
||||
self.edge_target.pack(side="left", padx=(5, 0), pady=6)
|
||||
profile_ids = list(load_router_config().profiles) or ["—"]
|
||||
self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["—", *profile_ids], width=140)
|
||||
self.edge_profile.set("—")
|
||||
self.edge_profile.pack(side="left", padx=(5, 0), pady=6)
|
||||
HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack(
|
||||
side="left", padx=5
|
||||
)
|
||||
HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack(
|
||||
side="left", padx=(0, 5)
|
||||
)
|
||||
self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||
self.zoom_label.pack(side="right", padx=10)
|
||||
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||
body.grid_rowconfigure(0, weight=1)
|
||||
body.grid_columnconfigure(0, weight=4)
|
||||
body.grid_columnconfigure(1, weight=1)
|
||||
canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER)
|
||||
canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
self.canvas = tk.Canvas(
|
||||
canvas_frame,
|
||||
bg=Theme.SURFACE_MUTED,
|
||||
highlightthickness=0,
|
||||
xscrollincrement=1,
|
||||
yscrollincrement=1,
|
||||
scrollregion=(0, 0, 1800, 1200),
|
||||
)
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
self.canvas.bind("<ButtonPress-1>", self._on_press)
|
||||
self.canvas.bind("<B1-Motion>", self._on_drag)
|
||||
self.canvas.bind("<ButtonRelease-1>", self._on_release)
|
||||
self.canvas.bind("<MouseWheel>", self._on_zoom)
|
||||
self.canvas.bind("<ButtonPress-2>", self._start_pan)
|
||||
self.canvas.bind("<B2-Motion>", self._pan)
|
||||
self.canvas.bind("<ButtonRelease-2>", self._end_pan)
|
||||
self.canvas.bind("<Control-z>", lambda _e: self._undo())
|
||||
self.canvas.bind("<Control-y>", lambda _e: self._redo())
|
||||
self.canvas.bind("<Delete>", self._delete_selected_edge)
|
||||
self.canvas.focus_set()
|
||||
|
||||
self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1)
|
||||
self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se")
|
||||
|
||||
self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER)
|
||||
self.inspector.grid(row=0, column=1, sticky="nsew")
|
||||
self.inspector_title = ctk.CTkLabel(
|
||||
self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY
|
||||
)
|
||||
self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2))
|
||||
self.inspector_status = ctk.CTkLabel(
|
||||
self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
|
||||
)
|
||||
self.inspector_status.pack(fill="x", padx=12, pady=(0, 8))
|
||||
self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent")
|
||||
self.chain_frame.pack(fill="both", expand=True, padx=8)
|
||||
HubButton(
|
||||
self.inspector,
|
||||
text="Открыть маршрутизацию",
|
||||
variant="primary",
|
||||
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
|
||||
).pack(fill="x", padx=10, pady=10)
|
||||
|
||||
def _world(self, x: float, y: float) -> tuple[float, float]:
|
||||
zoom = self.controller.graph.zoom
|
||||
return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom
|
||||
|
||||
def _on_press(self, event: Any) -> None:
|
||||
item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
|
||||
tags = self.canvas.gettags(item)
|
||||
edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "")
|
||||
if edge:
|
||||
self.selected_edge = edge
|
||||
selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None)
|
||||
if selected:
|
||||
self.edge_type.set(selected.edge_type)
|
||||
self.edge_target.set(selected.target)
|
||||
self.edge_profile.set(selected.profile_id or "—")
|
||||
self._draw_graph(rebuild=True)
|
||||
return
|
||||
role = next((tag[5:] for tag in tags if tag.startswith("role:")), "")
|
||||
if role:
|
||||
self.selected_role = role
|
||||
self.selected_edge = ""
|
||||
self._drag_role = role
|
||||
self._drag_moved = False
|
||||
self._drag_origin = self._world(event.x, event.y)
|
||||
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
|
||||
self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0)
|
||||
self._update_inspector()
|
||||
self._update_live_styles()
|
||||
|
||||
def _on_drag(self, event: Any) -> None:
|
||||
if not self._drag_role:
|
||||
return
|
||||
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
|
||||
if not node:
|
||||
return
|
||||
x, y = self._world(event.x, event.y)
|
||||
dx, dy = x - self._drag_origin[0], y - self._drag_origin[1]
|
||||
if abs(dx) + abs(dy) > 1.5:
|
||||
self._drag_moved = True
|
||||
self._drag_origin = (x, y)
|
||||
node.x += dx
|
||||
node.y += dy
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
|
||||
def _on_release(self, _event: Any) -> None:
|
||||
if self._drag_role:
|
||||
clicked_role = self._drag_role
|
||||
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
|
||||
if node:
|
||||
final_x, final_y = node.x, node.y
|
||||
node.x, node.y = self._drag_node_origin
|
||||
self.controller.move_node(node.role_id, final_x, final_y)
|
||||
self._drag_role = ""
|
||||
self._set_dirty_text()
|
||||
if not self._drag_moved and self.on_action:
|
||||
pipeline = self._pipeline_for(clicked_role)
|
||||
active_profile = pipeline.active_profile_id if pipeline else ""
|
||||
active_node = next(
|
||||
(item for item in list(getattr(pipeline, "nodes", []) or []) if item.profile_id == active_profile),
|
||||
None,
|
||||
)
|
||||
self.on_action(
|
||||
"agent_settings",
|
||||
{
|
||||
"role_id": clicked_role,
|
||||
"profile_id": active_profile,
|
||||
"provider": getattr(active_node, "provider", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def _on_zoom(self, event: Any) -> str:
|
||||
factor = 1.1 if event.delta > 0 else 0.9
|
||||
self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor))
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
return "break"
|
||||
|
||||
def _start_pan(self, event: Any) -> None:
|
||||
self.canvas.scan_mark(event.x, event.y)
|
||||
|
||||
def _pan(self, event: Any) -> None:
|
||||
self.canvas.scan_dragto(event.x, event.y, gain=1)
|
||||
|
||||
def _end_pan(self, _event: Any) -> None:
|
||||
zoom = self.controller.graph.zoom
|
||||
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
|
||||
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
|
||||
self.controller.dirty = True
|
||||
self._set_dirty_text()
|
||||
|
||||
def _restore_viewport(self) -> None:
|
||||
graph = self.controller.graph
|
||||
z = graph.zoom
|
||||
self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0))
|
||||
self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0))
|
||||
|
||||
def _node_coords(self, role_id: str) -> tuple[float, float, float, float]:
|
||||
node = next(item for item in self.controller.graph.nodes if item.role_id == role_id)
|
||||
z = self.controller.graph.zoom
|
||||
return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z
|
||||
|
||||
def _draw_graph(self, rebuild: bool = True) -> None:
|
||||
if rebuild:
|
||||
self.canvas.delete("all")
|
||||
self._node_items.clear()
|
||||
self._edge_items.clear()
|
||||
z = self.controller.graph.zoom
|
||||
for edge in self.controller.graph.edges:
|
||||
try:
|
||||
sx1, sy1, sx2, sy2 = self._node_coords(edge.source)
|
||||
tx1, ty1, _tx2, ty2 = self._node_coords(edge.target)
|
||||
except StopIteration:
|
||||
continue
|
||||
start = (sx2, (sy1 + sy2) / 2)
|
||||
end = (tx1, (ty1 + ty2) / 2)
|
||||
dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4)
|
||||
width = 3 if edge.edge_type == "PRIMARY" else 2
|
||||
line = self.canvas.create_line(
|
||||
*start,
|
||||
*end,
|
||||
smooth=True,
|
||||
arrow="last",
|
||||
width=width,
|
||||
dash=dash,
|
||||
fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT,
|
||||
tags=(f"edge:{edge.edge_id}", "edge"),
|
||||
)
|
||||
label = self.canvas.create_text(
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2 - 9,
|
||||
text=edge.edge_type,
|
||||
fill=Theme.TEXT_MUTED,
|
||||
font=("Segoe UI", max(7, int(8 * z)), "bold"),
|
||||
tags=(f"edge:{edge.edge_id}", "edge"),
|
||||
)
|
||||
self._edge_items[edge.edge_id] = (line, label)
|
||||
config = load_router_config()
|
||||
for node in self.controller.graph.nodes:
|
||||
x1, y1, x2, y2 = self._node_coords(node.role_id)
|
||||
pipeline = self._pipeline_for(node.role_id)
|
||||
active = pipeline.active_profile_id if pipeline else ""
|
||||
chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else []
|
||||
selected = node.role_id == self.selected_role
|
||||
rect = self.canvas.create_rectangle(
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
width=2 if selected else 1,
|
||||
outline=Theme.ACCENT if selected else Theme.BORDER,
|
||||
fill=Theme.SURFACE,
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
title = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 17 * z,
|
||||
anchor="w",
|
||||
text=node.label or node.role_id,
|
||||
fill=Theme.TEXT_PRIMARY,
|
||||
font=("Segoe UI", max(9, int(11 * z)), "bold"),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
active_text = f"Активен: {active}" if active else "Активный профиль: Н/Д"
|
||||
meta = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 43 * z,
|
||||
anchor="w",
|
||||
text=active_text,
|
||||
fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED,
|
||||
font=("Segoe UI", max(7, int(8 * z))),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
chain_text = " → ".join(chain[:3]) if chain else "Нет профилей"
|
||||
chain_item = self.canvas.create_text(
|
||||
x1 + 12 * z,
|
||||
y1 + 68 * z,
|
||||
anchor="w",
|
||||
width=(self.NODE_W - 24) * z,
|
||||
text=chain_text,
|
||||
fill=Theme.TEXT_SECONDARY,
|
||||
font=("Segoe UI", max(7, int(8 * z))),
|
||||
tags=(f"role:{node.role_id}", "node"),
|
||||
)
|
||||
self._node_items[node.role_id] = (rect, title, meta, chain_item)
|
||||
self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%")
|
||||
bounds = self.canvas.bbox("all")
|
||||
if bounds:
|
||||
self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120)))
|
||||
self._draw_minimap()
|
||||
self._update_inspector()
|
||||
|
||||
def _draw_minimap(self) -> None:
|
||||
self.minimap.delete("all")
|
||||
if not self.controller.graph.nodes:
|
||||
return
|
||||
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
|
||||
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
|
||||
scale = min(145 / max(max_x, 1), 85 / max(max_y, 1))
|
||||
for node in self.controller.graph.nodes:
|
||||
self.minimap.create_rectangle(
|
||||
5 + node.x * scale,
|
||||
5 + node.y * scale,
|
||||
5 + (node.x + self.NODE_W) * scale,
|
||||
5 + (node.y + self.NODE_H) * scale,
|
||||
outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER,
|
||||
fill=Theme.SURFACE_MUTED,
|
||||
)
|
||||
|
||||
def _update_inspector(self) -> None:
|
||||
for child in self.chain_frame.winfo_children():
|
||||
child.destroy()
|
||||
role = self.selected_role
|
||||
pipeline = self._pipeline_for(role)
|
||||
node = next((item for item in self.controller.graph.nodes if item.role_id == role), None)
|
||||
self.inspector_title.configure(text=node.label if node else role)
|
||||
self.inspector_status.configure(
|
||||
text=(
|
||||
f"Активный: {pipeline.active_profile_id}"
|
||||
if pipeline and pipeline.active_profile_id
|
||||
else "Активный: Н/Д — профиль не назначен"
|
||||
)
|
||||
)
|
||||
config = load_router_config()
|
||||
policy = config.roles.get(role)
|
||||
chain = list(policy.preferred_chain) if policy else []
|
||||
live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {}
|
||||
for index, profile_id in enumerate(chain):
|
||||
profile = config.profiles.get(profile_id)
|
||||
live = live_nodes.get(profile_id)
|
||||
card = HubCard(self.chain_frame)
|
||||
card.pack(fill="x", pady=4)
|
||||
rank = "PRIMARY" if index == 0 else f"FALLBACK {index}"
|
||||
ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(
|
||||
anchor="w", padx=8, pady=(6, 0)
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||
).pack(anchor="w", padx=8)
|
||||
provider = profile.provider if profile else "Н/Д — аккаунт не подключён"
|
||||
model = live.model if live else (
|
||||
profile.preferred_models[0]
|
||||
if profile and profile.preferred_models
|
||||
else "Н/Д — список моделей ещё не получен"
|
||||
)
|
||||
identity = live.account_identity if live and live.account_identity else "Аккаунт: Н/Д"
|
||||
quota = live.quota_status if live else "Н/Д — аккаунт не подключён"
|
||||
if profile_id in self._quota_overrides:
|
||||
raw_quota = self._quota_overrides[profile_id]
|
||||
quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota
|
||||
if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}:
|
||||
quota = "Н/Д — провайдер не отдал лимиты"
|
||||
reason = live.failover_reason if live and live.failover_reason else "переключений ещё не было"
|
||||
ctk.CTkLabel(
|
||||
card,
|
||||
text=f"{provider} • {model}\n{identity}\nКвота: {quota}\nFailover: {reason}",
|
||||
justify="left",
|
||||
anchor="w",
|
||||
font=Theme.font_micro(),
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
).pack(fill="x", padx=8, pady=(2, 7))
|
||||
controls = ctk.CTkFrame(card, fg_color="transparent")
|
||||
controls.pack(fill="x", padx=6, pady=(0, 6))
|
||||
HubButton(
|
||||
controls,
|
||||
text="↑",
|
||||
variant="secondary",
|
||||
width=34,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
|
||||
).pack(side="left", padx=2)
|
||||
HubButton(
|
||||
controls,
|
||||
text="↓",
|
||||
variant="secondary",
|
||||
width=34,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
|
||||
).pack(side="left", padx=2)
|
||||
HubButton(
|
||||
controls,
|
||||
text="Удалить",
|
||||
variant="ghost",
|
||||
width=76,
|
||||
height=26,
|
||||
command=lambda pid=profile_id: self._remove_chain_profile(pid),
|
||||
).pack(side="right", padx=2)
|
||||
if not chain:
|
||||
ctk.CTkLabel(
|
||||
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
|
||||
).pack(anchor="w", padx=6, pady=8)
|
||||
|
||||
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
|
||||
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
|
||||
add_row.pack(fill="x", pady=(8, 2))
|
||||
add_menu = ctk.CTkOptionMenu(
|
||||
add_row,
|
||||
values=available or ["—"],
|
||||
font=Theme.font_caption(),
|
||||
fg_color=Theme.SURFACE_MUTED,
|
||||
button_color=Theme.SECONDARY,
|
||||
button_hover_color=Theme.SURFACE_HOVER,
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
|
||||
HubButton(
|
||||
add_row,
|
||||
text="Добавить",
|
||||
variant="secondary",
|
||||
width=88,
|
||||
command=lambda: self._add_chain_profile(add_menu.get()),
|
||||
state="normal" if available else "disabled",
|
||||
).pack(side="right")
|
||||
|
||||
def _persist_selected_chain(self, chain: List[str]) -> None:
|
||||
ok, message = persist_role_chain(self.selected_role, chain)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
if ok:
|
||||
self._update_inspector()
|
||||
|
||||
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
|
||||
chain = list(self.controller.role_chain(self.selected_role))
|
||||
if profile_id not in chain:
|
||||
return
|
||||
source = chain.index(profile_id)
|
||||
target = source + direction
|
||||
if target < 0 or target >= len(chain):
|
||||
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
chain[source], chain[target] = chain[target], chain[source]
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _remove_chain_profile(self, profile_id: str) -> None:
|
||||
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _add_chain_profile(self, profile_id: str) -> None:
|
||||
if not profile_id or profile_id == "—":
|
||||
return
|
||||
chain = list(self.controller.role_chain(self.selected_role))
|
||||
if profile_id not in chain:
|
||||
chain.append(profile_id)
|
||||
self._persist_selected_chain(chain)
|
||||
|
||||
def _on_runtime_event(self, name: str, data: Any) -> None:
|
||||
# EventBus may publish from a worker. Tk mutation is always marshalled.
|
||||
try:
|
||||
self.after(0, lambda: self._apply_runtime_event(name, data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_runtime_event(self, name: str, data: Any) -> None:
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None:
|
||||
self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"]
|
||||
elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"):
|
||||
self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot")
|
||||
self._update_live_styles()
|
||||
|
||||
def _pipeline_for(self, role_id: str) -> Any:
|
||||
if role_id in self._live_pipelines:
|
||||
return self._live_pipelines[role_id]
|
||||
return self.snapshot.routing.get(role_id) if self.snapshot else None
|
||||
|
||||
def _update_live_styles(self) -> None:
|
||||
"""Update existing canvas items; runtime events never rebuild the canvas."""
|
||||
if not self.snapshot:
|
||||
return
|
||||
for role_id, items in self._node_items.items():
|
||||
pipeline = self._pipeline_for(role_id)
|
||||
active = pipeline.active_profile_id if pipeline else ""
|
||||
self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER)
|
||||
self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н/Д")
|
||||
self._update_inspector()
|
||||
|
||||
def update_data(self, snapshot: Optional[Any] = None) -> None:
|
||||
if not isinstance(snapshot, HubSnapshot):
|
||||
return
|
||||
self.snapshot = snapshot
|
||||
self._live_pipelines = {
|
||||
role_id: pipeline
|
||||
for role_id, pipeline in self._live_pipelines.items()
|
||||
if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id]
|
||||
}
|
||||
if set(snapshot.routing) != set(self._node_items):
|
||||
self._draw_graph(rebuild=True)
|
||||
else:
|
||||
self._update_live_styles()
|
||||
|
||||
def _search(self, _event: Any = None) -> str:
|
||||
query = self.search.get().strip().lower()
|
||||
config = load_router_config()
|
||||
for node in self.controller.graph.nodes:
|
||||
chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else []
|
||||
if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain):
|
||||
self.selected_role = node.role_id
|
||||
self._draw_graph(rebuild=True)
|
||||
break
|
||||
return "break"
|
||||
|
||||
def focus_role(self, role_id: str) -> None:
|
||||
"""Select a role when Routing delegates editing to this single editor."""
|
||||
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
|
||||
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
self.selected_role = role_id
|
||||
self.selected_edge = ""
|
||||
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
|
||||
self.edge_target.set(target)
|
||||
self._draw_graph(rebuild=True)
|
||||
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
|
||||
|
||||
def _connect_selected(self) -> None:
|
||||
if len(self.controller.graph.nodes) < 2:
|
||||
return
|
||||
source = self.selected_role
|
||||
target = self.edge_target.get()
|
||||
profile_id = self.edge_profile.get()
|
||||
profile_id = "" if profile_id == "—" else profile_id
|
||||
ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _change_selected_edge(self) -> None:
|
||||
if not self.selected_edge:
|
||||
self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING)
|
||||
return
|
||||
profile_id = self.edge_profile.get()
|
||||
ok, message = self.controller.set_edge_type(
|
||||
self.selected_edge,
|
||||
self.edge_type.get(),
|
||||
"" if profile_id == "—" else profile_id,
|
||||
)
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _delete_selected_edge(self, _event: Any = None) -> str:
|
||||
selected = self.canvas.find_withtag("current")
|
||||
edge_id = self.selected_edge
|
||||
if selected:
|
||||
edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "")
|
||||
if edge_id:
|
||||
self.controller.delete_edge(edge_id)
|
||||
self.selected_edge = ""
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
return "break"
|
||||
|
||||
def _auto_layout(self) -> None:
|
||||
self.controller.auto_layout()
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _undo(self) -> None:
|
||||
if self.controller.undo():
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _redo(self) -> None:
|
||||
if self.controller.redo():
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def fit_to_screen(self) -> None:
|
||||
if not self.controller.graph.nodes:
|
||||
return
|
||||
self.update_idletasks()
|
||||
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
|
||||
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
|
||||
self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9))
|
||||
self.controller.dirty = True
|
||||
self._draw_graph(rebuild=True)
|
||||
self._set_dirty_text()
|
||||
|
||||
def _save(self) -> None:
|
||||
zoom = self.controller.graph.zoom
|
||||
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
|
||||
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
|
||||
issues = self.controller.save()
|
||||
if issues:
|
||||
self._show_issues(issues)
|
||||
return
|
||||
self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY)
|
||||
|
||||
def _show_issues(self, issues: List[GraphIssue]) -> None:
|
||||
bad_nodes = {issue.node_id for issue in issues if issue.node_id}
|
||||
for role_id, items in self._node_items.items():
|
||||
self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER)
|
||||
message = " • ".join(issue.message for issue in issues[:3])
|
||||
if len(issues) > 3:
|
||||
message += f" • ещё {len(issues) - 3}"
|
||||
self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR)
|
||||
|
||||
def _set_dirty_text(self) -> None:
|
||||
self.state_label.configure(
|
||||
text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки",
|
||||
text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED,
|
||||
)
|
||||
|
||||
def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None:
|
||||
if self.on_action:
|
||||
self.on_action(action, profile)
|
||||
|
|
@ -255,21 +255,46 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
|||
settings_out[k] = v
|
||||
return JSONResponse(content=jsonable_encoder(settings_out))
|
||||
|
||||
def run_server():
|
||||
def run_web_server(host: Optional[str] = None, port: Optional[int] = None, open_browser: bool = False) -> None:
|
||||
import uvicorn
|
||||
import webbrowser
|
||||
settings = _web_settings()
|
||||
host = settings.get('web_api_host', '127.0.0.1')
|
||||
port = int(settings.get('web_api_port', 5800))
|
||||
host = host or settings.get('web_api_host', '127.0.0.1')
|
||||
port = port or int(settings.get('web_api_port', 5800))
|
||||
token = settings.get('web_api_token', '')
|
||||
|
||||
if host != '127.0.0.1' and not token:
|
||||
logger.error("Cannot bind Web API externally without web_api_token")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Starting Hermes Hub Web API on {host}:{port}")
|
||||
url = f"http://{host}:{port}"
|
||||
logger.info(f"Starting Hermes Hub Web on {url}")
|
||||
print(f"Hermes Hub running at {url}")
|
||||
|
||||
if open_browser:
|
||||
def _open():
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
|
||||
|
||||
def start_web_server_background(host: Optional[str] = None, port: Optional[int] = None) -> threading.Thread:
|
||||
"""Start web server in a daemon thread."""
|
||||
t = threading.Thread(target=run_web_server, kwargs={"host": host, "port": port, "open_browser": False}, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def run_server() -> None:
|
||||
run_web_server(open_browser=False)
|
||||
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Статика: сервер обязан отдавать сам интерфейс, а не только API.
|
||||
# Контракт описывал каталог static/, но не назвал, кто его монтирует,
|
||||
|
|
|
|||
|
|
@ -1507,6 +1507,802 @@ function formatIsoDate(isoStr) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── THEME SWITCHER ──
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light') {
|
||||
document.body.setAttribute('data-theme', 'light');
|
||||
document.body.classList.add('theme-light');
|
||||
} else if (theme === 'dark') {
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
document.body.classList.remove('theme-light');
|
||||
} else {
|
||||
document.body.removeAttribute('data-theme');
|
||||
document.body.classList.remove('theme-light');
|
||||
}
|
||||
}
|
||||
|
||||
// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ──
|
||||
async function checkUpdates(silent = false) {
|
||||
if (!silent) {
|
||||
showToast('Проверка обновлений...', 'info');
|
||||
}
|
||||
try {
|
||||
const res = await executeAction('check_updates', {});
|
||||
if (res && res.ok && res.data) {
|
||||
latestUpdateInfo = res.data;
|
||||
renderUpdateUI();
|
||||
if (!silent) {
|
||||
if (res.data.update_available) {
|
||||
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
||||
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
||||
} else {
|
||||
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (res && res.data) {
|
||||
latestUpdateInfo = res.data;
|
||||
renderUpdateUI();
|
||||
}
|
||||
if (!silent) {
|
||||
showToast((res && res.message) || 'Ошибка проверки обновлений', 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
showToast(`Ошибка проверки обновлений: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdateUI() {
|
||||
const badge = document.getElementById('header-update-badge');
|
||||
const badgeText = document.getElementById('header-update-text');
|
||||
const commitTag = document.getElementById('commit-tag');
|
||||
|
||||
const installedCommit = (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown')
|
||||
? latestUpdateInfo.installed_commit
|
||||
: (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : '');
|
||||
|
||||
if (commitTag) {
|
||||
commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —';
|
||||
}
|
||||
|
||||
if (badge && badgeText) {
|
||||
if (latestUpdateInfo && latestUpdateInfo.update_available) {
|
||||
badge.classList.remove('hidden');
|
||||
const c = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || 'new');
|
||||
badgeText.textContent = `Доступно обновление (${c})`;
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const updateInfoDesc = document.getElementById('update-installed-info');
|
||||
const statusBadge = document.getElementById('update-status-badge');
|
||||
const lastCheckedDesc = document.getElementById('update-last-checked-desc');
|
||||
const btnApply = document.getElementById('btn-apply-update');
|
||||
const detailsBlock = document.getElementById('update-details-block');
|
||||
const releaseTitle = document.getElementById('update-release-title');
|
||||
const releaseMeta = document.getElementById('update-release-meta');
|
||||
const releaseNotes = document.getElementById('update-release-notes');
|
||||
|
||||
const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '0.1.1';
|
||||
const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно';
|
||||
if (updateInfoDesc) {
|
||||
updateInfoDesc.textContent = `Hermes Hub v${curVer} (сборка: ${cDisplay})`;
|
||||
}
|
||||
|
||||
if (statusBadge) {
|
||||
if (latestUpdateInfo && latestUpdateInfo.error) {
|
||||
statusBadge.textContent = 'Ошибка проверки';
|
||||
statusBadge.className = 'badge badge-status warning';
|
||||
statusBadge.title = latestUpdateInfo.error;
|
||||
} else if (latestUpdateInfo && latestUpdateInfo.update_available) {
|
||||
statusBadge.textContent = 'Доступно обновление';
|
||||
statusBadge.className = 'badge badge-status warning';
|
||||
statusBadge.title = '';
|
||||
} else if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) {
|
||||
statusBadge.textContent = 'Актуально';
|
||||
statusBadge.className = 'badge healthy';
|
||||
statusBadge.title = '';
|
||||
} else {
|
||||
statusBadge.textContent = 'Не проверялось';
|
||||
statusBadge.className = 'badge';
|
||||
statusBadge.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCheckedDesc) {
|
||||
if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) {
|
||||
const tStr = new Date(latestUpdateInfo.checked_at * 1000).toLocaleTimeString('ru-RU');
|
||||
const errNote = latestUpdateInfo.error ? ` — Ошибка: ${latestUpdateInfo.error}` : '';
|
||||
lastCheckedDesc.textContent = `Последняя проверка: сегодня в ${tStr}${errNote}`;
|
||||
} else {
|
||||
lastCheckedDesc.textContent = 'Последняя проверка: еще не выполнялась';
|
||||
}
|
||||
}
|
||||
|
||||
if (btnApply) {
|
||||
btnApply.disabled = !(latestUpdateInfo && latestUpdateInfo.update_available);
|
||||
}
|
||||
|
||||
if (detailsBlock && releaseTitle && releaseMeta && releaseNotes) {
|
||||
if (latestUpdateInfo && latestUpdateInfo.update_available) {
|
||||
detailsBlock.classList.remove('hidden');
|
||||
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : '—';
|
||||
releaseTitle.textContent = `Релиз: ${latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || 'Новая сборка'} (коммит: ${latC})`;
|
||||
releaseMeta.textContent = latestUpdateInfo.published_at ? `Опубликован: ${latestUpdateInfo.published_at}` : '';
|
||||
releaseNotes.textContent = latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.';
|
||||
} else {
|
||||
detailsBlock.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openUpdateModal() {
|
||||
if (!latestUpdateInfo) {
|
||||
checkUpdates(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const instC = (latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown')
|
||||
? latestUpdateInfo.installed_commit.slice(0, 7)
|
||||
: 'неизвестно';
|
||||
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
||||
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
||||
if (elements.modalBody) {
|
||||
elements.modalBody.innerHTML = `
|
||||
<div class="update-modal-body">
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
||||
<div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(instC)}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:11px; color:var(--text-muted);">Новая сборка:</div>
|
||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(latC)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">
|
||||
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong>
|
||||
${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
|
||||
</div>
|
||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Список изменений (Release Notes):</div>
|
||||
<div style="max-height:200px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono);">
|
||||
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (elements.modalFooter) {
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
||||
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button>
|
||||
`;
|
||||
}
|
||||
showModal();
|
||||
}
|
||||
|
||||
async function handleInstallUpdateFromModal() {
|
||||
const btn = document.getElementById('btn-modal-install-update');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Установка...';
|
||||
}
|
||||
await applyUpdate();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
showToast('Загрузка и запуск обновления...', 'info');
|
||||
try {
|
||||
const res = await executeAction('apply_update', {});
|
||||
if (res && res.ok) {
|
||||
showToast(res.message || 'Обновление запущено успешно!', 'success');
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка установки обновления', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Ошибка установки: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── OVERVIEW & ROUTING NODE HANDLERS ──
|
||||
async function handleNodeAccountChange(roleId, profileId, isPrimary = true) {
|
||||
if (!roleId || !profileId) return;
|
||||
showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info');
|
||||
const res = await executeAction('assign_role', {
|
||||
role_id: roleId,
|
||||
profile_id: profileId,
|
||||
is_primary: isPrimary,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
showToast(`Аккаунт '${profileId}' успешно назначен`, 'success');
|
||||
fetchSnapshot();
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка назначения аккаунта', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNodeModelChange(roleId, profileId, newModel) {
|
||||
if (!newModel) return;
|
||||
showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info');
|
||||
const res = await executeAction('set_model', { profile_id: profileId, model: newModel, role_id: roleId });
|
||||
if (res && res.ok) {
|
||||
showToast(`Модель '${newModel}' успешно сохранена`, 'success');
|
||||
if (currentSnapshot) {
|
||||
if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
|
||||
currentSnapshot.all_profiles[profileId].preferred_models = [newModel];
|
||||
}
|
||||
if (currentSnapshot.routing && currentSnapshot.routing[roleId]) {
|
||||
currentSnapshot.routing[roleId].default_model = newModel;
|
||||
const node = (currentSnapshot.routing[roleId].nodes || []).find((n) => n.profile_id === profileId);
|
||||
if (node) node.model = newModel;
|
||||
}
|
||||
}
|
||||
renderCurrentView();
|
||||
fetchSnapshot();
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка сохранения модели', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||
const res = await executeAction('refresh_models', { provider: providerId });
|
||||
if (res && res.ok) {
|
||||
showToast('Запрос обновления моделей отправлен', 'success');
|
||||
if (profileId) {
|
||||
setTimeout(() => openAccountDetailsModal(profileId, true), 500);
|
||||
} else {
|
||||
fetchSnapshot();
|
||||
}
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка обновления моделей', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── ACCOUNT & AGENT MODALS ──
|
||||
function openAccountDetailsModal(profileId, isRefresh = false) {
|
||||
_openAccountModalProfile = profileId;
|
||||
if (!currentSnapshot) return;
|
||||
const profile = (currentSnapshot.all_profiles || {})[profileId];
|
||||
if (!profile) return;
|
||||
|
||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
||||
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
|
||||
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||
const qs = profile.quota_snapshot;
|
||||
const buckets = (qs && qs.buckets) ? qs.buckets : [];
|
||||
|
||||
let modelBlockHtml = '';
|
||||
if (discoveredModels.length > 0) {
|
||||
modelBlockHtml = `
|
||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
||||
<label style="display:block; font-weight:600; font-size:12px; margin-bottom:6px;">Предпочитаемая модель профиля:</label>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<select id="modal-model-select" class="select-filter" style="flex:1;">
|
||||
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
modelBlockHtml = `
|
||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
|
||||
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
||||
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">↻ Запросить список моделей</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="font-size:14px; font-weight:700;">${escapeHtml(profile.account_identity || profile.email || profileId)}</div>
|
||||
<div style="font-size:12px; color:var(--text-muted);">
|
||||
Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong> •
|
||||
Тариф: <strong>${escapeHtml(profile.plan || 'Неизвестен')}</strong> •
|
||||
Статус: <strong class="text-healthy">${escapeHtml(profile.health_label_ru || 'Работает')}</strong>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||||
Назначенные роли: <strong>${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${modelBlockHtml}
|
||||
|
||||
<div style="margin-bottom:8px; font-weight:600; font-size:12px;">Лимиты и квоты провайдера:</div>
|
||||
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
|
||||
${buckets.map((b) => `
|
||||
<div style="background:var(--surface-card); border:1px solid var(--border-subtle); padding:8px 10px; border-radius:var(--radius-sm);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:12px; font-weight:600; margin-bottom:4px;">
|
||||
<span>${escapeHtml(b.bucket_name || b.name || 'Квота')}</span>
|
||||
<span>${b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.round(b.remaining_percent) + '%' : 'Н/Д'}</span>
|
||||
</div>
|
||||
<div class="cell-bar-track" style="margin-bottom:4px;">
|
||||
<div class="cell-bar-fill" style="width:${b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.max(0, Math.min(100, b.remaining_percent)) : 0}%; background:${(b.remaining_percent !== null && b.remaining_percent < 20) ? 'var(--status-warning)' : 'var(--status-healthy)'};"></div>
|
||||
</div>
|
||||
<div style="font-size:10px; color:var(--text-muted);">
|
||||
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-text">Данные о квотах отсутствуют (провайдер не отдал лимиты).</div>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="handleTestProfile('${escapeHtml(profileId)}')">⚡ Проверить подключение</button>
|
||||
<button class="btn btn-secondary" onclick="executeAction('set_main', { profile_id: '${escapeHtml(profileId)}' })">★ Сделать основным</button>
|
||||
<button class="btn btn-secondary" onclick="handleDeleteCredentials('${escapeHtml(profileId)}')">Удалить ключ</button>
|
||||
<button class="btn btn-primary" onclick="closeModal()">Закрыть</button>
|
||||
`;
|
||||
|
||||
if (!isRefresh) {
|
||||
showModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveProfileModel(profileId) {
|
||||
const sel = document.getElementById('modal-model-select');
|
||||
if (!sel) return;
|
||||
const model = sel.value;
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
|
||||
}
|
||||
const res = await executeAction('set_model', { profile_id: profileId, model: model });
|
||||
if (feedbackArea) {
|
||||
if (res && res.ok) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
|
||||
if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
|
||||
currentSnapshot.all_profiles[profileId].preferred_models = [model];
|
||||
}
|
||||
fetchSnapshot();
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения модели')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openAgentModelModal(roleId, profileId) {
|
||||
if (!currentSnapshot) return;
|
||||
const profile = (currentSnapshot.all_profiles || {})[profileId];
|
||||
if (!profile) return;
|
||||
|
||||
const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider);
|
||||
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
|
||||
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
|
||||
const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId;
|
||||
|
||||
elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`;
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
<div style="margin-bottom:12px; font-size:12px; color:var(--text-muted);">
|
||||
Профиль агента: <strong>${escapeHtml(profile.display_name)} (${profileId})</strong> • Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
|
||||
</div>
|
||||
${discoveredModels.length > 0 ? `
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:6px;">Выберите модель из обнаруженного списка:</label>
|
||||
<select id="role-model-select" class="select-filter" style="width:100%;">
|
||||
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : `
|
||||
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
|
||||
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
|
||||
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')">↻ Запросить список моделей</button>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
${discoveredModels.length > 0 ? `<button class="btn btn-primary" onclick="handleSaveRoleModel('${escapeHtml(roleId)}', '${escapeHtml(profileId)}')">Сохранить модель</button>` : ''}
|
||||
`;
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
async function handleSaveRoleModel(roleId, profileId) {
|
||||
const sel = document.getElementById('role-model-select');
|
||||
if (!sel) return;
|
||||
const model = sel.value;
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
|
||||
}
|
||||
const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId });
|
||||
if (feedbackArea) {
|
||||
if (res && res.ok) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
|
||||
if (currentSnapshot) {
|
||||
if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
|
||||
currentSnapshot.all_profiles[profileId].preferred_models = [model];
|
||||
}
|
||||
if (currentSnapshot.routing && currentSnapshot.routing[roleId]) {
|
||||
currentSnapshot.routing[roleId].default_model = model;
|
||||
}
|
||||
if (currentSnapshot.agents) {
|
||||
const ag = currentSnapshot.agents.find((a) => a.role_id === roleId);
|
||||
if (ag) ag.model = model;
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
renderCurrentView();
|
||||
fetchSnapshot();
|
||||
}, 700);
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Ошибка сохранения модели')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestProfile(profileId) {
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Запуск тестового запроса к провайдеру...</div>';
|
||||
}
|
||||
const res = await executeAction('test', { profile_id: profileId });
|
||||
if (feedbackArea) {
|
||||
if (res && res.ok) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Тест завершился с ошибкой')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD ACCOUNT WIZARD (P0-1) ──
|
||||
function openAddAccountWizard() {
|
||||
window._wiz_device_profile = undefined;
|
||||
window._wiz_device_session = undefined;
|
||||
window._wiz_redirect_session = undefined;
|
||||
window._wiz_redirect_provider = undefined;
|
||||
window._wiz_redirect_slot_id = undefined;
|
||||
window._wiz_base_url = undefined;
|
||||
window._wiz_token = undefined;
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||
showWizardStep1();
|
||||
showModal();
|
||||
}
|
||||
|
||||
function showWizardStep1() {
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||
elements.modalBody.innerHTML = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 1 из 3: Выберите провайдера ИИ
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr; gap:8px;">
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('grok')">
|
||||
<span style="font-size:18px; color:var(--prov-grok);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">Grok (xAI)</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">Device Code OAuth (работает на сервере) или API Key</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('openai-codex')">
|
||||
<span style="font-size:18px; color:var(--prov-codex);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">OpenAI Codex</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">Device Code OAuth (работает на сервере) или API Key</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('opencode-go')">
|
||||
<span style="font-size:18px; color:var(--prov-opencode);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">OpenCode Go</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">API Key / Токен подписки</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('claude')">
|
||||
<span style="font-size:18px; color:var(--prov-claude);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">Claude (Anthropic)</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (с поддержкой SSH port-forward) или API Key</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('antigravity')">
|
||||
<span style="font-size:18px; color:var(--prov-antigravity);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">Google Antigravity</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (с поддержкой SSH port-forward)</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('local')">
|
||||
<span style="font-size:18px; color:var(--status-healthy, #22c55e);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">Локальная модель (Local LLM)</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">llama.cpp / Ollama / vLLM (OpenAI-совместимый сервер)</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('openrouter')">
|
||||
<span style="font-size:18px; color:var(--text-muted);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">OpenRouter</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">API Key + optional custom base URL</div>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('nvidia')">
|
||||
<span style="font-size:18px; color:var(--text-muted);">●</span>
|
||||
<div style="text-align:left; margin-left:8px;">
|
||||
<div style="font-weight:700;">NVIDIA NIM</div>
|
||||
<div style="font-size:11px; color:var(--text-muted);">API Key + optional custom base URL</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function showWizardStep2(providerId) {
|
||||
let bodyHtml = '';
|
||||
let footerHtml = '';
|
||||
|
||||
if (providerId === 'grok' || providerId === 'openai-codex') {
|
||||
const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex';
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Авторизация ${providerName} по коду устройства
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
|
||||
<select class="input-text" style="width:100%;" id="wiz-device-slot">${buildSlotOptions(providerId)}</select>
|
||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="startDeviceAuth('${escapeHtml(providerId)}')">Начать авторизацию</button>
|
||||
</div>
|
||||
<div id="device-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Начать авторизацию».</div>
|
||||
</div>
|
||||
`;
|
||||
footerHtml = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить →</button>
|
||||
`;
|
||||
} else if (providerId === 'antigravity' || providerId === 'claude') {
|
||||
const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude';
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Авторизация ${providerName}
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
|
||||
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
|
||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
|
||||
</div>
|
||||
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Получить ссылку».</div>
|
||||
</div>
|
||||
`;
|
||||
footerHtml = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить →</button>
|
||||
`;
|
||||
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
|
||||
// P0-1: reset stale wizard slot so local add_account does not reuse grok/antigravity slot
|
||||
window._wiz_device_profile = undefined;
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Настройка локального сервера (Local LLM)
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
|
||||
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:8081/v1" value="http://127.0.0.1:8081/v1">
|
||||
</div>
|
||||
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
||||
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn btn-secondary" style="width:100%;" id="wiz-discover-btn" onclick="discoverLocalServers('discover_local_models')" data-action="discover_local_models">🔍 Найти на этом компьютере</button>
|
||||
<div id="wiz-discover-status" style="font-size:12px; color:var(--text-secondary); margin-top:4px;"></div>
|
||||
</div>
|
||||
<!-- discover_local_models: async search runs on Hub machine, not browser -->
|
||||
<div id="wiz-discover-results" style="margin-bottom:12px;"></div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key (опционально):</label>
|
||||
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Оставьте пустым, если ключ не требуется">
|
||||
</div>
|
||||
`;
|
||||
footerHtml = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить →</button>
|
||||
`;
|
||||
} else if (providerId === 'openrouter' || providerId === 'nvidia') {
|
||||
const providerName = providerId === 'openrouter' ? 'OpenRouter' : 'NVIDIA NIM';
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Подключение ${providerName}
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который сохранить:</label>
|
||||
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
|
||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key:</label>
|
||||
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="sk-...">
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Base URL (опционально):</label>
|
||||
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="${providerId === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://integrate.api.nvidia.com/v1'}">
|
||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||
Оставьте пустым для значения по умолчанию.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
footerHtml = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить →</button>
|
||||
`;
|
||||
} else {
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Ввод API ключа ${escapeHtml(providerId)}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key / Subscription Token:</label>
|
||||
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="sk-...">
|
||||
</div>
|
||||
`;
|
||||
footerHtml = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить →</button>
|
||||
`;
|
||||
}
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
${bodyHtml}
|
||||
`;
|
||||
elements.modalFooter.innerHTML = footerHtml;
|
||||
}
|
||||
|
||||
function proceedToWizardStep3(providerId) {
|
||||
const baseInput = document.getElementById('wiz-base-url-input');
|
||||
if (baseInput) {
|
||||
window._wiz_base_url = baseInput.value.trim();
|
||||
}
|
||||
const tokenInput = document.getElementById('wiz-token-input');
|
||||
if (tokenInput) {
|
||||
window._wiz_token = tokenInput.value.trim();
|
||||
}
|
||||
// P0-1 BUG-1: persist owner-selected slot BEFORE showWizardStep3 destroys the select elements
|
||||
// Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot,
|
||||
// antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements.
|
||||
const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex';
|
||||
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
|
||||
if (isDeviceAuthFlow) {
|
||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
||||
if (deviceSlot && deviceSlot.value) {
|
||||
window._wiz_device_profile = deviceSlot.value;
|
||||
}
|
||||
} else if (isRedirectAuthFlow) {
|
||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||
if (redirectSlot && redirectSlot.value) {
|
||||
window._wiz_device_profile = redirectSlot.value;
|
||||
}
|
||||
}
|
||||
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
|
||||
showWizardStep3(providerId);
|
||||
}
|
||||
|
||||
function showWizardStep3(providerId) {
|
||||
// GAP-3: динамически строим options ролей из currentSnapshot.routing
|
||||
const routing = currentSnapshot && currentSnapshot.routing ? currentSnapshot.routing : {};
|
||||
const roleIds = Object.keys(routing);
|
||||
|
||||
let roleOptionsHtml = '';
|
||||
if (roleIds.length === 0) {
|
||||
// Fallback: минимальный набор если snapshot ещё не загружен
|
||||
roleOptionsHtml = `
|
||||
<option value="coder-primary">Кодер 1 (Primary Coder)</option>
|
||||
<option value="coder-secondary">Кодер 2 (Secondary Coder)</option>
|
||||
<option value="orchestrator">Оркестратор (Fallback Router)</option>
|
||||
<option value="reviewer">Ревьюер кода (Reviewer)</option>
|
||||
<option value="research">Исследователь (Researcher)</option>
|
||||
<option value="fast">Быстрый агент (Fast / Flash)</option>
|
||||
<option value="spare">Резервный пул (Spare Pool)</option>
|
||||
`;
|
||||
} else {
|
||||
roleOptionsHtml = roleIds.map((roleId) => {
|
||||
const pipeline = routing[roleId] || {};
|
||||
const label = pipeline.role_name_ru || roleId;
|
||||
const desc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || pipeline.role_description_ru || '';
|
||||
return `<option value="${escapeHtml(roleId)}">${escapeHtml(label)}${desc ? ' — ' + escapeHtml(desc) : ''}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 3 из 3: Назначение роли для нового аккаунта
|
||||
</div>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
|
||||
<select class="select-filter" style="width:100%;" id="wiz-target-role">
|
||||
${roleOptionsHtml}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep2('${escapeHtml(providerId)}')">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="finishAddAccount('${escapeHtml(providerId)}')">✓ Завершить подключение</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function finishAddAccount(providerId) {
|
||||
const roleSelect = document.getElementById('wiz-target-role');
|
||||
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
||||
|
||||
// GAP-2: owner-selected slot — читаем выбранный профиль из UI
|
||||
// Local providers (local, local-llm, llama.cpp, ollama, vllm) have no slot selector;
|
||||
// do NOT read stale DOM slot elements from previous flows in the same test session.
|
||||
const isLocalProvider = providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm';
|
||||
let selectedProfileId;
|
||||
if (isLocalProvider) {
|
||||
selectedProfileId = '';
|
||||
} else {
|
||||
const deviceSlot = document.getElementById('wiz-device-slot');
|
||||
const redirectSlot = document.getElementById('wiz-redirect-slot');
|
||||
// Use nullish coalescing: if _wiz_device_profile was explicitly set (not null/undefined),
|
||||
// it wins over any stale DOM element value (e.g. from a previous grok flow in the same test)
|
||||
selectedProfileId = window._wiz_device_profile ?? (deviceSlot?.value || redirectSlot?.value || '');
|
||||
}
|
||||
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение учетной записи в роутере...</div>';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
provider: providerId,
|
||||
target_role: targetRole,
|
||||
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
|
||||
profile_id: selectedProfileId,
|
||||
};
|
||||
if (window._wiz_base_url) {
|
||||
payload.base_url = window._wiz_base_url;
|
||||
}
|
||||
if (window._wiz_token) {
|
||||
payload.token = window._wiz_token;
|
||||
}
|
||||
|
||||
const res = await executeAction('add_account', payload);
|
||||
|
||||
if (res && res.ok) {
|
||||
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
|
||||
closeModal();
|
||||
fetchSnapshot();
|
||||
} else {
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml((res && res.message) || 'Не удалось завершить подключение')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Авторизация по коду устройства (Grok, OpenAI Codex)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -1525,7 +2321,16 @@ async function startDeviceAuth(providerId) {
|
|||
const box = document.getElementById('device-auth-box');
|
||||
if (!box) return;
|
||||
|
||||
const res = await executeAction('start_device_auth', { provider: providerId });
|
||||
// GAP-1: owner-selected slot — показать выбранное в UI до начала auth
|
||||
const slotSelect = document.getElementById('wiz-device-slot');
|
||||
const selectedSlot = slotSelect ? slotSelect.value : '';
|
||||
if (selectedSlot) {
|
||||
window._wiz_device_profile = selectedSlot;
|
||||
}
|
||||
|
||||
box.innerHTML = `<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>`;
|
||||
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
|
||||
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
|
||||
if (!res || !res.ok) {
|
||||
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||
return;
|
||||
|
|
@ -1533,7 +2338,8 @@ async function startDeviceAuth(providerId) {
|
|||
|
||||
const d = res.data || {};
|
||||
window._wiz_device_session = d.session_id;
|
||||
window._wiz_device_profile = d.profile_id;
|
||||
// P0-1 BUG-3: owner-selected slot wins over server-assigned profile_id
|
||||
window._wiz_device_profile = selectedSlot || d.profile_id;
|
||||
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку:</div>
|
||||
|
|
@ -1827,3 +2633,82 @@ async function handleDeleteCredentials(profileId) {
|
|||
fetchSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-3: Local LLM server discovery ──────────────────────────────────────
|
||||
|
||||
async function discoverLocalServers() {
|
||||
const btn = document.getElementById('wiz-discover-btn');
|
||||
const statusEl = document.getElementById('wiz-discover-status');
|
||||
const resultsEl = document.getElementById('wiz-discover-results');
|
||||
if (!btn || !statusEl || !resultsEl) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Поиск...';
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
// wiz-base-url-input: filled by selectDiscoveredServer when user picks a server
|
||||
const res = await executeAction('discover_local_models', {});
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 Найти на этом компьютере';
|
||||
|
||||
const servers = (res && res.data && res.data.servers) ? res.data.servers : [];
|
||||
|
||||
if (!servers.length) {
|
||||
statusEl.textContent = res && res.message ? res.message
|
||||
: 'Ничего не найдено. Запустите Ollama, LM Studio или llama.cpp, либо введите адрес вручную.';
|
||||
statusEl.style.color = 'var(--text-muted)';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show message if any servers have errors (occupied by other service)
|
||||
const errorServers = servers.filter(s => s.error);
|
||||
if (errorServers.length && errorServers.length === servers.length) {
|
||||
statusEl.textContent = 'Найдены серверы, но подключиться к ним не удалось:';
|
||||
statusEl.style.color = 'var(--text-secondary)';
|
||||
} else if (errorServers.length) {
|
||||
statusEl.textContent = `Найдено ${servers.length - errorServers.length} рабочих серверов (+${errorServers.length} с ошибкой). Выберите рабочий сервер:`;
|
||||
statusEl.style.color = 'var(--text-secondary)';
|
||||
} else {
|
||||
statusEl.textContent = `Найдено серверов: ${servers.length}. Выберите:`;
|
||||
statusEl.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
|
||||
// Render server list
|
||||
let html = '<div style="border:1px solid var(--border); border-radius:6px; overflow:hidden; margin-top:4px;">';
|
||||
servers.forEach((srv, idx) => {
|
||||
const hasError = !!srv.error;
|
||||
const modelCount = srv.models && srv.models.length ? srv.models.length : 0;
|
||||
const modelLabel = modelCount ? `, ${modelCount} модель${modelCount === 1 ? '' : modelCount < 5 ? 'ели' : 'елей'}` : '';
|
||||
const errorTitle = hasError ? ` title="${escapeHtml(srv.error)}"` : '';
|
||||
const rowStyle = hasError
|
||||
? 'padding:8px 10px; background:#2a1a1a; cursor:not-allowed; opacity:0.7;'
|
||||
: 'padding:8px 10px; cursor:pointer;';
|
||||
const clickAttr = hasError ? '' : ` onclick="selectDiscoveredServer('${escapeHtml(srv.base_url)}')"`;
|
||||
const icon = hasError ? '⚠️' : '🖥️';
|
||||
html += `<div style="${rowStyle}"${errorTitle}${clickAttr}>
|
||||
<div style="font-size:13px; font-weight:600;">${icon} ${escapeHtml(srv.name)}</div>
|
||||
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">${escapeHtml(srv.base_url)}${modelLabel}${hasError ? ` — <span style="color:var(--status-warning);">⚠ ${escapeHtml(srv.error)}</span>` : ''}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
resultsEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function selectDiscoveredServer(baseUrl) {
|
||||
const input = document.getElementById('wiz-base-url-input');
|
||||
if (input) {
|
||||
input.value = baseUrl;
|
||||
// Scroll input into view
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
// Clear results to keep UI clean
|
||||
const resultsEl = document.getElementById('wiz-discover-results');
|
||||
if (resultsEl) resultsEl.innerHTML = '';
|
||||
const statusEl = document.getElementById('wiz-discover-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `Выбран: ${baseUrl}`;
|
||||
statusEl.style.color = 'var(--status-healthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,35 +97,5 @@ def block_external_network_in_hermetic_tests(request, monkeypatch):
|
|||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment")
|
||||
config.addinivalue_line("markers", "ui: mark test as requiring UI environment")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Skip tests explicitly marked as needing the Tk graphical stack.
|
||||
|
||||
Selection is by the ``ui`` marker only. It used to also match any test whose
|
||||
*name* contained "ui", "view" or "wizard", which silently skipped static
|
||||
checks that never touch the toolkit — and hid real failures for weeks.
|
||||
Modules that import the GUI stack guard themselves with
|
||||
``pytest.importorskip("customtkinter")`` at module scope; that guard is
|
||||
enforced by tests/test_import_invariants.py.
|
||||
"""
|
||||
try:
|
||||
import customtkinter # noqa: F401
|
||||
except Exception:
|
||||
skip_ui = pytest.mark.skip(reason="customtkinter is not installed in current environment")
|
||||
for item in items:
|
||||
if "ui" in item.keywords:
|
||||
item.add_marker(skip_ui)
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root():
|
||||
"""Shared Tkinter root for all UI tests to avoid Tcl resource exhaustion."""
|
||||
try:
|
||||
import customtkinter as ctk
|
||||
root = ctk.CTk()
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
except Exception as e:
|
||||
pytest.skip(f"Tkinter could not be initialized: {e}")
|
||||
|
|
|
|||
|
|
@ -36,11 +36,6 @@ from antigravity_provider.router.codex_oauth import (
|
|||
get_codex_oauth_session,
|
||||
cancel_codex_oauth_session,
|
||||
)
|
||||
# Pulls customtkinter transitively; without this guard a headless run aborts
|
||||
# collection of the entire session instead of skipping this module.
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
from antigravity_provider.router.ui.components import enable_clipboard_shortcuts, HubEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -277,23 +272,7 @@ def test_g_extract_jwt_identity():
|
|||
assert sub2 == "auth0|openai-456"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# TEST H: HubEntry and enable_clipboard_shortcuts
|
||||
# ==============================================================================
|
||||
def test_h_enable_clipboard_shortcuts_binding():
|
||||
"""Test that enable_clipboard_shortcuts attaches paste/copy/cut/select-all handlers without error."""
|
||||
mock_entry = MagicMock()
|
||||
mock_entry._entry = MagicMock()
|
||||
mock_entry.clipboard_get.return_value = "pasted_text_123"
|
||||
|
||||
enable_clipboard_shortcuts(mock_entry)
|
||||
|
||||
# Check bind was called for multiple standard and Cyrillic keys
|
||||
bound_events = [c[0][0] for c in mock_entry._entry.bind.call_args_list]
|
||||
assert "<Control-v>" in bound_events
|
||||
assert "<Control-a>" in bound_events
|
||||
assert "<Shift-Insert>" in bound_events
|
||||
assert "<Control-cyrillic_em>" in bound_events
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
|
|
|||
|
|
@ -129,11 +129,10 @@ def test_adapter_no_browser_on_expired_token(clean_env, monkeypatch):
|
|||
assert "Авторизация истекла" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
@pytest.mark.unit
|
||||
def test_do_test_profile_no_browser_on_expired_token(clean_env):
|
||||
"""P0-3: Verify do_test_profile in UI layer catches expired token without invoking adapter."""
|
||||
pytest.importorskip("customtkinter")
|
||||
from antigravity_provider.router.hermes_hub_app import do_test_profile
|
||||
"""P0-3: Verify do_test_profile catches expired token without invoking adapter."""
|
||||
from antigravity_provider.router.action_handler import do_test_profile
|
||||
|
||||
expired_auth = {
|
||||
"provider": "antigravity",
|
||||
|
|
|
|||
|
|
@ -82,14 +82,8 @@ def test_module_is_importable(module_name: str) -> None:
|
|||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_gui_test_modules_guard_optional_ui_dependency() -> None:
|
||||
"""Test modules touching customtkinter must call pytest.importorskip.
|
||||
|
||||
Without the guard a headless environment aborts collection of the whole
|
||||
session ("Interrupted: 1 error during collection") instead of skipping the
|
||||
affected module, which takes the release gate down with it.
|
||||
"""
|
||||
# Modules that pull the GUI toolkit in transitively when imported.
|
||||
def test_no_tests_import_removed_desktop_ui() -> None:
|
||||
"""Verify that no test modules import the removed CustomTkinter desktop UI."""
|
||||
GUI_BEARING_PREFIXES = (
|
||||
"customtkinter",
|
||||
"antigravity_provider.router.ui",
|
||||
|
|
@ -113,17 +107,10 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None:
|
|||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
continue
|
||||
# A mention in prose is not an import; only real imports need the guard.
|
||||
if not _imports_gui(tree):
|
||||
continue
|
||||
if "importorskip" not in text:
|
||||
if _imports_gui(tree):
|
||||
offenders.append(path.name)
|
||||
|
||||
assert not offenders, (
|
||||
"test modules import customtkinter without pytest.importorskip: "
|
||||
+ ", ".join(offenders)
|
||||
+ " — add pytest.importorskip('customtkinter') above the import"
|
||||
)
|
||||
assert not offenders, f"The following test files still import removed desktop UI: {offenders}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -67,14 +67,14 @@ def test_windows_launcher_browser_search_and_health_check():
|
|||
assert "HermesHubWeb" in web_cs or "WebLauncher" in web_cs
|
||||
|
||||
|
||||
def test_windows_installer_creates_both_shortcuts():
|
||||
"""Verify HermesHubSetup.cs creates both Web and Desktop shortcuts."""
|
||||
def test_windows_installer_creates_standard_shortcut_and_cleans_legacy():
|
||||
"""Verify HermesHubSetup.cs creates standard Hermes Hub.lnk shortcut and cleans up legacy shortcuts."""
|
||||
setup_cs = (INSTALLER_DIR / "HermesHubSetup.cs").read_text(encoding="utf-8")
|
||||
|
||||
assert "Hermes Hub.lnk" in setup_cs
|
||||
assert "Hermes Hub (Web).lnk" in setup_cs
|
||||
assert "Hermes Hub (Desktop).lnk" in setup_cs
|
||||
assert "HermesHubWeb.exe" in setup_cs
|
||||
assert "HermesHub.exe" in setup_cs
|
||||
|
||||
|
||||
def test_linux_installer_script_structure():
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
from antigravity_provider.router.router_config import (
|
||||
|
|
@ -18,7 +16,6 @@ from antigravity_provider.router.router_config import (
|
|||
RouterProfileConfig,
|
||||
save_router_config,
|
||||
)
|
||||
from antigravity_provider.router.ui.model_catalog import CachedModels, refresh_models_async
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -138,25 +135,4 @@ def test_model_discovery_cache_retained_on_timeout_and_error(isolated_hub):
|
|||
assert service.get_models("antigravity") == ["existing-gemini-model"]
|
||||
|
||||
|
||||
def test_ui_model_catalog_refresh_models_async(isolated_hub):
|
||||
"""model_catalog.refresh_models_async dispatches and completes via service."""
|
||||
service = ModelDiscoveryService.get()
|
||||
with service._cache_lock:
|
||||
service._cache["antigravity"] = {
|
||||
"models": ["gemini-2.5-pro"],
|
||||
"discovered_at": 1000.0,
|
||||
}
|
||||
|
||||
completed: list[CachedModels] = []
|
||||
done_evt = threading.Event()
|
||||
|
||||
def _on_done(cm: CachedModels) -> None:
|
||||
completed.append(cm)
|
||||
done_evt.set()
|
||||
|
||||
with patch.object(service, "_probe_provider", return_value=["gemini-2.5-pro", "gemini-2.5-flash"]):
|
||||
ok = refresh_models_async("antigravity", _on_done)
|
||||
assert ok is True
|
||||
assert done_evt.wait(timeout=3.0) is True
|
||||
assert len(completed) == 1
|
||||
assert "gemini-2.5-pro" in completed[0].models
|
||||
|
|
|
|||
|
|
@ -162,87 +162,7 @@ def test_d_oauth_error_callback(tmp_path, monkeypatch):
|
|||
assert mock_exchange.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root):
|
||||
"""TEST E: Repeated 'Открыть в браузере' does NOT change session, state, verifier, or URL."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
pytest.importorskip("customtkinter")
|
||||
import customtkinter as ctk
|
||||
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||||
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
||||
|
||||
root = ctk.CTkToplevel(tk_root)
|
||||
root.withdraw()
|
||||
fake_session = MagicMock()
|
||||
fake_session.status = "pending"
|
||||
fake_session.is_dev_mode = False
|
||||
fake_session.error_msg = None
|
||||
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
|
||||
try:
|
||||
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
|
||||
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
|
||||
wizard = AddAccountWizard(root)
|
||||
wizard.selected_provider = "grok"
|
||||
wizard.target_slot = "grok-worker-1"
|
||||
wizard._show_step_2_auth()
|
||||
wizard._polling_active = False
|
||||
|
||||
orig_session_id = wizard.grok_session_id
|
||||
orig_url = wizard.grok_url
|
||||
|
||||
session = get_grok_oauth_session(orig_session_id)
|
||||
|
||||
with patch("webbrowser.open") as mock_open:
|
||||
wizard._open_grok_browser()
|
||||
wizard._open_grok_browser()
|
||||
wizard._open_grok_browser()
|
||||
|
||||
assert mock_open.call_count == 3
|
||||
for call in mock_open.call_args_list:
|
||||
assert call[0][0] == orig_url
|
||||
|
||||
assert wizard.grok_session_id == orig_session_id
|
||||
assert wizard.grok_url == orig_url
|
||||
|
||||
wizard.destroy()
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
|
||||
"""TEST F: Copy button works immediately upon entering Step 2 without opening browser."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
pytest.importorskip("customtkinter")
|
||||
import customtkinter as ctk
|
||||
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||||
|
||||
root = ctk.CTkToplevel(tk_root)
|
||||
root.withdraw()
|
||||
fake_session = MagicMock()
|
||||
fake_session.status = "pending"
|
||||
fake_session.is_dev_mode = False
|
||||
fake_session.error_msg = None
|
||||
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
|
||||
try:
|
||||
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
|
||||
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
|
||||
wizard = AddAccountWizard(root)
|
||||
wizard.selected_provider = "grok"
|
||||
wizard.target_slot = "grok-worker-1"
|
||||
wizard._show_step_2_auth()
|
||||
wizard._polling_active = False
|
||||
|
||||
assert wizard.grok_url is not None
|
||||
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
|
||||
|
||||
wizard._copy_grok_url()
|
||||
clipboard_content = wizard.clipboard_get()
|
||||
assert clipboard_content == wizard.grok_url
|
||||
|
||||
wizard.destroy()
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
267
tests/test_openai_compatible_providers_a34.py
Normal file
267
tests/test_openai_compatible_providers_a34.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""A34 P0-2a: OpenRouter + NVIDIA OpenAI-compatible adapters.
|
||||
|
||||
TDD tests (RED first, then GREEN):
|
||||
1. get_adapter registration for openrouter / nvidia provider keys.
|
||||
2. invoke() POSTs to the configurable base_url (profile.custom_base_url,
|
||||
then auth_config["base_url"], then provider default).
|
||||
3. discover_models() does GET {base_url}/models and returns the server's
|
||||
model list — no invented/hardcoded model list.
|
||||
4. Bearer auth header from auth_config api_key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.router_config import RouterProfileConfig
|
||||
|
||||
|
||||
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
NVIDIA_DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
|
||||
def _mock_urlopen(payload: dict) -> MagicMock:
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__.return_value = mock_response
|
||||
return mock_response
|
||||
|
||||
|
||||
def _chat_response(content: str = "OK") -> dict:
|
||||
return {
|
||||
"id": "chatcmpl-test",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _models_response(ids: list[str]) -> dict:
|
||||
return {"data": [{"id": m, "object": "model"} for m in ids]}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Registration
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_adapter_openrouter_registered():
|
||||
adapter = get_adapter("openrouter")
|
||||
assert adapter is not None
|
||||
assert type(adapter).__name__ == "OpenRouterAdapter"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_adapter_nvidia_registered():
|
||||
adapter = get_adapter("nvidia")
|
||||
assert adapter is not None
|
||||
assert type(adapter).__name__ == "NvidiaAdapter"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_adapter_aliases_registered():
|
||||
assert type(get_adapter("openrouter")).__name__ == "OpenRouterAdapter"
|
||||
assert type(get_adapter("nvidia")).__name__ == "NvidiaAdapter"
|
||||
assert type(get_adapter("nvidia-nim")).__name__ == "NvidiaAdapter"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. invoke() hits the configurable URL
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_invoke_uses_profile_custom_base_url(provider):
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-1",
|
||||
provider=provider,
|
||||
custom_base_url="http://127.0.0.1:9999/v1",
|
||||
preferred_models=["some-model"],
|
||||
auth_config={"api_key": "test-key"},
|
||||
)
|
||||
request = {
|
||||
"model": "some-model",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 0.5,
|
||||
}
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
|
||||
resp = adapter.invoke(profile, request)
|
||||
|
||||
assert resp["choices"][0]["message"]["content"] == "OK"
|
||||
req_arg = mock_urlopen.call_args[0][0]
|
||||
assert req_arg.get_full_url() == "http://127.0.0.1:9999/v1/chat/completions"
|
||||
assert req_arg.get_method() == "POST"
|
||||
assert req_arg.headers.get("Authorization") == "Bearer test-key"
|
||||
payload = json.loads(req_arg.data.decode("utf-8"))
|
||||
assert payload["model"] == "some-model"
|
||||
assert payload["messages"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_invoke_uses_auth_config_base_url(provider):
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-2",
|
||||
provider=provider,
|
||||
preferred_models=["some-model"],
|
||||
auth_config={"base_url": "http://127.0.0.1:9998/v1", "api_key": "k2"},
|
||||
)
|
||||
request = {
|
||||
"model": "some-model",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
}
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
|
||||
adapter.invoke(profile, request)
|
||||
|
||||
req_arg = mock_urlopen.call_args[0][0]
|
||||
assert req_arg.get_full_url() == "http://127.0.0.1:9998/v1/chat/completions"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invoke_openrouter_default_base_url():
|
||||
adapter = get_adapter("openrouter")
|
||||
profile = RouterProfileConfig(
|
||||
profile_id="openrouter-1",
|
||||
provider="openrouter",
|
||||
preferred_models=["openai/gpt-4o"],
|
||||
auth_config={"api_key": "or-key"},
|
||||
)
|
||||
request = {
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
}
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
|
||||
adapter.invoke(profile, request)
|
||||
|
||||
req_arg = mock_urlopen.call_args[0][0]
|
||||
assert req_arg.get_full_url() == f"{OPENROUTER_DEFAULT_BASE_URL}/chat/completions"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invoke_nvidia_default_base_url():
|
||||
adapter = get_adapter("nvidia")
|
||||
profile = RouterProfileConfig(
|
||||
profile_id="nvidia-1",
|
||||
provider="nvidia",
|
||||
preferred_models=["meta/llama-3.1-8b-instruct"],
|
||||
auth_config={"api_key": "nv-key"},
|
||||
)
|
||||
request = {
|
||||
"model": "meta/llama-3.1-8b-instruct",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
}
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response())) as mock_urlopen:
|
||||
adapter.invoke(profile, request)
|
||||
|
||||
req_arg = mock_urlopen.call_args[0][0]
|
||||
assert req_arg.get_full_url() == f"{NVIDIA_DEFAULT_BASE_URL}/chat/completions"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. discover_models() — GET /models, no invented list
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_discover_models_gets_models_endpoint(provider):
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-1",
|
||||
provider=provider,
|
||||
custom_base_url="http://127.0.0.1:9997/v1",
|
||||
auth_config={"api_key": "k"},
|
||||
)
|
||||
server_models = ["model-a", "model-b", "model-c"]
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_models_response(server_models))) as mock_urlopen:
|
||||
models = adapter.discover_models(profile)
|
||||
|
||||
req_arg = mock_urlopen.call_args[0][0]
|
||||
assert req_arg.get_full_url() == "http://127.0.0.1:9997/v1/models"
|
||||
assert req_arg.get_method() == "GET"
|
||||
assert models == sorted(server_models)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_discover_models_returns_server_list_not_invented(provider):
|
||||
"""The returned list must come from the server response, not a hardcoded list."""
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-1",
|
||||
provider=provider,
|
||||
custom_base_url="http://127.0.0.1:9996/v1",
|
||||
auth_config={"api_key": "k"},
|
||||
)
|
||||
server_models = ["totally-custom-model-xyz"]
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_models_response(server_models))):
|
||||
models = adapter.discover_models(profile)
|
||||
|
||||
assert models == ["totally-custom-model-xyz"]
|
||||
# No invented defaults: a model that the server did not list must not appear.
|
||||
assert "deepseek-chat" not in models
|
||||
assert "gpt-4o" not in models
|
||||
assert "llama" not in " ".join(models).lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_discover_models_fallback_on_error(provider):
|
||||
"""On transport error, fall back to profile.preferred_models (no invented list)."""
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-1",
|
||||
provider=provider,
|
||||
custom_base_url="http://127.0.0.1:9995/v1",
|
||||
preferred_models=["my-pref-model"],
|
||||
auth_config={"api_key": "k"},
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
|
||||
models = adapter.discover_models(profile)
|
||||
|
||||
assert models == ["my-pref-model"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. health_check
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openrouter", "nvidia"])
|
||||
def test_health_check(provider):
|
||||
adapter = get_adapter(provider)
|
||||
profile = RouterProfileConfig(
|
||||
profile_id=f"{provider}-1",
|
||||
provider=provider,
|
||||
custom_base_url="http://127.0.0.1:9994/v1",
|
||||
auth_config={"api_key": "k"},
|
||||
)
|
||||
|
||||
ok_resp = MagicMock()
|
||||
ok_resp.status = 200
|
||||
ok_resp.__enter__.return_value = ok_resp
|
||||
with patch("urllib.request.urlopen", return_value=ok_resp):
|
||||
assert adapter.health_check(profile) is True
|
||||
|
||||
import urllib.error
|
||||
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("refused")):
|
||||
assert adapter.health_check(profile) is False
|
||||
387
tests/test_p0_1_dangling_handlers_and_wizard.py
Normal file
387
tests/test_p0_1_dangling_handlers_and_wizard.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
APP_JS_PATH = Path("src/antigravity_provider/router/web/static/app.js")
|
||||
INDEX_HTML_PATH = Path("src/antigravity_provider/router/web/static/index.html")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_js_content():
|
||||
assert APP_JS_PATH.exists(), f"{APP_JS_PATH} does not exist"
|
||||
return APP_JS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_html_content():
|
||||
assert INDEX_HTML_PATH.exists(), f"{INDEX_HTML_PATH} does not exist"
|
||||
return INDEX_HTML_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _is_function_defined(code: str, func_name: str) -> bool:
|
||||
patterns = [
|
||||
rf"function\s+{re.escape(func_name)}\s*\(",
|
||||
rf"async\s+function\s+{re.escape(func_name)}\s*\(",
|
||||
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z0-9_$]+)\s*=>",
|
||||
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?function\s*\(",
|
||||
]
|
||||
return any(re.search(p, code) for p in patterns)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_open_add_account_wizard_defined(app_js_content):
|
||||
"""P0-1: openAddAccountWizard must be defined in app.js."""
|
||||
assert _is_function_defined(app_js_content, "openAddAccountWizard"), (
|
||||
"openAddAccountWizard is called in DOM/events but has 0 definitions in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_handle_node_account_change_defined(app_js_content):
|
||||
"""P0-1: handleNodeAccountChange must be defined in app.js."""
|
||||
assert _is_function_defined(app_js_content, "handleNodeAccountChange"), (
|
||||
"handleNodeAccountChange is called in overview/routing diagrams but has 0 definitions in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_handle_node_model_change_defined(app_js_content):
|
||||
"""P0-1: handleNodeModelChange must be defined in app.js."""
|
||||
assert _is_function_defined(app_js_content, "handleNodeModelChange"), (
|
||||
"handleNodeModelChange is called in overview/routing diagrams but has 0 definitions in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_handle_refresh_provider_models_defined(app_js_content):
|
||||
"""P0-1: handleRefreshProviderModels must be defined in app.js."""
|
||||
assert _is_function_defined(app_js_content, "handleRefreshProviderModels"), (
|
||||
"handleRefreshProviderModels is called in overview/routing diagrams but has 0 definitions in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_check_updates_defined(app_js_content):
|
||||
"""P0-1: checkUpdates must be defined in app.js."""
|
||||
assert _is_function_defined(app_js_content, "checkUpdates"), (
|
||||
"checkUpdates is called at DOMContentLoaded and button click but has 0 definitions in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_all_html_and_template_handlers_defined(app_js_content, index_html_content):
|
||||
"""P0-1: All inline event handlers used in HTML and template literals must have matching definitions in app.js."""
|
||||
combined = index_html_content + "\n" + app_js_content
|
||||
handler_pattern = re.compile(r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\x27](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(', re.IGNORECASE)
|
||||
|
||||
used_handlers = set(handler_pattern.findall(combined))
|
||||
assert used_handlers, "No event handlers found in HTML/JS"
|
||||
|
||||
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
|
||||
assert not missing, f"The following handlers are called in UI but missing function definitions: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_wizard_contracts_and_actions(app_js_content):
|
||||
"""P0-1: Wizard must use startDeviceAuth, startRedirectAuth, add_account and support manual slot selection."""
|
||||
assert "showWizardStep1" in app_js_content
|
||||
assert "showWizardStep2" in app_js_content
|
||||
assert "showWizardStep3" in app_js_content
|
||||
assert "finishAddAccount" in app_js_content
|
||||
# Wizard slot selection & SSH port-forward tunnel hints
|
||||
assert "buildSlotOptions" in app_js_content
|
||||
assert "startDeviceAuth" in app_js_content
|
||||
assert "startRedirectAuth" in app_js_content
|
||||
assert "wiz-redirect-tunnel" in app_js_content or "ssh -L" in app_js_content
|
||||
# Base url manual input for local LLM
|
||||
assert "wiz-base-url-input" in app_js_content
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_node_dom_execution_contract():
|
||||
"""P0-1: Execute targeted Node.js script to simulate DOM loading and verify all 5 handlers are callable."""
|
||||
res = subprocess.run(
|
||||
["node", "tests/test_web_handlers_dom_contract.js"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert res.returncode == 0, f"Node DOM contract test failed:\nSTDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_no_elementsmap_in_production_app_js(app_js_content):
|
||||
"""P0-1 regression: elementsMap must NOT appear in production app.js.
|
||||
elementsMap is a test-only artifact — its use in openAddAccountWizard caused
|
||||
a ReferenceError in the real browser where elementsMap is undefined.
|
||||
Any test-only element-caching must live inside the Node test sandbox only.
|
||||
"""
|
||||
assert 'elementsMap' not in app_js_content, (
|
||||
"elementsMap must not appear in production app.js — "
|
||||
"it is a test-only variable. openAddAccountWizard must not reference it."
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# TDD: owner-selected slots for every account-connect flow (P0-1 remainder)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tdd_grok_codex_shows_slot_picker_before_device_auth(app_js_content):
|
||||
"""TDD GAP-1: Grok/Codex device-auth must show owner slot picker BEFORE startDeviceAuth.
|
||||
The grok/openai-codex branch in showWizardStep2 must include wiz-device-slot select.
|
||||
"""
|
||||
# Find the grok/openai-codex branch in showWizardStep2
|
||||
# It must have a slot select element, not just device-auth-box
|
||||
assert "wiz-device-slot" in app_js_content, (
|
||||
"GAP-1: grok/codex flow must have 'wiz-device-slot' select element in the template"
|
||||
)
|
||||
# The slot select must use buildSlotOptions to populate options
|
||||
assert "buildSlotOptions" in app_js_content
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tdd_finish_add_account_sends_profile_id(app_js_content):
|
||||
"""TDD GAP-2: finishAddAccount must send chosen profile_id/slot in add_account payload.
|
||||
It must NOT silently find_free_slot for the owner.
|
||||
"""
|
||||
# finishAddAccount must read wiz-device-slot OR wiz-redirect-slot
|
||||
# and include profile_id in the payload
|
||||
assert "wiz-device-slot" in app_js_content or "wiz-redirect-slot" in app_js_content, (
|
||||
"GAP-2: finishAddAccount must read a slot select element (wiz-device-slot or wiz-redirect-slot)"
|
||||
)
|
||||
# The payload passed to add_account must include profile_id
|
||||
# Look for pattern: payload.profile_id or payload['profile_id']
|
||||
finish_fn_match = re.search(
|
||||
r'async function finishAddAccount\([^)]*\)\s*\{(.*?)\n\}',
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert finish_fn_match, "finishAddAccount function not found"
|
||||
finish_body = finish_fn_match.group(1)
|
||||
# Must contain profile_id in the payload
|
||||
assert "profile_id" in finish_body, (
|
||||
"GAP-2: finishAddAccount must include 'profile_id' in the add_account payload"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tdd_wizard_step3_dynamic_roles_from_snapshot_routing(app_js_content):
|
||||
"""TDD GAP-3: Wizard step 3 must NOT hardcode only 6 legacy role IDs.
|
||||
It must build role options from currentSnapshot.routing if snapshot agents exist.
|
||||
"""
|
||||
# Find showWizardStep3 function
|
||||
step3_match = re.search(
|
||||
r'function showWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\n$)',
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert step3_match, "showWizardStep3 function not found"
|
||||
step3_body = step3_match.group(1)
|
||||
|
||||
# Must reference currentSnapshot.routing for dynamic role building
|
||||
assert "currentSnapshot.routing" in step3_body, (
|
||||
"GAP-3: showWizardStep3 must use currentSnapshot.routing to build role options dynamically"
|
||||
)
|
||||
|
||||
# The dynamic branch (roleIds.length > 0) must NOT be the fallback hardcoded list.
|
||||
# Check that roleIds (from Object.keys(routing)) is used to build options dynamically.
|
||||
# The presence of "roleIds.map" or "Object.keys(routing)" indicates dynamic path.
|
||||
assert ("roleIds.map" in step3_body or "Object.keys" in step3_body), (
|
||||
"GAP-3: showWizardStep3 must use roleIds.map(...) to build options dynamically from currentSnapshot.routing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tdd_all_new_template_handlers_defined(app_js_content):
|
||||
"""TDD GAP-4: All onclick/onchange in app.js template literals must have function definitions.
|
||||
After GAP-1/2/3 fixes, verify no new dangling handlers were introduced.
|
||||
"""
|
||||
combined = app_js_content # index.html already checked in existing test
|
||||
handler_pattern = re.compile(
|
||||
r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\'](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(',
|
||||
re.IGNORECASE
|
||||
)
|
||||
used_handlers = set(handler_pattern.findall(combined))
|
||||
if not used_handlers:
|
||||
pytest.skip("No event handlers found in app.js template literals")
|
||||
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
|
||||
assert not missing, f"GAP-4: Handlers called in templates but missing definitions: {missing}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# TDD: P0-2b OpenRouter / NVIDIA wizard buttons
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2b_wizard_openrouter_button_exists(app_js_content):
|
||||
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('openrouter')."""
|
||||
assert "showWizardStep2('openrouter')" in app_js_content, (
|
||||
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('openrouter') button"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2b_wizard_nvidia_button_exists(app_js_content):
|
||||
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('nvidia')."""
|
||||
assert "showWizardStep2('nvidia')" in app_js_content, (
|
||||
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('nvidia') button"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2b_wizard_openrouter_step2_has_slot_and_base_url(app_js_content):
|
||||
"""P0-2b: showWizardStep2('openrouter') must render slot picker + base URL input."""
|
||||
# The openrouter branch must use wiz-redirect-slot and wiz-base-url-input
|
||||
step2_match = re.search(
|
||||
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert step2_match, "showWizardStep2 function not found"
|
||||
step2_body = step2_match.group(1)
|
||||
# Find the openrouter branch
|
||||
openrouter_branch = re.search(
|
||||
r"providerId\s*===\s*['\"]openrouter['\"]",
|
||||
step2_body
|
||||
)
|
||||
assert openrouter_branch, "showWizardStep2 has no openrouter branch"
|
||||
# The openrouter branch must have slot selector and base URL input
|
||||
openrouter_section = step2_body[openrouter_branch.start():]
|
||||
# Find the next provider branch or end
|
||||
next_branch = re.search(
|
||||
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
||||
openrouter_section[20:]
|
||||
)
|
||||
openrouter_chunk = openrouter_section[:20 + next_branch.start()] if next_branch else openrouter_section[20:]
|
||||
assert "wiz-redirect-slot" in openrouter_chunk, (
|
||||
"P0-2b: openrouter branch must use wiz-redirect-slot for slot selection"
|
||||
)
|
||||
assert "wiz-base-url-input" in openrouter_chunk, (
|
||||
"P0-2b: openrouter branch must have wiz-base-url-input for optional base URL"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2b_wizard_nvidia_step2_has_slot_and_base_url(app_js_content):
|
||||
"""P0-2b: showWizardStep2('nvidia') must render slot picker + base URL input."""
|
||||
step2_match = re.search(
|
||||
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert step2_match, "showWizardStep2 function not found"
|
||||
step2_body = step2_match.group(1)
|
||||
# nvidia may be in a combined OR branch with openrouter, or standalone
|
||||
nvidia_branch = re.search(
|
||||
r"providerId\s*===\s*['\"]nvidia['\"]",
|
||||
step2_body
|
||||
)
|
||||
assert nvidia_branch, "showWizardStep2 has no nvidia branch"
|
||||
nvidia_section = step2_body[nvidia_branch.start():]
|
||||
next_branch = re.search(
|
||||
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
||||
nvidia_section[20:]
|
||||
)
|
||||
nvidia_chunk = nvidia_section[:20 + next_branch.start()] if next_branch else nvidia_section[20:]
|
||||
assert "wiz-redirect-slot" in nvidia_chunk, (
|
||||
"P0-2b: nvidia branch must use wiz-redirect-slot for slot selection"
|
||||
)
|
||||
assert "wiz-base-url-input" in nvidia_chunk, (
|
||||
"P0-2b: nvidia branch must have wiz-base-url-input for optional base URL"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2b_proceedtostep3_sets_slot_for_openrouter_and_nvidia(app_js_content):
|
||||
"""P0-2b: proceedToWizardStep3 must set window._wiz_device_profile for openrouter and nvidia."""
|
||||
proceed_match = re.search(
|
||||
r"function proceedToWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nasync function|\n$)",
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert proceed_match, "proceedToWizardStep3 function not found"
|
||||
proceed_body = proceed_match.group(1)
|
||||
# openrouter and nvidia must be listed alongside antigravity/claude redirect-auth flow
|
||||
# so that window._wiz_device_profile is set before showWizardStep3
|
||||
assert "openrouter" in proceed_body, (
|
||||
"P0-2b: proceedToWizardStep3 must handle openrouter to persist slot selection"
|
||||
)
|
||||
assert "nvidia" in proceed_body, (
|
||||
"P0-2b: proceedToWizardStep3 must handle nvidia to persist slot selection"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# TDD: P0-3 Local LLM server discovery button in wizard step 2
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_discover_local_models_action_referenced(app_js_content):
|
||||
"""P0-3: discover_local_models action must be referenced in app.js."""
|
||||
assert "discover_local_models" in app_js_content, (
|
||||
"P0-3: discover_local_models action must be referenced in app.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_discover_button_in_local_wizard_step2(app_js_content):
|
||||
"""P0-3: showWizardStep2 local branch must have 'Найти на этом компьютере' button
|
||||
that calls discoverLocalServers or executeAction('discover_local_models')."""
|
||||
step2_match = re.search(
|
||||
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert step2_match, "showWizardStep2 function not found"
|
||||
step2_body = step2_match.group(1)
|
||||
# Find the local provider branch
|
||||
local_branch = re.search(
|
||||
r"providerId\s*===\s*['\"]local['\"]",
|
||||
step2_body
|
||||
)
|
||||
assert local_branch, "showWizardStep2 has no local provider branch"
|
||||
local_section = step2_body[local_branch.start():]
|
||||
# Find the extent of this branch (until next major provider branch or end)
|
||||
next_branch = re.search(
|
||||
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
||||
local_section[20:]
|
||||
)
|
||||
local_chunk = local_section[:20 + next_branch.start()] if next_branch else local_section[20:]
|
||||
# Must contain the discover button label
|
||||
assert "Найти на этом компьютере" in local_chunk, (
|
||||
"P0-3: local wizard step 2 must have 'Найти на этом компьютере' button"
|
||||
)
|
||||
# Must call discover_local_models
|
||||
assert "discover_local_models" in local_chunk, (
|
||||
"P0-3: local wizard step 2 must call discover_local_models action"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_discover_fills_base_url_input(app_js_content):
|
||||
"""P0-3: discovered servers list must fill wiz-base-url-input on selection."""
|
||||
# There must be a handler that sets wiz-base-url-input value from discovered server
|
||||
assert "wiz-base-url-input" in app_js_content, (
|
||||
"P0-3: wiz-base-url-input must be present for filling discovered server URL"
|
||||
)
|
||||
# The handler for discovered servers must reference both servers result and wiz-base-url-input
|
||||
# Look for a function that handles discover_local_models response
|
||||
discover_match = re.search(
|
||||
r"function\s+(\w*discover\w*)\s*\(",
|
||||
app_js_content,
|
||||
re.IGNORECASE
|
||||
)
|
||||
assert discover_match, "P0-3: no function handling discover_local_models response found"
|
||||
handler_name = discover_match.group(1)
|
||||
handler_match = re.search(
|
||||
rf"function\s+{re.escape(handler_name)}\s*\([^)]*\)\s*\{{(.*?)\n\}}",
|
||||
app_js_content,
|
||||
re.DOTALL
|
||||
)
|
||||
assert handler_match, f"handler {handler_name} body not found"
|
||||
handler_body = handler_match.group(1)
|
||||
assert "wiz-base-url-input" in handler_body, (
|
||||
"P0-3: discover handler must fill wiz-base-url-input with selected server URL"
|
||||
)
|
||||
|
|
@ -53,19 +53,18 @@ def test_p0_1_installer_dependencies():
|
|||
cs_content = setup_cs.read_text(encoding="utf-8")
|
||||
|
||||
assert "EnsurePythonDependencies" in cs_content, "Canonical installer must define dependency checking and installation"
|
||||
assert "customtkinter" in cs_content and "pillow" in cs_content.lower(), "Canonical installer must install customtkinter and Pillow"
|
||||
assert "fastapi" in cs_content and "uvicorn" in cs_content.lower(), "Canonical installer must install fastapi and uvicorn"
|
||||
assert "HERMES_HUB_IMPORT_OK" in cs_content, "Canonical installer must execute post-install import smoke test"
|
||||
assert "assets" in cs_content, "Canonical installer must deploy branding and UI assets"
|
||||
|
||||
# 2. Verify Runtime Dependency Availability
|
||||
pytest.importorskip("customtkinter")
|
||||
import customtkinter
|
||||
from PIL import Image
|
||||
import fastapi
|
||||
import uvicorn
|
||||
import psutil
|
||||
import yaml
|
||||
|
||||
assert customtkinter is not None
|
||||
assert Image is not None
|
||||
assert fastapi is not None
|
||||
assert uvicorn is not None
|
||||
assert psutil is not None
|
||||
assert yaml is not None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,396 +0,0 @@
|
|||
"""Acceptance coverage for UI state contract v1.1 fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import AccountCardWidget, QuotaBucketWidget
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
|
||||
from antigravity_provider.router.ui.views.routing_view import RoutingRoleWidget
|
||||
from antigravity_provider.router.ui.views.team_view import AgentCardWidget
|
||||
from antigravity_provider.router.unified_health import (
|
||||
AgentViewModel,
|
||||
PipelineNode,
|
||||
ProfileViewModel,
|
||||
ProviderSummary,
|
||||
RolePipeline,
|
||||
SystemReadiness,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ui_root(tk_root):
|
||||
root = ctk.CTkToplevel(tk_root)
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> ProfileViewModel:
|
||||
return ProfileViewModel(
|
||||
profile_id="account-1",
|
||||
display_name="Primary",
|
||||
account_identity="user@example.test",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
assigned_roles=["developer-1"],
|
||||
primary_role="developer-1",
|
||||
is_main_account=True,
|
||||
is_main_orchestrator=False,
|
||||
auth_state="AUTHENTICATED",
|
||||
health_state="healthy",
|
||||
health_label_ru="Работает",
|
||||
model_states={},
|
||||
cooldown_remaining_sec=0,
|
||||
last_checked_at="12:00:00",
|
||||
enabled=True,
|
||||
is_cold_spare=False,
|
||||
is_empty_slot=False,
|
||||
plan_code=plan_code,
|
||||
plan_source=plan_source,
|
||||
)
|
||||
|
||||
|
||||
def _readiness() -> SystemReadiness:
|
||||
return SystemReadiness(
|
||||
state="healthy",
|
||||
title_ru="Система готова",
|
||||
summary_ru="Все роли доступны",
|
||||
roles_ready_count=1,
|
||||
total_roles=1,
|
||||
accounts_connected_count=1,
|
||||
total_accounts=1,
|
||||
providers_ready_count=1,
|
||||
total_providers=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_plan_badge_distinguishes_trusted_inferred_and_unknown(ui_root) -> None:
|
||||
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
|
||||
try:
|
||||
card.pack()
|
||||
card.update_account(_profile("PRO", "provider_api"))
|
||||
ui_root.update_idletasks()
|
||||
assert card.plan_badge.label.cget("text") == "Тариф PRO"
|
||||
assert card.plan_badge.winfo_manager() == "pack"
|
||||
|
||||
card.update_account(_profile("PRO", "inferred"))
|
||||
ui_root.update_idletasks()
|
||||
assert card.plan_badge.label.cget("text") == "Тариф PRO • выведено"
|
||||
assert card.plan_badge.label.cget("text_color") == Theme.TEXT_MUTED
|
||||
|
||||
card.update_account(_profile("UNKNOWN", "unknown"))
|
||||
ui_root.update_idletasks()
|
||||
assert card.plan_badge.winfo_manager() == ""
|
||||
finally:
|
||||
card.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_live_quota_overrides_stale_exhausted_card_status(ui_root) -> None:
|
||||
card = AccountCardWidget(ui_root, "account-1", "user@example.test", "Antigravity")
|
||||
profile = replace(_profile(), health_state="quota_exhausted", health_label_ru="Квота исчерпана")
|
||||
snapshot = QuotaSnapshot(
|
||||
account_id="account-1",
|
||||
provider="antigravity",
|
||||
source="provider_api",
|
||||
buckets=[
|
||||
QuotaBucket(id="claude", display_name="Claude", remaining_percent=100.0),
|
||||
QuotaBucket(id="gemini", display_name="Gemini", remaining_percent=100.0),
|
||||
],
|
||||
)
|
||||
try:
|
||||
card.pack()
|
||||
card.update_account(profile, snapshot)
|
||||
ui_root.update_idletasks()
|
||||
assert card.status.label.cget("text") == "Работает"
|
||||
assert card.status.dot.cget("text_color") == Theme.STATUS_HEALTHY
|
||||
finally:
|
||||
card.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) -> None:
|
||||
widget = QuotaBucketWidget(ui_root, "bucket", "Claude 5h")
|
||||
try:
|
||||
widget.pack()
|
||||
widget.update_bucket("Claude 5h", None, unavailable_reason="Провайдер не вернул лимит")
|
||||
ui_root.update_idletasks()
|
||||
assert widget.bar.detail.cget("text") == "Н/Д"
|
||||
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEUTRAL
|
||||
assert "Провайдер не вернул лимит" in widget.unavailable_reason.cget("text")
|
||||
|
||||
widget.update_bucket("Claude 5h", 0.0)
|
||||
ui_root.update_idletasks()
|
||||
assert widget.bar.detail.cget("text") == "0%"
|
||||
assert widget.bar.progress.cget("progress_color") == Theme.COLOR_NEGATIVE
|
||||
assert widget.unavailable_reason.winfo_manager() == ""
|
||||
finally:
|
||||
widget.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
|
||||
agent = AgentViewModel(
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
role_description_ru="Основной кодер",
|
||||
assigned_profile_id="account-2",
|
||||
assigned_display_name="Reserve",
|
||||
provider="codex",
|
||||
provider_display_name="OpenAI Codex",
|
||||
model="gpt-5",
|
||||
account_identity="reserve@example.test",
|
||||
routing_position="Fallback 1",
|
||||
status="healthy",
|
||||
status_label_ru="Работает",
|
||||
is_active=True,
|
||||
is_main_orchestrator=False,
|
||||
session_id="session-42",
|
||||
active_quota_status="warning",
|
||||
active_quota_label="Осталось 12%",
|
||||
)
|
||||
team_card = AgentCardWidget(ui_root)
|
||||
pipeline = RolePipeline(
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
default_model="gpt-5",
|
||||
max_failover=2,
|
||||
session_affinity=True,
|
||||
active_profile_id="account-2",
|
||||
nodes=[
|
||||
PipelineNode(
|
||||
profile_id="account-1",
|
||||
display_name="Primary",
|
||||
provider="Google Antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
status="quota_exhausted",
|
||||
status_label_ru="Исчерпан",
|
||||
is_active=False,
|
||||
account_identity="primary@example.test",
|
||||
quota_status="exhausted",
|
||||
failover_reason="Исчерпана квота (429)",
|
||||
),
|
||||
PipelineNode(
|
||||
profile_id="account-2",
|
||||
display_name="Reserve",
|
||||
provider="OpenAI Codex",
|
||||
model="gpt-5",
|
||||
status="healthy",
|
||||
status_label_ru="Работает",
|
||||
is_active=True,
|
||||
account_identity="reserve@example.test",
|
||||
quota_status="warning",
|
||||
),
|
||||
],
|
||||
)
|
||||
route = RoutingRoleWidget(ui_root, pipeline)
|
||||
try:
|
||||
team_card.pack()
|
||||
route.pack()
|
||||
team_card.update_agent(agent)
|
||||
route.update_from_pipeline(pipeline)
|
||||
ui_root.update_idletasks()
|
||||
assert "Осталось 12%" in team_card.quota_lbl.cget("text")
|
||||
assert "session-42" in team_card.quota_lbl.cget("text")
|
||||
assert "Исчерпана квота (429)" in route._nodes["account-1"].failover.cget("text")
|
||||
assert route._nodes["account-2"].failover.cget("text") == ""
|
||||
assert "Квота: исчерпана" == route._nodes["account-1"].quota.cget("text")
|
||||
finally:
|
||||
route.destroy()
|
||||
team_card.destroy()
|
||||
|
||||
|
||||
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
|
||||
agent = AgentViewModel(
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
role_description_ru="Основной кодер",
|
||||
assigned_profile_id="ag-w1",
|
||||
assigned_display_name="Primary",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
model="gemini-3.1-pro",
|
||||
account_identity="user@example.test",
|
||||
routing_position="Primary",
|
||||
status="healthy",
|
||||
status_label_ru="Работает",
|
||||
is_active=True,
|
||||
is_main_orchestrator=False,
|
||||
active_quota_status="healthy",
|
||||
active_quota_label="Осталось 73%",
|
||||
)
|
||||
snapshot = HubSnapshot(
|
||||
generation=1,
|
||||
seq=1,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={},
|
||||
all_profiles={},
|
||||
readiness=_readiness(),
|
||||
agents=[agent],
|
||||
providers=[],
|
||||
routing={},
|
||||
quotas={
|
||||
"ag-w1": QuotaSnapshot(
|
||||
account_id="ag-w1",
|
||||
provider="antigravity",
|
||||
source="provider_api",
|
||||
buckets=[
|
||||
QuotaBucket(
|
||||
id="antigravity.gemini.7d",
|
||||
display_name="Gemini • неделя",
|
||||
model_family="gemini",
|
||||
remaining_percent=73.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
label, percent = DashboardView._agent_quota_measurement(snapshot, agent)
|
||||
|
||||
assert label == "Осталось 73%"
|
||||
assert percent == 73.0
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_stale_snapshot_is_visibly_marked_with_sequence(ui_root) -> None:
|
||||
snapshot = HubSnapshot(
|
||||
generation=7,
|
||||
seq=11,
|
||||
timestamp=time.time() - 301,
|
||||
profiles_by_provider={},
|
||||
all_profiles={},
|
||||
readiness=_readiness(),
|
||||
agents=[],
|
||||
providers=[],
|
||||
routing={},
|
||||
quotas={},
|
||||
is_stale=True,
|
||||
)
|
||||
view = DashboardView(ui_root)
|
||||
try:
|
||||
view.pack()
|
||||
view.update_data(snapshot)
|
||||
ui_root.update_idletasks()
|
||||
label = view.snapshot_freshness.cget("text")
|
||||
assert "#11" in label
|
||||
assert "устарели" in label
|
||||
assert view.snapshot_freshness.cget("text_color") == Theme.STATUS_WARNING
|
||||
finally:
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_root) -> None:
|
||||
profiles = [
|
||||
replace(
|
||||
_profile(),
|
||||
profile_id=f"ag-{index}",
|
||||
account_identity=f"user{index}@example.test",
|
||||
email=f"user{index}@example.test",
|
||||
)
|
||||
for index in range(6)
|
||||
]
|
||||
readiness = replace(_readiness(), accounts_connected_count=6, total_accounts=6)
|
||||
provider = ProviderSummary(
|
||||
provider_id="antigravity",
|
||||
provider_name="Google Antigravity",
|
||||
total_slots=10,
|
||||
connected_count=6,
|
||||
online_count=6,
|
||||
auth_required_count=0,
|
||||
quota_exhausted_count=0,
|
||||
cold_spare_count=0,
|
||||
discovered_models=["gemini-3.7-flash"],
|
||||
last_refresh_at="12:00:00",
|
||||
)
|
||||
snapshot = HubSnapshot(
|
||||
generation=1,
|
||||
seq=1,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
readiness=readiness,
|
||||
agents=[],
|
||||
providers=[provider],
|
||||
routing={},
|
||||
quotas={},
|
||||
)
|
||||
view = DashboardView(ui_root)
|
||||
try:
|
||||
view.pack()
|
||||
view.update_data(snapshot)
|
||||
ui_root.update_idletasks()
|
||||
assert view.agents_metric.val_label.cget("text") == "6"
|
||||
assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text")
|
||||
assert not hasattr(view, "realtime")
|
||||
assert view.route_diagram.provider_slots[0].winfo_manager() == "place"
|
||||
finally:
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_snapshot_unavailable_reason_can_flow_to_account_bucket() -> None:
|
||||
snapshot = QuotaSnapshot(
|
||||
account_id="account-1",
|
||||
provider="antigravity",
|
||||
buckets=[QuotaBucket(id="b", display_name="Gemini 5h")],
|
||||
unavailable_reason="Авторизация недоступна",
|
||||
)
|
||||
assert snapshot.buckets[0].remaining_percent is None
|
||||
assert snapshot.unavailable_reason == "Авторизация недоступна"
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_dashboard_renders_all_five_providers_in_order(ui_root) -> None:
|
||||
provider_ids = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
|
||||
providers = [
|
||||
ProviderSummary(
|
||||
provider_id=pid,
|
||||
provider_name=pid.upper(),
|
||||
total_slots=2,
|
||||
connected_count=1,
|
||||
online_count=1,
|
||||
auth_required_count=0,
|
||||
quota_exhausted_count=0,
|
||||
cold_spare_count=0,
|
||||
discovered_models=["model-1"],
|
||||
last_refresh_at="12:00:00",
|
||||
)
|
||||
for pid in provider_ids
|
||||
]
|
||||
snapshot = HubSnapshot(
|
||||
generation=1,
|
||||
seq=1,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={},
|
||||
all_profiles={},
|
||||
readiness=_readiness(),
|
||||
agents=[],
|
||||
providers=providers,
|
||||
routing={},
|
||||
quotas={},
|
||||
)
|
||||
view = DashboardView(ui_root)
|
||||
try:
|
||||
view.pack()
|
||||
view.update_data(snapshot)
|
||||
ui_root.update_idletasks()
|
||||
assert len(view._provider_cards) == 5
|
||||
assert set(view._provider_cards.keys()) == set(provider_ids)
|
||||
for slot in view.route_diagram.provider_slots[:5]:
|
||||
assert slot.winfo_manager() == "place"
|
||||
finally:
|
||||
view.destroy()
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"""Regression checks for the Hermes Hub UI design-system foundation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
from antigravity_provider.router.ui import components
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_required_component_library_is_available() -> None:
|
||||
required = {
|
||||
"HubCard",
|
||||
"SectionHeader",
|
||||
"StatusBadge",
|
||||
"PlanBadge",
|
||||
"ProviderBadge",
|
||||
"QuotaBar",
|
||||
"QuotaBucketWidget",
|
||||
"AccountCardWidget",
|
||||
"AgentCardWidget",
|
||||
"RouteTargetWidget",
|
||||
"EmptyState",
|
||||
"SearchField",
|
||||
"FilterButton",
|
||||
"ActionButton",
|
||||
"IconButton",
|
||||
"ConfirmDialog",
|
||||
"Toast",
|
||||
}
|
||||
assert required <= set(vars(components))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_brand_gold_is_not_used_as_healthy_status() -> None:
|
||||
assert Theme.COLOR_BRAND == Theme.ACCENT
|
||||
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
|
||||
assert Theme.COLOR_POSITIVE != Theme.COLOR_BRAND
|
||||
assert components._semantic_color("healthy") == Theme.COLOR_POSITIVE
|
||||
assert components._semantic_color("reserve") == Theme.COLOR_CAUTION
|
||||
assert components._semantic_color("error") == Theme.COLOR_NEGATIVE
|
||||
assert components._semantic_color("unknown") == Theme.COLOR_NEUTRAL
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_layout_and_typography_tokens_are_centralized() -> None:
|
||||
assert Theme.PAGE_PAD_X in {Theme.SPACE_MD, Theme.SPACE_LG, Theme.SPACE_XL}
|
||||
assert Theme.CARD_PAD_X in {Theme.SPACE_SM, Theme.SPACE_MD, Theme.SPACE_LG}
|
||||
assert Theme.HEIGHT_INPUT == Theme.HEIGHT_BTN_MD - 2
|
||||
assert Theme.font_title() == Theme.font_title_page()
|
||||
assert Theme.font_title_page()[1] > Theme.font_heading()[1] > Theme.font_body()[1]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_quota_bucket_has_stable_key_and_in_place_update_api() -> None:
|
||||
signature = inspect.signature(components.QuotaBucketWidget.__init__)
|
||||
assert "bucket_key" in signature.parameters
|
||||
assert callable(getattr(components.QuotaBucketWidget, "update_bucket"))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_quota_is_supported_explicitly() -> None:
|
||||
signature = inspect.signature(components.QuotaBar.set_value)
|
||||
annotation = signature.parameters["value"].annotation
|
||||
# inspect resolves the real typing object here (components.py does not use
|
||||
# `from __future__ import annotations`), so compare types, not source text.
|
||||
assert type(None) in typing.get_args(annotation), (
|
||||
"QuotaBar.set_value must accept None so an unknown quota can be rendered "
|
||||
f"as such instead of a fabricated number; got {annotation!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_long_identity_is_ellipsized_without_losing_source_value() -> None:
|
||||
identity = "very.long.account.identity.for.daily.operations@example.enterprise"
|
||||
shortened = components.ellipsize_text(identity, 28)
|
||||
assert len(shortened) == 28
|
||||
assert shortened.endswith("…")
|
||||
assert identity.startswith(shortened[:-1])
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
"""Acceptance tests for the approved three-theme mockup redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.components import AccountCardWidget
|
||||
from antigravity_provider.router.ui.theme import Theme
|
||||
from antigravity_provider.router import hermes_hub_app as app_module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ui_root(tk_root):
|
||||
root = ctk.CTkToplevel(tk_root)
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_every_restored_account_handler_has_a_ui_trigger() -> None:
|
||||
required = {"test", "set_main", "set_orchestrator", "assign_role"}
|
||||
configured = {action for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS}
|
||||
assert configured == required
|
||||
|
||||
app_path = Path("src/antigravity_provider/router/hermes_hub_app.py")
|
||||
tree = ast.parse(app_path.read_text(encoding="utf-8"))
|
||||
handled = {
|
||||
node.comparators[0].value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Compare)
|
||||
and isinstance(node.left, ast.Name)
|
||||
and node.left.id == "action"
|
||||
and len(node.ops) == 1
|
||||
and isinstance(node.ops[0], ast.Eq)
|
||||
and len(node.comparators) == 1
|
||||
and isinstance(node.comparators[0], ast.Constant)
|
||||
and isinstance(node.comparators[0].value, str)
|
||||
}
|
||||
assert required <= handled
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_three_schemes_have_identical_token_coverage() -> None:
|
||||
assert set(Theme.PALETTES) == {"dark", "hybrid", "light"}
|
||||
token_sets = [set(palette) for palette in Theme.PALETTES.values()]
|
||||
assert token_sets[0] == token_sets[1] == token_sets[2]
|
||||
backgrounds = {palette["BG_WINDOW"] for palette in Theme.PALETTES.values()}
|
||||
assert len(backgrounds) == 3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ui_colors_are_centralized_in_theme_tokens() -> None:
|
||||
roots = [
|
||||
Path("src/antigravity_provider/router/ui"),
|
||||
Path("src/antigravity_provider/router/hermes_hub_app.py"),
|
||||
]
|
||||
offenders = []
|
||||
for root in roots:
|
||||
files = [root] if root.is_file() else list(root.rglob("*.py"))
|
||||
for file in files:
|
||||
if file.name == "theme.py":
|
||||
continue
|
||||
if re.search(r"#[0-9A-Fa-f]{3,8}", file.read_text(encoding="utf-8")):
|
||||
offenders.append(str(file))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
|
||||
source_files = list(Path("src/antigravity_provider/router/ui").rglob("*.py"))
|
||||
source_files.append(Path("src/antigravity_provider/router/hermes_hub_app.py"))
|
||||
source = "\n".join(file.read_text(encoding="utf-8") for file in source_files)
|
||||
for fictional_value in ("78%", "09:00–21:00", "842 rps", "99.98%", "42 Мбит/с"):
|
||||
assert fictional_value not in source
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_approved_logo_and_consistent_sidebar_icon_system_are_wired() -> None:
|
||||
assert Path("assets/branding/logo/logo_approved.png").is_file()
|
||||
assets_source = Path("src/antigravity_provider/router/ui/assets.py").read_text(encoding="utf-8")
|
||||
app_source = Path("src/antigravity_provider/router/hermes_hub_app.py").read_text(encoding="utf-8")
|
||||
assert 'self.logo_dir / "logo_approved.png"' in assets_source
|
||||
assert "def get_nav_icon" in assets_source
|
||||
assert "get_nav_icon(icon, size=19)" in app_source
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_apply_scheme_updates_shared_semantic_aliases() -> None:
|
||||
original = Theme.current_scheme
|
||||
try:
|
||||
for scheme in Theme.SCHEMES:
|
||||
assert Theme.apply_scheme(scheme) == scheme
|
||||
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
|
||||
assert Theme.COLOR_CAUTION == Theme.STATUS_WARNING
|
||||
assert Theme.COLOR_BRAND == Theme.ACCENT
|
||||
finally:
|
||||
Theme.apply_scheme(original)
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
|
||||
calls = []
|
||||
card = AccountCardWidget(
|
||||
ui_root,
|
||||
"profile-1",
|
||||
"user@example.test",
|
||||
"OpenAI Codex",
|
||||
on_action=lambda action, profile: calls.append((action, profile.profile_id)),
|
||||
)
|
||||
card.profile_model = SimpleNamespace(profile_id="profile-1")
|
||||
try:
|
||||
card.pack()
|
||||
for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS:
|
||||
card.action_buttons[action].invoke()
|
||||
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
|
||||
finally:
|
||||
card.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
|
||||
policy = SimpleNamespace(preferred_chain=[])
|
||||
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"manager": policy}))
|
||||
monkeypatch.setattr(
|
||||
app_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
lambda *_args, **_kwargs: (False, "Профиль не найден"),
|
||||
)
|
||||
ui_root._show_account_action_result = lambda *_args: None
|
||||
modal = app_module.HermesHubApp._open_assign_role_modal(ui_root, "missing", "Claude")
|
||||
try:
|
||||
modal.save_button.invoke()
|
||||
modal.update_idletasks()
|
||||
assert modal.winfo_exists()
|
||||
assert "Профиль не найден" in modal.result_label.cget("text")
|
||||
finally:
|
||||
modal.destroy()
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
"""Acceptance coverage for the phase 2–6 snapshot-driven UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import AccountCardWidget
|
||||
from antigravity_provider.router.ui.views.accounts_view import AccountsView
|
||||
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
|
||||
from antigravity_provider.router.ui.views.routing_view import RoutingView
|
||||
from antigravity_provider.router.ui.views.team_view import TeamView
|
||||
from antigravity_provider.router.unified_health import ProfileViewModel, SystemReadiness
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ui_root(tk_root):
|
||||
root = ctk.CTkToplevel(tk_root)
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _profile(index: int) -> ProfileViewModel:
|
||||
return ProfileViewModel(
|
||||
profile_id=f"account-{index}",
|
||||
display_name=f"Worker {index}",
|
||||
account_identity=f"user-{index}@example.test",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
assigned_roles=["coder" if index else "manager"],
|
||||
primary_role="coder" if index else "manager",
|
||||
is_main_account=index == 0,
|
||||
is_main_orchestrator=index == 0,
|
||||
auth_state="AUTHENTICATED",
|
||||
health_state="healthy",
|
||||
health_label_ru="Работает",
|
||||
model_states={},
|
||||
cooldown_remaining_sec=0,
|
||||
last_checked_at="12:00:00",
|
||||
enabled=True,
|
||||
is_cold_spare=False,
|
||||
is_empty_slot=False,
|
||||
email=f"mail-{index}@example.test",
|
||||
plan="PRO",
|
||||
plan_code="PRO",
|
||||
preferred_models=["gemini-2.5-pro"],
|
||||
)
|
||||
|
||||
|
||||
def _quota(profile_id: str, remaining: float | None = None) -> QuotaSnapshot:
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider="antigravity",
|
||||
buckets=[
|
||||
QuotaBucket(
|
||||
id="antigravity.gemini.5h",
|
||||
display_name="Gemini 5h",
|
||||
model_family="gemini",
|
||||
remaining_percent=remaining,
|
||||
period="5h",
|
||||
)
|
||||
],
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(count: int = 50, changed_remaining: float | None = None) -> HubSnapshot:
|
||||
profiles = [_profile(index) for index in range(count)]
|
||||
quotas = {
|
||||
profile.profile_id: _quota(
|
||||
profile.profile_id,
|
||||
changed_remaining if profile.profile_id == "account-0" else None,
|
||||
)
|
||||
for profile in profiles
|
||||
}
|
||||
return HubSnapshot(
|
||||
generation=1 if changed_remaining is None else 2,
|
||||
seq=1 if changed_remaining is None else 2,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
readiness=SystemReadiness(
|
||||
state="healthy",
|
||||
title_ru="Система готова",
|
||||
summary_ru="Все назначенные роли доступны",
|
||||
roles_ready_count=6,
|
||||
total_roles=6,
|
||||
accounts_connected_count=count,
|
||||
total_accounts=count,
|
||||
providers_ready_count=1,
|
||||
total_providers=1,
|
||||
),
|
||||
agents=[],
|
||||
providers=[],
|
||||
routing={},
|
||||
quotas=quotas,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_identity_priority_and_plan_badge_suppression() -> None:
|
||||
profile = _profile(1)
|
||||
assert AccountCardWidget.resolve_identity(profile) == profile.email
|
||||
assert "PlanBadge" not in Path("src/antigravity_provider/router/ui/views/accounts_view.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
@pytest.mark.timeout(90)
|
||||
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
|
||||
view = AccountsView(ui_root)
|
||||
try:
|
||||
view.pack(fill="both", expand=True)
|
||||
view.update_data(_snapshot())
|
||||
ui_root.update_idletasks()
|
||||
before = view.render_stats()
|
||||
card_ids = {key: id(card) for key, card in view._cards.items()}
|
||||
assert all(card.compact for card in view._cards.values())
|
||||
|
||||
view.update_data(_snapshot(changed_remaining=42.0))
|
||||
ui_root.update_idletasks()
|
||||
after = view.render_stats()
|
||||
|
||||
assert len(view._cards) == 50
|
||||
assert before["cards_created"] == after["cards_created"] == 50
|
||||
assert before["cards_destroyed"] == after["cards_destroyed"] == 0
|
||||
assert before["quota_widgets_created"] == after["quota_widgets_created"] == 50
|
||||
assert before["quota_widgets_destroyed"] == after["quota_widgets_destroyed"] == 0
|
||||
assert {key: id(card) for key, card in view._cards.items()} == card_ids
|
||||
bucket = view._cards["account-0"]._quota_widgets["antigravity.gemini.5h"]
|
||||
assert "оценка" in bucket.title.cget("text")
|
||||
finally:
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_removing_account_destroys_only_its_card_and_views_accept_snapshot(ui_root) -> None:
|
||||
children = []
|
||||
try:
|
||||
view = AccountsView(ui_root)
|
||||
children.append(view)
|
||||
first = _snapshot(3)
|
||||
view.update_data(first)
|
||||
retained = id(view._cards["account-1"])
|
||||
profiles = first.profiles_by_provider["antigravity"][1:]
|
||||
second = replace(
|
||||
first,
|
||||
generation=2,
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
quotas={key: value for key, value in first.quotas.items() if key != "account-0"},
|
||||
)
|
||||
view.update_data(second)
|
||||
assert "account-0" not in view._cards
|
||||
assert id(view._cards["account-1"]) == retained
|
||||
assert view.render_stats()["cards_destroyed"] == 1
|
||||
|
||||
for child in (DashboardView(ui_root), TeamView(ui_root), RoutingView(ui_root)):
|
||||
children.append(child)
|
||||
child.update_data(second)
|
||||
ui_root.update_idletasks()
|
||||
finally:
|
||||
for child in children:
|
||||
child.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_data_views_do_not_call_backend_services() -> None:
|
||||
view_dir = Path("src/antigravity_provider/router/ui/views")
|
||||
forbidden = ("HubStateStore", "scan_all(", "AccountQuotaService", "HermesRefreshScheduler", "EventLogService")
|
||||
for name in (
|
||||
"accounts_view.py",
|
||||
"dashboard_view.py",
|
||||
"team_view.py",
|
||||
"routing_view.py",
|
||||
"providers_view.py",
|
||||
"health_view.py",
|
||||
"logs_view.py",
|
||||
):
|
||||
source = (view_dir / name).read_text(encoding="utf-8")
|
||||
assert not any(token in source for token in forbidden), f"{name} accesses backend directly"
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for UI refinement, test action safety, and settings persistence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
from antigravity_provider.router.hermes_hub_app import do_test_profile, do_set_main, do_set_orchestrator
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
|
||||
def test_test_action_safe_on_unauthenticated():
|
||||
"""Verify that testing an unauthenticated profile returns an error and never triggers OAuth/browser."""
|
||||
# ag-spare-2 is unauthenticated
|
||||
res = do_test_profile("antigravity", "ag-spare-2")
|
||||
assert res["success"] is False
|
||||
assert len(res.get("error", "")) > 0
|
||||
|
||||
|
||||
def test_test_action_non_existent_profile():
|
||||
"""Verify that testing a non-existent profile returns a clean error."""
|
||||
res = do_test_profile("antigravity", "non_existent_profile_xyz")
|
||||
assert res["success"] is False
|
||||
assert len(res.get("error", "")) > 0
|
||||
|
||||
|
||||
def test_set_main_profile_action():
|
||||
"""Verify that do_set_main updates the default profile."""
|
||||
# Ensure profile has saved auth
|
||||
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "valid"}})
|
||||
|
||||
# Test setting main profile
|
||||
ok, msg = do_set_main("antigravity", "ag-w1")
|
||||
assert ok is True
|
||||
assert "ag-w1" in msg
|
||||
|
||||
# Verify via ProfileAuthManager
|
||||
main_pid = ProfileAuthManager.get_main_profile("antigravity")
|
||||
assert main_pid == "ag-w1"
|
||||
|
||||
|
||||
def test_set_orchestrator_action():
|
||||
"""Verify that do_set_orchestrator updates orchestrator role policy."""
|
||||
ok, msg = do_set_orchestrator("ag-w2")
|
||||
assert ok is True
|
||||
assert "ag-w2" in msg
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
from antigravity_provider.router.ui import routing_graph as graph_module
|
||||
from antigravity_provider.router import action_handler
|
||||
from antigravity_provider.router.ui import add_account_wizard as wizard_module
|
||||
from antigravity_provider.router.ui.views import team_view as team_module
|
||||
from antigravity_provider.router import hermes_hub_app as app_module
|
||||
from antigravity_provider.router.ui.routing_graph import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
RoutingGraph,
|
||||
RoutingGraphController,
|
||||
RoutingGraphStore,
|
||||
default_graph,
|
||||
validate_graph,
|
||||
)
|
||||
|
||||
|
||||
def _config():
|
||||
profiles = {
|
||||
"orch": SimpleNamespace(provider="openai-codex", preferred_models=["gpt-5"]),
|
||||
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
|
||||
}
|
||||
roles = {
|
||||
"manager": SimpleNamespace(preferred_chain=["orch"]),
|
||||
"developer-1": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"developer-2": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"code-reviewer": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"researcher": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"tester": SimpleNamespace(preferred_chain=["coder"]),
|
||||
}
|
||||
return SimpleNamespace(roles=roles, profiles=profiles)
|
||||
|
||||
|
||||
def test_agent_catalog_uses_backend_cache_without_literal_fallback(monkeypatch):
|
||||
from antigravity_provider.router.ui import model_catalog
|
||||
|
||||
service = SimpleNamespace(get_cached=lambda _provider: {"models": ["provider-model-a", "provider-model-b"]})
|
||||
monkeypatch.setattr(model_catalog, "_service", lambda: service)
|
||||
cached = model_catalog.get_cached_models("antigravity")
|
||||
assert cached.models == ("provider-model-a", "provider-model-b")
|
||||
|
||||
|
||||
def test_agent_catalog_empty_cache_is_explicit(monkeypatch):
|
||||
from antigravity_provider.router.ui import model_catalog
|
||||
|
||||
monkeypatch.setattr(model_catalog, "_service", lambda: None)
|
||||
cached = model_catalog.get_cached_models("antigravity")
|
||||
assert cached.models == ()
|
||||
assert "ещё не подключена" in cached.unavailable_reason
|
||||
|
||||
|
||||
def test_default_graph_migrates_six_roles_without_changing_chains():
|
||||
config = _config()
|
||||
before = {key: list(value.preferred_chain) for key, value in config.roles.items()}
|
||||
graph = default_graph(config)
|
||||
assert {node.role_id for node in graph.nodes} == set(config.roles)
|
||||
assert {edge.edge_type for edge in graph.edges} == {"DELEGATE"}
|
||||
assert before == {key: value.preferred_chain for key, value in config.roles.items()}
|
||||
|
||||
|
||||
def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
|
||||
path = tmp_path / "routing_graph.json"
|
||||
store = RoutingGraphStore(path)
|
||||
graph = default_graph(_config())
|
||||
graph.nodes[0].x = 777
|
||||
graph.zoom = 1.35
|
||||
graph.viewport_x = 42
|
||||
store.save(graph)
|
||||
loaded = store.load(_config())
|
||||
assert loaded.nodes[0].x == 777
|
||||
assert loaded.zoom == 1.35
|
||||
assert loaded.viewport_x == 42
|
||||
assert loaded.schema_version == 1
|
||||
|
||||
|
||||
def test_validation_finds_cycle_unreachable_and_missing_profile():
|
||||
config = _config()
|
||||
config.roles["code-reviewer"].preferred_chain = ["ghost"]
|
||||
graph = RoutingGraph(
|
||||
nodes=[
|
||||
GraphNode("manager", 0, 0),
|
||||
GraphNode("developer-1", 1, 0),
|
||||
GraphNode("code-reviewer", 2, 0),
|
||||
],
|
||||
edges=[
|
||||
GraphEdge("manager", "developer-1"),
|
||||
GraphEdge("developer-1", "manager"),
|
||||
],
|
||||
)
|
||||
codes = {issue.code for issue in validate_graph(graph, config)}
|
||||
assert {"cycle", "unreachable", "missing-profile"} <= codes
|
||||
|
||||
|
||||
def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
monkeypatch.setattr(graph_module, "load_router_config", _config)
|
||||
monkeypatch.setattr(
|
||||
graph_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||
)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
ok, _message = controller.add_edge("manager", "developer-1", "FALLBACK", "orch")
|
||||
assert ok
|
||||
assert calls == [("orch", "developer-1", False)]
|
||||
|
||||
|
||||
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(graph_module, "load_router_config", _config)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
original = controller.graph.nodes[0].x
|
||||
controller.move_node("manager", original + 100, 50)
|
||||
assert controller.dirty
|
||||
assert controller.undo()
|
||||
assert controller.graph.nodes[0].x == original
|
||||
assert controller.redo()
|
||||
assert controller.graph.nodes[0].x == original + 100
|
||||
|
||||
|
||||
def test_graph_store_handles_twenty_nodes(tmp_path):
|
||||
graph = RoutingGraph(nodes=[GraphNode(f"role-{index}", index * 70, index * 35) for index in range(20)])
|
||||
store = RoutingGraphStore(tmp_path / "routing_graph.json")
|
||||
store.save(graph)
|
||||
assert len(store.load(_config()).nodes) == 20
|
||||
|
||||
|
||||
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
|
||||
from antigravity_provider.router import auto_assigner as assigner_module
|
||||
|
||||
config = _config()
|
||||
calls = []
|
||||
monkeypatch.setattr(assigner_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
assigner_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||
)
|
||||
assert wizard_module.ensure_profile_in_routing("orch")[0]
|
||||
assert calls == []
|
||||
monkeypatch.setattr(
|
||||
assigner_module.AutoAssigner,
|
||||
"get_display_name_and_role",
|
||||
lambda _profile: ("Новый кодер", "coder", "fallback"),
|
||||
)
|
||||
assert wizard_module.ensure_profile_in_routing("new-slot")[0]
|
||||
assert calls == [("new-slot", "coder", False)]
|
||||
|
||||
|
||||
def test_profile_test_invokes_model_with_timeout_and_records_success(monkeypatch):
|
||||
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
|
||||
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
|
||||
|
||||
class Adapter:
|
||||
@staticmethod
|
||||
def invoke(profile, req, *args, **kwargs):
|
||||
return {"choices": [{"message": {"content": "pong"}}]}
|
||||
|
||||
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": False})
|
||||
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
||||
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
|
||||
monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
|
||||
|
||||
def mock_mark_success(self, p, m):
|
||||
self.marked = True
|
||||
monkeypatch.setattr("antigravity_provider.router.health_tracker.HealthTracker.mark_success", mock_mark_success)
|
||||
|
||||
result = action_handler.do_test_profile("antigravity", "connected")
|
||||
assert result["success"] is True
|
||||
assert "Авторизация подтверждена" in result["response"]
|
||||
|
||||
|
||||
def test_profile_test_expired_credentials_fail_immediately(monkeypatch):
|
||||
profile = SimpleNamespace(provider="antigravity", preferred_models=["gemini"], profile_id="connected")
|
||||
config = SimpleNamespace(get_profile=lambda _profile_id: profile)
|
||||
|
||||
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True, "is_expired": True})
|
||||
|
||||
result = action_handler.do_test_profile("antigravity", "connected")
|
||||
assert result["success"] is False
|
||||
assert "Авторизация истекла" in result["error"]
|
||||
|
||||
|
||||
def test_wizard_finish_closes_logs_and_clears_reused_slot(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module, "ensure_profile_in_routing", lambda _profile: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wizard_module.EventLogService,
|
||||
"get",
|
||||
lambda: SimpleNamespace(log=lambda *args, **kwargs: calls.append(("log", args, kwargs))),
|
||||
)
|
||||
from antigravity_provider.router import router_engine
|
||||
|
||||
monkeypatch.setattr(
|
||||
router_engine,
|
||||
"get_router_engine",
|
||||
lambda: SimpleNamespace(health=SimpleNamespace(clear_cooldown=lambda profile: calls.append(("clear", profile)))),
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
target_slot="ag-orch-fallback",
|
||||
selected_provider="antigravity",
|
||||
discovered_identity="account",
|
||||
finish_status_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
|
||||
on_complete=lambda payload: calls.append(("complete", payload)),
|
||||
destroy=lambda: calls.append(("destroy",)),
|
||||
)
|
||||
wizard_module.AddAccountWizard._finish(fake)
|
||||
assert ("clear", "ag-orch-fallback") in calls
|
||||
assert any(item[0] == "log" for item in calls)
|
||||
assert calls[-1] == ("destroy",)
|
||||
|
||||
|
||||
def test_wizard_stops_when_provider_has_no_real_free_slot(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module.AutoAssigner, "find_free_slot", lambda _provider: None)
|
||||
fake = SimpleNamespace(
|
||||
selected_provider="grok",
|
||||
target_slot="old-value",
|
||||
_clear_body=lambda: calls.append("clear"),
|
||||
title_lbl=SimpleNamespace(configure=lambda **_kwargs: None),
|
||||
_show_no_free_slot=lambda: calls.append("no-slot"),
|
||||
)
|
||||
|
||||
wizard_module.AddAccountWizard._show_step_2_auth(fake)
|
||||
|
||||
assert fake.target_slot == ""
|
||||
assert calls == ["clear", "no-slot"]
|
||||
|
||||
|
||||
def test_wizard_finish_without_slot_shows_error_and_does_not_assign(monkeypatch):
|
||||
updates = []
|
||||
monkeypatch.setattr(
|
||||
wizard_module,
|
||||
"ensure_profile_in_routing",
|
||||
lambda _profile: pytest.fail("an invented or empty slot must not be assigned"),
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
target_slot="",
|
||||
finish_status_lbl=SimpleNamespace(configure=lambda **kwargs: updates.append(kwargs)),
|
||||
)
|
||||
|
||||
wizard_module.AddAccountWizard._finish(fake)
|
||||
|
||||
assert "свободный слот" in updates[-1]["text"]
|
||||
|
||||
|
||||
def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch):
|
||||
config = SimpleNamespace(
|
||||
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
|
||||
roles={
|
||||
"manager": SimpleNamespace(preferred_chain=["a", "b", "c"]),
|
||||
"code-reviewer": SimpleNamespace(preferred_chain=["b"]),
|
||||
},
|
||||
)
|
||||
calls = []
|
||||
|
||||
def assign(profile_id, role_id, is_primary=True):
|
||||
calls.append((profile_id, role_id, is_primary))
|
||||
if role_id == "spare":
|
||||
for policy in config.roles.values():
|
||||
policy.preferred_chain = [item for item in policy.preferred_chain if item != profile_id]
|
||||
return True, "spare"
|
||||
chain = config.roles[role_id].preferred_chain
|
||||
chain = [item for item in chain if item != profile_id]
|
||||
if is_primary:
|
||||
chain.insert(0, profile_id)
|
||||
else:
|
||||
chain.append(profile_id)
|
||||
config.roles[role_id].preferred_chain = chain
|
||||
return True, "assigned"
|
||||
|
||||
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
|
||||
|
||||
ok, _message = team_module.persist_role_chain("manager", ["c", "a"])
|
||||
|
||||
assert ok
|
||||
assert config.roles["manager"].preferred_chain == ["c", "a"]
|
||||
assert config.roles["code-reviewer"].preferred_chain == ["b"]
|
||||
assert ("b", "spare", False) in calls
|
||||
|
||||
|
||||
def test_account_action_result_is_sent_to_originating_card():
|
||||
calls = []
|
||||
accounts = SimpleNamespace(
|
||||
show_action_result=lambda profile_id, message, success: calls.append(
|
||||
("card", profile_id, message, success)
|
||||
)
|
||||
)
|
||||
fake = SimpleNamespace(
|
||||
_views={"accounts": accounts},
|
||||
_show_toast=lambda message: calls.append(("toast", message)),
|
||||
)
|
||||
|
||||
app_module.HermesHubApp._show_account_action_result(fake, "profile-1", "Не найден", False)
|
||||
|
||||
assert calls[0] == ("card", "profile-1", "Не найден", False)
|
||||
assert calls[1][0] == "toast"
|
||||
|
||||
|
||||
def test_device_code_step_contains_numbered_instructions_and_copy_actions():
|
||||
source = Path(wizard_module.__file__).read_text(encoding="utf-8")
|
||||
assert "1. Откройте ссылку" in source
|
||||
assert "2. Введите на странице код:" in source
|
||||
assert "3. Подтвердите доступ" in source
|
||||
assert source.count('text="Копировать ссылку"') == 2
|
||||
assert source.count('text="📋 Копировать код"') == 2
|
||||
|
||||
|
||||
def test_grok_slot_is_registered_before_role_assignment(monkeypatch):
|
||||
config = SimpleNamespace(profiles={})
|
||||
saved = []
|
||||
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"antigravity_provider.router.auto_assigner.load_router_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"antigravity_provider.router.auto_assigner.save_router_config",
|
||||
lambda current: saved.append(current) or True,
|
||||
)
|
||||
|
||||
ok, _message = wizard_module.AutoAssigner.ensure_profile_definition("grok", "grok-orch")
|
||||
|
||||
assert ok
|
||||
assert config.profiles["grok-orch"].provider == "grok"
|
||||
# Список моделей заполняется обнаружением у провайдера, а не литералом.
|
||||
# Пока обнаружение не выполнено, профиль остаётся без моделей: профиль
|
||||
# без списка честнее профиля с выдуманным. Раньше сюда подставлялось
|
||||
# "grok-3", и по тому же образцу в конфигурацию владельца попал
|
||||
# gemini-3.7-flash, которого у провайдера не существует.
|
||||
assert config.profiles["grok-orch"].preferred_models == []
|
||||
assert saved == [config]
|
||||
|
||||
|
||||
def test_opencode_paste_targets_entry_and_reports_success():
|
||||
calls = []
|
||||
entry = SimpleNamespace(
|
||||
clipboard_get=lambda: " opencode-token-123 ",
|
||||
delete=lambda *_args: calls.append("delete"),
|
||||
insert=lambda *_args: calls.append(("insert", _args[-1])),
|
||||
focus_set=lambda: calls.append("focus"),
|
||||
icursor=lambda *_args: calls.append("cursor"),
|
||||
)
|
||||
status = SimpleNamespace(configure=lambda **kwargs: calls.append(("status", kwargs["text"])))
|
||||
fake = SimpleNamespace(key_entry=entry, key_status_lbl=status)
|
||||
|
||||
wizard_module.AddAccountWizard._paste_into_entry(fake, entry)
|
||||
|
||||
assert ("insert", "opencode-token-123") in calls
|
||||
assert ("status", "✓ Ключ вставлен. Нажмите «Проверить и продолжить».") in calls
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
"""Manual screenshot harness for every tab in all approved color schemes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
pytest.importorskip("PIL")
|
||||
|
||||
from PIL import ImageGrab
|
||||
|
||||
from antigravity_provider.router.hermes_hub_app import HermesHubApp
|
||||
|
||||
|
||||
def capture_all(output_dir: Path) -> list[Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
app = HermesHubApp()
|
||||
app.geometry("1366x768+20+20")
|
||||
app.deiconify()
|
||||
app.lift()
|
||||
app.attributes("-topmost", True)
|
||||
for _index in range(20):
|
||||
app.update()
|
||||
time.sleep(0.04)
|
||||
|
||||
captured = []
|
||||
try:
|
||||
for scheme in ("dark", "hybrid", "light"):
|
||||
app._apply_theme(scheme)
|
||||
for view_name, _label, _icon in app._nav_items:
|
||||
app._show_view(view_name)
|
||||
app.update_idletasks()
|
||||
app.update()
|
||||
time.sleep(0.08)
|
||||
left = app.winfo_rootx()
|
||||
top = app.winfo_rooty()
|
||||
target = output_dir / f"{view_name}-{scheme}.png"
|
||||
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
|
||||
captured.append(target)
|
||||
finally:
|
||||
app.attributes("-topmost", False)
|
||||
app._shutting_down = True
|
||||
app.destroy()
|
||||
return captured
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("artifacts/mockup-redesign")
|
||||
for screenshot in capture_all(destination):
|
||||
print(screenshot)
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"""Мастер подключения обязан закрываться по кнопке «Завершить».
|
||||
|
||||
Дефект, ради которого написан файл: `_finish` вызывал
|
||||
`EventLogService.log_event` — метода, которого у сервиса нет. Под `pythonw`
|
||||
консоли нет, трейсбек Tk уходил в никуда, и для пользователя кнопка
|
||||
«Завершить подключение» просто не работала: окно оставалось открытым,
|
||||
аккаунт в маршрутизацию не попадал.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ui_root(tk_root):
|
||||
app = ctk.CTkToplevel(tk_root)
|
||||
app.withdraw()
|
||||
yield app
|
||||
try:
|
||||
app.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _wizard(root, on_complete=None):
|
||||
w = AddAccountWizard(root, on_complete=on_complete)
|
||||
w.withdraw()
|
||||
w.selected_provider = "antigravity"
|
||||
w.target_slot = "ag-w2"
|
||||
w.discovered_identity = "user@example.com"
|
||||
return w
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_finish_closes_wizard_and_reports_result(ui_root):
|
||||
seen = []
|
||||
w = _wizard(ui_root, on_complete=seen.append)
|
||||
|
||||
w._finish()
|
||||
|
||||
assert w.winfo_exists() == 0, "окно мастера осталось открытым после «Завершить»"
|
||||
assert seen == [
|
||||
{"provider": "antigravity", "profile_id": "ag-w2", "identity": "user@example.com"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_finish_closes_even_if_callback_raises(ui_root):
|
||||
"""Сбой в обработчике владельца не должен запирать пользователя в мастере."""
|
||||
|
||||
def boom(_result):
|
||||
raise RuntimeError("обновление данных упало")
|
||||
|
||||
w = _wizard(ui_root, on_complete=boom)
|
||||
|
||||
w._finish()
|
||||
|
||||
assert w.winfo_exists() == 0
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"""Startup contract for UI views — runs without customtkinter.
|
||||
|
||||
Guards the crash that took the app down on launch:
|
||||
|
||||
TeamView.__init__ forwarded its legacy ``app_state`` dict into
|
||||
``update_data(snapshot)``. The guard there only handled ``None``, so ``{}``
|
||||
slipped through and ``snapshot.readiness`` raised
|
||||
``'dict' object has no attribute 'readiness'``. "Команда" is the default
|
||||
view, so the failure happened before the window ever appeared.
|
||||
|
||||
These checks are static: they read the view sources rather than build widgets,
|
||||
so they run in headless environments where the GUI toolkit is absent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
VIEWS_DIR = Path(__file__).resolve().parent.parent / "src" / "antigravity_provider" / "router" / "ui" / "views"
|
||||
|
||||
|
||||
def _view_files() -> list[Path]:
|
||||
return sorted(VIEWS_DIR.glob("*_view.py"))
|
||||
|
||||
|
||||
def _self_update_data_calls(tree: ast.AST, inside: str) -> list[ast.Call]:
|
||||
"""Return self.update_data(...) calls made from the named method."""
|
||||
calls: list[ast.Call] = []
|
||||
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
|
||||
for fn in (n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == inside):
|
||||
for node in ast.walk(fn):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "update_data"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "self"
|
||||
):
|
||||
calls.append(node)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
|
||||
def test_constructor_does_not_forward_app_state_to_update_data(view_path: Path) -> None:
|
||||
"""A view constructor must not pass its legacy app_state into update_data."""
|
||||
tree = ast.parse(view_path.read_text(encoding="utf-8"))
|
||||
for call in _self_update_data_calls(tree, inside="__init__"):
|
||||
assert not call.args and not call.keywords, (
|
||||
f"{view_path.name}: __init__ calls self.update_data(...) with an argument. "
|
||||
"update_data expects a HubSnapshot; constructors hold app_state dicts. "
|
||||
"Call self.update_data() and let it pull the current snapshot."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
|
||||
def test_update_data_guard_rejects_non_snapshot(view_path: Path) -> None:
|
||||
"""update_data must fall back on anything that is not a HubSnapshot, not just None."""
|
||||
source = view_path.read_text(encoding="utf-8")
|
||||
if "def update_data" not in source or "snapshot" not in source:
|
||||
pytest.skip("view has no snapshot-driven update_data")
|
||||
# Views are pure renderers now: they either fall back to the store or return
|
||||
# early. Either way the guard must reject anything that is not a snapshot —
|
||||
# `snapshot is None` alone lets a legacy app_state dict through, which is
|
||||
# what crashed the app on launch.
|
||||
assert "isinstance(snapshot, HubSnapshot)" in source, (
|
||||
f"{view_path.name}: update_data does not guard with isinstance(snapshot, HubSnapshot). "
|
||||
"A legacy dict would pass a `snapshot is None` check and then fail on attribute access."
|
||||
)
|
||||
315
tests/test_web_handlers_dom_contract.js
Normal file
315
tests/test_web_handlers_dom_contract.js
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* Test contract for Hermes Hub Web Client app.js handlers.
|
||||
* Tests that all dangling handlers are defined, can be called in a simulated browser environment,
|
||||
* and that startup execution (DOMContentLoaded checkUpdates) does not throw ReferenceError.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
|
||||
const appJsPath = path.resolve(__dirname, '../src/antigravity_provider/router/web/static/app.js');
|
||||
const appJsCode = fs.readFileSync(appJsPath, 'utf8');
|
||||
|
||||
const executedActions = [];
|
||||
|
||||
function createMockElement(id = '', tag = 'div') {
|
||||
const el = {
|
||||
id,
|
||||
tagName: tag.toUpperCase(),
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerText: '',
|
||||
innerHTML: '',
|
||||
className: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
classList: {
|
||||
_classes: new Set(),
|
||||
add: function (...cls) { cls.forEach(c => this._classes.add(c)); },
|
||||
remove: function (...cls) { cls.forEach(c => this._classes.delete(c)); },
|
||||
toggle: function (cls, force) {
|
||||
if (force !== undefined) {
|
||||
if (force) this._classes.add(cls); else this._classes.delete(cls);
|
||||
} else {
|
||||
if (this._classes.has(cls)) this._classes.delete(cls); else this._classes.add(cls);
|
||||
}
|
||||
},
|
||||
contains: function (cls) { return this._classes.has(cls); },
|
||||
},
|
||||
style: {},
|
||||
setAttribute: () => {},
|
||||
removeAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
appendChild: (child) => child,
|
||||
removeChild: (child) => child,
|
||||
remove: () => {},
|
||||
focus: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const elementsMap = new Map();
|
||||
function getOrCreateElement(id, tag = 'div') {
|
||||
if (!elementsMap.has(id)) {
|
||||
elementsMap.set(id, createMockElement(id, tag));
|
||||
}
|
||||
return elementsMap.get(id);
|
||||
}
|
||||
|
||||
const mockDoc = {
|
||||
addEventListener: (event, cb) => {
|
||||
if (event === 'DOMContentLoaded') {
|
||||
mockDoc._domContentLoadedCb = cb;
|
||||
}
|
||||
},
|
||||
getElementById: (id) => getOrCreateElement(id),
|
||||
querySelector: (sel) => getOrCreateElement(sel.replace(/^[#.]/, '')),
|
||||
querySelectorAll: (sel) => [getOrCreateElement(sel.replace(/^[#.]/, ''))],
|
||||
createElement: (tag) => createMockElement('', tag),
|
||||
body: getOrCreateElement('body'),
|
||||
location: { search: '', protocol: 'http:', hostname: '127.0.0.1' },
|
||||
};
|
||||
|
||||
const mockWindow = {
|
||||
// P0-1: expose elementsMap so app.js can call elementsMap.clear() on openAddAccountWizard
|
||||
elementsMap,
|
||||
location: { search: '', protocol: 'http:', hostname: '127.0.0.1' },
|
||||
document: mockDoc,
|
||||
localStorage: {
|
||||
_data: {},
|
||||
getItem: (k) => mockWindow.localStorage._data[k] || '',
|
||||
setItem: (k, v) => { mockWindow.localStorage._data[k] = String(v); },
|
||||
removeItem: (k) => { delete mockWindow.localStorage._data[k]; },
|
||||
},
|
||||
setInterval: (fn) => setTimeout(fn, 100000),
|
||||
clearInterval: (id) => clearTimeout(id),
|
||||
setTimeout: (fn, ms) => {
|
||||
// If setTimeout with 0 or small ms, don't block
|
||||
return setTimeout(fn, ms);
|
||||
},
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
fetch: async (url, opts = {}) => {
|
||||
if (url.includes('/api/action')) {
|
||||
const payload = JSON.parse(opts.body || '{}');
|
||||
executedActions.push(payload);
|
||||
const act = payload.action;
|
||||
if (act === 'start_device_auth') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
message: 'Код устройства получен',
|
||||
data: { session_id: 'sess-dev-1', url: 'https://grok.com/device', code: 'GRK-1234', profile_id: 'grok-1' }
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (act === 'start_redirect_auth') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
message: 'Ссылка получена',
|
||||
data: { session_id: 'sess-redir-1', url: 'https://accounts.google.com/o/oauth2/auth?...', port: 5801, profile_id: 'ag-w1', provider: payload.data?.provider || 'antigravity' }
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (act === 'check_updates') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
message: 'Проверка обновлений завершена',
|
||||
data: { update_available: true, current_version: '0.1.1', latest_commit: 'abcdef123456', installed_commit: '1234567890ab' }
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, message: 'Action executed', data: {} }),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/snapshot')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
seq: 1,
|
||||
all_profiles: {
|
||||
'ag-w1': { profile_id: 'ag-w1', provider: 'antigravity', display_name: 'Antigravity 1', preferred_models: ['gemini-2.5-pro'] }
|
||||
},
|
||||
routing: {
|
||||
'coder-primary': { role_name_ru: 'Основной кодер', nodes: [{ profile_id: 'ag-w1', provider: 'antigravity' }] }
|
||||
},
|
||||
providers: [
|
||||
{ provider_id: 'antigravity', provider_name: 'Google Antigravity', discovered_models: ['gemini-2.5-pro', 'gemini-2.5-flash'] }
|
||||
],
|
||||
agents: [],
|
||||
settings: {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
};
|
||||
},
|
||||
console: {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
},
|
||||
URLSearchParams: class {
|
||||
constructor(str) { this.str = str; }
|
||||
get() { return null; }
|
||||
},
|
||||
};
|
||||
|
||||
mockWindow.window = mockWindow;
|
||||
mockWindow.document = mockDoc;
|
||||
mockDoc.defaultView = mockWindow;
|
||||
|
||||
const sandbox = {
|
||||
...mockWindow,
|
||||
global: mockWindow,
|
||||
globalThis: mockWindow,
|
||||
};
|
||||
// P0-1: expose elementsMap in sandbox so app.js can call elementsMap.clear()
|
||||
sandbox.elementsMap = elementsMap;
|
||||
|
||||
vm.createContext(sandbox);
|
||||
|
||||
try {
|
||||
vm.runInContext(appJsCode, sandbox);
|
||||
} catch (err) {
|
||||
console.error('FAILED TO EVALUATE app.js in sandbox:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requiredHandlers = [
|
||||
'openAddAccountWizard',
|
||||
'handleNodeAccountChange',
|
||||
'handleNodeModelChange',
|
||||
'handleRefreshProviderModels',
|
||||
'checkUpdates',
|
||||
];
|
||||
|
||||
for (const handler of requiredHandlers) {
|
||||
if (typeof sandbox[handler] !== 'function') {
|
||||
console.error(`RED ERROR: ${handler} is NOT defined as a function in app.js!`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('1. Testing DOMContentLoaded startup without ReferenceError...');
|
||||
assert(typeof mockDoc._domContentLoadedCb === 'function', 'DOMContentLoaded listener was not registered');
|
||||
mockDoc._domContentLoadedCb();
|
||||
|
||||
console.log('2. Testing checkUpdates handler execution...');
|
||||
await sandbox.checkUpdates(true);
|
||||
const checkAction = executedActions.find(a => a.action === 'check_updates');
|
||||
assert(checkAction, 'checkUpdates did not trigger check_updates action');
|
||||
|
||||
console.log('3. Testing openAddAccountWizard & Step 1...');
|
||||
sandbox.openAddAccountWizard();
|
||||
const modalBody = getOrCreateElement('modal-body');
|
||||
assert(modalBody.innerHTML.includes('Шаг 1 из 3'), 'Wizard Step 1 did not render');
|
||||
|
||||
console.log('4. Testing showWizardStep2 for grok (device auth flow)...');
|
||||
sandbox.showWizardStep2('grok');
|
||||
// GAP-1: grok/codex now shows slot picker first; startDeviceAuth is user-initiated
|
||||
const deviceSlot = getOrCreateElement('wiz-device-slot');
|
||||
deviceSlot.value = 'ag-w1';
|
||||
await sandbox.startDeviceAuth('grok');
|
||||
const devAuthAction = executedActions.find(a => a.action === 'start_device_auth');
|
||||
assert(devAuthAction, 'startDeviceAuth(grok) did not trigger start_device_auth action');
|
||||
// P0-1 BUG-2: startDeviceAuth must send profile_id in payload
|
||||
assert(
|
||||
devAuthAction.data && devAuthAction.data.profile_id === 'ag-w1',
|
||||
`BUG-2: startDeviceAuth must include profile_id='ag-w1' in start_device_auth payload, got: ${JSON.stringify(devAuthAction.data)}`
|
||||
);
|
||||
|
||||
console.log('4b. Testing slot persistence through proceedToWizardStep3 -> finishAddAccount...');
|
||||
// Reset: fresh wizard, grok flow with owner-selected slot
|
||||
sandbox.openAddAccountWizard();
|
||||
sandbox.showWizardStep2('grok');
|
||||
const slotForStep3 = getOrCreateElement('wiz-device-slot');
|
||||
slotForStep3.value = 'owner-slot-42';
|
||||
// proceedToWizardStep3 replaces modalBody (destroying wiz-device-slot)
|
||||
sandbox.proceedToWizardStep3('grok');
|
||||
// finishAddAccount reads from destroyed selects — P0-1 BUG-1: profile_id must survive
|
||||
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
|
||||
targetRoleForStep3.value = 'coder-primary';
|
||||
await sandbox.finishAddAccount('grok');
|
||||
const addAccFromStep3 = executedActions.find(a => a.action === 'add_account');
|
||||
assert(addAccFromStep3, 'finishAddAccount did not trigger add_account after step3');
|
||||
// P0-1 BUG-1: profile_id must be owner-selected slot, NOT empty
|
||||
assert(
|
||||
addAccFromStep3.data && addAccFromStep3.data.profile_id === 'owner-slot-42',
|
||||
`BUG-1: finishAddAccount profile_id must be 'owner-slot-42' (owner choice persisted), got: ${JSON.stringify(addAccFromStep3.data)}`
|
||||
);
|
||||
|
||||
console.log('5. Testing showWizardStep2 for antigravity (redirect auth flow)...');
|
||||
sandbox.showWizardStep2('antigravity');
|
||||
const redirectSlot = getOrCreateElement('wiz-redirect-slot');
|
||||
redirectSlot.value = 'ag-w1';
|
||||
await sandbox.startRedirectAuth('antigravity');
|
||||
const redirAuthAction = executedActions.find(a => a.action === 'start_redirect_auth');
|
||||
assert(redirAuthAction, 'startRedirectAuth(antigravity) did not trigger start_redirect_auth action');
|
||||
|
||||
console.log('6. Testing showWizardStep2 for local and finishAddAccount...');
|
||||
// Reset wizard state (including elementsMap) before local flow to prevent stale slot elements
|
||||
sandbox.openAddAccountWizard();
|
||||
sandbox.showWizardStep2('local');
|
||||
const baseInput = getOrCreateElement('wiz-base-url-input');
|
||||
baseInput.value = 'http://127.0.0.1:8081/v1';
|
||||
sandbox.proceedToWizardStep3('local');
|
||||
const targetRole = getOrCreateElement('wiz-target-role');
|
||||
targetRole.value = 'coder-primary';
|
||||
await sandbox.finishAddAccount('local');
|
||||
// Use [...].reverse().find() to get the LAST add_account action (test 6's, not test 4b's)
|
||||
const addAccAction = [...executedActions].reverse().find(a => a.action === 'add_account');
|
||||
assert(addAccAction, 'finishAddAccount did not trigger add_account action');
|
||||
assert.strictEqual(addAccAction.data.base_url, 'http://127.0.0.1:8081/v1');
|
||||
// P0-1: local flow has no slot selector, profile_id should be empty/undefined
|
||||
assert(
|
||||
!addAccAction.data.profile_id,
|
||||
`BUG-1: local flow should not have a device slot profile_id, got: ${JSON.stringify(addAccAction.data)}`
|
||||
);
|
||||
|
||||
console.log('7. Testing handleNodeAccountChange...');
|
||||
await sandbox.handleNodeAccountChange('coder-primary', 'ag-w1', true);
|
||||
const assignRoleAction = executedActions.find(a => a.action === 'assign_role');
|
||||
assert(assignRoleAction, 'handleNodeAccountChange did not trigger assign_role action');
|
||||
assert.strictEqual(assignRoleAction.data.profile_id, 'ag-w1');
|
||||
|
||||
console.log('8. Testing handleNodeModelChange...');
|
||||
await sandbox.handleNodeModelChange('coder-primary', 'ag-w1', 'gemini-2.5-flash');
|
||||
const setModelAction = executedActions.find(a => a.action === 'set_model');
|
||||
assert(setModelAction, 'handleNodeModelChange did not trigger set_model action');
|
||||
assert.strictEqual(setModelAction.data.model, 'gemini-2.5-flash');
|
||||
|
||||
console.log('9. Testing handleRefreshProviderModels...');
|
||||
await sandbox.handleRefreshProviderModels('antigravity');
|
||||
const refreshModelsAction = executedActions.find(a => a.action === 'refresh_models');
|
||||
assert(refreshModelsAction, 'handleRefreshProviderModels did not trigger refresh_models action');
|
||||
|
||||
console.log('\nAll targeted Node.js DOM and contract test assertions PASSED successfully!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error('\nTEST FAILURE:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
146
uv.lock
146
uv.lock
|
|
@ -236,28 +236,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "customtkinter"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "darkdetect" },
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/36/aee6ed9171a5a232da66a8b257752bd03d25b487c9d67ba367ad159bd976/customtkinter-6.0.0.tar.gz", hash = "sha256:c782df167bc64ab3fc3140286df06967ac2de4dcc97dd5958426fe9c1a98057b", size = 267195, upload-time = "2026-06-24T20:14:32.144Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/e2d3387432c5a94dec64031c86b5ba737216de7e7490abb55efd64dec054/customtkinter-6.0.0-py3-none-any.whl", hash = "sha256:704fcbf1d2fca06bd7881b0afacfae72aed9864c9b6de2870e632e55eb3a0456", size = 302166, upload-time = "2026-06-24T20:14:30.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darkdetect"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/77/7575be73bf12dee231d0c6e60ce7fb7a7be4fcd58823374fc59a6e48262e/darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1", size = 7681, upload-time = "2022-12-16T14:14:42.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955, upload-time = "2022-12-16T14:14:40.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
|
|
@ -300,13 +278,13 @@ name = "hermes-hub"
|
|||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "customtkinter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -314,30 +292,26 @@ dev = [
|
|||
{ name = "anyio" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
legacy = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
{ name = "customtkinter", specifier = ">=6.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'legacy'", specifier = ">=0.110.0" },
|
||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "psutil", specifier = ">=5.9.0" },
|
||||
{ name = "pydantic", specifier = ">=2.6.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.1" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'legacy'", specifier = ">=0.28.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.28.0" },
|
||||
]
|
||||
provides-extras = ["dev", "legacy"]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
|
|
@ -394,100 +368,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -697,6 +577,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue