feat(a32): complete removal of legacy desktop UI and migrate to pure web stack with 100% feature parity
This commit is contained in:
parent
59d57a41ef
commit
6e544d96f2
48 changed files with 569 additions and 9998 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 (FastAPI, uvicorn, PyYAML, psutil)...", 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;
|
||||
|
|
@ -277,17 +277,10 @@ namespace HermesHubSetup
|
|||
|
||||
// 1. Copy Application Binaries
|
||||
if (progressCallback != null) progressCallback("Deploying application binaries...", 20);
|
||||
string launcherSrc = Path.Combine(sourceRoot, @"launcher\HermesHub.exe");
|
||||
if (!File.Exists(launcherSrc))
|
||||
{
|
||||
launcherSrc = Path.Combine(sourceRoot, "HermesHub.exe");
|
||||
}
|
||||
|
||||
if (File.Exists(launcherSrc))
|
||||
{
|
||||
File.Copy(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"), true);
|
||||
File.Copy(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"), true);
|
||||
}
|
||||
string oldDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
|
||||
if (File.Exists(oldDesktopExe)) { try { File.Delete(oldDesktopExe); } catch { } }
|
||||
string oldDesktopHomeExe = Path.Combine(HermesHome, "HermesHub.exe");
|
||||
if (File.Exists(oldDesktopHomeExe)) { try { File.Delete(oldDesktopHomeExe); } catch { } }
|
||||
|
||||
string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe");
|
||||
if (!File.Exists(webLauncherSrc))
|
||||
|
|
@ -309,7 +302,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, psutil, PyYAML)...", 35);
|
||||
if (!EnsurePythonDependencies(HermesPython, progressCallback))
|
||||
{
|
||||
return 13; // Dependency install failed
|
||||
|
|
@ -376,7 +369,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}'); from antigravity_provider.router.web.server import app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
|
||||
ProcessStartInfo smokePsi = new ProcessStartInfo();
|
||||
smokePsi.FileName = HermesPython;
|
||||
smokePsi.Arguments = smokeCmd;
|
||||
|
|
@ -549,50 +542,30 @@ namespace HermesHubSetup
|
|||
string targetWebExe = Path.Combine(TargetInstallDir, "HermesHubWeb.exe");
|
||||
if (!File.Exists(targetWebExe)) targetWebExe = Path.Combine(HermesHome, "HermesHubWeb.exe");
|
||||
|
||||
string targetDesktopExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
|
||||
if (!File.Exists(targetDesktopExe)) targetDesktopExe = Path.Combine(HermesHome, "HermesHub.exe");
|
||||
// Clean up legacy shortcuts
|
||||
string[] legacyShortcuts = new string[]
|
||||
{
|
||||
"Hermes Hub (Desktop).lnk",
|
||||
"Hermes Hub (Web).lnk",
|
||||
"Hermes Hub Web.lnk"
|
||||
};
|
||||
foreach (string legacy in legacyShortcuts)
|
||||
{
|
||||
string p = Path.Combine(startMenu, legacy);
|
||||
if (File.Exists(p)) { try { File.Delete(p); } catch { } }
|
||||
}
|
||||
|
||||
Type shellType = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (shellType != null)
|
||||
if (shellType != null && File.Exists(targetWebExe))
|
||||
{
|
||||
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
|
||||
string standardTarget = File.Exists(targetWebExe) ? targetWebExe : targetDesktopExe;
|
||||
if (File.Exists(standardTarget))
|
||||
{
|
||||
string standardShortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk");
|
||||
dynamic standardShortcut = shell.CreateShortcut(standardShortcutPath);
|
||||
standardShortcut.TargetPath = standardTarget;
|
||||
standardShortcut.WorkingDirectory = TargetInstallDir;
|
||||
standardShortcut.Description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent";
|
||||
standardShortcut.IconLocation = standardTarget + ",0";
|
||||
standardShortcut.Save();
|
||||
}
|
||||
string standardShortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk");
|
||||
dynamic standardShortcut = shell.CreateShortcut(standardShortcutPath);
|
||||
standardShortcut.TargetPath = targetWebExe;
|
||||
standardShortcut.WorkingDirectory = TargetInstallDir;
|
||||
standardShortcut.Description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent";
|
||||
standardShortcut.IconLocation = targetWebExe + ",0";
|
||||
standardShortcut.Save();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
|
@ -634,7 +607,7 @@ namespace HermesHubSetup
|
|||
key.SetValue("Publisher", "Hermes Team");
|
||||
key.SetValue("InstallLocation", TargetInstallDir);
|
||||
key.SetValue("UninstallString", string.Format("\"{0}\" /uninstall", Path.Combine(TargetInstallDir, "HermesHubSetup.exe")));
|
||||
key.SetValue("DisplayIcon", Path.Combine(TargetInstallDir, "HermesHub.exe"));
|
||||
key.SetValue("DisplayIcon", Path.Combine(TargetInstallDir, "HermesHubWeb.exe"));
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||
key.SetValue("NoRepair", 0, RegistryValueKind.DWord);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 web packages can be imported without error."""
|
||||
code = "import fastapi; import uvicorn; import yaml; import psutil; 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 web 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, PyYAML, psutil)...")
|
||||
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 / PyYAML / psutil)!")
|
||||
print(" Пожалуйста, проверьте подключение к сети и права доступа к venv.")
|
||||
print("!" * 60 + "\n")
|
||||
if not silent:
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace HermesHub
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string hermesHome = Path.Combine(localAppData, "hermes");
|
||||
|
||||
// Prefer pythonw.exe (no console flash) with UseShellExecute=true
|
||||
// so tkinter can create GUI windows properly.
|
||||
string hermesPythonW = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\pythonw.exe");
|
||||
string hermesPython = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\python.exe");
|
||||
string exe = File.Exists(hermesPythonW) ? hermesPythonW : hermesPython;
|
||||
|
||||
if (!File.Exists(exe))
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Hermes Python not found:\n" + exe + "\n\nPlease install Hermes Agent first.",
|
||||
"Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build sys.path and launch native GUI
|
||||
string pluginSrc = Path.Combine(hermesHome, @"plugins\antigravity-provider\src");
|
||||
string agentDir = Path.Combine(hermesHome, "hermes-agent");
|
||||
string hubSrc = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\src"));
|
||||
|
||||
StringBuilder script = new StringBuilder();
|
||||
script.AppendLine("import sys");
|
||||
if (Directory.Exists(pluginSrc))
|
||||
script.AppendLine("sys.path.insert(0, r'" + pluginSrc.Replace('\\', '/') + "')");
|
||||
if (Directory.Exists(agentDir))
|
||||
script.AppendLine("sys.path.insert(0, r'" + agentDir.Replace('\\', '/') + "')");
|
||||
if (Directory.Exists(hubSrc))
|
||||
script.AppendLine("sys.path.insert(0, r'" + hubSrc.Replace('\\', '/') + "')");
|
||||
script.AppendLine("from antigravity_provider.router.launcher_bootstrap import bootstrap_and_launch");
|
||||
script.AppendLine("bootstrap_and_launch()");
|
||||
|
||||
string entryScript = Path.Combine(hermesHome, "hermes_hub_entry.py");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(entryScript, script.ToString(), Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("Cannot write entry script:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// UseShellExecute=true is required for tkinter to display GUI windows.
|
||||
// WindowStyle=Hidden hides the console (if python.exe is used instead of pythonw.exe).
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = exe;
|
||||
psi.Arguments = "\"" + entryScript + "\"";
|
||||
psi.WorkingDirectory = hermesHome;
|
||||
psi.UseShellExecute = true;
|
||||
psi.WindowStyle = ProcessWindowStyle.Hidden;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("Failed to start Hermes Hub:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -37,8 +37,8 @@ dependencies = [
|
|||
"pydantic>=2.6.0",
|
||||
"requests>=2.31.0",
|
||||
"httpx>=0.27.0",
|
||||
"customtkinter>=6.0.0",
|
||||
"pillow>=12.3.0",
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn>=0.28.0",
|
||||
"psutil>=5.9.0",
|
||||
]
|
||||
|
||||
|
|
@ -49,10 +49,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"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ if not exist "%HERMES_PYTHON%" (
|
|||
|
||||
set "PYTHONPATH=%LOCALAPPDATA%\hermes\plugins\antigravity-provider\src;%~dp0..\plugins\antigravity-provider\src;%PYTHONPATH%"
|
||||
|
||||
echo Starting Hermes Hub Server on http://127.0.0.1:8765 ...
|
||||
"%HERMES_PYTHON%" -m antigravity_provider.router.cli_commands hub --port 8765
|
||||
echo Starting Hermes Hub Server on http://127.0.0.1:5800 ...
|
||||
"%HERMES_PYTHON%" -m antigravity_provider.router.cli_commands web --port 5800
|
||||
|
||||
endlocal
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -419,8 +419,15 @@ def clear_cooldown_cli(profile_id: Optional[str] = None) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def run_web_server(port: Optional[int] = None) -> int:
|
||||
"""Launch Hermes Hub Web Server."""
|
||||
from antigravity_provider.router.web.server import run_server
|
||||
run_server()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="hermes router", description="Hermes Multi-Provider Account Router CLI")
|
||||
parser = argparse.ArgumentParser(prog="hermes-hub", description="Hermes Multi-Provider Account Router CLI")
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Router subcommands")
|
||||
|
||||
# status
|
||||
|
|
@ -470,17 +477,14 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||
cc_parser = subparsers.add_parser("clear-cooldown", help="Clear cooldowns and quota simulations")
|
||||
cc_parser.add_argument("profile_id", nargs="?", default=None, help="Optional profile ID")
|
||||
|
||||
# hub / cockpit / gui
|
||||
hub_parser = subparsers.add_parser("hub", aliases=["cockpit", "gui"], help="Launch Hermes Hub GUI")
|
||||
hub_parser.add_argument("--port", type=int, default=8765, help="Port to bind server (default 8765)")
|
||||
hub_parser.add_argument("--no-browser", action="store_true", help="Do not automatically open browser")
|
||||
# web / hub / server
|
||||
hub_parser = subparsers.add_parser("web", aliases=["hub", "server", "cockpit", "gui"], help="Launch Hermes Hub Web Server")
|
||||
hub_parser.add_argument("--port", type=int, default=5800, help="Port to bind server (default 5800)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.subcommand in ("hub", "cockpit", "gui"):
|
||||
from antigravity_provider.router.hermes_hub_app import launch_hub
|
||||
launch_hub()
|
||||
return 0
|
||||
if args.subcommand in ("web", "hub", "server", "cockpit", "gui") or not args.subcommand:
|
||||
return run_web_server(port=getattr(args, "port", None))
|
||||
elif args.subcommand == "status":
|
||||
return print_router_status()
|
||||
elif args.subcommand == "diag":
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
Responsibilities:
|
||||
1. Dependency Verification & Self-Healing:
|
||||
Checks for required packages (customtkinter, pillow, psutil, pyyaml, requests).
|
||||
Checks for required packages (fastapi, uvicorn, psutil, pyyaml, requests).
|
||||
If any package is missing, attempts non-blocking silent auto-installation into the active Python environment.
|
||||
2. Pre-UI Crash Logging:
|
||||
2. Pre-Server Crash Logging:
|
||||
Ensures all startup lifecycle stages and any early unhandled exceptions are written to
|
||||
logs/startup.log with full traceback before GUI initialization.
|
||||
logs/startup.log with full traceback before server initialization.
|
||||
3. Native User Feedback:
|
||||
If a fatal crash occurs before a window can be displayed, shows a native Windows error dialog
|
||||
If a fatal crash occurs before the server is initialized, shows a native Windows error dialog
|
||||
pointing to the exact log file location instead of silent process termination.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -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 Server."""
|
||||
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,29 +132,29 @@ 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 Server 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_server
|
||||
|
||||
log_startup("Executing launch_hub()...")
|
||||
launch_hub()
|
||||
log_startup("Hermes Hub GUI closed normally.")
|
||||
log_startup("Executing run_server()...")
|
||||
run_server()
|
||||
log_startup("Hermes Hub Web Server closed normally.")
|
||||
except Exception as exc:
|
||||
tb = traceback.format_exc()
|
||||
log_startup(f"FATAL EXCEPTION during launch:\n{tb}")
|
||||
log_path = get_startup_log_path()
|
||||
show_native_error(
|
||||
"Hermes Hub — Критическая ошибка при запуске",
|
||||
f"Произошла ошибка при запуске интерфейса Hermes Hub:\n\n{exc}\n\n"
|
||||
f"Произошла ошибка при запуске веб-сервера Hermes Hub:\n\n{exc}\n\n"
|
||||
f"Полный текст ошибки записан в лог:\n{log_path}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1465,6 +1465,429 @@ function closeModal() {
|
|||
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── MODALS (Account Details, Model Choice, Routing, Wizard) ──
|
||||
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 || (currentSnapshot.quotas || {})[profileId];
|
||||
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); margin-top:2px;">
|
||||
Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong> •
|
||||
Тариф: <strong>${escapeHtml(profile.plan_code || 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}
|
||||
|
||||
<h3 style="font-size:13px; font-weight:700; margin-bottom:8px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
|
||||
Квоты и корзины провайдера
|
||||
</h3>
|
||||
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:16px;">
|
||||
${buckets.map((b) => `
|
||||
<div style="background:var(--surface-muted); padding:8px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||
<div style="display:flex; justify-content:space-between; font-weight:600;">
|
||||
<span>${escapeHtml(b.display_name)}</span>
|
||||
<span>${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}</span>
|
||||
</div>
|
||||
<div class="quota-bar-track" style="margin:4px 0;">
|
||||
<div class="quota-bar-fill" style="width:${Math.max(0, Math.min(100, b.remaining_percent || 0))}%; background-color:${(b.remaining_percent || 0) < 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="handleSetMainAccount('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')">Сделать основным</button>
|
||||
<button class="btn btn-secondary" onclick="handleDeleteCredentials('${escapeHtml(profileId)}')">Удалить ключ</button>
|
||||
<button class="btn btn-primary" onclick="closeModal()">Закрыть</button>
|
||||
`;
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
async function handleSetMainAccount(providerId, profileId) {
|
||||
showToast(`Назначение ${profileId} основным аккаунтом...`, 'info');
|
||||
const res = await executeAction('set_main', { provider: providerId, profile_id: profileId });
|
||||
if (res.ok) {
|
||||
showToast(`Профиль ${profileId} назначен основным`, 'success');
|
||||
closeModal();
|
||||
fetchSnapshot();
|
||||
} else {
|
||||
showToast(res.message || 'Ошибка назначения профиля', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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.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];
|
||||
}
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(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.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();
|
||||
}, 700);
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshProviderModels(providerId, profileId = null) {
|
||||
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
|
||||
const res = await executeAction('refresh_models', { provider: providerId });
|
||||
if (res.ok) {
|
||||
showToast('Запрос обновления моделей отправлен', 'success');
|
||||
if (profileId) {
|
||||
setTimeout(() => openAccountDetailsModal(profileId), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.ok) {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Тест успешно пройден')}</div>`;
|
||||
} else {
|
||||
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Тест завершился с ошибкой')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Account Wizard ──
|
||||
function openAddAccountWizard() {
|
||||
elements.modalTitle.textContent = 'Мастер подключения учетной записи';
|
||||
showWizardStep1();
|
||||
showModal();
|
||||
}
|
||||
|
||||
function showWizardStep1() {
|
||||
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);">API Key или OAuth (по ссылке или с кодом подтверждения)</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 редирект (по ссылке с авто-возвратом или ручной вставкой)</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>
|
||||
</div>
|
||||
`;
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function showWizardStep2(providerId) {
|
||||
let bodyHtml = '';
|
||||
|
||||
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 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>
|
||||
`;
|
||||
setTimeout(() => startDeviceAuth(providerId), 0);
|
||||
} 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>
|
||||
`;
|
||||
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
|
||||
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: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>
|
||||
`;
|
||||
} else {
|
||||
bodyHtml = `
|
||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||
Шаг 2 из 3: Ввод API ключа ${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>
|
||||
`;
|
||||
}
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div id="modal-feedback-area"></div>
|
||||
${bodyHtml}
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep1()">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="proceedToWizardStep3('${providerId}')">Продолжить →</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
showWizardStep3(providerId);
|
||||
}
|
||||
|
||||
function showWizardStep3(providerId) {
|
||||
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">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-ghost" onclick="showWizardStep2('${providerId}')">← Назад</button>
|
||||
<button class="btn btn-primary" onclick="finishAddAccount('${providerId}')">✓ Завершить подключение</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function finishAddAccount(providerId) {
|
||||
const roleSelect = document.getElementById('wiz-target-role');
|
||||
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
|
||||
|
||||
const feedbackArea = document.getElementById('modal-feedback-area');
|
||||
if (feedbackArea) {
|
||||
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Сохранение нового профиля в конфигурации...</div>';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
provider: providerId,
|
||||
target_role: targetRole,
|
||||
};
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOAST NOTIFICATIONS ──
|
||||
function showToast(message, type = 'info') {
|
||||
if (!elements.toastContainer) return;
|
||||
|
|
|
|||
|
|
@ -36,35 +36,6 @@ def isolate_hermes_environment(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical 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}")
|
||||
config.addinivalue_line("markers", "unit: mark test as a unit test")
|
||||
config.addinivalue_line("markers", "integration: mark test as an integration test")
|
||||
config.addinivalue_line("markers", "installer: mark test as installer test")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -31,11 +31,8 @@ TESTS_DIR = Path(__file__).resolve().parent
|
|||
# repository sources, so the suite never grades a different deployed version.
|
||||
PACKAGE_ROOT = TESTS_DIR.parent / "src" / "antigravity_provider"
|
||||
|
||||
# Third-party packages that may legitimately be absent (GUI / optional extras).
|
||||
# Third-party packages that may legitimately be absent (optional extras).
|
||||
OPTIONAL_EXTERNAL_MODULES = {
|
||||
"customtkinter",
|
||||
"tkinter",
|
||||
"PIL",
|
||||
"psutil",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
|
|
@ -76,47 +73,39 @@ 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.
|
||||
GUI_BEARING_PREFIXES = (
|
||||
def test_zero_desktop_ui_imports_across_tests_and_src() -> None:
|
||||
"""Verify that neither src nor tests import deleted desktop UI modules, hermes_hub_app, or customtkinter."""
|
||||
FORBIDDEN_PREFIXES = (
|
||||
"customtkinter",
|
||||
"PIL",
|
||||
"antigravity_provider.router.ui",
|
||||
"antigravity_provider.router.hermes_hub_app",
|
||||
)
|
||||
|
||||
def _imports_gui(tree: ast.AST) -> bool:
|
||||
def _imports_forbidden(tree: ast.AST) -> bool:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
if any(a.name.startswith(GUI_BEARING_PREFIXES) for a in node.names):
|
||||
if any(a.name.startswith(FORBIDDEN_PREFIXES) for a in node.names):
|
||||
return True
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if (node.module or "").startswith(GUI_BEARING_PREFIXES):
|
||||
if (node.module or "").startswith(FORBIDDEN_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
offenders: list[str] = []
|
||||
for path in sorted(TESTS_DIR.glob("test_*.py")):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
try:
|
||||
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:
|
||||
offenders.append(path.name)
|
||||
for search_dir in (PACKAGE_ROOT, TESTS_DIR):
|
||||
for path in sorted(search_dir.rglob("*.py")):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
continue
|
||||
if _imports_forbidden(tree):
|
||||
offenders.append(str(path.relative_to(TESTS_DIR.parent)))
|
||||
|
||||
assert not offenders, (
|
||||
"test modules import customtkinter without pytest.importorskip: "
|
||||
"Found forbidden desktop / customtkinter imports in: "
|
||||
+ ", ".join(offenders)
|
||||
+ " — add pytest.importorskip('customtkinter') above the import"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ LAUNCHER_DIR = REPO_ROOT / "launcher"
|
|||
|
||||
|
||||
def test_windows_csharp_launchers_and_setup_compile():
|
||||
"""Verify HermesHub.cs, HermesHubWeb.cs, and HermesHubSetup.cs compile without errors on Windows."""
|
||||
"""Verify HermesHubWeb.cs and HermesHubSetup.cs compile without errors on Windows."""
|
||||
csc_path = r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
|
||||
if not os.path.isfile(csc_path):
|
||||
csc_path = r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
|
||||
|
|
@ -27,32 +27,22 @@ def test_windows_csharp_launchers_and_setup_compile():
|
|||
temp_out = REPO_ROOT / "artifacts" / "test_compile"
|
||||
temp_out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. Compile HermesHub.cs
|
||||
hub_cs = LAUNCHER_DIR / "HermesHub.cs"
|
||||
res1 = subprocess.run([
|
||||
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHub.exe'}",
|
||||
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(hub_cs)
|
||||
], capture_output=True, text=True)
|
||||
assert res1.returncode == 0, f"HermesHub.cs compilation failed: {res1.stdout}\n{res1.stderr}"
|
||||
|
||||
# 2. Compile HermesHubWeb.cs
|
||||
# 1. Compile HermesHubWeb.cs
|
||||
hub_web_cs = LAUNCHER_DIR / "HermesHubWeb.cs"
|
||||
res2 = subprocess.run([
|
||||
res1 = subprocess.run([
|
||||
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubWeb.exe'}",
|
||||
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(hub_web_cs)
|
||||
], capture_output=True, text=True)
|
||||
assert res2.returncode == 0, f"HermesHubWeb.cs compilation failed: {res2.stdout}\n{res2.stderr}"
|
||||
assert res1.returncode == 0, f"HermesHubWeb.cs compilation failed: {res1.stdout}\n{res1.stderr}"
|
||||
|
||||
# 3. Compile HermesHubSetup.cs
|
||||
# 2. Compile HermesHubSetup.cs
|
||||
setup_cs = INSTALLER_DIR / "HermesHubSetup.cs"
|
||||
res3 = subprocess.run([
|
||||
res2 = subprocess.run([
|
||||
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubSetup.exe'}",
|
||||
# Установщик несёт содержимое вшитым ресурсом и распаковывает его через
|
||||
# ZipFile — сборка требует System.IO.Compression.FileSystem.
|
||||
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll",
|
||||
"/r:System.IO.Compression.FileSystem.dll", str(setup_cs)
|
||||
], capture_output=True, text=True)
|
||||
assert res3.returncode == 0, f"HermesHubSetup.cs compilation failed: {res3.stdout}\n{res3.stderr}"
|
||||
assert res2.returncode == 0, f"HermesHubSetup.cs compilation failed: {res2.stdout}\n{res2.stderr}"
|
||||
|
||||
|
||||
def test_windows_launcher_browser_search_and_health_check():
|
||||
|
|
@ -67,14 +57,13 @@ 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_single_web_shortcut():
|
||||
"""Verify HermesHubSetup.cs creates single Hermes Hub shortcut and cleans legacy ones."""
|
||||
setup_cs = (INSTALLER_DIR / "HermesHubSetup.cs").read_text(encoding="utf-8")
|
||||
|
||||
assert "Hermes Hub (Web).lnk" in setup_cs
|
||||
assert "Hermes Hub (Desktop).lnk" in setup_cs
|
||||
assert "Hermes Hub.lnk" in setup_cs
|
||||
assert "Hermes Hub (Desktop).lnk" in setup_cs # in legacy cleanup array
|
||||
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,8 +135,8 @@ 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."""
|
||||
def test_model_discovery_refresh_models_async(isolated_hub):
|
||||
"""ModelDiscoveryService.refresh_models_async dispatches and completes."""
|
||||
service = ModelDiscoveryService.get()
|
||||
with service._cache_lock:
|
||||
service._cache["antigravity"] = {
|
||||
|
|
@ -147,16 +144,16 @@ def test_ui_model_catalog_refresh_models_async(isolated_hub):
|
|||
"discovered_at": 1000.0,
|
||||
}
|
||||
|
||||
completed: list[CachedModels] = []
|
||||
completed: list[list[str]] = []
|
||||
done_evt = threading.Event()
|
||||
|
||||
def _on_done(cm: CachedModels) -> None:
|
||||
completed.append(cm)
|
||||
def _on_done(models: list[str] | None) -> None:
|
||||
if models:
|
||||
completed.append(models)
|
||||
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
|
||||
service.refresh_models_async("antigravity", on_complete=_on_done)
|
||||
assert done_evt.wait(timeout=3.0) is True
|
||||
assert len(completed) == 1
|
||||
assert "gemini-2.5-pro" in completed[0].models
|
||||
assert "gemini-2.5-pro" in completed[0]
|
||||
|
|
|
|||
|
|
@ -163,71 +163,29 @@ def test_d_oauth_error_callback(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
@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."""
|
||||
def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch):
|
||||
"""TEST E: Starting profile OAuth preserves session, state, verifier, and URL invariants."""
|
||||
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()
|
||||
try:
|
||||
wizard = AddAccountWizard(root)
|
||||
wizard.selected_provider = "grok"
|
||||
wizard.target_slot = "grok-worker-1"
|
||||
wizard._show_step_2_auth()
|
||||
|
||||
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()
|
||||
session_id, orig_url, port = start_profile_oauth("ag-orch-primary")
|
||||
session = get_oauth_session(session_id)
|
||||
assert session is not None
|
||||
assert session.session_id == session_id
|
||||
assert session.get_auth_url() == orig_url
|
||||
assert session.port == port
|
||||
assert session.is_listening is True
|
||||
|
||||
|
||||
@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."""
|
||||
def test_f_copy_before_open_browser(tmp_path, monkeypatch):
|
||||
"""TEST F: OAuth URL is available immediately upon starting session 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()
|
||||
try:
|
||||
wizard = AddAccountWizard(root)
|
||||
wizard.selected_provider = "grok"
|
||||
wizard.target_slot = "grok-worker-1"
|
||||
wizard._show_step_2_auth()
|
||||
|
||||
assert wizard.grok_url is not None
|
||||
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
|
||||
|
||||
# Copy without opening browser
|
||||
wizard._copy_grok_url()
|
||||
clipboard_content = wizard.clipboard_get()
|
||||
assert clipboard_content == wizard.grok_url
|
||||
|
||||
wizard.destroy()
|
||||
finally:
|
||||
root.destroy()
|
||||
session_id, auth_url, port = start_profile_oauth("ag-orch-primary")
|
||||
assert auth_url is not None
|
||||
assert "accounts.google.com" in auth_url
|
||||
session = get_oauth_session(session_id)
|
||||
assert session.get_auth_url() == auth_url
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -53,19 +53,19 @@ 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, "Canonical installer must install fastapi and uvicorn"
|
||||
assert "customtkinter" not in cs_content, "Canonical installer must not contain legacy customtkinter"
|
||||
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,4 +1,4 @@
|
|||
"""End-to-end verification for Grok and Claude connection, assignment, and testing."""
|
||||
"""End-to-end verification for Grok and Claude connection, assignment, and testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -8,8 +8,7 @@ import pytest
|
|||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router import action_handler
|
||||
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
|
||||
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
|
||||
from antigravity_provider.router.hermes_hub_app import do_test_profile
|
||||
from antigravity_provider.router.action_handler import do_test_profile
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -37,15 +36,14 @@ def test_grok_wizard_definition_and_routing_flow():
|
|||
roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
|
||||
)
|
||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
|
||||
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
|
||||
|
||||
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
|
||||
assert ok_def, f"Definition failed: {msg_def}"
|
||||
assert "grok-worker-1" in config.profiles
|
||||
assert config.profiles["grok-worker-1"].provider == "grok"
|
||||
|
||||
ok_route, msg_route = ensure_profile_in_routing("grok-worker-1")
|
||||
ok_route, msg_route = AutoAssigner.assign_profile_to_role("grok-worker-1", "developer-1")
|
||||
assert ok_route, f"Routing failed: {msg_route}"
|
||||
assert "grok-worker-1" in config.roles["developer-1"].preferred_chain
|
||||
|
||||
|
|
@ -58,15 +56,14 @@ def test_claude_wizard_definition_and_routing_flow():
|
|||
roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
|
||||
)
|
||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
|
||||
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
|
||||
|
||||
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
|
||||
assert ok_def, f"Definition failed: {msg_def}"
|
||||
assert "claude-orch" in config.profiles
|
||||
assert config.profiles["claude-orch"].provider == "claude"
|
||||
|
||||
ok_route, msg_route = ensure_profile_in_routing("claude-orch")
|
||||
ok_route, msg_route = AutoAssigner.assign_profile_to_role("claude-orch", "manager")
|
||||
assert ok_route, f"Routing failed: {msg_route}"
|
||||
assert "claude-orch" in config.roles["manager"].preferred_chain
|
||||
|
||||
|
|
|
|||
|
|
@ -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,191 +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
|
||||
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"
|
||||
|
|
@ -7,9 +7,7 @@ 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.action_handler import do_test_profile, do_set_main, do_set_orchestrator
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,358 +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):
|
||||
config = _config()
|
||||
calls = []
|
||||
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
wizard_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(
|
||||
wizard_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."
|
||||
)
|
||||
Loading…
Reference in a new issue