From 6e544d96f2cd1b31b39148b468e0cb1fceba8ee9 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Wed, 26 Aug 2026 09:42:57 +0700 Subject: [PATCH] feat(a32): complete removal of legacy desktop UI and migrate to pure web stack with 100% feature parity --- installer/HermesHubSetup.cs | 93 +- installer/HermesHubSetup.py | 12 +- launcher/HermesHub.cs | 79 - launcher/HermesHub.exe | Bin 6144 -> 0 bytes pyproject.toml | 8 +- scripts/launch_hermes_hub.bat | 4 +- .../router/cli_commands.py | 24 +- .../router/hermes_hub_app.py | 1345 ------------- .../router/launcher_bootstrap.py | 34 +- .../router/ui/__init__.py | 8 - .../router/ui/add_account_wizard.py | 1750 ----------------- src/antigravity_provider/router/ui/assets.py | 228 --- .../router/ui/components.py | 1255 ------------ .../router/ui/model_catalog.py | 106 - .../router/ui/routing_graph.py | 274 --- src/antigravity_provider/router/ui/splash.py | 78 - src/antigravity_provider/router/ui/theme.py | 379 ---- .../router/ui/views/__init__.py | 3 - .../router/ui/views/about_view.py | 110 -- .../router/ui/views/accounts_view.py | 266 --- .../router/ui/views/analytics_view.py | 75 - .../router/ui/views/dashboard_view.py | 746 ------- .../router/ui/views/health_view.py | 89 - .../router/ui/views/logs_view.py | 127 -- .../router/ui/views/providers_view.py | 96 - .../router/ui/views/quotas_view.py | 83 - .../router/ui/views/routing_view.py | 120 -- .../router/ui/views/settings_view.py | 122 -- .../router/ui/views/team_view.py | 996 ---------- .../router/web/static/app.js | 423 ++++ tests/conftest.py | 35 +- tests/test_codex_opencode_wizard.py | 21 - tests/test_deployment_doctor.py | 7 +- tests/test_import_invariants.py | 47 +- tests/test_installer_windows_and_linux.py | 33 +- tests/test_model_validation_a23.py | 19 +- tests/test_oauth_lifecycle.py | 74 +- tests/test_p0_release_gate.py | 12 +- tests/test_ui_claude_grok_connection.py | 15 +- tests/test_ui_contract_v11.py | 396 ---- tests/test_ui_design_system.py | 84 - tests/test_ui_mockup_redesign.py | 147 -- tests/test_ui_phase2_6.py | 191 -- tests/test_ui_refinement.py | 4 +- tests/test_ui_routing_graph.py | 358 ---- tests/test_ui_screenshot_harness.py | 54 - tests/test_ui_wizard_finish.py | 65 - tests/test_view_startup_contract.py | 72 - 48 files changed, 569 insertions(+), 9998 deletions(-) delete mode 100644 launcher/HermesHub.cs delete mode 100644 launcher/HermesHub.exe delete mode 100644 src/antigravity_provider/router/hermes_hub_app.py delete mode 100644 src/antigravity_provider/router/ui/__init__.py delete mode 100644 src/antigravity_provider/router/ui/add_account_wizard.py delete mode 100644 src/antigravity_provider/router/ui/assets.py delete mode 100644 src/antigravity_provider/router/ui/components.py delete mode 100644 src/antigravity_provider/router/ui/model_catalog.py delete mode 100644 src/antigravity_provider/router/ui/routing_graph.py delete mode 100644 src/antigravity_provider/router/ui/splash.py delete mode 100644 src/antigravity_provider/router/ui/theme.py delete mode 100644 src/antigravity_provider/router/ui/views/__init__.py delete mode 100644 src/antigravity_provider/router/ui/views/about_view.py delete mode 100644 src/antigravity_provider/router/ui/views/accounts_view.py delete mode 100644 src/antigravity_provider/router/ui/views/analytics_view.py delete mode 100644 src/antigravity_provider/router/ui/views/dashboard_view.py delete mode 100644 src/antigravity_provider/router/ui/views/health_view.py delete mode 100644 src/antigravity_provider/router/ui/views/logs_view.py delete mode 100644 src/antigravity_provider/router/ui/views/providers_view.py delete mode 100644 src/antigravity_provider/router/ui/views/quotas_view.py delete mode 100644 src/antigravity_provider/router/ui/views/routing_view.py delete mode 100644 src/antigravity_provider/router/ui/views/settings_view.py delete mode 100644 src/antigravity_provider/router/ui/views/team_view.py delete mode 100644 tests/test_ui_contract_v11.py delete mode 100644 tests/test_ui_design_system.py delete mode 100644 tests/test_ui_mockup_redesign.py delete mode 100644 tests/test_ui_phase2_6.py delete mode 100644 tests/test_ui_routing_graph.py delete mode 100644 tests/test_ui_screenshot_harness.py delete mode 100644 tests/test_ui_wizard_finish.py delete mode 100644 tests/test_view_startup_contract.py diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 80b101f..8ea7009 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -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); } diff --git a/installer/HermesHubSetup.py b/installer/HermesHubSetup.py index 5c66933..f42488f 100644 --- a/installer/HermesHubSetup.py +++ b/installer/HermesHubSetup.py @@ -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: diff --git a/launcher/HermesHub.cs b/launcher/HermesHub.cs deleted file mode 100644 index c80e5cb..0000000 --- a/launcher/HermesHub.cs +++ /dev/null @@ -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); - } - } - } -} diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe deleted file mode 100644 index a5aa2fb9e32ca3ed3495f24f72100cde7b65db6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6144 zcmeHLU2Ggz6+U=iw{7FKgHw@2c4IrX<4x_@$=VKSBiZBK>-99V zGuu0}j@?#ODnM%0R!Wcx;;B@C1X3kH0;QxNs=V~6;(>>PgrWfA1(3>99}r6U&YfNF zI~qR>{V(YqDDNQe@^s1dT#w1{(Eo*!|~lW;`Gy&kN3PU z#y;LNwP^chNqGfj70sOGx?W(;OH-9y({|0FiL6=l@-p4l7TZ@bJvmG?CK~9upS|&X z)!S_{M5}0pad4s`_mxBFO+5X07?|jcFm7Nve(vcC9`Lyt)cKCPtVK~Tp!hf%h*6;EC3CmO6HH9CjqH zb}|B`)T2NS(5?OKbr&1h&@0`-B6~tWf#^lC{TpH*BF1d~Q*4SF`xzh1o*oo>RfcPP z=}5Xe-P7H3_!#Rnkps9H>u&EWSik6h%Xl_Wwp;L7VhT&}E7<7vPG{*o%o)EzdrwVg zhS2{4^oL9jI^H~bZcrDewitw!Mf8vTJ^U^qApJ^VOyf**$h?0t&JW|9KjZxR9H$<@ zOY{ujH4T3RPK-XGo8Wu~d?$80mQ0CVqM4r3a6qukgt!U#tcC>*R{)#oEsQqPZ^TEC z|C^SN7>pkdX(kLRiK(C48v_jt!!+<{&ry=u!I76>$-B;-k zB1^AV6Bbq~TTL1#qq#OY{@^ zwed1?*oi39)Joq3?4Wn(tJHV}>8xocsQPVP&=Y!mE$`+!ydKTdpm*HMmmT>ujY?IN z{%CnVEte%th6i1ZOGQ8DDaW3t?1~@AB2COcFLMEnTeeG2qZ_u!}N%(kqs1 zN2;t;OLk8B6f&&9_S|#Qv6eOF4+L1q^W{KNIv03~D(Wl@fqC1pgO!?0c4}a1QAsOb z$I{`X&)aU^yX>b&JXQ2*pj2|~oc2J&t~Kw-8QU*gPBvI^5X!K-WGl}tN;jZvN#<0*GXn_o=e$9?=nX{o|j!FNbV}vU_Sphgx== z`aq}n;0+@hEZaIE*pzO5jKi*}o|7fV%1N5?D&Y^iIWLd>L_40I8aYbml^w_d$I&K- zmvgej6Q{>3Nmi?aL<_Fx2X@ZqWrL~qvw@|8jJx2Gk3_g!XRM;s#DFT4x$eHE&U@+- z+}0vv`DtHf7p3D2FUuS+jEd24hV=}Fx_ZV#x*~$y;aIQhwndVps0fHn@|k+7lPoHL z?@~Y)X-Q*Cz_OHshD{~#eb6Pm^8qcA2g%EL4<#+*5t;wi)~cnRQP``-XwqbzKNCJZ zcsFSQeHm09`z_9%Obl=mXM#i)KA?$+U0?xd2R{?{oqDSStGTi*8~#`}O|)gL+$g-v z6V@PXl%W$^y$GvZCmS3Ed=haj!!Ac>}f^f-wt?<|1yV9eYg*x7r6O>2!Z z46@lYQnqxFb8yIP=S6pF;9St0uJN$st=OfF-Lr-m0qO_s}AThw|1H6)3SjX>8%loFF?LdydTmrytv z>n#nf{rg>SZR`GUw*T!t*Avs9G~c4=^%pPB>^O4!b)355v3R2?M6|&q(J1g5;>IAr zk0%~$A|oD;Ho&C~!i9;3#@;dngpRN@#8toA-A@pwGmWQz6yKqKDJz$2LmHGtio zVcXh)0TGdyZYI%wq5W)oq>b%ygI@c1`&lC@D&JPS_$DxAY(1~6(izWP-N$f<;IKf5 zW0h|N-ZNarO~&qRb9HjH(bpViRA{ zxZl%J^lPIEZ{U*~@E6zNv;JL1z~OaIbQRBc`mw$Yro#63L88Be!96>*Wb7vNC zn9c#q&;*?UodFyH&h3|tpWdn3)r<-_QR(rLRJ_(khcs^nuepMq@Kw)X3*pm*+dg8g zDZRDv>A@Qo@A7=sh3&_U?;%!!n0VXaJ-D!rrPs7fH+JRlcLbYNHw+xYR|em>L(fCl zXzDkjnjaIg-FR7RvNMq7GpZ)jjV-VC5cT3qK%hdrA+jv)pNRhqAfNX9z0ch6WND@KRB*2NEEczNo361DQ8%~X3Licj%i5yn5ghMd-hSyhpGGfI->KMgbfedY#AGI zfO%eSORh*idE)7|Sg+;#vN-Rom@we_eaW(NkNderS+xAlqMcKo z?=1wKIj?x!@{8%E!%4Gfx%PtegPD47FlCyn#xi-_^l*V+6A?ac5tWMzVPA55rS7Jl zwo0XBS7;$nWnbSQA26OTCAA)W`?xgX?z&Q;!BO&sGUAl^No6nLE?SWO16u8=S~byX zcEXD(Dm*5aq+>b^`;wNQahJSHQYFo@J&@yja9?u4a(r1yN*n1~Cz@*LU29_R?OH7Y g1bVxwGX#QX`(e9p1=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" diff --git a/scripts/launch_hermes_hub.bat b/scripts/launch_hermes_hub.bat index 819f963..d80534c 100644 --- a/scripts/launch_hermes_hub.bat +++ b/scripts/launch_hermes_hub.bat @@ -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 diff --git a/src/antigravity_provider/router/cli_commands.py b/src/antigravity_provider/router/cli_commands.py index 5f91c56..24e2bcb 100644 --- a/src/antigravity_provider/router/cli_commands.py +++ b/src/antigravity_provider/router/cli_commands.py @@ -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": diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py deleted file mode 100644 index abc3533..0000000 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ /dev/null @@ -1,1345 +0,0 @@ -"""Hermes Hub — Native Windows Desktop Application (Stabilization & UX v3). - -Features: -- Windows AppUserModelID (HermesHub.Desktop) for proper Taskbar Pinning & Identity -- High-Performance Sub-100ms Tab Switching (Persistent View Cache, Zero I/O on switch) -- Instrumentation: tab_switch_ms Logging & Benchmark Verification -- Debounced Resize & Movement Handling -- Unified Health Presentation (Strict Priority Resolver, No Stale Quota on Unadded Accounts) -- Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go) -- Non-blocking Background Workers & Graceful Shutdown Coordinator -""" - -from __future__ import annotations - -import ctypes -import json -import logging -import os -import sys -import threading -import time -import webbrowser -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import customtkinter as ctk - -# ── Set Windows AppUserModelID before UI initialization ── -if sys.platform == "win32": - try: - APP_ID = "HermesHub.Desktop" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) - except Exception: - pass - -# ── Ensure plugin and repo paths are on sys.path ── -_SRC_DIR = Path(__file__).resolve().parent.parent.parent -if str(_SRC_DIR) not in sys.path: - sys.path.insert(0, str(_SRC_DIR)) - -from antigravity_provider import paths -_hermes_home = paths.get_hermes_home() -_PLUGIN_SRC = _hermes_home / "plugins" / "antigravity-provider" / "src" -_AGENT_DIR = _hermes_home / "hermes-agent" -for _p in [_PLUGIN_SRC, _AGENT_DIR]: - _ps = str(_p) - if _p.exists() and _ps not in sys.path: - sys.path.insert(0, _ps) - -from antigravity_provider.router.router_config import load_router_config, save_router_config -from antigravity_provider.router.profile_manager import ProfileAuthManager -from antigravity_provider.router.auto_assigner import AutoAssigner -from antigravity_provider.router.adapters import get_adapter -# Единственная реализация действий живёт в action_handler: её используют -# и десктоп, и веб-API. Второй копии в проекте быть не должно. -from antigravity_provider.router.action_handler import ( - ActionExecutor, - do_delete_credentials, - do_save_settings, - do_set_main, - do_set_model, - do_set_orchestrator, - do_test_profile, -) -from antigravity_provider import paths -from antigravity_provider.version import __version__ - -from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.assets import AssetManager -from antigravity_provider.router.ui.components import AccountCardWidget, HubButton, HubModal -from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard - -from antigravity_provider.router.unified_health import ( - UnifiedHealthService, - EventLogService, - SystemReadiness, - STATUS_HEALTHY, -) -from antigravity_provider.router.state_store import HubStateStore - -from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain -from antigravity_provider.router.ui.views.dashboard_view import DashboardView -from antigravity_provider.router.ui.views.accounts_view import AccountsView -from antigravity_provider.router.ui.views.providers_view import ProvidersView -from antigravity_provider.router.ui.views.routing_view import RoutingView -from antigravity_provider.router.ui.views.health_view import HealthView -from antigravity_provider.router.ui.views.logs_view import LogsView -from antigravity_provider.router.ui.views.settings_view import SettingsView -from antigravity_provider.router.ui.views.about_view import AboutView -from antigravity_provider.router.ui.views.analytics_view import AnalyticsView -from antigravity_provider.router.ui.model_catalog import get_cached_models, refresh_models_async - -logger = logging.getLogger("hermes.hub.gui") - -def _load_saved_theme() -> str: - settings_file = paths.get_hermes_home() / "hub_settings.json" - try: - settings = json.loads(settings_file.read_text(encoding="utf-8")) - except (OSError, ValueError, TypeError): - return "dark" - return str(settings.get("theme", "dark")) - - -# ═══════════════════════════════════════════════════════════════ -# Actions Layer (Safe & Non-blocking) -# ═══════════════════════════════════════════════════════════════ - - -class HermesHubApp(ctk.CTk): - def __init__(self): - self._theme_name = Theme.apply_scheme(_load_saved_theme()) - ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light") - super().__init__() - self.title("Hermes Hub") - self.geometry("1380x880") - self.minsize(1100, 700) - - ctk.set_default_color_theme("blue") - self.configure(fg_color=Theme.BG_WINDOW) - - # Set Windows Multi-Resolution Icon - ico_path = AssetManager.get().get_ico_path() - if ico_path and os.path.exists(ico_path): - try: - self.iconbitmap(ico_path) - except Exception: - pass - - self._current_view = "overview" - self._views: Dict[str, ctk.CTkFrame] = {} - self._view_generations: Dict[str, int] = {} - self._shutting_down = False - self._resize_timer_id = None - - self.protocol("WM_DELETE_WINDOW", self._on_close) - self.bind("", self._on_window_configure) - - self._build_layout() - self._show_view("overview") - - try: - from antigravity_provider.router.scheduler import HermesRefreshScheduler - - HermesRefreshScheduler.get().start() - except Exception: - pass - - try: - from antigravity_provider.router.quota_collector import AccountQuotaService - - AccountQuotaService.get().start_background_scheduler() - except Exception: - pass - - self.after(50, self._refresh_data) - self.after(800, self._refresh_quotas_on_startup) - - def _refresh_quotas_on_startup(self) -> None: - """Populate measured quota cards immediately instead of after the 5-minute scheduler tick.""" - if self._shutting_down: - return - try: - from antigravity_provider.router.scheduler import HermesRefreshScheduler - - HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data)) - except Exception as exc: - logger.warning("Initial quota refresh could not start: %s", exc) - - def _build_layout(self): - # ── Sidebar (Left) ── - self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0) - self.sidebar.pack(side="left", fill="y") - self.sidebar.pack_propagate(False) - - # Top Centered Brand Logo - brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent") - brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM)) - - logo_img = AssetManager.get().get_logo_image(size=(78, 78)) - if logo_img: - logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") - logo_lbl.pack(anchor="center", pady=(0, 6)) - - ctk.CTkLabel( - brand_container, - text="HERMES HUB", - font=(Theme.FONT_FAMILY_TITLE, 17, "bold"), - text_color=Theme.TEXT_ACCENT, - ).pack(anchor="center") - ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack( - fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM) - ) - - # Nav Items with clean Fluent glyphs - self._nav_items = [ - ("overview", "Обзор", "overview"), - ("team", "Команда", "team"), - ("accounts", "Аккаунты", "accounts"), - ("routing", "Маршрутизация", "routing"), - ("providers", "Модели и провайдеры", "providers"), - ("analytics", "Аналитика", "analytics"), - ("health", "Состояние", "health"), - ("logs", "Журнал событий", "logs"), - ("settings", "Настройки", "settings"), - ("about", "О программе", "about"), - ] - - self.nav_frame = ctk.CTkScrollableFrame( - self.sidebar, - fg_color="transparent", - corner_radius=0, - scrollbar_fg_color=Theme.BG_SIDEBAR, - scrollbar_button_color=Theme.BG_SIDEBAR, - scrollbar_button_hover_color=Theme.BORDER_HOVER, - ) - self.nav_frame.pack(fill="both", expand=True) - self._nav_buttons: Dict[str, ctk.CTkButton] = {} - for key, label, icon in self._nav_items: - icon_image = AssetManager.get().get_nav_icon(icon, size=19) - btn = ctk.CTkButton( - self.nav_frame, - text=label, - image=icon_image, - compound="left", - font=Theme.font_body(), - height=Theme.HEIGHT_NAV_ITEM, - fg_color="transparent", - hover_color=Theme.SIDEBAR_HOVER, - text_color=Theme.SIDEBAR_TEXT, - anchor="w", - corner_radius=Theme.RADIUS_SM, - command=lambda k=key: self._show_view(k), - ) - btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1) - self._nav_buttons[key] = btn - - self.sidebar_version = ctk.CTkLabel( - self.sidebar, - text=f"Hermes Hub v{__version__}", - font=Theme.font_micro(), - text_color=Theme.SIDEBAR_MUTED, - ) - self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM)) - user_card = ctk.CTkFrame( - self.sidebar, - fg_color=Theme.SIDEBAR_SELECTED, - border_width=1, - border_color=Theme.BORDER, - corner_radius=Theme.RADIUS_MD, - ) - user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) - ctk.CTkLabel( - user_card, - text="AD", - width=30, - height=30, - corner_radius=15, - fg_color=Theme.ACCENT, - text_color=Theme.TEXT_ON_ACCENT, - font=Theme.font_badge_bold(), - ).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) - ctk.CTkLabel( - user_card, - text="Administrator\nОсновная команда", - justify="left", - font=Theme.font_micro(), - text_color=Theme.SIDEBAR_TEXT, - ).pack(side="left") - - # ── Global top bar ── - self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0) - self.statusbar.pack(side="top", fill="x") - self.statusbar.pack_propagate(False) - - self.status_left = ctk.CTkLabel( - self.statusbar, - text="● Состояние загружается", - font=Theme.font_caption(), - text_color=Theme.STATUS_HEALTHY, - ) - self.status_left.pack(side="left", padx=Theme.SPACE_LG) - self.status_left.configure(cursor="hand2") - self.status_left.bind("", lambda _event: self._show_view("health"), add="+") - - self.global_search = ctk.CTkEntry( - self.statusbar, - placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K", - width=360, - height=Theme.HEIGHT_INPUT, - fg_color=Theme.SURFACE, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.global_search.pack(side="left", padx=Theme.SPACE_MD) - self.global_search.bind("", self._run_global_search) - self.bind_all("", self._focus_global_search) - - for icon_name, command in ( - ("settings", lambda: self._show_view("settings")), - ("about", lambda: self._show_view("about")), - ("logs", lambda: self._show_view("logs")), - ): - HubButton( - self.statusbar, - text="", - image=AssetManager.get().get_nav_icon(icon_name, size=18), - variant="ghost", - width=Theme.HEIGHT_BTN_MD, - command=command, - ).pack(side="right", padx=Theme.SPACE_XS) - self.add_account_button = HubButton( - self.statusbar, - text="+ Добавить аккаунт", - variant="primary", - command=lambda: self._handle_action("add_account", {}), - ) - self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD)) - - self.status_right = ctk.CTkLabel( - self.statusbar, - text="Snapshot: Н/Д", - font=Theme.font_micro(), - text_color=Theme.TEXT_MUTED, - ) - - # ── Main Content Area ── - self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0) - self.content.pack(side="right", fill="both", expand=True) - - # Pre-instantiate all views so switching is 100% instant (0-15 ms) - for key, _, _ in self._nav_items: - self._views[key] = self._create_view(key) - - def _focus_global_search(self, _event=None) -> str: - self.global_search.focus_set() - return "break" - - def _run_global_search(self, _event=None) -> str: - query = self.global_search.get().strip() - self._show_view("accounts") - accounts = self._views.get("accounts") - if accounts and hasattr(accounts, "search"): - accounts.search.delete(0, "end") - accounts.search.insert(0, query) - accounts._set_search(query) - return "break" - - def _create_view(self, view_name: str) -> ctk.CTkFrame: - """Create view widget instance.""" - if view_name == "overview": - return DashboardView( - self.content, - app_state={}, - on_navigate=self._show_view, - on_action=self._handle_action, - ) - elif view_name == "team": - return TeamView(self.content, app_state={}, on_action=self._handle_action) - elif view_name == "accounts": - return AccountsView(self.content, app_state={}, on_action=self._handle_action) - elif view_name == "providers": - return ProvidersView(self.content, app_state={}, on_action=self._handle_action) - elif view_name == "routing": - return RoutingView(self.content, on_action=self._handle_action) - elif view_name == "analytics": - return AnalyticsView(self.content) - elif view_name == "health": - return HealthView(self.content, app_state={}, on_refresh=self._refresh_data) - elif view_name == "logs": - return LogsView(self.content) - elif view_name == "settings": - return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name) - elif view_name == "about": - return AboutView(self.content) - else: - return TeamView(self.content, app_state={}, on_action=self._handle_action) - - def _show_view(self, view_name: str): - """Instant view switching using pack_forget() and cached widgets with lazy generation update.""" - t0 = time.time() - prev_view = self._current_view - self._current_view = view_name - - # Update sidebar button states - for key, btn in self._nav_buttons.items(): - if key == view_name: - btn.configure( - fg_color=Theme.SIDEBAR_SELECTED, - text_color=Theme.TEXT_ACCENT, - border_width=1, - border_color=Theme.BORDER_ACCENT, - ) - else: - btn.configure( - fg_color="transparent", - text_color=Theme.SIDEBAR_TEXT, - border_width=0, - ) - - # Hide currently active views - for v in self._views.values(): - v.pack_forget() - - # Show target view instantly - target_view = self._views.get(view_name) - if target_view: - target_view.pack(fill="both", expand=True) - - # Lazy update if view state is behind current snapshot generation - from antigravity_provider.router.state_store import HubStateStore - - snap = HubStateStore.get().get_snapshot() - if self._view_generations.get(view_name, 0) < snap.generation: - if hasattr(target_view, "update_data"): - try: - target_view.update_data(snap) - except Exception as ex: - logger.warning("Error in lazy view update for %s: %s", view_name, ex) - self._view_generations[view_name] = snap.generation - self._update_auxiliary_data(target_view) - - # Instrument tab switch latency - el_ms = round((time.time() - t0) * 1000, 2) - if el_ms > 100: - logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms") - else: - logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms") - - def _on_window_configure(self, event): - """Debounce window resize to maintain 60fps smoothness.""" - if event.widget != self: - return - if self._resize_timer_id: - try: - self.after_cancel(self._resize_timer_id) - except Exception: - pass - self._resize_timer_id = self.after(100, self._handle_debounced_resize) - - def _handle_debounced_resize(self): - self._resize_timer_id = None - - # ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ─────── - - def _refresh_data(self): - if self._shutting_down: - return - try: - self.status_left.configure(text="Обновление состояния...") - except Exception: - pass - - def _load(): - if self._shutting_down: - return - try: - from antigravity_provider.router.state_store import HubStateStore - - snap = HubStateStore.get().refresh(force_scan=True) - if not self._shutting_down: - self.after(0, lambda: self._on_data_loaded(snap)) - except Exception as e: - if not self._shutting_down: - try: - self.after(0, lambda err=str(e): self._on_data_error(err)) - except Exception: - pass - - threading.Thread(target=_load, daemon=True).start() - - def _on_data_loaded(self, snapshot_or_readiness: Any): - if self._shutting_down: - return - - from antigravity_provider.router.state_store import HubSnapshot, HubStateStore - - if isinstance(snapshot_or_readiness, HubSnapshot): - snap = snapshot_or_readiness - readiness = snap.readiness - else: - snap = HubStateStore.get().get_snapshot() - readiness = snapshot_or_readiness - - freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}" - self.status_left.configure( - text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}", - text_color=Theme.STATUS_HEALTHY - if readiness.state == "healthy" - else Theme.STATUS_WARNING - if readiness.state in ("limited", "degraded") - else Theme.STATUS_ERROR, - ) - - self.status_right.configure( - text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей", - text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED, - ) - - # Update ONLY the currently visible view (others are updated lazily on tab switch) - curr_view = self._views.get(self._current_view) - if curr_view and hasattr(curr_view, "update_data"): - try: - curr_view.update_data(snap) - except Exception as ex: - logger.warning("Error updating current view %s: %s", self._current_view, ex) - self._view_generations[self._current_view] = snap.generation - self._update_auxiliary_data(curr_view) - - @staticmethod - def _update_auxiliary_data(view: Any) -> None: - if hasattr(view, "update_events"): - try: - view.update_events(EventLogService.get().get_events(limit=20)) - except Exception as ex: - logger.warning("Error updating event presentation: %s", ex) - - def _on_data_error(self, error: str): - if self._shutting_down: - return - self.status_left.configure(text=f"Ошибка: {error}") - - # ─────── Action Handler ─────── - - def _handle_action(self, action: str, data: Dict[str, Any]): - pid = data.get("profile_id", "") - prov = data.get("provider", "") - - if action == "set_main": - self._run_in_thread( - lambda: do_set_main(prov, pid), - on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), - ) - elif action == "set_orchestrator": - self._run_in_thread( - lambda: do_set_orchestrator(pid), - on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), - ) - elif action == "test": - self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None) - self._run_in_thread( - lambda: do_test_profile(prov, pid), - on_success=lambda result: self._show_test_result(result, pid), - ) - elif action == "oauth" or action == "add_account": - self._open_add_account_wizard() - elif action == "delete_credentials": - self._run_in_thread( - lambda: do_delete_credentials(prov, pid), - on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]), - ) - elif action == "assign_role": - self._open_assign_role_modal(pid, data.get("display_name", pid)) - elif action == "account_details": - self._open_account_details_modal(pid) - elif action == "agent_settings": - self._open_agent_settings_modal( - data.get("role_id", ""), - pid, - ) - elif action == "auto_assign_all": - self._show_toast("⚡ Автоматическое распределение ролей...") - self._run_in_thread( - lambda: AutoAssigner.auto_assign_all(), - on_success=lambda r: self._show_toast("✅ Роли успешно распределены"), - ) - elif action == "refresh_data": - self._refresh_data() - elif action == "refresh_all": - from antigravity_provider.router.scheduler import HermesRefreshScheduler - - HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data)) - elif action == "refresh_account": - from antigravity_provider.router.scheduler import HermesRefreshScheduler - - HermesRefreshScheduler.get().trigger_refresh_account( - prov, - pid, - on_complete=lambda: self.after(0, self._refresh_data), - ) - elif action == "edit_route": - role_id = data.get("role_id", "") - self._open_route_editor_modal(role_id) - elif action == "open_routing": - self._show_view("routing") - routing = self._views.get("routing") - if routing and hasattr(routing, "focus_role"): - routing.focus_role(data.get("role_id", "")) - elif action == "save_settings": - - def _settings_saved(result: Tuple[bool, str]) -> None: - requested_theme = str(data.get("theme", self._theme_name)) - if requested_theme != self._theme_name: - self._apply_theme(requested_theme) - self._show_toast(f"✅ {result[1]}") - - self._run_in_thread( - lambda: do_save_settings(data), - on_success=_settings_saved, - ) - elif action == "check_updates": - from antigravity_provider.updater import UpdateManager - - self._run_in_thread( - lambda: UpdateManager().check_for_updates(), - on_success=lambda result: self._show_toast( - f"Доступна версия {result.manifest.version}" - if result.update_available and result.manifest - else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия") - ), - ) - - def _open_assign_role_modal(self, profile_id: str, display_name: str): - modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420) - - ctk.CTkLabel( - modal.body, - text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - wraplength=440, - justify="left", - ).pack(anchor="w", pady=(0, 12)) - - config = load_router_config() - current_role = next( - (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain), - "orchestrator", - ) - role_var = ctk.StringVar(value=current_role) - roles = [ - ("orchestrator", "👑 Главный оркестратор"), - ("coder-primary", "💻 Основной кодер"), - ("coder-secondary", "💻 Резервный кодер"), - ("reviewer", "🔍 Ревьюер (Code Review)"), - ("research", "🌐 Исследователь (Search / Docs)"), - ("fast", "⚡ Быстрый агент"), - ("spare", "🛡️ Резерв (Spare)"), - ] - - for val, lbl in roles: - ctk.CTkRadioButton( - modal.body, - text=lbl, - variable=role_var, - value=val, - font=Theme.font_body(), - text_color=Theme.TEXT_PRIMARY, - fg_color=Theme.ACCENT, - hover_color=Theme.ACCENT_HOVER, - ).pack(anchor="w", padx=8, pady=3) - - primary_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox( - modal.body, - text="Сделать основным в выбранной цепочке", - variable=primary_var, - font=Theme.font_body(), - text_color=Theme.TEXT_PRIMARY, - fg_color=Theme.ACCENT, - hover_color=Theme.ACCENT_HOVER, - ).pack(anchor="w", padx=8, pady=(12, 3)) - - result_label = ctk.CTkLabel( - modal.body, - text="", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_SECONDARY, - wraplength=440, - justify="left", - anchor="w", - ) - result_label.pack(fill="x", padx=8, pady=(8, 0)) - modal.result_label = result_label - - def _save(): - chosen = role_var.get() - ok, msg = AutoAssigner.assign_profile_to_role( - profile_id, - chosen, - is_primary=primary_var.get() and chosen != "spare", - ) - result_label.configure( - text=f"{'✓' if ok else '✕'} {msg}", - text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, - ) - self._show_account_action_result(profile_id, msg, ok) - if ok: - save_button.configure(text="Готово", command=modal.destroy) - self._refresh_data() - - HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left") - save_button = HubButton( - modal.footer, text="Применить роль", variant="primary", width=160, command=_save - ) - save_button.pack(side="right") - modal.save_button = save_button - return modal - - def _open_account_details_modal(self, profile_id: str): - """Open account details from a click anywhere on its compact row.""" - snapshot = HubStateStore.get().get_snapshot() - profile = snapshot.all_profiles.get(profile_id) - if profile is None: - self._show_toast(f"❌ Профиль '{profile_id}' не найден") - return None - modal = HubModal(self, title=AccountCardWidget.resolve_identity(profile), width=680, height=520) - summary = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) - summary.pack(fill="x", pady=(0, Theme.SPACE_MD)) - roles = ", ".join(profile.assigned_roles or []) or "роль не назначена" - ctk.CTkLabel( - summary, - text=f"{profile.provider_display_name} • {profile.display_name}", - font=Theme.font_heading(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=12, pady=(10, 2)) - ctk.CTkLabel( - summary, - text=f"Роли: {roles} • Авторизация: {profile.auth_label_ru} • {profile.health_label_ru}", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", padx=12, pady=(0, 10)) - - config = load_router_config() - profile_config = config.profiles.get(profile_id) - catalog = get_cached_models(profile.provider) - configured_model = ( - profile_config.preferred_models[0] if profile_config and profile_config.preferred_models else "" - ) - model_values = list(catalog.models) - if configured_model and configured_model not in model_values: - model_values.insert(0, configured_model) - model_var = ctk.StringVar( - value=configured_model or (model_values[0] if model_values else "Список моделей ещё не получен") - ) - model_row = ctk.CTkFrame(modal.body, fg_color="transparent") - model_row.pack(fill="x", pady=(0, Theme.SPACE_SM)) - model_menu = ctk.CTkOptionMenu( - model_row, - values=model_values or ["Список моделей ещё не получен"], - variable=model_var, - fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - button_hover_color=Theme.ACCENT_HOVER, - text_color=Theme.TEXT_PRIMARY, - ) - model_menu.pack(side="left", fill="x", expand=True) - model_note = catalog.unavailable_reason if not catalog.models else ( - f"⚠ «{configured_model}» отсутствует в обнаруженном списке" - if configured_model and configured_model not in catalog.models - else f"{len(catalog.models)} моделей из кэша" - ) - ctk.CTkLabel( - modal.body, - text=model_note, - font=Theme.font_micro(), - text_color=Theme.STATUS_WARNING if not catalog.models or configured_model not in catalog.models else Theme.TEXT_MUTED, - ).pack(anchor="w", pady=(0, Theme.SPACE_SM)) - - def _save_profile_model() -> None: - selected = model_var.get() - ok, msg = do_set_model(profile_id, selected) - if ok: - self._show_toast(f"✅ {msg}") - self._refresh_data() - else: - self._show_toast(f"❌ {msg}") - - HubButton( - model_row, - text="Сохранить модель", - variant="secondary", - width=140, - command=_save_profile_model, - ).pack(side="right", padx=(Theme.SPACE_SM, 0)) - HubButton( - model_row, - text="Обновить список", - variant="ghost", - width=120, - command=lambda: refresh_models_async( - profile.provider, - lambda _result: self.after( - 0, - lambda: (modal.destroy(), self._open_account_details_modal(profile_id)), - ), - ), - ).pack(side="right", padx=(Theme.SPACE_SM, 0)) - - ctk.CTkLabel( - modal.body, text="Квоты и периоды", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY - ).pack(anchor="w") - quota_box = ctk.CTkFrame(modal.body, fg_color="transparent") - quota_box.pack(fill="both", expand=True, pady=(4, 8)) - quota = snapshot.quotas.get(profile_id) - buckets = list(getattr(quota, "buckets", None) or []) - if not buckets: - reason = getattr(quota, "unavailable_reason", None) or "Провайдер не отдал данные о лимитах" - ctk.CTkLabel( - quota_box, text=f"Н/Д — {reason}", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED - ).pack(anchor="w", pady=8) - for bucket in buckets: - row = ctk.CTkFrame(quota_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - row.pack(fill="x", pady=2) - ctk.CTkLabel( - row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY - ).pack(side="left", padx=10, pady=7) - detail = bucket.formatted_remaining() - if detail == "Н/Д": - detail = getattr(bucket, "unavailable_reason", None) or getattr(quota, "unavailable_reason", None) or detail - reset = bucket.formatted_reset() or "время сброса не предоставлено" - ctk.CTkLabel( - row, - text=f"{detail} • {reset}", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ).pack(side="right", padx=10, pady=7) - - HubButton( - modal.footer, - text="Проверить", - variant="secondary", - command=lambda: (modal.destroy(), self._handle_action("test", profile.__dict__)), - ).pack(side="left", padx=(0, 5)) - HubButton( - modal.footer, - text="Назначить роль", - variant="secondary", - command=lambda: (modal.destroy(), self._open_assign_role_modal(profile_id, profile.display_name)), - ).pack(side="left") - HubButton(modal.footer, text="Закрыть", variant="primary", command=modal.destroy).pack(side="right") - return modal - - def _open_route_editor_modal(self, role_id: str): - """Direct, visible editor for one ordered failover chain.""" - config = load_router_config() - policy = config.roles.get(role_id) - if policy is None: - self._show_toast(f"❌ Роль '{role_id}' не найдена") - return None - modal = HubModal(self, title=f"Цепочка маршрутизации: {role_id}", width=620, height=520) - ctk.CTkLabel( - modal.body, - text="Первый профиль — основной. Ниже идут резервы в порядке переключения.", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", pady=(0, 10)) - chain = list(policy.preferred_chain) - rows = ctk.CTkFrame(modal.body, fg_color="transparent") - rows.pack(fill="both", expand=True) - result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) - result.pack(fill="x", pady=(6, 0)) - - def _render() -> None: - for child in rows.winfo_children(): - child.destroy() - for index, pid in enumerate(chain): - pcfg = load_router_config().profiles.get(pid) - row = ctk.CTkFrame(rows, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - row.pack(fill="x", pady=3) - ctk.CTkLabel( - row, - text=f"{index + 1}. {pid}", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(side="left", padx=10, pady=8) - ctk.CTkLabel( - row, - text=(pcfg.provider if pcfg else "профиль не найден"), - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, - ).pack(side="left", padx=6) - - def _move(delta: int, current: int = index) -> None: - target = current + delta - if 0 <= target < len(chain): - chain[current], chain[target] = chain[target], chain[current] - _render() - - def _remove(current: int = index) -> None: - chain.pop(current) - _render() - - HubButton(row, text="Удалить", variant="ghost", width=70, command=_remove).pack( - side="right", padx=(2, 8), pady=5 - ) - HubButton(row, text="↓", variant="secondary", width=34, command=lambda i=index: _move(1, i)).pack( - side="right", padx=2, pady=5 - ) - HubButton(row, text="↑", variant="secondary", width=34, command=lambda i=index: _move(-1, i)).pack( - side="right", padx=2, pady=5 - ) - - add_row = ctk.CTkFrame(modal.body, fg_color="transparent") - add_row.pack(fill="x", pady=(8, 0)) - # Keep every profile in the selector so a just-removed item can be - # re-added immediately without closing and reopening the editor. - available = list(config.profiles) - add_var = ctk.StringVar(value=available[0] if available else "Нет доступных профилей") - add_menu = ctk.CTkOptionMenu( - add_row, - values=available or ["Нет доступных профилей"], - variable=add_var, - fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - text_color=Theme.TEXT_PRIMARY, - ) - add_menu.pack(side="left", fill="x", expand=True) - - def _add() -> None: - pid = add_var.get() - if pid in config.profiles and pid not in chain: - chain.append(pid) - _render() - - HubButton(add_row, text="+ Добавить в цепочку", variant="secondary", command=_add).pack(side="right", padx=(8, 0)) - - def _save_chain() -> None: - ok, message = persist_role_chain(role_id, chain) - result.configure( - text=f"{'✓' if ok else '✕'} {message}", - text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, - ) - if ok: - save_button.configure(text="Готово", command=modal.destroy) - self._refresh_data() - - _render() - HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left") - save_button = HubButton(modal.footer, text="Сохранить цепочку", variant="primary", command=_save_chain) - save_button.pack(side="right") - return modal - - def _open_agent_settings_modal(self, role_id: str, profile_id: str): - """Open practical role settings from a click anywhere on an agent card.""" - from antigravity_provider.router.quota_collector import AccountQuotaService - - config = load_router_config() - role = config.roles.get(role_id) - live_snapshot = HubStateStore.get().get_snapshot() - pipeline = live_snapshot.get_role_pipeline(role_id) - role_labels = { - "orchestrator": "Главный оркестратор", - "coder-primary": "Кодер 1", - "coder-secondary": "Кодер 2", - "reviewer": "Ревьюер", - "research": "Исследователь", - "fast": "Быстрый агент", - } - modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=620, height=720) - ctk.CTkLabel( - modal.body, - text="Аккаунт, модель и реальные лимиты агента", - font=Theme.font_heading(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", pady=(0, 12)) - - chain_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) - chain_card.pack(fill="x", pady=(0, 10)) - chain = list(role.preferred_chain) if role else [] - chain_text = " → ".join( - f"{'● ' if pipeline and node_id == pipeline.active_profile_id else ''}{node_id}" for node_id in chain - ) or "Профили не назначены" - active_node = next( - (node for node in list(getattr(pipeline, "nodes", []) or []) if node.profile_id == pipeline.active_profile_id), - None, - ) - failover_reason = getattr(active_node, "failover_reason", None) or "переключений ещё не было" - ctk.CTkLabel( - chain_card, - text=f"Цепочка: {chain_text}", - font=Theme.font_caption(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=10, pady=(8, 2)) - ctk.CTkLabel( - chain_card, - text=f"Последнее переключение: {failover_reason}", - font=Theme.font_micro(), - text_color=Theme.TEXT_MUTED, - anchor="w", - ).pack(fill="x", padx=10, pady=(0, 8)) - - choices: dict[str, str] = {} - for pid, pcfg in config.profiles.items(): - status = ProfileAuthManager.get_profile_status(pcfg.provider, pid) - if not status.get("authenticated"): - continue - identity = AccountQuotaService.get().get_identity(pcfg.provider, pid).primary_identifier() - label = f"{pid} • {identity}" - choices[label] = pid - if not choices: - ctk.CTkLabel( - modal.body, - text="Нет подключённых аккаунтов. Сначала добавьте аккаунт.", - text_color=Theme.STATUS_WARNING, - ).pack(anchor="w") - HubButton(modal.footer, text="Закрыть", variant="secondary", command=modal.destroy).pack(side="right") - return modal - - selected_label = next((label for label, pid in choices.items() if pid == profile_id), next(iter(choices))) - account_var = ctk.StringVar(value=selected_label) - model_var = ctk.StringVar(value="") - - ctk.CTkLabel(modal.body, text="Аккаунт", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - anchor="w" - ) - account_menu = ctk.CTkOptionMenu( - modal.body, - values=list(choices), - variable=account_var, - fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - button_hover_color=Theme.ACCENT_HOVER, - text_color=Theme.TEXT_PRIMARY, - ) - account_menu.pack(fill="x", pady=(3, 10)) - - ctk.CTkLabel(modal.body, text="Модель агента", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - anchor="w" - ) - model_menu = ctk.CTkOptionMenu( - modal.body, - values=["default"], - variable=model_var, - fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - button_hover_color=Theme.ACCENT_HOVER, - text_color=Theme.TEXT_PRIMARY, - ) - model_menu.pack(fill="x", pady=(3, 10)) - model_status = ctk.CTkLabel( - modal.body, - text="", - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, - anchor="w", - justify="left", - ) - model_status.pack(fill="x", pady=(0, 5)) - - def _apply_cached_models() -> None: - current_pid = choices[account_var.get()] - current_cfg = load_router_config().profiles[current_pid] - catalog = get_cached_models(current_cfg.provider) - configured = ( - role.default_model - if role and role.default_model - else current_cfg.preferred_models[0] - if current_cfg.preferred_models - else "" - ) - values = list(catalog.models) - if configured and configured not in values: - values.insert(0, configured) - model_menu.configure(values=values or ["Список моделей ещё не получен"]) - model_var.set(configured or (values[0] if values else "Список моделей ещё не получен")) - if not catalog.models: - model_status.configure(text=catalog.unavailable_reason, text_color=Theme.STATUS_WARNING) - elif configured and configured not in catalog.models: - model_status.configure( - text=f"⚠ Настроенная модель «{configured}» отсутствует в обнаруженном списке. Она не изменена.", - text_color=Theme.STATUS_WARNING, - ) - else: - freshness = f" • получено {catalog.fetched_at}" if catalog.fetched_at else "" - stale = " • кэш устарел" if catalog.is_stale else "" - model_status.configure( - text=f"{len(catalog.models)} моделей из кэша{freshness}{stale}", - text_color=Theme.TEXT_MUTED if not catalog.is_stale else Theme.STATUS_WARNING, - ) - - def _refresh_models() -> None: - current_pid = choices[account_var.get()] - provider = load_router_config().profiles[current_pid].provider - model_status.configure(text="Обновление моделей запущено в фоне…", text_color=Theme.TEXT_SECONDARY) - started = refresh_models_async(provider, lambda _result: self.after(0, _apply_cached_models)) - if not started: - model_status.configure( - text="Фоновое обновление пока недоступно; показан последний кэш.", - text_color=Theme.STATUS_WARNING, - ) - - HubButton( - modal.body, - text="Обновить список моделей", - variant="secondary", - height=30, - command=_refresh_models, - ).pack(anchor="w", pady=(0, 8)) - - quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) - quota_card.pack(fill="both", expand=True, pady=(2, 10)) - quota_title = ctk.CTkLabel( - quota_card, text="Реальные лимиты", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY - ) - quota_title.pack(anchor="w", padx=12, pady=(10, 5)) - quota_rows = ctk.CTkFrame(quota_card, fg_color="transparent") - quota_rows.pack(fill="both", expand=True, padx=12, pady=(0, 8)) - - def _refresh_account_panel(_choice: Optional[str] = None) -> None: - current_pid = choices[account_var.get()] - current_cfg = load_router_config().profiles[current_pid] - _apply_cached_models() - for child in quota_rows.winfo_children(): - child.destroy() - snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid) - buckets = list(snapshot.buckets) if snapshot else [] - quota_title.configure(text=f"Реальные лимиты • {current_cfg.provider}") - if not buckets: - ctk.CTkLabel( - quota_rows, text="Лимиты пока не получены", text_color=Theme.TEXT_MUTED, font=Theme.font_caption() - ).pack(anchor="w", pady=5) - for bucket in buckets[:6]: - row = ctk.CTkFrame(quota_rows, fg_color="transparent") - row.pack(fill="x", pady=3) - ctk.CTkLabel( - row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY - ).pack(side="left") - reset = bucket.formatted_reset() or "Сброс: Н/Д" - ctk.CTkLabel( - row, - text=f"{bucket.formatted_remaining()} • {reset}", - font=Theme.font_caption(), - text_color=Theme.STATUS_HEALTHY if bucket.status == "healthy" else Theme.STATUS_WARNING, - ).pack(side="right") - - account_menu.configure(command=_refresh_account_panel) - _refresh_account_panel() - result = ctk.CTkLabel(modal.body, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) - result.pack(fill="x") - - def _save_agent() -> None: - current_pid = choices[account_var.get()] - current_model = model_var.get() - if current_model and current_model != "Список моделей ещё не получен": - ok_model, msg_model = do_set_model(current_pid, current_model, role_id=role_id) - if not ok_model: - result.configure(text=f"✕ {msg_model}", text_color=Theme.STATUS_ERROR) - return - ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True) - result.configure( - text=f"{'✓' if ok else '✕'} {message}", - text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR, - ) - if ok: - save_button.configure(text="Готово", command=modal.destroy) - self._refresh_data() - - HubButton(modal.footer, text="Отмена", variant="secondary", command=modal.destroy).pack(side="left") - save_button = HubButton(modal.footer, text="Сохранить настройки", variant="primary", command=_save_agent) - save_button.pack(side="right") - return modal - - def _open_add_account_wizard(self): - wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete) - - def _on_wizard_complete(self, result: Dict[str, Any]): - self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён") - self._refresh_data() - - def _show_account_action_result(self, profile_id: str, message: str, success: Optional[bool]) -> None: - view = self._views.get("accounts") - if view and hasattr(view, "show_action_result"): - view.show_action_result(profile_id, message, success) - prefix = "✅" if success is True else "❌" if success is False else "⚡" - self._show_toast(f"{prefix} {message}") - - def _show_test_result(self, result: Dict[str, Any], profile_id: str = ""): - if result.get("success"): - msg = f"Профиль готов • модель: {result.get('model')} • время: {result.get('duration_sec')} с" - else: - msg = f"Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" - self._show_account_action_result(profile_id, msg, bool(result.get("success"))) - - def _apply_theme(self, scheme: str) -> None: - """Apply all palette tokens by rebuilding presentation widgets in place.""" - current_view = self._current_view - self._theme_name = Theme.apply_scheme(scheme) - ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light") - for child in list(self.winfo_children()): - child.destroy() - self._views.clear() - self._view_generations.clear() - self.configure(fg_color=Theme.BG_WINDOW) - self._build_layout() - self._show_view(current_view) - from antigravity_provider.router.state_store import HubStateStore - - self._on_data_loaded(HubStateStore.get().get_snapshot()) - - def _run_in_thread(self, func, on_success=None, on_error=None): - def _worker(): - if self._shutting_down: - return - try: - result = func() - if not self._shutting_down: - if on_success: - self.after(0, lambda: on_success(result)) - self.after(300, self._refresh_data) - except Exception as e: - if not self._shutting_down: - try: - if on_error: - self.after(0, lambda err=str(e): on_error(err)) - else: - self.after(0, lambda err=str(e): self._show_toast(f"❌ Ошибка: {err}")) - except Exception: - pass - - threading.Thread(target=_worker, daemon=True).start() - - def _show_toast(self, message: str): - if self._shutting_down: - return - self.status_left.configure(text=message) - self.after(6000, self._restore_status) - - def _restore_status(self): - if self._shutting_down: - return - from antigravity_provider.router.state_store import HubStateStore - - readiness = HubStateStore.get().get_snapshot().readiness - self.status_left.configure( - text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}", - text_color=Theme.STATUS_HEALTHY - if readiness.state == "healthy" - else Theme.STATUS_WARNING - if readiness.state in ("limited", "degraded") - else Theme.STATUS_ERROR, - ) - - def _on_close(self): - """Graceful shutdown coordinator without leaving orphan processes.""" - self._shutting_down = True - try: - from antigravity_provider.router.scheduler import HermesRefreshScheduler - - HermesRefreshScheduler.get().stop() - except Exception: - pass - try: - from antigravity_provider.router.quota_collector import AccountQuotaService - - AccountQuotaService.get().stop_background_scheduler() - except Exception: - pass - try: - if self._resize_timer_id: - self.after_cancel(self._resize_timer_id) - except Exception: - pass - try: - global _APP_MUTEX_HANDLE - if _APP_MUTEX_HANDLE and sys.platform == "win32": - ctypes.windll.kernel32.CloseHandle(_APP_MUTEX_HANDLE) - _APP_MUTEX_HANDLE = None - except Exception: - pass - try: - self.destroy() - except Exception: - pass - os._exit(0) - - -_APP_MUTEX_HANDLE = None - - -def check_single_instance() -> bool: - """Ensure only one Hermes Hub instance runs. If already running, activate existing window and return False.""" - global _APP_MUTEX_HANDLE - if sys.platform != "win32": - return True - try: - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 - - mutex_name = "Global\\HermesHubSingleInstanceMutex" - mutex = kernel32.CreateMutexW(None, True, mutex_name) - last_err = kernel32.GetLastError() - - # ERROR_ALREADY_EXISTS = 183 - if last_err == 183: - hwnd = user32.FindWindowW(None, "Hermes Hub") - if hwnd: - user32.ShowWindow(hwnd, 9) # SW_RESTORE - user32.SetForegroundWindow(hwnd) - if mutex: - kernel32.CloseHandle(mutex) - return False - - _APP_MUTEX_HANDLE = mutex - return True - except Exception: - return True - - -def launch_hub(): - from antigravity_provider import paths - from antigravity_provider.version import __version__ - - # Startup logging - log_file = paths.get_startup_log_file() - try: - with open(log_file, "a", encoding="utf-8") as f: - f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting Hermes Hub v{__version__} (PID {os.getpid()})\n") - except Exception: - pass - - if not check_single_instance(): - try: - with open(log_file, "a", encoding="utf-8") as f: - f.write( - f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Another instance is already running. Focused existing window and exiting.\n" - ) - except Exception: - pass - sys.exit(0) - - try: - app = HermesHubApp() - app.mainloop() - except Exception as exc: - try: - import traceback - - tb = traceback.format_exc() - with open(log_file, "a", encoding="utf-8") as f: - f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FATAL CRASH:\n{tb}\n") - if sys.platform == "win32": - ctypes.windll.user32.MessageBoxW( - 0, - f"Произошла критическая ошибка при запуске Hermes Hub:\n\n{exc}\n\nПодробности записаны в: {log_file}", - "Hermes Hub — Ошибка запуска", - 0x10, # MB_ICONERROR - ) - except Exception: - pass - sys.exit(1) - - -if __name__ == "__main__": - launch_hub() diff --git a/src/antigravity_provider/router/launcher_bootstrap.py b/src/antigravity_provider/router/launcher_bootstrap.py index 4a7d71d..3ffa23b 100644 --- a/src/antigravity_provider/router/launcher_bootstrap.py +++ b/src/antigravity_provider/router/launcher_bootstrap.py @@ -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) diff --git a/src/antigravity_provider/router/ui/__init__.py b/src/antigravity_provider/router/ui/__init__.py deleted file mode 100644 index 633d752..0000000 --- a/src/antigravity_provider/router/ui/__init__.py +++ /dev/null @@ -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"] diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py deleted file mode 100644 index b636f68..0000000 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ /dev/null @@ -1,1750 +0,0 @@ -"""Hermes Hub — Add Account Multi-Step Wizard Modal (v4). - -Supports 5 AI Providers: -1. Google Antigravity (Cockpit Tools model OAuth with immediate URL & manual fallback) -2. OpenAI Codex (OAuth / ChatGPT Device Code flow + OpenAI API Key mode) -3. OpenCode Go (API Key / Token input with clipboard Paste button & full keyboard shortcuts) -4. Claude / Anthropic (OAuth PKCE for Claude Pro/Max + Anthropic API Key mode) -5. Grok / xAI (OAuth Device Code for SuperGrok + xAI API Key mode) - -Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles. -""" - -from __future__ import annotations - -import json -import os -import threading -import time -import webbrowser -from typing import Any, Callable, Dict, List, Optional -import customtkinter as ctk - -from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal -from antigravity_provider.router.auto_assigner import AutoAssigner -from antigravity_provider.router.profile_manager import ProfileAuthManager -from antigravity_provider.router.router_config import load_router_config -from antigravity_provider.router.unified_health import EventLogService - - -def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]: - """Keep existing chain rank or route a newly introduced profile slot.""" - config = load_router_config() - assigned_role = next( - (role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain), - "", - ) - if assigned_role: - return True, f"Профиль уже входит в цепочку '{assigned_role}'" - _display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id) - return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary") - - -class AddAccountWizard(HubModal): - """4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment.""" - - def __init__(self, parent: Any, on_complete: Optional[Callable[[Dict[str, Any]], None]] = None): - super().__init__(parent, title="Мастер подключения аккаунта", width=640, height=560) - self.on_complete = on_complete - self.step = 1 - - self.selected_provider: str = "antigravity" - self.codex_auth_mode: str = "oauth" # oauth | api_key - self.claude_auth_mode: str = "oauth" # oauth | api_key - self.grok_auth_mode: str = "oauth" # oauth | api_key - - self.target_slot: str = "" - self.discovered_identity: str = "" - self.discovered_plan: str = "Тариф: неизвестен" - self.discovered_models: List[str] = [] - self.is_verified: bool = False - - # Session tracking - self.oauth_session_id: Optional[str] = None - self.oauth_url: Optional[str] = None - self.oauth_port: Optional[int] = None - - self.codex_session_id: Optional[str] = None - self.codex_url: Optional[str] = None - self.codex_user_code: Optional[str] = None - - self.claude_session_id: Optional[str] = None - self.claude_url: Optional[str] = None - - self.grok_session_id: Optional[str] = None - self.grok_url: Optional[str] = None - self.grok_user_code: Optional[str] = None - - self._polling_active = False - - self._show_step_1_provider() - - def destroy(self): - self._polling_active = False - self._cancel_active_sessions() - super().destroy() - - def _clear_body(self): - for w in self.body.winfo_children(): - w.destroy() - for w in self.footer.winfo_children(): - w.destroy() - - def _cancel_active_sessions(self): - if self.oauth_session_id: - try: - from antigravity_provider.router.profile_oauth import cancel_oauth_session - - cancel_oauth_session(self.oauth_session_id) - except Exception: - pass - self.oauth_session_id = None - self.oauth_port = None - - if self.codex_session_id: - try: - from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session - - cancel_codex_oauth_session(self.codex_session_id) - except Exception: - pass - self.codex_session_id = None - - if self.claude_session_id: - try: - from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session - - cancel_claude_oauth_session(self.claude_session_id) - except Exception: - pass - self.claude_session_id = None - - if self.grok_session_id: - try: - from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session - - cancel_grok_oauth_session(self.grok_session_id) - except Exception: - pass - self.grok_session_id = None - - # ═══════════════════════════════════════════════════════════════ - # STEP 1: Provider Selection - # ═══════════════════════════════════════════════════════════════ - - def _show_step_1_provider(self): - self._cancel_active_sessions() - self._polling_active = False - self._clear_body() - self.title_lbl.configure(text="Шаг 1 из 4: Выберите провайдера ИИ") - - ctk.CTkLabel( - self.body, - text="Выберите платформу для подключения учетной записи:", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", pady=(0, 10)) - - providers = [ - ("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT), - ("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", Theme.PROVIDER_CODEX), - ("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE), - ("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK), - ("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC), - ("local", "Локальная модель (Local LLM / llama.cpp)", "Локальный сервер / Ollama / vLLM / llama.cpp", Theme.STATUS_HEALTHY), - ] - - self.provider_var = ctk.StringVar(value=self.selected_provider) - - for p_id, p_title, p_desc, p_color in providers: - card = HubCard(self.body) - card.pack(fill="x", pady=3) - - r_frame = ctk.CTkFrame(card, fg_color="transparent") - r_frame.pack(fill="x", padx=12, pady=6) - - rb = ctk.CTkRadioButton( - r_frame, - text=p_title, - value=p_id, - variable=self.provider_var, - font=Theme.font_body_bold(), - fg_color=p_color, - command=lambda pid=p_id: self._select_provider(pid), - ) - rb.pack(side="left") - - ctk.CTkLabel( - r_frame, - text=f"• {p_desc}", - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, - ).pack(side="left", padx=(10, 0)) - - # Footer - HubButton( - self.footer, - text="Отмена", - variant="ghost", - command=self.destroy, - ).pack(side="left", padx=10, pady=10) - - HubButton( - self.footer, - text="Далее →", - variant="primary", - command=self._proceed_to_step_2, - ).pack(side="right", padx=10, pady=10) - - def _select_provider(self, p_id: str): - self.selected_provider = p_id - - def _proceed_to_step_2(self): - self.selected_provider = self.provider_var.get() - self.step = 2 - self._show_step_2_auth() - - # ═══════════════════════════════════════════════════════════════ - # STEP 2: Authentication - # ═══════════════════════════════════════════════════════════════ - - def _show_step_2_auth(self): - self._clear_body() - self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи") - - self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or "" - if not self.target_slot: - self._show_no_free_slot() - return - - if self.selected_provider == "antigravity": - self._build_antigravity_oauth_flow() - elif self.selected_provider == "openai-codex": - if self.codex_auth_mode == "oauth": - self._build_codex_oauth_flow() - else: - self._build_api_key_flow() - elif self.selected_provider == "claude": - if self.claude_auth_mode == "oauth": - self._build_claude_oauth_flow() - else: - self._build_api_key_flow() - elif self.selected_provider == "grok": - if self.grok_auth_mode == "oauth": - self._build_grok_oauth_flow() - else: - self._build_api_key_flow() - elif self.selected_provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): - self._build_local_llm_flow() - else: - self._build_api_key_flow() - - def _show_no_free_slot(self) -> None: - """Stop before OAuth when the configured provider has no real free slot.""" - provider_labels = { - "antigravity": "Google Antigravity", - "openai-codex": "OpenAI Codex", - "opencode-go": "OpenCode Go", - "claude": "Claude", - "grok": "Grok", - "local": "Local LLM", - } - card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.SURFACE_MUTED) - card.pack(fill="x", pady=20) - ctk.CTkLabel( - card, - text="Нет свободного слота", - font=Theme.font_heading(), - text_color=Theme.STATUS_WARNING, - ).pack(anchor="w", padx=16, pady=(14, 6)) - ctk.CTkLabel( - card, - text=( - f"Все слоты провайдера {provider_labels.get(self.selected_provider, self.selected_provider)} заняты.\n" - "Освободите один слот или удалите неиспользуемый аккаунт, затем повторите подключение." - ), - font=Theme.font_body(), - text_color=Theme.TEXT_PRIMARY, - justify="left", - wraplength=520, - ).pack(anchor="w", padx=16, pady=(0, 14)) - self._build_step_2_footer() - - # ───────────────────────────────────────────────────────────── - # GOOGLE ANTIGRAVITY OAUTH FLOW (Native agy CLI login) - # ───────────────────────────────────────────────────────────── - - def _build_antigravity_oauth_flow(self): - disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot) - - ctk.CTkLabel( - self.body, - text=f"Подключение Google Antigravity к слоту: {self.target_slot} ({disp_name})", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", pady=(0, 4)) - - info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - info_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - info_card, - text="Родной вход через CLI agy в изолированном окружении профиля:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=12, pady=(10, 4)) - - ctk.CTkLabel( - info_card, - text=( - "1. Нажмите «Открыть терминал входа Antigravity (agy)» ниже.\n" - "2. Откроется видимое окно терминала agy и запустит вход Google в браузере.\n" - "3. Войдите в нужный Google-аккаунт в открывшемся браузере.\n" - "4. Hub автоматически обнаружит успешный вход и перейдет к следующему шагу." - ), - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - justify="left", - anchor="w", - ).pack(fill="x", padx=12, pady=(0, 10)) - - btn_row = ctk.CTkFrame(self.body, fg_color="transparent") - btn_row.pack(fill="x", pady=(0, 6)) - - self.launch_agy_btn = HubButton( - btn_row, - text="🚀 Открыть терминал входа Antigravity (agy)", - variant="primary", - height=40, - command=self._launch_agy_login, - ) - self.launch_agy_btn.pack(side="left", fill="x", expand=True) - - self.oauth_status_lbl = ctk.CTkLabel( - self.body, - text="Нажмите кнопку выше для открытия окна входа...", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.oauth_status_lbl.pack(fill="x", pady=6) - - self._build_step_2_footer() - self._check_initial_native_status() - - def _launch_agy_login(self): - try: - from antigravity_provider.agy_subprocess import launch_native_agy_login - - self.agy_proc = launch_native_agy_login(self.target_slot) - self.oauth_status_lbl.configure( - text="Окно agy открыто. Завершите вход в браузере...", - text_color=Theme.ACCENT, - ) - self._polling_active = True - threading.Thread(target=self._poll_native_agy_auth, daemon=True).start() - except Exception as e: - self.oauth_status_lbl.configure( - text=f"Ошибка запуска agy: {e}", - text_color=Theme.STATUS_ERROR, - ) - - def _check_initial_native_status(self): - from antigravity_provider.agy_subprocess import check_profile_native_auth_status - - is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot) - if is_authed: - self.discovered_identity = email or "Google Account" - self.discovered_plan = "PRO" - self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"] - self.is_verified = True - self._show_step_3_validation() - - def _poll_native_agy_auth(self): - from antigravity_provider.agy_subprocess import check_profile_native_auth_status - - for _ in range(300): - if not self._polling_active: - return - time.sleep(1) - is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot) - if is_authed: - self.discovered_identity = email or "Google Account" - self.discovered_plan = "PRO" - self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"] - self.is_verified = True - self.after(0, self._show_step_3_validation) - return - - if getattr(self, "agy_proc", None) and self.agy_proc.poll() is not None: - # Process exited, check one last time - is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot) - if is_authed: - self.discovered_identity = email or "Google Account" - self.discovered_plan = "PRO" - self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"] - self.is_verified = True - self.after(0, self._show_step_3_validation) - return - else: - self.after( - 0, - lambda: self.oauth_status_lbl.configure( - text="⚠️ Окно agy было закрыто до завершения авторизации. Нажмите кнопку, чтобы попробовать снова.", - text_color=Theme.STATUS_WARNING, - ), - ) - return - - # ───────────────────────────────────────────────────────────── - # OPENAI CODEX OAUTH FLOW - # ───────────────────────────────────────────────────────────── - - def _build_codex_oauth_flow(self): - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - - self.codex_mode_var = ctk.StringVar(value="oauth") - - rb1 = ctk.CTkRadioButton( - mode_card, - text="OAuth — OpenAI / ChatGPT аккаунт (Рекомендуется)", - value="oauth", - variable=self.codex_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.ACCENT, - command=self._on_codex_mode_toggle, - ) - rb1.pack(anchor="w", padx=4, pady=2) - - rb2 = ctk.CTkRadioButton( - mode_card, - text="API Key — OpenAI API (sk-...)", - value="api_key", - variable=self.codex_mode_var, - font=Theme.font_body(), - fg_color=Theme.ACCENT, - command=self._on_codex_mode_toggle, - ) - rb2.pack(anchor="w", padx=4, pady=2) - - auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - auth_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - auth_card, - text="1. Откройте ссылку — кнопка ниже откроет страницу OpenAI", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=10, pady=(8, 2)) - - url_row = ctk.CTkFrame(auth_card, fg_color="transparent") - url_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.codex_url_entry = HubEntry( - url_row, - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.codex_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - url_row, - text="Копировать ссылку", - variant="secondary", - width=130, - height=32, - command=self._copy_codex_url, - ).pack(side="right") - - code_card = ctk.CTkFrame(auth_card, fg_color="transparent") - code_card.pack(fill="x", padx=10, pady=(0, 6)) - - ctk.CTkLabel( - code_card, - text="2. Введите на странице код:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(side="left", padx=(0, 8)) - - self.codex_code_lbl = ctk.CTkLabel( - code_card, - text="...", - font=("Consolas", 18, "bold"), - text_color=Theme.ACCENT, - ) - self.codex_code_lbl.pack(side="left", padx=(0, 8)) - - HubButton( - code_card, - text="📋 Копировать код", - variant="secondary", - width=130, - height=28, - command=self._copy_codex_code, - ).pack(side="left") - - action_row = ctk.CTkFrame(auth_card, fg_color="transparent") - action_row.pack(fill="x", padx=10, pady=(0, 8)) - - HubButton( - action_row, - text="🌐 Открыть в браузере", - variant="primary", - width=180, - command=self._open_codex_browser, - ).pack(side="left", padx=(0, 8)) - - ctk.CTkLabel( - auth_card, - text="3. Подтвердите доступ — мастер продолжит автоматически", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=10, pady=(0, 8)) - - # Manual Fallback Card - manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - manual_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - manual_card, - text="▸ Вставить токен вручную:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=10, pady=(6, 2)) - - manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent") - manual_entry_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.codex_manual_entry = HubEntry( - manual_entry_row, - placeholder_text='{"access_token": "..."} или токен...', - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.codex_manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - manual_entry_row, - text="📋 Вставить", - variant="secondary", - width=80, - height=32, - command=lambda: self._paste_into_entry(self.codex_manual_entry), - ).pack(side="right") - - HubButton( - manual_card, - text="✓ Завершить авторизацию", - variant="secondary", - height=30, - command=self._handle_codex_manual_submit, - ).pack(anchor="w", padx=10, pady=(0, 6)) - - self.codex_status_lbl = ctk.CTkLabel( - self.body, - text="Создание сессии входа OpenAI...", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.codex_status_lbl.pack(fill="x", pady=4) - - self._build_step_2_footer() - self._init_codex_oauth() - - def _on_codex_mode_toggle(self): - self.codex_auth_mode = self.codex_mode_var.get() - self._show_step_2_auth() - - def _init_codex_oauth(self): - try: - from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session - - session_id, url, code = start_codex_oauth(self.target_slot) - self.codex_session_id = session_id - self.codex_url = url - self.codex_user_code = code - - session = get_codex_oauth_session(session_id) - if not code or (session and session.status == "failed"): - err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации" - self.codex_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR) - return - - self.codex_url_entry.delete(0, "end") - self.codex_url_entry.insert(0, url) - self.codex_code_lbl.configure(text=code) - - if getattr(session, "is_dev_mode", False): - self.codex_status_lbl.configure( - text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}", - text_color=Theme.STATUS_WARNING, - ) - else: - self.codex_status_lbl.configure( - text=f"Ожидание подтверждения кода {code} в браузере...", - text_color=Theme.TEXT_SECONDARY, - ) - - self._polling_active = True - threading.Thread(target=self._poll_codex_oauth, daemon=True).start() - except Exception as e: - self.codex_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR) - - def _copy_codex_url(self): - if self.codex_url: - self.clipboard_clear() - self.clipboard_append(self.codex_url) - - def _copy_codex_code(self): - if self.codex_user_code: - self.clipboard_clear() - self.clipboard_append(self.codex_user_code) - - def _open_codex_browser(self): - if self.codex_url: - self._copy_codex_code() - self.codex_status_lbl.configure( - text=( - "Код скопирован. Вставьте его на открывшейся странице OpenAI; " - "затем вернитесь сюда — Hub продолжит автоматически." - ), - text_color=Theme.TEXT_SECONDARY, - ) - webbrowser.open(self.codex_url) - - def _handle_codex_manual_submit(self): - raw = self.codex_manual_entry.get().strip() - if not raw: - self.codex_status_lbl.configure(text="❌ Введите данные токена", text_color=Theme.STATUS_ERROR) - return - - from antigravity_provider.router.codex_oauth import get_codex_oauth_session - - session = get_codex_oauth_session(self.codex_session_id) - if not session: - self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) - return - - ok, msg = session.handle_manual_input(raw) - if ok: - info = getattr(session, "completed_profile_info", {}) or {} - self.discovered_identity = info.get("email") or "ChatGPT Account" - self.discovered_plan = "PLUS" - self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"] - self.is_verified = True - self._show_step_3_validation() - else: - self.codex_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR) - - def _poll_codex_oauth(self): - from antigravity_provider.router.codex_oauth import get_codex_oauth_session - - for _ in range(900): - if not self._polling_active: - return - time.sleep(1) - session = get_codex_oauth_session(self.codex_session_id) - if not session: - continue - - status = getattr(session, "status", "").lower() - if status in ("completed", "success"): - info = getattr(session, "completed_profile_info", {}) or {} - self.discovered_identity = info.get("email") or "ChatGPT Account" - self.discovered_plan = "PLUS" - self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"] - self.is_verified = True - self.after(0, self._show_step_3_validation) - return - - # ───────────────────────────────────────────────────────────── - # CLAUDE (ANTHROPIC) OAUTH FLOW - # ───────────────────────────────────────────────────────────── - - def _build_claude_oauth_flow(self): - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - - self.claude_mode_var = ctk.StringVar(value="oauth") - - rb1 = ctk.CTkRadioButton( - mode_card, - text="OAuth — Claude Pro/Max Account (Рекомендуется)", - value="oauth", - variable=self.claude_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.PROVIDER_CLAUDE, - command=self._on_claude_mode_toggle, - ) - rb1.pack(anchor="w", padx=4, pady=2) - - rb2 = ctk.CTkRadioButton( - mode_card, - text="API Key — Anthropic API (sk-ant-...)", - value="api_key", - variable=self.claude_mode_var, - font=Theme.font_body(), - fg_color=Theme.PROVIDER_CLAUDE, - command=self._on_claude_mode_toggle, - ) - rb2.pack(anchor="w", padx=4, pady=2) - - auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - auth_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - auth_card, - text="Ссылка для входа в Claude:", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", padx=10, pady=(6, 2)) - - url_row = ctk.CTkFrame(auth_card, fg_color="transparent") - url_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.claude_url_entry = HubEntry( - url_row, - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.claude_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - url_row, - text="Копировать ссылку", - variant="secondary", - width=130, - height=32, - command=self._copy_claude_url, - ).pack(side="right") - - action_row = ctk.CTkFrame(auth_card, fg_color="transparent") - action_row.pack(fill="x", padx=10, pady=(0, 8)) - - HubButton( - action_row, - text="🌐 Открыть в браузере", - variant="primary", - width=180, - command=self._open_claude_browser, - ).pack(side="left", padx=(0, 8)) - - # Code Entry Card - code_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - code_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - code_card, - text="Вставьте код авторизации после входа на сайте Claude:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=10, pady=(6, 2)) - - code_row = ctk.CTkFrame(code_card, fg_color="transparent") - code_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.claude_code_entry = HubEntry( - code_row, - placeholder_text="Вставьте код...", - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.claude_code_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - code_row, - text="📋 Вставить", - variant="secondary", - width=80, - height=32, - command=lambda: self._paste_into_entry(self.claude_code_entry), - ).pack(side="right") - - HubButton( - code_card, - text="✓ Завершить авторизацию", - variant="secondary", - height=30, - command=self._handle_claude_code_submit, - ).pack(anchor="w", padx=10, pady=(0, 6)) - - self.claude_status_lbl = ctk.CTkLabel( - self.body, - text="Создание ссылки входа Claude...", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.claude_status_lbl.pack(fill="x", pady=4) - - self._build_step_2_footer() - self._init_claude_oauth() - - def _on_claude_mode_toggle(self): - self.claude_auth_mode = self.claude_mode_var.get() - self._show_step_2_auth() - - def _init_claude_oauth(self): - try: - from antigravity_provider.router.claude_oauth import start_claude_oauth - - session_id, url = start_claude_oauth(self.target_slot) - self.claude_session_id = session_id - self.claude_url = url - - self.claude_url_entry.delete(0, "end") - self.claude_url_entry.insert(0, url) - self.claude_status_lbl.configure(text="Перейдите по ссылке и скопируйте код авторизации.") - except Exception as e: - self.claude_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR) - - def _copy_claude_url(self): - if self.claude_url: - self.clipboard_clear() - self.clipboard_append(self.claude_url) - - def _open_claude_browser(self): - if self.claude_url: - webbrowser.open(self.claude_url) - - def _handle_claude_code_submit(self): - raw = self.claude_code_entry.get().strip() - if not raw: - self.claude_status_lbl.configure(text="❌ Вставьте код авторизации", text_color=Theme.STATUS_ERROR) - return - - from antigravity_provider.router.claude_oauth import get_claude_oauth_session - - session = get_claude_oauth_session(self.claude_session_id) - if not session: - self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) - return - - ok, msg = session.handle_auth_code(raw) - if ok: - info = getattr(session, "completed_profile_info", {}) or {} - self.discovered_identity = info.get("email") or "Claude Account" - self.discovered_plan = "MAX" - self.discovered_models = ["claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus"] - self.is_verified = True - self._show_step_3_validation() - else: - self.claude_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR) - - # ───────────────────────────────────────────────────────────── - # GROK (XAI) OAUTH FLOW - # ───────────────────────────────────────────────────────────── - - def _build_grok_oauth_flow(self): - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - - self.grok_mode_var = ctk.StringVar(value="oauth") - - rb1 = ctk.CTkRadioButton( - mode_card, - text="OAuth — xAI / SuperGrok Account (Рекомендуется)", - value="oauth", - variable=self.grok_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.PROVIDER_GROK, - command=self._on_grok_mode_toggle, - ) - rb1.pack(anchor="w", padx=4, pady=2) - - rb2 = ctk.CTkRadioButton( - mode_card, - text="API Key — xAI API (xai-...)", - value="api_key", - variable=self.grok_mode_var, - font=Theme.font_body(), - fg_color=Theme.PROVIDER_GROK, - command=self._on_grok_mode_toggle, - ) - rb2.pack(anchor="w", padx=4, pady=2) - - auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - auth_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - auth_card, - text="1. Откройте ссылку — кнопка ниже откроет страницу xAI", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=10, pady=(8, 2)) - - url_row = ctk.CTkFrame(auth_card, fg_color="transparent") - url_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.grok_url_entry = HubEntry( - url_row, - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.grok_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - url_row, - text="📋", - variant="secondary", - width=40, - height=32, - command=self._copy_grok_url, - ).pack(side="right") - - code_card = ctk.CTkFrame(auth_card, fg_color="transparent") - code_card.pack(fill="x", padx=10, pady=(0, 6)) - - ctk.CTkLabel( - code_card, - text="2. Введите на странице код:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(side="left", padx=(0, 8)) - - self.grok_code_lbl = ctk.CTkLabel( - code_card, - text="...", - font=("Consolas", 18, "bold"), - text_color=Theme.PROVIDER_GROK, - ) - self.grok_code_lbl.pack(side="left", padx=(0, 8)) - - HubButton( - code_card, - text="📋 Копировать код", - variant="secondary", - width=130, - height=28, - command=self._copy_grok_code, - ).pack(side="left") - - action_row = ctk.CTkFrame(auth_card, fg_color="transparent") - action_row.pack(fill="x", padx=10, pady=(0, 8)) - - HubButton( - action_row, - text="🌐 Открыть в браузере", - variant="primary", - width=180, - command=self._open_grok_browser, - ).pack(side="left", padx=(0, 8)) - - ctk.CTkLabel( - auth_card, - text="3. Подтвердите доступ — мастер продолжит автоматически", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=10, pady=(0, 8)) - - # Manual Entry Card - manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - manual_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - manual_card, - text="▸ Вставить токен вручную:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=10, pady=(6, 2)) - - manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent") - manual_entry_row.pack(fill="x", padx=10, pady=(0, 6)) - - self.grok_manual_entry = HubEntry( - manual_entry_row, - placeholder_text='{"access_token": "..."} или токен...', - font=Theme.font_mono(), - height=32, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.grok_manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - - HubButton( - manual_entry_row, - text="📋 Вставить", - variant="secondary", - width=80, - height=32, - command=lambda: self._paste_into_entry(self.grok_manual_entry), - ).pack(side="right") - - HubButton( - manual_card, - text="✓ Завершить авторизацию", - variant="secondary", - height=30, - command=self._handle_grok_manual_submit, - ).pack(anchor="w", padx=10, pady=(0, 6)) - - self.grok_status_lbl = ctk.CTkLabel( - self.body, - text="Создание сессии xAI Grok...", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.grok_status_lbl.pack(fill="x", pady=4) - - self._build_step_2_footer() - self._init_grok_oauth() - - def _on_grok_mode_toggle(self): - self.grok_auth_mode = self.grok_mode_var.get() - self._show_step_2_auth() - - def _init_grok_oauth(self): - try: - from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session - - session_id, url, code = start_grok_oauth(self.target_slot) - self.grok_session_id = session_id - self.grok_url = url - self.grok_user_code = code - - session = get_grok_oauth_session(session_id) - if not code or (session and session.status == "failed"): - err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации" - self.grok_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR) - return - - self.grok_url_entry.delete(0, "end") - self.grok_url_entry.insert(0, url) - self.grok_code_lbl.configure(text=code) - - if getattr(session, "is_dev_mode", False): - self.grok_status_lbl.configure( - text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}", - text_color=Theme.STATUS_WARNING, - ) - else: - self.grok_status_lbl.configure( - text=f"Ожидание подтверждения кода {code} в браузере...", - text_color=Theme.TEXT_SECONDARY, - ) - - self._polling_active = True - threading.Thread(target=self._poll_grok_oauth, daemon=True).start() - except Exception as e: - self.grok_status_lbl.configure(text=f"Ошибка: {e}", text_color=Theme.STATUS_ERROR) - - def _copy_grok_url(self): - if self.grok_url: - self.clipboard_clear() - self.clipboard_append(self.grok_url) - - def _copy_grok_code(self): - if self.grok_user_code: - self.clipboard_clear() - self.clipboard_append(self.grok_user_code) - - def _open_grok_browser(self): - if self.grok_url: - self._copy_grok_code() - self.grok_status_lbl.configure( - text=( - "Код скопирован. Вставьте его на открывшейся странице xAI; " - "затем вернитесь сюда — Hub продолжит автоматически." - ), - text_color=Theme.TEXT_SECONDARY, - ) - webbrowser.open(self.grok_url) - - def _handle_grok_manual_submit(self): - raw = self.grok_manual_entry.get().strip() - if not raw: - self.grok_status_lbl.configure(text="❌ Введите данные токена", text_color=Theme.STATUS_ERROR) - return - - from antigravity_provider.router.grok_oauth import get_grok_oauth_session - - session = get_grok_oauth_session(self.grok_session_id) - if not session: - self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) - return - - ok, msg = session.handle_manual_input(raw) - if ok: - info = getattr(session, "completed_profile_info", {}) or {} - self.discovered_identity = info.get("email") or "Grok Account" - self.discovered_plan = "Grok Pro" - self.discovered_models = ["grok-3", "grok-3-mini", "grok-2"] - self.is_verified = True - self._show_step_3_validation() - else: - self.grok_status_lbl.configure(text=f"❌ {msg}", text_color=Theme.STATUS_ERROR) - - def _poll_grok_oauth(self): - from antigravity_provider.router.grok_oauth import get_grok_oauth_session - - for _ in range(900): - if not self._polling_active: - return - time.sleep(1) - session = get_grok_oauth_session(self.grok_session_id) - if not session: - continue - - status = getattr(session, "status", "").lower() - if status in ("completed", "success"): - info = getattr(session, "completed_profile_info", {}) or {} - self.discovered_identity = info.get("email") or "Grok Account" - self.discovered_plan = "Grok Pro" - self.discovered_models = ["grok-3", "grok-3-mini", "grok-2"] - self.is_verified = True - self.after(0, self._show_step_3_validation) - return - - # ───────────────────────────────────────────────────────────── - # API KEY FLOW - # ───────────────────────────────────────────────────────────── - - def _build_api_key_flow(self): - # Mode switchers for Codex, Claude, Grok - if self.selected_provider == "openai-codex": - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - self.codex_mode_var = ctk.StringVar(value="api_key") - ctk.CTkRadioButton( - mode_card, - text="OAuth — OpenAI / ChatGPT", - value="oauth", - variable=self.codex_mode_var, - font=Theme.font_body(), - fg_color=Theme.ACCENT, - command=self._on_codex_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - ctk.CTkRadioButton( - mode_card, - text="API Key — OpenAI API (sk-...)", - value="api_key", - variable=self.codex_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.ACCENT, - command=self._on_codex_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - elif self.selected_provider == "claude": - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - self.claude_mode_var = ctk.StringVar(value="api_key") - ctk.CTkRadioButton( - mode_card, - text="OAuth — Claude Pro/Max", - value="oauth", - variable=self.claude_mode_var, - font=Theme.font_body(), - fg_color=Theme.PROVIDER_CLAUDE, - command=self._on_claude_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - ctk.CTkRadioButton( - mode_card, - text="API Key — Anthropic API (sk-ant-...)", - value="api_key", - variable=self.claude_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.PROVIDER_CLAUDE, - command=self._on_claude_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - elif self.selected_provider == "grok": - mode_card = HubCard(self.body, fg_color="transparent") - mode_card.pack(fill="x", pady=(0, 8)) - self.grok_mode_var = ctk.StringVar(value="api_key") - ctk.CTkRadioButton( - mode_card, - text="OAuth — xAI / SuperGrok", - value="oauth", - variable=self.grok_mode_var, - font=Theme.font_body(), - fg_color=Theme.PROVIDER_GROK, - command=self._on_grok_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - ctk.CTkRadioButton( - mode_card, - text="API Key — xAI API (xai-...)", - value="api_key", - variable=self.grok_mode_var, - font=Theme.font_body_bold(), - fg_color=Theme.PROVIDER_GROK, - command=self._on_grok_mode_toggle, - ).pack(anchor="w", padx=4, pady=2) - - prompt_map = { - "openai-codex": "Введите ключ OpenAI API (sk-...):", - "claude": "Введите ключ Anthropic API (sk-ant-...):", - "grok": "Введите ключ xAI API (xai-...):", - "opencode-go": "Введите ключ API / Bearer Token для OpenCode Go:", - } - ctk.CTkLabel( - self.body, - text=prompt_map.get(self.selected_provider, "Введите ключ API:"), - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", pady=(0, 6)) - - entry_row = ctk.CTkFrame(self.body, fg_color="transparent") - entry_row.pack(fill="x", pady=(0, 8)) - - placeholder_map = { - "openai-codex": "sk-...", - "claude": "sk-ant-...", - "grok": "xai-...", - "opencode-go": "opencode-...", - } - self.key_entry = HubEntry( - entry_row, - placeholder_text=placeholder_map.get(self.selected_provider, "sk-..."), - font=Theme.font_mono(), - show="" if self.selected_provider == "opencode-go" else "*", - height=38, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) - self.key_entry.focus_set() - - HubButton( - entry_row, - text="📋 Вставить", - variant="secondary", - width=90, - height=38, - command=lambda: self._paste_into_entry(self.key_entry), - ).pack(side="right") - - self.key_status_lbl = ctk.CTkLabel( - self.body, - text="", - font=Theme.font_caption(), - text_color=Theme.STATUS_ERROR, - ) - self.key_status_lbl.pack(anchor="w") - - def _save_key(): - k = self.key_entry.get().strip() - if not k: - self.key_status_lbl.configure(text="Пожалуйста, введите ключ API.") - return - - is_valid = False - masked_id = None - models: List[str] = [] - - if self.selected_provider == "openai-codex": - is_valid, masked_id, models = ProfileAuthManager.verify_codex_token(k) - self.discovered_plan = "PLUS" - elif self.selected_provider == "claude": - is_valid, masked_id, models = ProfileAuthManager.verify_claude_token(k) - self.discovered_plan = "MAX" - elif self.selected_provider == "grok": - is_valid, masked_id, models = ProfileAuthManager.verify_grok_token(k) - self.discovered_plan = "Grok Pro" - elif self.selected_provider == "opencode-go": - is_valid, masked_id, models = ProfileAuthManager.verify_opencode_token(k) - self.discovered_plan = "SUBSCRIPTION" - - auth_data = { - "provider": self.selected_provider, - "profile_id": self.target_slot, - "auth_mode": "api_key", - "api_key": k, - "created_at": time.time(), - } - ProfileAuthManager.save_profile_auth(self.selected_provider, self.target_slot, auth_data) - - self.is_verified = is_valid - self.discovered_identity = masked_id or (k[:8] + "..." + k[-4:] if len(k) > 12 else "API Key") - self.discovered_models = models if is_valid else [] - self._show_step_3_validation() - - self._build_step_2_footer(next_cmd=_save_key) - - def _build_step_2_footer(self, next_cmd: Optional[Callable] = None): - HubButton( - self.footer, - text="← Назад", - variant="ghost", - command=self._show_step_1_provider, - ).pack(side="left", padx=10, pady=10) - - if next_cmd: - HubButton( - self.footer, - text="Проверить и продолжить →", - variant="primary", - command=next_cmd, - ).pack(side="right", padx=10, pady=10) - - def _paste_into_entry(self, entry_widget: HubEntry): - try: - content = entry_widget.clipboard_get().strip() - if content: - entry_widget.delete(0, "end") - entry_widget.insert(0, content) - entry_widget.focus_set() - entry_widget.icursor("end") - if hasattr(self, "key_status_lbl") and entry_widget is getattr(self, "key_entry", None): - self.key_status_lbl.configure(text="✓ Ключ вставлен. Нажмите «Проверить и продолжить».") - elif hasattr(self, "key_status_lbl"): - self.key_status_lbl.configure(text="Буфер обмена пуст.") - except Exception as exc: - if hasattr(self, "key_status_lbl"): - self.key_status_lbl.configure(text=f"Не удалось вставить: {exc}") - - # ───────────────────────────────────────────────────────────── - # LOCAL LLM FLOW (llama.cpp / vLLM / Ollama / Local Server) - # ───────────────────────────────────────────────────────────── - - def _build_local_llm_flow(self): - disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot) - - ctk.CTkLabel( - self.body, - text=f"Подключение локального сервера к слоту: {self.target_slot} ({disp_name})", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", pady=(0, 4)) - - info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED) - info_card.pack(fill="x", pady=(0, 8)) - - ctk.CTkLabel( - info_card, - text="Подключение к локальному серверу (llama.cpp / vLLM / Ollama / Local LLM):", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x", padx=12, pady=(10, 4)) - - ctk.CTkLabel( - info_card, - text=( - "Укажите базовый URL OpenAI-совместимого эндпоинта (например, http://127.0.0.1:8081/v1 или http://localhost:8080/v1).\n" - "Если сервер защищен токеном, введите его в поле API Key." - ), - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - justify="left", - anchor="w", - ).pack(fill="x", padx=12, pady=(0, 10)) - - # URL Input - ctk.CTkLabel( - self.body, - text="URL сервера (Base URL):", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", pady=(0, 4)) - - url_row = ctk.CTkFrame(self.body, fg_color="transparent") - url_row.pack(fill="x", pady=(0, 8)) - - self.local_url_entry = HubEntry( - url_row, - placeholder_text="http://127.0.0.1:8081/v1", - font=Theme.font_mono(), - height=36, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.local_url_entry.insert(0, "http://127.0.0.1:8081/v1") - self.local_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) - - HubButton( - url_row, - text="📋 Вставить", - variant="secondary", - width=90, - height=36, - command=lambda: self._paste_into_entry(self.local_url_entry), - ).pack(side="right") - - # Optional API Key Input - ctk.CTkLabel( - self.body, - text="API Key (опционально):", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(anchor="w", pady=(0, 4)) - - key_row = ctk.CTkFrame(self.body, fg_color="transparent") - key_row.pack(fill="x", pady=(0, 8)) - - self.local_key_entry = HubEntry( - key_row, - placeholder_text="Оставьте пустым, если ключ не требуется", - font=Theme.font_mono(), - show="*", - height=36, - fg_color=Theme.PRIMARY, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.local_key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) - - HubButton( - key_row, - text="📋 Вставить", - variant="secondary", - width=90, - height=36, - command=lambda: self._paste_into_entry(self.local_key_entry), - ).pack(side="right") - - # Test button and Status label - test_row = ctk.CTkFrame(self.body, fg_color="transparent") - test_row.pack(fill="x", pady=(0, 6)) - - self.local_test_btn = HubButton( - test_row, - text="🔍 Проверить подключение", - variant="secondary", - height=32, - command=self._test_local_connection, - ) - self.local_test_btn.pack(side="left") - - self.local_status_lbl = ctk.CTkLabel( - self.body, - text="", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.local_status_lbl.pack(fill="x", pady=4) - - def _save_local(): - url = self.local_url_entry.get().strip() - key = self.local_key_entry.get().strip() - if not url: - self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR) - return - - ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None) - auth_data = { - "provider": "local", - "profile_id": self.target_slot, - "base_url": url, - "api_key": key if key else None, - "models": models if ok else [], - "created_at": time.time(), - } - ProfileAuthManager.save_profile_auth("local", self.target_slot, auth_data) - - self.is_verified = ok - self.discovered_identity = url - self.discovered_plan = "Локальная модель (без квот)" - self.discovered_models = models if ok else [] - self._show_step_3_validation() - - self._build_step_2_footer(next_cmd=_save_local) - - def _test_local_connection(self): - url = self.local_url_entry.get().strip() - key = self.local_key_entry.get().strip() - if not url: - self.local_status_lbl.configure(text="Пожалуйста, введите URL сервера.", text_color=Theme.STATUS_ERROR) - return - - self.local_status_lbl.configure(text="⏳ Проверка подключения к серверу...", text_color=Theme.TEXT_SECONDARY) - - def _worker(): - ok, dname, models, err = ProfileAuthManager.verify_local_endpoint(url, key if key else None) - if ok: - m_str = ", ".join(models[:3]) + (f" (+{len(models)-3})" if len(models) > 3 else "") - self.after( - 0, - lambda: self.local_status_lbl.configure( - text=f"✓ Подключение успешно! Доступные модели: {m_str}", - text_color=Theme.STATUS_HEALTHY, - ), - ) - else: - self.after( - 0, - lambda: self.local_status_lbl.configure( - text=f"❌ Ошибка подключения: {err}", - text_color=Theme.STATUS_ERROR, - ), - ) - - threading.Thread(target=_worker, daemon=True).start() - - # ═══════════════════════════════════════════════════════════════ - # STEP 3: Validation & Identity - # ═══════════════════════════════════════════════════════════════ - - def _show_step_3_validation(self): - self._polling_active = False - self._clear_body() - self.step = 3 - self.title_lbl.configure(text="Шаг 3 из 4: Проверка учетной записи") - - # Duplicate check - dup_profile = AutoAssigner.check_duplicate_identity( - self.selected_provider, - self.discovered_identity, - exclude_profile_id=self.target_slot, - ) - - card = HubCard(self.body) - card.pack(fill="x", pady=10) - - ctk.CTkLabel( - card, - text="Результат проверки подключения:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=14, pady=(10, 6)) - - # Identity line - id_row = ctk.CTkFrame(card, fg_color="transparent") - id_row.pack(fill="x", padx=14, pady=2) - ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - side="left" - ) - ctk.CTkLabel( - id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY - ).pack(side="right") - - # Plan line - plan_row = ctk.CTkFrame(card, fg_color="transparent") - plan_row.pack(fill="x", padx=14, pady=2) - ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - side="left" - ) - ctk.CTkLabel( - plan_row, - text=self.discovered_plan or "Тариф: неизвестен", - font=Theme.font_body_bold(), - text_color=Theme.ACCENT, - ).pack(side="right") - - # Status line - stat_row = ctk.CTkFrame(card, fg_color="transparent") - stat_row.pack(fill="x", padx=14, pady=2) - ctk.CTkLabel( - stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY - ).pack(side="left") - stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)" - stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING - ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right") - - if dup_profile: - warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.ACCENT_DIM) - warn_card.pack(fill="x", pady=6) - ctk.CTkLabel( - warn_card, - text=f"⚠️ Этот аккаунт уже подключен к слоту {dup_profile}.", - font=Theme.font_caption(), - text_color=Theme.STATUS_WARNING, - ).pack(padx=10, pady=8) - - HubButton( - self.footer, - text="← Назад", - variant="ghost", - command=self._show_step_2_auth, - ).pack(side="left", padx=10, pady=10) - - HubButton( - self.footer, - text="Продолжить к распределению роли →", - variant="primary", - command=self._show_step_4_assignment, - ).pack(side="right", padx=10, pady=10) - - # ═══════════════════════════════════════════════════════════════ - # STEP 4: Auto-Assignment & Completion - # ═══════════════════════════════════════════════════════════════ - - def _show_step_4_assignment(self): - self._clear_body() - self.step = 4 - self.title_lbl.configure(text="Шаг 4 из 4: Назначение роли в роутере") - - disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot) - - card = HubCard(self.body) - card.pack(fill="x", pady=10) - - ctk.CTkLabel( - card, - text="Назначенная роль в команде:", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=14, pady=(10, 6)) - - r_row = ctk.CTkFrame(card, fg_color="transparent") - r_row.pack(fill="x", padx=14, pady=4) - ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - side="left" - ) - ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right") - - s_row = ctk.CTkFrame(card, fg_color="transparent") - s_row.pack(fill="x", padx=14, pady=4) - ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - side="left" - ) - ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack( - side="right" - ) - - ctk.CTkLabel( - self.body, - text="Аккаунт готов к работе в роутере Hermes Hub и приему запросов.", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ).pack(fill="x", pady=8) - - self.finish_status_lbl = ctk.CTkLabel( - self.body, - text="Нажмите «Завершить подключение», чтобы сохранить роль и обновить Hub.", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ) - self.finish_status_lbl.pack(fill="x", pady=(0, 4)) - - # Sequential multi-account helper for Antigravity and other providers - next_slot = AutoAssigner.find_free_slot(self.selected_provider) - if next_slot and next_slot != self.target_slot: - HubButton( - self.footer, - text=f"➕ Подключить следующий слот ({next_slot}) →", - variant="secondary", - command=lambda: self._finish_and_next_slot(next_slot), - ).pack(side="right", padx=(0, 10), pady=10) - - HubButton( - self.footer, - text="✓ Завершить подключение", - variant="primary", - command=self._finish, - ).pack(side="right", padx=10, pady=10) - - def _finish_and_next_slot(self, next_slot: str): - if not self.target_slot: - return - provider = getattr(self, "selected_provider", "antigravity") - slot = self.target_slot - profile_ok, _ = AutoAssigner.ensure_profile_definition(provider, slot) - if not profile_ok: - return - ok, _ = ensure_profile_in_routing(slot) - if not ok: - return - try: - from antigravity_provider.router.router_engine import get_router_engine - - get_router_engine().health.clear_cooldown(slot) - EventLogService.get().log( - "account", - f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.", - level="success", - ) - except Exception: - pass - if getattr(self, "on_complete", None): - try: - self.on_complete( - { - "provider": provider, - "profile_id": slot, - "identity": getattr(self, "discovered_identity", ""), - } - ) - except Exception: - pass - - # Reset state for next slot in sequence - self.target_slot = next_slot - self.discovered_identity = "" - self.discovered_plan = "Тариф: неизвестен" - self.discovered_models = [] - self.is_verified = False - self.step = 2 - self._show_step_2_auth() - - def _finish(self): - if not getattr(self, "target_slot", None): - if hasattr(self, "finish_status_lbl"): - self.finish_status_lbl.configure( - text="❌ Подключение невозможно: свободный слот не найден.", - text_color=Theme.STATUS_ERROR, - ) - return - - provider = getattr(self, "selected_provider", "antigravity") - slot = self.target_slot - - profile_ok, profile_message = AutoAssigner.ensure_profile_definition( - provider, - slot, - ) - if not profile_ok: - if hasattr(self, "finish_status_lbl"): - self.finish_status_lbl.configure(text=f"❌ {profile_message}", text_color=Theme.STATUS_ERROR) - return - - ok, message = ensure_profile_in_routing(slot) - if not ok: - if hasattr(self, "finish_status_lbl"): - self.finish_status_lbl.configure(text=f"❌ {message}", text_color=Theme.STATUS_ERROR) - return - - try: - from antigravity_provider.router.router_engine import get_router_engine - - get_router_engine().health.clear_cooldown(slot) - EventLogService.get().log( - "account", - f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.", - level="success", - ) - except Exception: - pass - - if getattr(self, "on_complete", None): - try: - self.on_complete( - { - "provider": provider, - "profile_id": slot, - "identity": getattr(self, "discovered_identity", ""), - } - ) - except Exception: - pass - - if hasattr(self, "destroy"): - self.destroy() diff --git a/src/antigravity_provider/router/ui/assets.py b/src/antigravity_provider/router/ui/assets.py deleted file mode 100644 index a4f231a..0000000 --- a/src/antigravity_provider/router/ui/assets.py +++ /dev/null @@ -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 diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py deleted file mode 100644 index fe3e217..0000000 --- a/src/antigravity_provider/router/ui/components.py +++ /dev/null @@ -1,1255 +0,0 @@ -import tkinter as tk -from typing import Any, Callable, Dict, List, Optional, Tuple - -try: - import customtkinter as ctk -except ImportError: - import unittest.mock as _mock - - ctk = _mock.MagicMock() - -from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.assets import AssetManager - - -class HubButton(ctk.CTkButton): - """Design-system compliant button supporting primary, secondary, ghost, danger and accent_outline variants.""" - - def __init__( - self, - master: Any, - text: str, - variant: str = "primary", # primary | secondary | ghost | danger | accent_outline - command: Optional[Callable] = None, - width: int = 120, - height: int = Theme.HEIGHT_BTN_MD, - font: Optional[Tuple[str, int, str]] = None, - image: Optional[ctk.CTkImage] = None, - **kwargs, - ): - if font is None: - font = Theme.font_subheading() if variant in ("primary", "danger") else Theme.font_body() - - fg_color = Theme.ACCENT - hover_color = Theme.ACCENT_HOVER - text_color = Theme.TEXT_ON_ACCENT - border_width = 0 - border_color = None - - if variant == "primary": - fg_color = Theme.ACCENT - hover_color = Theme.ACCENT_HOVER - text_color = Theme.TEXT_ON_ACCENT - elif variant == "secondary": - fg_color = Theme.SURFACE - hover_color = Theme.SURFACE_HOVER - text_color = Theme.TEXT_PRIMARY - border_width = 1 - border_color = Theme.BORDER - elif variant == "ghost": - fg_color = "transparent" - hover_color = Theme.SURFACE_HOVER - text_color = Theme.TEXT_PRIMARY - elif variant == "danger": - fg_color = Theme.DANGER_SURFACE - hover_color = Theme.DANGER_SURFACE_HOVER - text_color = Theme.DANGER_TEXT - border_width = 1 - border_color = Theme.DANGER_BORDER - elif variant == "accent_outline": - fg_color = "transparent" - hover_color = Theme.ACCENT_DIM - text_color = Theme.ACCENT - border_width = 1 - border_color = Theme.ACCENT - - btn_kwargs = { - "master": master, - "text": text, - "command": command, - "width": width, - "height": height, - "corner_radius": Theme.RADIUS_SM, - "fg_color": fg_color, - "hover_color": hover_color, - "text_color": text_color, - "border_width": border_width, - "font": font, - "image": image, - } - if border_color is not None: - btn_kwargs["border_color"] = border_color - btn_kwargs.update(kwargs) - - super().__init__(**btn_kwargs) - - -class HubCard(ctk.CTkFrame): - """Card container with standardized surface color, subtle border and corner radius.""" - - def __init__( - self, - master: Any, - corner_radius: int = Theme.RADIUS_MD, - border_width: int = 1, - border_color: Optional[str] = None, - fg_color: Optional[str] = None, - **kwargs, - ): - super().__init__( - master=master, - corner_radius=corner_radius, - border_width=border_width, - border_color=border_color or Theme.BORDER, - fg_color=fg_color or Theme.SURFACE, - **kwargs, - ) - - -def enable_clipboard_shortcuts(entry_widget: Any) -> None: - """Enable robust clipboard (Ctrl+V, Ctrl+C, Ctrl+X, Ctrl+A, Shift+Insert) across English and Cyrillic layouts.""" - inner = getattr(entry_widget, "_entry", entry_widget) - - def _paste_handler(event=None): - try: - text = entry_widget.clipboard_get() - if text is not None: - try: - inner.delete("sel.first", "sel.last") - except Exception: - pass - inner.insert("insert", text) - return "break" - except Exception: - return "break" - - def _select_all_handler(event=None): - try: - inner.select_range(0, "end") - inner.icursor("end") - return "break" - except Exception: - return "break" - - def _copy_handler(event=None): - try: - if inner.select_present(): - sel = inner.selection_get() - entry_widget.clipboard_clear() - entry_widget.clipboard_append(sel) - return "break" - except Exception: - pass - - def _cut_handler(event=None): - try: - if inner.select_present(): - sel = inner.selection_get() - entry_widget.clipboard_clear() - entry_widget.clipboard_append(sel) - inner.delete("sel.first", "sel.last") - return "break" - except Exception: - pass - - # Standard Latin shortcuts - for p in ( - "", - "", - "", - "", - "", - "", - ): - try: - inner.bind(p, _paste_handler, add=False) - except Exception: - pass - for a in ("", "", "", ""): - try: - inner.bind(a, _select_all_handler, add=False) - except Exception: - pass - for c in ("", "", "", ""): - try: - inner.bind(c, _copy_handler, add=False) - except Exception: - pass - for x in ("", "", "", ""): - try: - inner.bind(x, _cut_handler, add=False) - except Exception: - pass - - # Windows Cyrillic / Russian keyboard layouts - for p in ("", "", "", ""): - try: - inner.bind(p, _paste_handler, add=False) - except Exception: - pass - for a in ("", "", "", ""): - try: - inner.bind(a, _select_all_handler, add=False) - except Exception: - pass - for c in ("", "", "", ""): - try: - inner.bind(c, _copy_handler, add=False) - except Exception: - pass - for x in ("", "", "", ""): - try: - inner.bind(x, _cut_handler, add=False) - except Exception: - pass - - -class HubEntry(ctk.CTkEntry): - """Design-system compliant Entry with automatic clipboard & keyboard layout support.""" - - def __init__(self, master: Any, **kwargs): - super().__init__(master=master, **kwargs) - enable_clipboard_shortcuts(self) - - -class HubSectionHeader(ctk.CTkFrame): - """Section title with optional subtitle and right-aligned action button.""" - - def __init__( - self, - master: Any, - title: str, - subtitle: Optional[str] = None, - action_text: Optional[str] = None, - action_cmd: Optional[Callable] = None, - **kwargs, - ): - super().__init__(master=master, fg_color="transparent", **kwargs) - - left = ctk.CTkFrame(self, fg_color="transparent") - left.pack(side="left", fill="both", expand=True) - - ctk.CTkLabel( - left, - text=title, - font=Theme.font_title_page(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w") - - if subtitle: - ctk.CTkLabel( - left, - text=subtitle, - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, - ).pack(anchor="w", pady=(Theme.SPACE_XS, 0)) - - if action_text and action_cmd: - HubButton( - self, - text=action_text, - variant="primary", - command=action_cmd, - height=Theme.HEIGHT_BTN_MD, - ).pack(side="right", padx=(Theme.SPACE_MD, 0)) - - -class HubStatusBadge(ctk.CTkFrame): - """Status pill with dot and localized label for all normalized health states.""" - - STATUS_LABELS = { - "healthy": "Работает", - "quota_low": "Квота заканчивается", - "quota_exhausted": "Квота исчерпана", - "cooldown": "Ожидание сброса", - "rate_limited": "Лимит запросов", - "not_configured": "Аккаунт не добавлен", - "auth_required": "Требуется вход", - "auth_expired": "Требуется повторная авторизация", - "disabled": "Отключён", - "cold_spare": "Холодный резерв", - "unhealthy": "Ошибка", - "not_tested": "Не проверен", - } - - def __init__(self, master: Any, status_key: str, **kwargs): - super().__init__(master=master, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_PILL, **kwargs) - - inner = ctk.CTkFrame(self, fg_color="transparent") - inner.pack(padx=Theme.SPACE_MD, pady=Theme.SPACE_XS) - - self.dot = ctk.CTkLabel( - inner, - text="●", - font=Theme.font_badge_bold(), - ) - self.dot.pack(side="left", padx=(0, Theme.SPACE_XS)) - - self.label = ctk.CTkLabel( - inner, - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ) - self.label.pack(side="left") - self.set_status(status_key) - - def set_status(self, status_key: str, label: Optional[str] = None) -> None: - clean_key = (status_key or "unknown").lower().replace("-", "_") - default_label = self.STATUS_LABELS.get(clean_key, status_key or "Н/Д") - color = _semantic_color(clean_key) - self.dot.configure(text_color=color) - self.label.configure(text=label or default_label) - - -class HubProviderBadge(ctk.CTkFrame): - """Badge indicating the AI provider with real provider mark.""" - - def __init__(self, master: Any, provider: str, size: Tuple[int, int] = (18, 18), **kwargs): - super().__init__(master=master, fg_color="transparent", **kwargs) - - prov = provider.lower() - if "antigravity" in prov: - color = Theme.PROVIDER_ANTIGRAVITY - name = "Google Antigravity" - elif "codex" in prov or "openai" in prov: - color = Theme.PROVIDER_CODEX - name = "OpenAI Codex" - elif "opencode" in prov: - color = Theme.PROVIDER_OPENCODE - name = "OpenCode Go" - else: - color = Theme.TEXT_MUTED - name = provider - - badge = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - badge.pack(side="left") - - img = AssetManager.get().get_provider_image(prov, size=size) - if img: - ctk.CTkLabel(badge, image=img, text="").pack( - side="left", padx=(Theme.SPACE_SM, Theme.SPACE_XS), pady=Theme.SPACE_XS - ) - - ctk.CTkLabel( - badge, - text=name, - font=Theme.font_caption(), - text_color=color, - ).pack(side="left", padx=(0 if img else Theme.SPACE_SM, Theme.SPACE_SM), pady=Theme.SPACE_XS) - - -class HubMetricCard(HubCard): - """Dashboard metric card with icon, large number and label.""" - - def __init__( - self, - master: Any, - title: str, - value: str, - subtext: str, - icon: str = "◈", - accent: bool = False, - **kwargs, - ): - border_color = Theme.BORDER_ACCENT if accent else Theme.BORDER - super().__init__(master=master, border_color=border_color, **kwargs) - - top = ctk.CTkFrame(self, fg_color="transparent") - top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) - - ctk.CTkLabel( - top, - text=icon, - font=Theme.font_icon(), - text_color=Theme.ACCENT if accent else Theme.TEXT_MUTED, - ).pack(side="left", padx=(0, Theme.SPACE_SM)) - - ctk.CTkLabel( - top, - text=title.upper(), - font=Theme.font_micro(), - text_color=Theme.TEXT_MUTED, - ).pack(side="left") - - val_color = Theme.ACCENT if accent else Theme.TEXT_PRIMARY - self.val_label = ctk.CTkLabel( - self, - text=value, - font=Theme.font_metric(), - text_color=val_color, - ) - self.val_label.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_XS)) - - self.sub_label = ctk.CTkLabel( - self, - text=subtext, - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - ) - self.sub_label.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) - - -class HubToolbar(ctk.CTkFrame): - """Cockpit-grade toolbar with Search, Category Filter, and Sorting.""" - - def __init__( - self, - master: Any, - on_search: Optional[Callable[[str], None]] = None, - on_filter: Optional[Callable[[str], None]] = None, - on_sort: Optional[Callable[[str], None]] = None, - **kwargs, - ): - super().__init__(master=master, fg_color="transparent", **kwargs) - self.on_search = on_search - self.on_filter = on_filter - self.on_sort = on_sort - - # Search Entry - self.search_entry = ctk.CTkEntry( - self, - placeholder_text="🔍 Поиск аккаунта или модели...", - font=Theme.font_body(), - height=Theme.HEIGHT_BTN_MD, - width=260, - fg_color=Theme.SURFACE, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - ) - self.search_entry.pack(side="left", padx=(0, Theme.INLINE_GAP)) - self.search_entry.bind("", self._handle_search) - - # Filter Option Menu - self.filter_menu = ctk.CTkOptionMenu( - self, - values=["Все статусы", "Подключённые", "Требуют входа", "Квота исчерпана"], - font=Theme.font_caption(), - height=Theme.HEIGHT_BTN_MD, - width=160, - fg_color=Theme.SURFACE, - button_color=Theme.SECONDARY, - text_color=Theme.TEXT_PRIMARY, - command=self._handle_filter, - ) - self.filter_menu.pack(side="left", padx=(0, Theme.INLINE_GAP)) - - # Sort Option Menu - self.sort_menu = ctk.CTkOptionMenu( - self, - values=["По умолчанию", "По имени", "По статусу", "По провайдеру"], - font=Theme.font_caption(), - height=Theme.HEIGHT_BTN_MD, - width=150, - fg_color=Theme.SURFACE, - button_color=Theme.SECONDARY, - text_color=Theme.TEXT_PRIMARY, - command=self._handle_sort, - ) - self.sort_menu.pack(side="left") - - def _handle_search(self, event=None): - if self.on_search: - self.on_search(self.search_entry.get().strip().lower()) - - def _handle_filter(self, choice: str): - if self.on_filter: - self.on_filter(choice) - - def _handle_sort(self, choice: str): - if self.on_sort: - self.on_sort(choice) - - -class HubModal(ctk.CTkToplevel): - """Reusable modal dialog on dark backdrop.""" - - def __init__(self, parent: Any, title: str, width: int = 580, height: int = 500): - super().__init__(parent) - self.title(title) - self.geometry(f"{width}x{height}") - self.configure(fg_color=Theme.BG_WINDOW) - self.resizable(False, False) - self.transient(parent) - self.grab_set() - - self.update_idletasks() - px = parent.winfo_x() + (parent.winfo_width() - width) // 2 - py = parent.winfo_y() + (parent.winfo_height() - height) // 2 - self.geometry(f"+{px}+{py}") - - self.container = HubCard( - self, - corner_radius=Theme.RADIUS_LG, - border_color=Theme.BORDER_ACCENT, - # Contrast-safe in dark, hybrid and light palettes. - fg_color=Theme.SURFACE, - ) - self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG) - - self.hdr = ctk.CTkFrame(self.container, fg_color="transparent") - self.hdr.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)) - - self.title_lbl = ctk.CTkLabel( - self.hdr, - text=title, - font=Theme.font_title_section(), - text_color=Theme.TEXT_PRIMARY, - ) - self.title_lbl.pack(side="left") - - self.body = ctk.CTkFrame(self.container, fg_color="transparent") - self.body.pack(fill="both", expand=True, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) - - self.footer = ctk.CTkFrame(self.container, fg_color="transparent") - self.footer.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) - - -# Public design-system names. Existing Hub-prefixed imports remain compatible. -SectionHeader = HubSectionHeader -StatusBadge = HubStatusBadge -ProviderBadge = HubProviderBadge - - -def _semantic_color(status: str) -> str: - """Return an operational colour without using brand gold as health.""" - key = (status or "unknown").lower().replace("-", "_") - if key in {"healthy", "active", "authenticated", "online", "ready"}: - return Theme.COLOR_POSITIVE - if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited", "auth_required", "auth_expired"}: - return Theme.COLOR_CAUTION - if key in {"error", "unhealthy", "quota_exhausted", "offline"}: - return Theme.COLOR_NEGATIVE - return Theme.COLOR_NEUTRAL - - -def ellipsize_text(text: str, max_chars: int = 34) -> str: - """Truncate long identities while retaining the full value for a tooltip.""" - value = str(text or "") - if len(value) <= max_chars: - return value - return f"{value[: max(1, max_chars - 1)]}…" - - -class Tooltip: - """Lightweight tooltip implemented with Tk only; no extra dependency.""" - - def __init__(self, widget: Any, text: str): - self.widget = widget - self.text = text - self._window: Optional[tk.Toplevel] = None - widget.bind("", self._show, add="+") - widget.bind("", self._hide, add="+") - - def _show(self, _event=None) -> None: - if not self.text or self._window is not None: - return - self._window = tk.Toplevel(self.widget) - self._window.wm_overrideredirect(True) - self._window.wm_geometry(f"+{self.widget.winfo_rootx() + Theme.SPACE_MD}+{self.widget.winfo_rooty() + 28}") - label = tk.Label( - self._window, - text=self.text, - background=Theme.BG_HEADER, - foreground=Theme.TEXT_PRIMARY, - relief="solid", - borderwidth=1, - font=Theme.font_caption(), - ) - label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS) - - def _hide(self, _event=None) -> None: - if self._window is not None: - self._window.destroy() - self._window = None - - -class EllipsizedLabel(ctk.CTkLabel): - """Label that shortens long account identities and exposes the full value.""" - - def __init__(self, master: Any, text: str, max_chars: int = 34, **kwargs): - self.full_text = str(text or "") - self.max_chars = max_chars - super().__init__(master, text=ellipsize_text(self.full_text, max_chars), **kwargs) - self.tooltip = Tooltip(self, self.full_text) if len(self.full_text) > max_chars else None - - def set_text(self, text: str) -> None: - self.full_text = str(text or "") - self.configure(text=ellipsize_text(self.full_text, self.max_chars)) - if self.tooltip: - self.tooltip.text = self.full_text - elif len(self.full_text) > self.max_chars: - self.tooltip = Tooltip(self, self.full_text) - - -class _TextBadge(ctk.CTkFrame): - """Small neutral badge used for plans and metadata, not health.""" - - def __init__(self, master: Any, text: str, text_color: Optional[str] = None, **kwargs): - super().__init__( - master=master, - height=Theme.HEIGHT_BADGE, - fg_color=Theme.SURFACE_MUTED, - border_width=1, - border_color=Theme.BORDER_SUBTLE, - corner_radius=Theme.RADIUS_PILL, - **kwargs, - ) - self.label = ctk.CTkLabel( - self, - text=text, - font=Theme.font_micro(), - text_color=text_color or Theme.TEXT_SECONDARY, - ) - self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS) - - def set_text(self, text: str) -> None: - self.label.configure(text=text) - - -class PlanBadge(_TextBadge): - """Provider plan badge that makes provider truth distinct from inference.""" - - TRUSTED_SOURCES = {"provider_api", "jwt_claim", "provider_auth"} - - def set_plan(self, plan_code: str, plan_source: str) -> bool: - """Update the badge and return whether it should be visible.""" - code = str(plan_code or "UNKNOWN").strip().upper() - source = str(plan_source or "unknown").strip().lower() - if code == "UNKNOWN" or source == "unknown": - return False - if source in self.TRUSTED_SOURCES: - self.set_text(f"Тариф {code}") - self.configure(border_color=Theme.BORDER_ACCENT, fg_color=Theme.SURFACE_MUTED) - self.label.configure(text_color=Theme.TEXT_ACCENT) - else: - self.set_text(f"Тариф {code} • выведено") - self.configure(border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED) - self.label.configure(text_color=Theme.TEXT_MUTED) - return True - - -class QuotaBar(ctk.CTkFrame): - """Compact quota bar with honest unknown state and semantic thresholds.""" - - def __init__( - self, - master: Any, - value: Optional[float] = None, - label: str = "Квота", - detail: str = "Н/Д", - **kwargs, - ): - super().__init__(master=master, fg_color="transparent", **kwargs) - self.header = ctk.CTkFrame(self, fg_color="transparent") - self.header.pack(fill="x") - self.label = ctk.CTkLabel(self.header, text=label, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) - self.label.pack(side="left") - self.detail = ctk.CTkLabel(self.header, text=detail, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.detail.pack(side="right") - self.progress = ctk.CTkProgressBar( - self, - height=Theme.SPACE_SM, - corner_radius=Theme.RADIUS_PILL, - fg_color=Theme.BG_SIDEBAR, - progress_color=Theme.COLOR_NEUTRAL, - ) - self.progress.pack(fill="x", pady=(Theme.SPACE_XS, 0)) - self.set_value(value, detail) - - def set_value(self, value: Optional[float], detail: Optional[str] = None) -> None: - if value is None: - self.progress.set(0) - self.progress.configure(progress_color=Theme.COLOR_NEUTRAL) - self.detail.configure(text=detail or "Н/Д", text_color=Theme.TEXT_MUTED) - return - normalized = max(0.0, min(1.0, float(value))) - color = ( - Theme.COLOR_NEGATIVE - if normalized <= 0.05 - else (Theme.COLOR_CAUTION if normalized <= 0.20 else Theme.COLOR_POSITIVE) - ) - self.progress.set(normalized) - self.progress.configure(progress_color=color) - self.detail.configure(text=detail or f"{normalized:.0%}", text_color=color) - - -class QuotaBucketWidget(HubCard): - """Reusable quota bucket updated in place by a stable bucket key.""" - - def __init__( - self, - master: Any, - bucket_key: str, - label: str, - remaining_ratio: Optional[float] = None, - reset_text: str = "", - is_estimated: bool = False, - **kwargs, - ): - super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE, **kwargs) - self.bucket_key = bucket_key - self.title = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) - self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.SPACE_XS)) - self.bar = QuotaBar(self, label="Остаток") - self.bar.pack(fill="x", padx=Theme.CARD_PAD_X) - 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=(Theme.SPACE_XS, 0)) - self.unavailable_reason = ctk.CTkLabel( - self, - text="", - font=Theme.font_micro(), - text_color=Theme.COLOR_CAUTION, - wraplength=320, - justify="left", - ) - self.update_bucket(label, remaining_ratio, reset_text, is_estimated) - - def update_bucket( - self, - label: str, - remaining_ratio: Optional[float], - reset_text: str = "", - is_estimated: bool = False, - detail: Optional[str] = None, - unavailable_reason: Optional[str] = None, - ) -> None: - suffix = " • оценка" if is_estimated else "" - self.title.configure(text=f"{label}{suffix}") - self.bar.set_value(remaining_ratio, detail or ("Н/Д" if remaining_ratio is None else None)) - self.reset.configure(text=reset_text or "Сброс: Н/Д") - if unavailable_reason: - self.reset.pack_configure(pady=(Theme.SPACE_XS, 0)) - self.unavailable_reason.configure(text=f"Недоступно: {unavailable_reason}") - self.unavailable_reason.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) - else: - self.unavailable_reason.pack_forget() - self.reset.pack_configure(pady=(Theme.SPACE_XS, Theme.SPACE_SM)) - - -class SearchField(HubEntry): - """Normalized searchable text field with clipboard support.""" - - def __init__(self, master: Any, placeholder_text: str = "Поиск…", width: int = 260, **kwargs): - super().__init__( - master, - placeholder_text=placeholder_text, - width=width, - height=Theme.HEIGHT_INPUT, - font=Theme.font_body(), - fg_color=Theme.SURFACE, - border_color=Theme.BORDER, - text_color=Theme.TEXT_PRIMARY, - **kwargs, - ) - - -class FilterButton(ctk.CTkOptionMenu): - """Compact filter selector with a consistent visual treatment.""" - - def __init__(self, master: Any, values: List[str], command: Optional[Callable] = None, **kwargs): - super().__init__( - master, - values=values, - command=command, - height=Theme.HEIGHT_INPUT, - font=Theme.font_caption(), - fg_color=Theme.SURFACE, - button_color=Theme.SECONDARY, - button_hover_color=Theme.SURFACE_HOVER, - text_color=Theme.TEXT_PRIMARY, - **kwargs, - ) - - -class ActionButton(HubButton): - """Semantic alias used for text actions in views.""" - - -class IconButton(HubButton): - """Square icon action using the existing lightweight asset infrastructure.""" - - def __init__(self, master: Any, text: str, size: int = Theme.HEIGHT_BTN_SM, **kwargs): - super().__init__(master, text=text, width=size, height=size, variant="ghost", **kwargs) - - -class EmptyState(HubCard): - """Honest empty/unknown state with an optional recovery action.""" - - def __init__( - self, - master: Any, - title: str, - message: str, - action_text: Optional[str] = None, - action_cmd: Optional[Callable] = None, - **kwargs, - ): - super().__init__(master, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED, **kwargs) - ctk.CTkLabel(self, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack( - pady=(Theme.SPACE_LG, Theme.SPACE_XS) - ) - ctk.CTkLabel(self, text=message, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack( - padx=Theme.SPACE_LG, pady=(0, Theme.SPACE_MD) - ) - if action_text and action_cmd: - ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack(pady=(0, Theme.SPACE_LG)) - - -class RouteTargetWidget(HubCard): - """One node in a primary → reserve failover chain.""" - - def __init__(self, master: Any, rank: str, title: str, subtitle: str, status: str = "unknown", **kwargs): - super().__init__(master, corner_radius=Theme.RADIUS_SM, **kwargs) - self.rank = ctk.CTkLabel(self, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.rank.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, 0)) - self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) - self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) - self.subtitle = ctk.CTkLabel(self, text=subtitle, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X) - self.quota = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.quota.pack(anchor="w", padx=Theme.CARD_PAD_X) - self.quota_tooltip = Tooltip(self.quota, "") - self.failover = ctk.CTkLabel( - self, text="", font=Theme.font_micro(), text_color=Theme.COLOR_CAUTION, wraplength=260, justify="left" - ) - self.update_target(rank, title, subtitle, status) - - def update_target( - self, - rank: str, - title: str, - subtitle: str, - status: str = "unknown", - quota_status: str = "unknown", - failover_reason: Optional[str] = None, - ) -> None: - self.rank.configure(text=rank) - self.title.configure(text=title) - self.subtitle.configure(text=subtitle) - quota_labels = { - "healthy": "Квота: доступна", - "warning": "Квота: заканчивается", - "exhausted": "Квота: исчерпана", - } - if quota_status in quota_labels: - quota_text = quota_labels[quota_status] - quota_reason = "" - elif status in {"not_configured", "auth_required", "auth_expired"}: - quota_text = "Квота: Н/Д — аккаунт не подключён" - quota_reason = "Аккаунт не авторизован, поэтому лимиты запросить нельзя" - else: - quota_text = "Квота: Н/Д — провайдер не отдаёт лимиты" - quota_reason = "В текущем ответе провайдера отсутствуют измеримые данные квоты" - self.quota.configure(text=quota_text) - self.quota_tooltip.text = quota_reason - if failover_reason: - self.failover.configure(text=f"Переключено: {failover_reason}") - self.failover.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) - else: - self.failover.pack_forget() - self.quota.pack_configure(pady=(0, Theme.SPACE_SM)) - self.set_status(status) - - def set_status(self, status: str) -> None: - self.configure(border_color=_semantic_color(status)) - - -class AccountCardWidget(HubCard): - """Keyed account card whose quota buckets are updated without rebuilding the card.""" - - MANAGEMENT_ACTIONS = ( - ("test", "⚡ Тест"), - ("set_main", "★ Основной"), - ("set_orchestrator", "♛ Оркестратор"), - ("assign_role", "Назначить роль"), - ) - - def __init__( - self, - master: Any, - profile_id: str, - identity: str, - provider: str, - status: str = "unknown", - compact: bool = False, - on_action: Optional[Callable[[str, Any], None]] = None, - **kwargs, - ): - super().__init__(master, height=148, **kwargs) - self.pack_propagate(False) - self.grid_propagate(False) - self.profile_id = profile_id - self.profile_model: Any = None - self.on_action = on_action - self.compact = True - self._quota_widgets: Dict[str, QuotaBucketWidget] = {} - self.widgets_created = 0 - self.widgets_destroyed = 0 - - top = ctk.CTkFrame(self, fg_color="transparent") - top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) - self.provider = ctk.CTkLabel(top, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.provider.pack(side="left") - self.profile_mark = ctk.CTkButton( - top, - text="⋮", - width=28, - height=20, - fg_color="transparent", - hover_color=Theme.SURFACE_HOVER, - text_color=Theme.TEXT_SECONDARY, - command=lambda: self._trigger("account_details"), - ) - self.profile_mark.pack(side="right") - - self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) - self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X) - self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) - self.badges = ctk.CTkFrame(self, fg_color="transparent") - self.badges.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) - self.plan_badge = PlanBadge(self.badges, text="") - self.status = StatusBadge(self.badges, status) - self.status.pack(side="left") - - self.compact_quota = ctk.CTkFrame(self, fg_color="transparent") - self.compact_quota_cells: list[dict[str, Any]] = [] - for index in range(4): - cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent") - cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2)) - top_line = ctk.CTkFrame(cell, fg_color="transparent") - top_line.pack(fill="x") - title = ctk.CTkLabel(top_line, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY, anchor="w") - title.pack(side="left") - value = ctk.CTkLabel(top_line, text="", font=Theme.font_micro_bold(), text_color=Theme.TEXT_PRIMARY, anchor="e") - value.pack(side="right") - progress = ctk.CTkProgressBar( - cell, - height=4, - corner_radius=2, - fg_color=Theme.SURFACE_MUTED, - progress_color=Theme.STATUS_HEALTHY, - ) - progress.pack(fill="x", pady=(1, 0)) - progress.set(0) - reset = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, anchor="w") - reset.pack(fill="x") - self.compact_quota_cells.append( - {"frame": cell, "title": title, "value": value, "progress": progress, "reset": reset} - ) - self.compact_quota.grid_columnconfigure(index % 2, weight=1) - self.compact_actions = ctk.CTkFrame(self, fg_color="transparent") - for text, action in ( - ("⚡", "test"), - ("★", "set_main"), - ("♛", "set_orchestrator"), - ("Роль", "assign_role"), - ("↻", "refresh_account"), - ): - ActionButton( - self.compact_actions, - text=text, - variant="ghost", - width=48, - height=Theme.HEIGHT_BTN_SM, - command=lambda action_name=action: self._trigger(action_name), - ).pack(side="left", padx=(0, Theme.SPACE_XS)) - - self.details = ctk.CTkFrame(self, fg_color="transparent") - self.details.pack(fill="x") - self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent") - self.quota_box.pack(fill="x", padx=Theme.CARD_PAD_X) - self.empty_quota = ctk.CTkLabel( - self.quota_box, text="Квота: Н/Д", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED - ) - self.actions = ctk.CTkFrame(self.details, fg_color="transparent") - self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) - self.action_feedback = ctk.CTkLabel( - self.actions, - text="", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - wraplength=330, - justify="left", - anchor="w", - ) - self.action_buttons: Dict[str, ActionButton] = {} - management = ctk.CTkFrame(self.actions, fg_color="transparent") - management.pack(fill="x", pady=(0, Theme.SPACE_XS)) - for index, (action, label) in enumerate(self.MANAGEMENT_ACTIONS): - button = ActionButton( - management, - text=label, - variant="secondary" if index < 2 else "ghost", - height=Theme.HEIGHT_BTN_SM, - command=lambda action_name=action: self._trigger(action_name), - ) - button.grid(row=index // 2, column=index % 2, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="ew") - management.grid_columnconfigure(index % 2, weight=1) - self.action_buttons[action] = button - utility = ctk.CTkFrame(self.actions, fg_color="transparent") - utility.pack(fill="x") - ActionButton( - utility, - text="Обновить", - variant="secondary", - width=86, - command=lambda: self._trigger("refresh_account"), - ).pack(side="left") - ActionButton( - utility, - text="Удалить", - variant="ghost", - width=72, - command=lambda: self._trigger("delete_credentials"), - ).pack(side="right") - self.set_compact(True) - self._bind_details_click(self) - try: - self._canvas.configure(takefocus=1) - except (AttributeError, tk.TclError): - pass - self.bind("", self._open_details, add="+") - self.bind("", self._open_details, add="+") - self.bind("", self._hover_on, add="+") - self.bind("", self._hover_off, add="+") - - def _bind_details_click(self, widget: Any) -> None: - if isinstance(widget, ctk.CTkButton): - return - widget.configure(cursor="hand2") - widget.bind("", self._open_details, add="+") - for child in widget.winfo_children(): - self._bind_details_click(child) - - def _open_details(self, _event: Any = None) -> str: - self._trigger("account_details") - return "break" - - 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: - profile = self.profile_model - border = Theme.BORDER_ACCENT if profile and getattr(profile, "is_main_account", False) else Theme.BORDER - self.configure(fg_color=Theme.SURFACE, border_color=border) - - @staticmethod - def resolve_identity(profile: Any) -> str: - """Contract order: email → normalized account identity → display name → profile id.""" - for value in ( - getattr(profile, "email", None), - getattr(profile, "account_identity", None), - getattr(profile, "display_name", None), - getattr(profile, "profile_id", None), - ): - if value and str(value).strip() and str(value).strip() not in {"—", "Н/Д"}: - return str(value).strip() - return "Аккаунт: Н/Д" - - def _trigger(self, action: str) -> None: - if self.on_action and self.profile_model is not None: - self.set_action_feedback("Выполняется…", None) - self.on_action(action, self.profile_model) - - def set_action_feedback(self, message: str, success: Optional[bool]) -> None: - """Show an action result where the user initiated it.""" - color = Theme.TEXT_SECONDARY - if success is True: - color = Theme.STATUS_HEALTHY - elif success is False: - color = Theme.STATUS_ERROR - self.action_feedback.configure(text=message, text_color=color) - self.action_feedback.pack(fill="x", pady=(0, Theme.SPACE_XS), before=self.actions.winfo_children()[1]) - - def toggle_compact(self) -> None: - # Account cards intentionally have one fixed Cockpit-style layout. - self.set_compact(True) - - def set_compact(self, compact: bool) -> None: - self.compact = True - self.details.pack_forget() - self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS)) - self.compact_actions.pack_forget() - - def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: - self.profile_model = profile - self.profile_id = profile.profile_id - self.identity.set_text(self.resolve_identity(profile)) - self.provider.configure(text=profile.provider_display_name or profile.provider) - roles = ", ".join(getattr(profile, "assigned_roles", []) or []) or "Роль: Н/Д" - self.meta.configure(text=f"{profile.display_name} • {roles}") - if self.plan_badge.set_plan( - getattr(profile, "plan_code", "UNKNOWN"), getattr(profile, "plan_source", "unknown") - ): - self.plan_badge.pack(side="left", padx=(0, Theme.SPACE_XS), before=self.status) - else: - self.plan_badge.pack_forget() - self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER) - - snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None) - buckets = list(getattr(snapshot, "buckets", None) or []) - estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True - unavailable_reason = getattr(snapshot, "unavailable_reason", None) if snapshot else None - measured_remaining = [ - float(bucket.remaining_percent) - for bucket in buckets - if getattr(bucket, "remaining_percent", None) is not None - ] - if measured_remaining and not estimated: - if all(remaining <= 0 for remaining in measured_remaining): - card_health, card_label = "quota_exhausted", "Квота исчерпана" - elif any(remaining <= 0 for remaining in measured_remaining): - card_health, card_label = "warning", "Часть квот исчерпана" - else: - card_health, card_label = "healthy", "Работает" - else: - card_health = profile.health_state - card_label = getattr(profile, "health_label_ru", None) - self.status.set_status(card_health, card_label) - visible_buckets = sorted( - buckets, - key=lambda bucket: ( - getattr(bucket, "remaining_percent", None) is None, - float(getattr(bucket, "remaining_percent", 101.0) or 0.0), - ), - )[:4] - if visible_buckets: - for index, cell in enumerate(self.compact_quota_cells): - if index < len(visible_buckets): - bucket = visible_buckets[index] - remaining = getattr(bucket, "remaining_percent", None) - color = ( - Theme.STATUS_HEALTHY - if getattr(bucket, "status", "unknown") == "healthy" - else Theme.STATUS_WARNING - if getattr(bucket, "status", "unknown") == "warning" - else (Theme.STATUS_ERROR if getattr(bucket, "status", "unknown") == "exhausted" else Theme.TEXT_MUTED) - ) - detail = bucket.formatted_remaining() - if detail == "Н/Д" and unavailable_reason: - limit = getattr(bucket, "limit_absolute", None) - unit = getattr(bucket, "unit", None) - prefix = f"Лимит ${limit} • " if limit is not None and unit == "USD" else "" - detail = f"{prefix}{unavailable_reason}" - cell["title"].configure(text=bucket.display_name) - cell["value"].configure(text=detail, text_color=color) - cell["progress"].configure(progress_color=color) - cell["progress"].set(float(remaining) / 100.0 if remaining is not None else 0) - reset_txt = bucket.formatted_reset() or "Период указан провайдером" - cell["reset"].configure(text=reset_txt) - cell["frame"].grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 8), pady=(1, 2)) - else: - cell["frame"].grid_remove() - else: - # Fallback when no bucket objects exist - reason = unavailable_reason or ( - "Аккаунт не подключён" if profile.health_state in {"not_configured", "auth_required", "auth_expired"} - else "Провайдер не отдаёт лимиты" - ) - cell = self.compact_quota_cells[0] - cell["title"].configure(text="Квота") - cell["value"].configure(text=f"Н/Д", text_color=Theme.TEXT_MUTED) - cell["progress"].configure(progress_color=Theme.COLOR_NEUTRAL) - cell["progress"].set(0) - cell["reset"].configure(text=reason) - cell["frame"].grid(row=0, column=0, columnspan=2, sticky="nsew", padx=(0, 8), pady=(1, 2)) - for other in self.compact_quota_cells[1:]: - other["frame"].grid_remove() - seen: set[str] = set() - for bucket in buckets: - key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket")) - seen.add(key) - remaining = getattr(bucket, "remaining_percent", None) - ratio = float(remaining) / 100.0 if remaining is not None else None - if ratio is None: - remaining_abs = getattr(bucket, "remaining_absolute", None) - limit_abs = getattr(bucket, "limit_absolute", None) - if remaining_abs is not None and limit_abs: - ratio = float(remaining_abs) / float(limit_abs) - detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н/Д" - reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None - if key not in self._quota_widgets: - widget = QuotaBucketWidget(self.quota_box, key, bucket.display_name) - widget.pack(fill="x", pady=(0, Theme.SPACE_SM)) - self._quota_widgets[key] = widget - self.widgets_created += 1 - self._quota_widgets[key].update_bucket( - bucket.display_name, - ratio, - reset or "Сброс: Н/Д", - estimated, - detail, - getattr(bucket, "unavailable_reason", None) or getattr(snapshot, "unavailable_reason", None), - ) - - for key in list(self._quota_widgets): - if key not in seen: - self._quota_widgets.pop(key).destroy() - self.widgets_destroyed += 1 - if buckets: - self.empty_quota.pack_forget() - else: - self.empty_quota.pack(anchor="w", pady=Theme.SPACE_SM) - - -class AgentCardWidget(HubCard): - """Model-agnostic agent-card shell shared by team views.""" - - def __init__(self, master: Any, name: str, role: str, detail: str = "Н/Д", **kwargs): - super().__init__(master, **kwargs) - ctk.CTkLabel(self, text=role, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack( - anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS) - ) - ctk.CTkLabel(self, text=name, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack( - anchor="w", padx=Theme.CARD_PAD_X - ) - ctk.CTkLabel(self, text=detail, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( - anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.CARD_PAD_Y) - ) - - -class ConfirmDialog(HubModal): - """Reusable destructive/non-destructive confirmation dialog.""" - - def __init__( - self, - parent: Any, - title: str, - message: str, - on_confirm: Callable[[], None], - destructive: bool = False, - ): - super().__init__(parent, title=title, width=440, height=240) - ctk.CTkLabel( - self.body, - text=message, - wraplength=380, - justify="left", - font=Theme.font_body(), - text_color=Theme.TEXT_SECONDARY, - ).pack(fill="x") - ActionButton(self.footer, text="Отмена", variant="ghost", command=self.destroy).pack(side="right") - - def _confirm() -> None: - self.destroy() - on_confirm() - - ActionButton( - self.footer, - text="Подтвердить", - variant="danger" if destructive else "primary", - command=_confirm, - ).pack(side="right", padx=(0, Theme.SPACE_SM)) - - -class Toast(HubCard): - """Non-blocking in-window notification with an optional auto-dismiss timer.""" - - def __init__(self, master: Any, message: str, status: str = "unknown", duration_ms: int = 4000, **kwargs): - super().__init__(master, border_color=_semantic_color(status), fg_color=Theme.BG_HEADER, **kwargs) - ctk.CTkLabel(self, text=message, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack( - padx=Theme.CARD_PAD_X, pady=Theme.CARD_PAD_Y - ) - if duration_ms > 0: - self.after(duration_ms, self.destroy) diff --git a/src/antigravity_provider/router/ui/model_catalog.py b/src/antigravity_provider/router/ui/model_catalog.py deleted file mode 100644 index 0b1b6ef..0000000 --- a/src/antigravity_provider/router/ui/model_catalog.py +++ /dev/null @@ -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 diff --git a/src/antigravity_provider/router/ui/routing_graph.py b/src/antigravity_provider/router/ui/routing_graph.py deleted file mode 100644 index 6e7fa0b..0000000 --- a/src/antigravity_provider/router/ui/routing_graph.py +++ /dev/null @@ -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 ()) diff --git a/src/antigravity_provider/router/ui/splash.py b/src/antigravity_provider/router/ui/splash.py deleted file mode 100644 index 62e8685..0000000 --- a/src/antigravity_provider/router/ui/splash.py +++ /dev/null @@ -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 diff --git a/src/antigravity_provider/router/ui/theme.py b/src/antigravity_provider/router/ui/theme.py deleted file mode 100644 index a3e53a9..0000000 --- a/src/antigravity_provider/router/ui/theme.py +++ /dev/null @@ -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") diff --git a/src/antigravity_provider/router/ui/views/__init__.py b/src/antigravity_provider/router/ui/views/__init__.py deleted file mode 100644 index 78987d4..0000000 --- a/src/antigravity_provider/router/ui/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Hermes Hub UI Views Package.""" - -from __future__ import annotations diff --git a/src/antigravity_provider/router/ui/views/about_view.py b/src/antigravity_provider/router/ui/views/about_view.py deleted file mode 100644 index 8bef094..0000000 --- a/src/antigravity_provider/router/ui/views/about_view.py +++ /dev/null @@ -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)) diff --git a/src/antigravity_provider/router/ui/views/accounts_view.py b/src/antigravity_provider/router/ui/views/accounts_view.py deleted file mode 100644 index 13d0e34..0000000 --- a/src/antigravity_provider/router/ui/views/accounts_view.py +++ /dev/null @@ -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("", 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 diff --git a/src/antigravity_provider/router/ui/views/analytics_view.py b/src/antigravity_provider/router/ui/views/analytics_view.py deleted file mode 100644 index 3388b7e..0000000 --- a/src/antigravity_provider/router/ui/views/analytics_view.py +++ /dev/null @@ -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") diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py deleted file mode 100644 index e857618..0000000 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ /dev/null @@ -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("", self._activate, add="+") - self.bind("", self._activate, add="+") - self.bind("", self._hover_on, add="+") - self.bind("", self._hover_off, add="+") - - def _bind_click_tree(self, widget: Any) -> None: - widget.configure(cursor="hand2") - widget.bind("", 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("", self._schedule_redraw) - - def _schedule_redraw(self, event: Any = None) -> None: - """Свести поток к одной перерисовке. - - При перетаскивании окна Tk шлёт непрерывно, и раньше каждое - событие вызывало полную перерисовку канвы: удаление и построение всех - связей, подписей и узлов. Очередь не успевала разгребаться, и окно - продолжало ползти ещё несколько секунд после того, как мышь отпущена. - - Перерисовываем один раз, когда поток событий утих. - """ - 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) diff --git a/src/antigravity_provider/router/ui/views/health_view.py b/src/antigravity_provider/router/ui/views/health_view.py deleted file mode 100644 index fff4fec..0000000 --- a/src/antigravity_provider/router/ui/views/health_view.py +++ /dev/null @@ -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) diff --git a/src/antigravity_provider/router/ui/views/logs_view.py b/src/antigravity_provider/router/ui/views/logs_view.py deleted file mode 100644 index c27db3e..0000000 --- a/src/antigravity_provider/router/ui/views/logs_view.py +++ /dev/null @@ -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("", 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 diff --git a/src/antigravity_provider/router/ui/views/providers_view.py b/src/antigravity_provider/router/ui/views/providers_view.py deleted file mode 100644 index 41998c2..0000000 --- a/src/antigravity_provider/router/ui/views/providers_view.py +++ /dev/null @@ -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) diff --git a/src/antigravity_provider/router/ui/views/quotas_view.py b/src/antigravity_provider/router/ui/views/quotas_view.py deleted file mode 100644 index 6a5f46e..0000000 --- a/src/antigravity_provider/router/ui/views/quotas_view.py +++ /dev/null @@ -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) diff --git a/src/antigravity_provider/router/ui/views/routing_view.py b/src/antigravity_provider/router/ui/views/routing_view.py deleted file mode 100644 index 1f5aa35..0000000 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ /dev/null @@ -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) diff --git a/src/antigravity_provider/router/ui/views/settings_view.py b/src/antigravity_provider/router/ui/views/settings_view.py deleted file mode 100644 index 116ea07..0000000 --- a/src/antigravity_provider/router/ui/views/settings_view.py +++ /dev/null @@ -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, - }, - ) diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py deleted file mode 100644 index dc75bc5..0000000 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ /dev/null @@ -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("", self._open_settings, add="+") - self.bind("", self._open_settings, add="+") - self.bind("", self._hover_on, add="+") - self.bind("", self._hover_off, add="+") - - def _bind_settings_click(self, widget: Any) -> None: - if isinstance(widget, ctk.CTkButton): - return - widget.configure(cursor="hand2") - widget.bind("", 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("", 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("", self._on_press) - self.canvas.bind("", self._on_drag) - self.canvas.bind("", self._on_release) - self.canvas.bind("", self._on_zoom) - self.canvas.bind("", self._start_pan) - self.canvas.bind("", self._pan) - self.canvas.bind("", self._end_pan) - self.canvas.bind("", lambda _e: self._undo()) - self.canvas.bind("", lambda _e: self._redo()) - self.canvas.bind("", 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) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 1c4958b..4f65bcb 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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 = ` +
+ +
+ + +
+
+ `; + } else { + modelBlockHtml = ` +
+
+ ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. +
+ +
+ `; + } + + elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; + elements.modalBody.innerHTML = ` + +
+
${escapeHtml(profile.account_identity || profile.email || profileId)}
+
+ Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • + Тариф: ${escapeHtml(profile.plan_code || profile.plan || 'Неизвестен')} • + Статус: ${escapeHtml(profile.health_label_ru || 'Работает')} +
+
+ Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')} +
+
+ + ${modelBlockHtml} + +

+ Квоты и корзины провайдера +

+
+ ${buckets.map((b) => ` +
+
+ ${escapeHtml(b.display_name)} + ${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'} +
+
+
+
+
+ ${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')} +
+
+ `).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'} +
+ `; + + elements.modalFooter.innerHTML = ` + + + + + `; + + 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 = ''; + } + const res = await executeAction('set_model', { profile_id: profileId, model: model }); + if (feedbackArea) { + if (res.ok) { + feedbackArea.innerHTML = ``; + if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { + currentSnapshot.all_profiles[profileId].preferred_models = [model]; + } + } else { + feedbackArea.innerHTML = ``; + } + } +} + +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 = ` + +
+ Профиль агента: ${escapeHtml(profile.display_name)} (${profileId}) • Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} +
+ ${discoveredModels.length > 0 ? ` +
+ + +
+ ` : ` +
+
+ ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. +
+ +
+ `} + `; + + elements.modalFooter.innerHTML = ` + + ${discoveredModels.length > 0 ? `` : ''} + `; + + 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 = ''; + } + const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId }); + if (feedbackArea) { + if (res.ok) { + feedbackArea.innerHTML = ``; + 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 = ``; + } + } +} + +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 = ''; + } + const res = await executeAction('test', { profile_id: profileId }); + if (feedbackArea) { + if (res.ok) { + feedbackArea.innerHTML = ``; + } else { + feedbackArea.innerHTML = ``; + } + } +} + +// ── Add Account Wizard ── +function openAddAccountWizard() { + elements.modalTitle.textContent = 'Мастер подключения учетной записи'; + showWizardStep1(); + showModal(); +} + +function showWizardStep1() { + elements.modalBody.innerHTML = ` +
+ Шаг 1 из 3: Выберите провайдера ИИ +
+
+ + + + + + +
+ `; + elements.modalFooter.innerHTML = ` + + `; +} + +function showWizardStep2(providerId) { + let bodyHtml = ''; + + if (providerId === 'grok' || providerId === 'openai-codex') { + const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; + bodyHtml = ` +
+ Шаг 2 из 3: Авторизация ${providerName} по коду устройства +
+
+
Запрашиваем код у провайдера…
+
+ `; + setTimeout(() => startDeviceAuth(providerId), 0); + } else if (providerId === 'antigravity' || providerId === 'claude') { + const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'; + bodyHtml = ` +
+ Шаг 2 из 3: Авторизация ${providerName} +
+
+ + +
+ Вход в занятый слот заменит учётные данные, которые в нём сейчас. +
+
+
+ +
+
+
Выберите слот и нажмите «Получить ссылку».
+
+ `; + } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') { + bodyHtml = ` +
+ Шаг 2 из 3: Настройка локального сервера (Local LLM) +
+
+ + +
+
+ + +
+ `; + } else { + bodyHtml = ` +
+ Шаг 2 из 3: Ввод API ключа ${providerId} +
+
+ + +
+ `; + } + + elements.modalBody.innerHTML = ` + + ${bodyHtml} + `; + + elements.modalFooter.innerHTML = ` + + + `; +} + +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 = ` + +
+ Шаг 3 из 3: Назначение роли для нового аккаунта +
+
+ + +
+ `; + + elements.modalFooter.innerHTML = ` + + + `; +} + +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 = ''; + } + + 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 = ``; + } + } +} + // ── TOAST NOTIFICATIONS ── function showToast(message, type = 'info') { if (!elements.toastContainer) return; diff --git a/tests/conftest.py b/tests/conftest.py index 8e913bb..6677f12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_codex_opencode_wizard.py b/tests/test_codex_opencode_wizard.py index 5494d23..f925e6d 100644 --- a/tests/test_codex_opencode_wizard.py +++ b/tests/test_codex_opencode_wizard.py @@ -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 "" in bound_events - assert "" in bound_events - assert "" in bound_events - assert "" in bound_events # ============================================================================== diff --git a/tests/test_deployment_doctor.py b/tests/test_deployment_doctor.py index 8e99296..c51f2bd 100644 --- a/tests/test_deployment_doctor.py +++ b/tests/test_deployment_doctor.py @@ -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", diff --git a/tests/test_import_invariants.py b/tests/test_import_invariants.py index bcc1ddf..7a64222 100644 --- a/tests/test_import_invariants.py +++ b/tests/test_import_invariants.py @@ -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" ) diff --git a/tests/test_installer_windows_and_linux.py b/tests/test_installer_windows_and_linux.py index 78a25a8..b2fe489 100644 --- a/tests/test_installer_windows_and_linux.py +++ b/tests/test_installer_windows_and_linux.py @@ -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(): diff --git a/tests/test_model_validation_a23.py b/tests/test_model_validation_a23.py index f915e54..da11258 100644 --- a/tests/test_model_validation_a23.py +++ b/tests/test_model_validation_a23.py @@ -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] diff --git a/tests/test_oauth_lifecycle.py b/tests/test_oauth_lifecycle.py index 584914e..03811f2 100644 --- a/tests/test_oauth_lifecycle.py +++ b/tests/test_oauth_lifecycle.py @@ -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 diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index c460d4f..747e9d9 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -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 diff --git a/tests/test_ui_claude_grok_connection.py b/tests/test_ui_claude_grok_connection.py index 340fc1f..5a8d63a 100644 --- a/tests/test_ui_claude_grok_connection.py +++ b/tests/test_ui_claude_grok_connection.py @@ -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 diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py deleted file mode 100644 index 044fd33..0000000 --- a/tests/test_ui_contract_v11.py +++ /dev/null @@ -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() diff --git a/tests/test_ui_design_system.py b/tests/test_ui_design_system.py deleted file mode 100644 index 2af2de9..0000000 --- a/tests/test_ui_design_system.py +++ /dev/null @@ -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]) diff --git a/tests/test_ui_mockup_redesign.py b/tests/test_ui_mockup_redesign.py deleted file mode 100644 index 3c87789..0000000 --- a/tests/test_ui_mockup_redesign.py +++ /dev/null @@ -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() diff --git a/tests/test_ui_phase2_6.py b/tests/test_ui_phase2_6.py deleted file mode 100644 index 9a8cf43..0000000 --- a/tests/test_ui_phase2_6.py +++ /dev/null @@ -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" diff --git a/tests/test_ui_refinement.py b/tests/test_ui_refinement.py index 801def4..e4c70b7 100644 --- a/tests/test_ui_refinement.py +++ b/tests/test_ui_refinement.py @@ -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 diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py deleted file mode 100644 index eedd20d..0000000 --- a/tests/test_ui_routing_graph.py +++ /dev/null @@ -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 diff --git a/tests/test_ui_screenshot_harness.py b/tests/test_ui_screenshot_harness.py deleted file mode 100644 index 60643f2..0000000 --- a/tests/test_ui_screenshot_harness.py +++ /dev/null @@ -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) diff --git a/tests/test_ui_wizard_finish.py b/tests/test_ui_wizard_finish.py deleted file mode 100644 index 1b3b1f6..0000000 --- a/tests/test_ui_wizard_finish.py +++ /dev/null @@ -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 diff --git a/tests/test_view_startup_contract.py b/tests/test_view_startup_contract.py deleted file mode 100644 index be3f7e7..0000000 --- a/tests/test_view_startup_contract.py +++ /dev/null @@ -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." - )