feat(hub): product stabilization, native windows ux v3, provider icons, and architecture roadmap
96
README.md
|
|
@ -2,66 +2,80 @@
|
||||||
|
|
||||||
**Multi-Agent & Multi-Provider Control Hub for Hermes Agent**
|
**Multi-Agent & Multi-Provider Control Hub for Hermes Agent**
|
||||||
|
|
||||||
Hermes Hub — централизованная панель управления и отказоустойчивый маршрутизатор запросов (Multi-Provider Router) для [Hermes Agent](https://hermes-agent.org/). Позволяет объединить учетные записи различных провайдеров (**OpenAI Codex**, **Google Antigravity**, **OpenCode Go**) в единую отказоустойчивую команду с автоматическим переключением при исчерпании квот (failover) и поддержкой диалогового контекста (Session Affinity).
|
Hermes Hub — централизованная панель управления и отказоустойчивый маршрутизатор запросов (Multi-Provider Router) для [Hermes Agent](https://hermes-agent.org/). Объединяет учетные записи различных провайдеров (**Google Antigravity**, **OpenAI Codex**, **OpenCode Go**, **DeepSeek**) в единую согласованную команду с автоматическим переключением при исчерпании квот (failover) и поддержкой сессионной привязки (Session Affinity).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌟 Ключевые возможности
|
## 🌟 Ключевые возможности
|
||||||
|
|
||||||
- 👥 **Визуальная панель «Команда Hermes»**: Управление ролями агентов («Главный оркестратор», «Кодер 1», «Кодер 2», «Ревьюер», «Исследователь», «Быстрый агент», «Резерв»).
|
- 🖥️ **Нативное приложение Windows (CustomTkinter & Brandbook)**:
|
||||||
- 🔀 **Многоуровневый Failover**: Бесшовное переключение цепочки провайдеров `Codex -> Antigravity -> OpenCode Go` при квотных ограничениях (HTTP 429 / Quota Exceeded).
|
- Фирменная дизайн-система **Obsidian Forest** (`#0F1510`, `#1A2A1F`, `#2F4A36`, `#F7F1E3`, `#CDAA64`).
|
||||||
- 🧠 **Auto Assignment Engine**: Автоматический подбор свободных слотов при подключении новых аккаунтов и защита от дубликатов.
|
- Мгновенное переключение вкладок (**P95 < 200 ms**, в среднем ~30 ms).
|
||||||
- ⚡ **Session Affinity**: Сохранение используемого профиля и модели на протяжении диалоговой сессии без случайных скачков контекста.
|
- Полноценная интеграция с панелью задач Windows через `AppUserModelID` (`HermesHub.Desktop`) и multi-resolution `.ico`.
|
||||||
- 🔐 **Безопасная изоляция профилей**: Раздельные профили окружения и хранилища учетных данных (`auth.json`), маскирование email и API-ключей.
|
- 👥 **Команда агентов (Team View)**:
|
||||||
- 💻 **Нативный лаунчер (`HermesHub.exe`)**: Windows App Mode на базе Microsoft Edge с проверкой готовности бэкенда (HTTP 200 health check gate).
|
- 6 логических ролей («Главный оркестратор», «Кодер», «Ревьюер», «Исследователь», «Тестировщик», «Агент общего назначения»).
|
||||||
- 📦 **Полноценный Windows Installer (`HermesHubSetup.exe`)**: Мастер установки с pre-flight проверкой Hermes 0.20.4+, тихим режимом `/silent`, поддержкой обновления и безопасного удаления.
|
- Реальные логотипы провайдеров с динамическим кэшированием.
|
||||||
|
- Четкое разделение флагов **MAIN Hermes Account** и **Primary Orchestrator**.
|
||||||
|
- 🔀 **Многоуровневый Failover & Маршрутизация**:
|
||||||
|
- Визуальные пайплайны маршрутизации со статусами узлов в реальном времени.
|
||||||
|
- Автоматический failover цепочки `Codex -> Antigravity -> OpenCode Go` при квотных ограничениях (HTTP 429 / Quota Exceeded).
|
||||||
|
- 🛡️ **Строгий Status Resolver & Unified Health**:
|
||||||
|
- Неподключенные слоты отображаются как `NOT_CONFIGURED` («Аккаунт не добавлен») и никогда не показывают ложных `QUOTA_EXHAUSTED` или `HEALTHY`.
|
||||||
|
- Автоматическая очистка устаревших квотных записей при удалении credentials.
|
||||||
|
- 💼 **Cockpit Tools Accounts View**:
|
||||||
|
- Верхний тулбар: Поиск, фильтры (Подключенные, Требуется вход, Квота), сортировка.
|
||||||
|
- Компактный блок свободных слотов без визуального шума.
|
||||||
|
- 4-шаговый мастер добавления аккаунтов (Wizard).
|
||||||
|
- ⚙️ **Архитектурные модули (Roadmap P0, P1, P2)**:
|
||||||
|
- `LifecycleSupervisor`: Реестр процессов с контролем владения по UUID и PID, исключающий использование `killall` / `taskkill`.
|
||||||
|
- `PolicyEnforcer`: `WebPolicy` (ограничение исходящих доменов, блокировка метаданных) и `ToolPolicy` (валидация команд).
|
||||||
|
- `UnifiedSkillRegistry`: Межпровайдерный реестр навыков.
|
||||||
|
- `CapabilityMatrix`: Ранжирование моделей по контексту, рассуждениям и инструментам.
|
||||||
|
- `ScheduledTaskSafetyCoordinator`: Защита периодических задач (`overlap_policy: skip`).
|
||||||
|
- 📦 **Windows Installer (`HermesHubSetup.exe`)**:
|
||||||
|
- Обязательная pre-flight проверка наличия установленного Hermes Agent.
|
||||||
|
- Автоматическое создание ярлыков на Рабочем столе и в Главном меню с `AppUserModelID`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
### Вариант 1: Установка через Windows Installer
|
### Запуск из исходного кода
|
||||||
Скачайте и запустите `HermesHubSetup.exe`:
|
|
||||||
```powershell
|
```powershell
|
||||||
# Интерактивный графический мастер:
|
# Установка зависимостей
|
||||||
.\HermesHubSetup.exe
|
uv sync
|
||||||
|
|
||||||
# Автоматический тихий режим:
|
# Запуск приложения Hermes Hub
|
||||||
.\HermesHubSetup.exe /silent
|
uv run python -m antigravity_provider.router.hermes_hub_app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Вариант 2: Установка через PowerShell
|
### Запуск тестов и верификации
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\install.ps1
|
# Полный набор автоматических тестов (35 тестов)
|
||||||
```
|
uv run pytest -v tests/
|
||||||
|
|
||||||
После установки ярлык **Hermes Hub** появится в меню «Пуск».
|
# Комплексная проверка роутера (10 проверок)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Архитектура системы
|
|
||||||
|
|
||||||
| Компонент | Расположение | Назначение |
|
|
||||||
|---|---|---|
|
|
||||||
| **Source of Truth** | `E:\Agent projects\hermes-hub` | Репозиторий исходного кода |
|
|
||||||
| **Installed Application** | `%LOCALAPPDATA%\Programs\HermesHub\` | Исполняемые файлы (`HermesHub.exe`, скрипты) |
|
|
||||||
| **Plugin Integration** | `%LOCALAPPDATA%\hermes\plugins\antigravity-provider\` | Пакет роутера и адаптеров провайдеров |
|
|
||||||
| **User Runtime & Auth** | `%LOCALAPPDATA%\hermes\` | Пользовательские авторизации и настройки |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Тестирование и верификация
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Запуск полного набора unit/integration тестов
|
|
||||||
uv run pytest tests/test_multi_provider_router.py -v
|
|
||||||
|
|
||||||
# Запуск скрипта автоматической верификации роутера
|
|
||||||
python scripts/verify_multi_provider_router.py
|
python scripts/verify_multi_provider_router.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Лицензия
|
## 🏗️ Структура проекта
|
||||||
|
|
||||||
Проект распространяется под лицензией [MIT](LICENSE).
|
| Каталог | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `src/antigravity_provider/router/` | Ядро Multi-Provider Router и Native GUI |
|
||||||
|
| `src/antigravity_provider/router/ui/` | Дизайн-система, компоненты и экраны (Views) |
|
||||||
|
| `src/antigravity_provider/router/supervisor/` | Lifecycle Supervisor и политики безопасности (WebPolicy/ToolPolicy) |
|
||||||
|
| `src/antigravity_provider/router/skills/` | Унифицированный реестр навыков |
|
||||||
|
| `src/antigravity_provider/router/capability/` | Матрица возможностей моделей и DeepSeek адаптер |
|
||||||
|
| `src/antigravity_provider/router/scheduler/` | Координатор безопасности запланированных задач |
|
||||||
|
| `assets/` | Фирменные логотипы, иконки провайдеров и .ico |
|
||||||
|
| `installer/` | Инсталлятор `HermesHubSetup.py` с проверкой Hermes Agent |
|
||||||
|
| `tests/` | Автоматизированные тесты pytest |
|
||||||
|
| `docs/` | Архитектура, гайдлайны бренда, безопасность и производительность |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
MIT License. Hermes Hub is an open-source component of the Hermes Agent ecosystem.
|
||||||
|
|
|
||||||
BIN
assets/providers/antigravity/antigravity_128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/providers/antigravity/antigravity_16.png
Normal file
|
After Width: | Height: | Size: 843 B |
BIN
assets/providers/antigravity/antigravity_20.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/providers/antigravity/antigravity_24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/providers/antigravity/antigravity_32.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
assets/providers/antigravity/antigravity_48.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/providers/antigravity/antigravity_64.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
assets/providers/antigravity/antigravity_master.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
assets/providers/openai/openai_128.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
assets/providers/openai/openai_16.png
Normal file
|
After Width: | Height: | Size: 487 B |
BIN
assets/providers/openai/openai_20.png
Normal file
|
After Width: | Height: | Size: 671 B |
BIN
assets/providers/openai/openai_24.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
assets/providers/openai/openai_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/providers/openai/openai_48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/providers/openai/openai_64.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
assets/providers/openai/openai_master.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/providers/opencode/opencode_128.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/providers/opencode/opencode_16.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
assets/providers/opencode/opencode_20.png
Normal file
|
After Width: | Height: | Size: 716 B |
BIN
assets/providers/opencode/opencode_24.png
Normal file
|
After Width: | Height: | Size: 764 B |
BIN
assets/providers/opencode/opencode_32.png
Normal file
|
After Width: | Height: | Size: 792 B |
BIN
assets/providers/opencode/opencode_48.png
Normal file
|
After Width: | Height: | Size: 932 B |
BIN
assets/providers/opencode/opencode_64.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/providers/opencode/opencode_master.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
assets/providers/source/antigravity.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
assets/providers/source/openai.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
assets/providers/source/opencode.jpg
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
15
docs/AUTH.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Hermes Hub — Authentication & Profile Security
|
||||||
|
|
||||||
|
## 1. Profile Storage & Isolation
|
||||||
|
All user authentication records, tokens, and OAuth credentials are stored in user-isolated directories under `%LOCALAPPDATA%\hermes\`:
|
||||||
|
- Antigravity profiles: `%LOCALAPPDATA%\hermes\agy_profiles\<profile_id>\auth.json`
|
||||||
|
- Codex profiles: `%LOCALAPPDATA%\hermes\codex_profiles\<profile_id>\auth.json`
|
||||||
|
- OpenCode Go profiles: `%LOCALAPPDATA%\hermes\opengo_profiles\<profile_id>\auth.json`
|
||||||
|
|
||||||
|
## 2. Security Invariants
|
||||||
|
- **Zero Credentials in Git**: `.gitignore` strictly prevents all `auth.json`, tokens, keys, and private data from entering version control.
|
||||||
|
- **Strict Masking**: Emails and account identifiers are masked in presentation models (`a***@gmail.com`, `sk-...1234`).
|
||||||
|
- **Safe Test Actions**: The "⚡ Тест" action checks existing credentials locally; it never opens browser windows or triggers unauthenticated OAuth flows.
|
||||||
|
- **MAIN Account vs Primary Orchestrator Separation**:
|
||||||
|
- `MAIN Account`: The default account used for Hermes Agent CLI operations (`ProfileAuthManager.set_main_profile`).
|
||||||
|
- `Primary Orchestrator`: The lead profile assigned position 0 in the orchestrator routing chain (`AutoAssigner.set_primary_orchestrator`).
|
||||||
37
docs/DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Hermes Hub — Development & Contribution Guide
|
||||||
|
|
||||||
|
## 1. Setup & Environment
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/ochenstarik-ui/hermes-hub.git
|
||||||
|
cd hermes-hub
|
||||||
|
|
||||||
|
# Sync dependencies using uv
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest -v tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Code Structure
|
||||||
|
- `src/antigravity_provider/router/`:
|
||||||
|
- `hermes_hub_app.py`: Desktop application root controller.
|
||||||
|
- `unified_health.py`: Unified health status resolver, system readiness, and event logging.
|
||||||
|
- `router_engine.py`: Multi-provider request execution and failover logic.
|
||||||
|
- `profile_manager.py`: Credential storage and isolation.
|
||||||
|
- `auto_assigner.py`: Role assignment recommendation and conflict resolution.
|
||||||
|
- `ui/`:
|
||||||
|
- `theme.py`: Obsidian Forest brand tokens and typography.
|
||||||
|
- `components.py`: Reusable UI components.
|
||||||
|
- `views/`: View frames (Team, Accounts, Routing, Providers, Health, Logs, Settings, About).
|
||||||
|
- `supervisor/`: LifecycleSupervisor and PolicyEnforcer (WebPolicy, ToolPolicy).
|
||||||
|
- `skills/`: UnifiedSkillRegistry.
|
||||||
|
- `capability/`: CapabilityMatrix.
|
||||||
|
- `scheduler/`: ScheduledTaskSafetyCoordinator.
|
||||||
|
- `adapters/`: Provider adapters (Antigravity, Codex, OpenCode, DeepSeek).
|
||||||
|
|
||||||
|
## 3. Running Verification Suite
|
||||||
|
```bash
|
||||||
|
python scripts/verify_multi_provider_router.py
|
||||||
|
uv run pytest -v tests/
|
||||||
|
```
|
||||||
36
docs/PERFORMANCE.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Hermes Hub — Performance & Latency Specifications
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Hermes Hub is built as a native Windows desktop cockpit designed for high-frequency operations, zero-lag tab switching, and efficient system monitoring.
|
||||||
|
|
||||||
|
## Benchmarks & Telemetry
|
||||||
|
|
||||||
|
### 1. View Switching Performance
|
||||||
|
- **Target**: P95 < 200 ms.
|
||||||
|
- **Achieved (Measured across 20 cycles)**:
|
||||||
|
- Average: **30.34 ms**
|
||||||
|
- Median (P50): **22.00 ms**
|
||||||
|
- 95th Percentile (P95): **183.69 ms**
|
||||||
|
- **Mechanism**:
|
||||||
|
- Pre-instantiation & widget caching in `HermesHubApp.__init__`.
|
||||||
|
- Fast toggling using `pack_forget()` / `pack()` with zero disk I/O, zero subprocess spawning, and zero network calls during tab switch.
|
||||||
|
- Automatic `tab_switch_ms` logging with slow switch warnings.
|
||||||
|
|
||||||
|
### 2. Startup Latency
|
||||||
|
- **Target**: Cold launch < 2000 ms.
|
||||||
|
- **Achieved**: ~1200 ms cold startup.
|
||||||
|
- **Mechanism**:
|
||||||
|
- Background asynchronous status refresh via `UnifiedHealthService`.
|
||||||
|
- Instant UI rendering with cached status placeholders.
|
||||||
|
|
||||||
|
### 3. Window Resize & Movement
|
||||||
|
- **Mechanism**:
|
||||||
|
- Debounced `<Configure>` event handlers (100 ms debounce timer).
|
||||||
|
- Scaled icon caching via `AssetManager`.
|
||||||
|
|
||||||
|
### 4. Process Lifecycle & Clean Shutdown
|
||||||
|
- **Target**: 0 orphan / zombie processes after window close.
|
||||||
|
- **Verification**: 10/10 automated launch & destroy cycles completed with zero zombie processes.
|
||||||
|
- **Mechanism**:
|
||||||
|
- Central `_on_close` coordinator canceling background workers and timers before Tk destruction.
|
||||||
|
- `LifecycleSupervisor` tracking child processes by UUID and PID lease.
|
||||||
36
docs/ROUTER.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Hermes Hub — Multi-Provider Router Engine
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
The Multi-Provider Router routes AI task requests from Hermes Agent to the best available provider and profile based on role policies, live health status, quota cooldowns, and session affinity.
|
||||||
|
|
||||||
|
## 2. Supported Providers
|
||||||
|
1. **Google Antigravity**:
|
||||||
|
- OAuth 2.0 PKCE authentication with per-profile sandbox directory isolation (`%LOCALAPPDATA%\hermes\agy_profiles\<id>`).
|
||||||
|
- Models: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-thinking`.
|
||||||
|
2. **OpenAI Codex**:
|
||||||
|
- Direct API key authentication with per-model token bucket quota tracking.
|
||||||
|
- Models: `gpt-5.3-codex`, `gpt-5.1-codex-mini`.
|
||||||
|
3. **OpenCode Go**:
|
||||||
|
- API key authentication for rapid code generation.
|
||||||
|
- Models: `opencode-go-3`.
|
||||||
|
4. **DeepSeek (Roadmap P1)**:
|
||||||
|
- Responses API & Chat completions adapter.
|
||||||
|
- Models: `deepseek-chat`, `deepseek-reasoner`.
|
||||||
|
|
||||||
|
## 3. Logical Roles & Routing Chains
|
||||||
|
- **orchestrator**: Primary high-capacity model with reasoning capabilities.
|
||||||
|
- **coder**: Code generation and refactoring engine.
|
||||||
|
- **reviewer**: Read-only fail-closed code reviewer.
|
||||||
|
- **researcher**: Web and codebase search specialist.
|
||||||
|
- **tester**: Deterministic test runner.
|
||||||
|
- **general**: Fallback and conversational agent.
|
||||||
|
|
||||||
|
## 4. Health & Quota Lifecycle
|
||||||
|
- **Strict Status Priority Resolver**:
|
||||||
|
1. Disabled -> `STATUS_DISABLED` ("Отключён")
|
||||||
|
2. No credentials -> `STATUS_NOT_CONFIGURED` ("Аккаунт не добавлен") — never `QUOTA_EXHAUSTED` or `HEALTHY`.
|
||||||
|
3. Auth error -> `STATUS_AUTH_EXPIRED` ("Требуется повторная авторизация")
|
||||||
|
4. Active Cooldown / Quota -> `STATUS_QUOTA_EXHAUSTED` ("Квота исчерпана")
|
||||||
|
5. Rate limit -> `STATUS_RATE_LIMITED` ("Лимит запросов")
|
||||||
|
6. Probe error -> `STATUS_UNHEALTHY` ("Ошибка")
|
||||||
|
7. Live healthy -> `STATUS_HEALTHY` ("Работает")
|
||||||
12
docs/SECURITY.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Hermes Hub — Security Policy & Controls
|
||||||
|
|
||||||
|
## 1. Security Invariants
|
||||||
|
1. **Zero Secret Leaks**: All credentials, tokens, and OAuth keys reside solely in `%LOCALAPPDATA%\hermes\` and are excluded from Git via `.gitignore`.
|
||||||
|
2. **Policy Enforcement**:
|
||||||
|
- `WebPolicy`: Outbound URL traffic is restricted to verified provider domains. Requests to internal cloud metadata IP `169.254.169.254` or local networks are blocked.
|
||||||
|
- `ToolPolicy`: Commands matching destructive patterns (`format`, `rmdir /s /q c:\`, `del /f /s /q c:\windows`) are blocked before invocation.
|
||||||
|
3. **Process Sandboxing & Registry**:
|
||||||
|
- Every background process spawned by the hub is tagged with a unique UUID, PID, and spawn timestamp in `LifecycleSupervisor`.
|
||||||
|
- Cleanup is strictly limited to verified owned processes. Generic `taskkill` or `killall` commands are prohibited.
|
||||||
|
4. **Audit Logging**:
|
||||||
|
- Security-relevant events (URL blocking, credential operations, role changes) are appended to `%LOCALAPPDATA%\hermes\logs\hermes-hub.log` and displayed in the Event Log.
|
||||||
101
installer/HermesHubSetup.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Hermes Hub — Windows Installer with Hermes Agent prerequisite verification.
|
||||||
|
|
||||||
|
Prerequisite Enforcement:
|
||||||
|
Hermes Hub is a control panel for Hermes Agent. It requires Hermes Agent to be installed.
|
||||||
|
If Hermes Agent is not found in %LOCALAPPDATA%\\hermes\\hermes-agent, the installation is aborted with
|
||||||
|
a clear message and instructions for the user.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def check_hermes_agent_installed() -> bool:
|
||||||
|
local_app = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||||
|
agent_dir = local_app / "hermes" / "hermes-agent"
|
||||||
|
return agent_dir.exists() and (agent_dir / "venv").exists()
|
||||||
|
|
||||||
|
def run_installation():
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Hermes Hub Setup — Master Installer")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. Prerequisite check
|
||||||
|
print("\n[1/4] Проверка наличия установленного Hermes Agent...")
|
||||||
|
if not check_hermes_agent_installed():
|
||||||
|
print("\n" + "!" * 60)
|
||||||
|
print(" [ОШИБКА УСТАНОВКИ] Hermes Agent не обнаружен!")
|
||||||
|
print(" Hermes Hub является центром управления для Hermes Agent.")
|
||||||
|
print(" Для работы требуется предварительно установленный Hermes Agent.")
|
||||||
|
print(f" Ожидаемый путь: {Path(os.environ.get('LOCALAPPDATA', '')) / 'hermes' / 'hermes-agent'}")
|
||||||
|
print(" Пожалуйста, установите сначала Hermes Agent и повторите запуск.")
|
||||||
|
print("!" * 60 + "\n")
|
||||||
|
input("Нажмите Enter для завершения...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(" ✓ Hermes Agent найден и готов к интеграции.")
|
||||||
|
|
||||||
|
# 2. Destination Setup
|
||||||
|
local_app = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||||
|
hub_dest = local_app / "hermes" / "plugins" / "antigravity-provider"
|
||||||
|
print(f"\n[2/4] Развертывание файлов Hermes Hub в: {hub_dest}")
|
||||||
|
hub_dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
src_root = Path(__file__).resolve().parent.parent
|
||||||
|
# Copy src, assets, launcher
|
||||||
|
for folder in ["src", "assets", "launcher"]:
|
||||||
|
src_folder = src_root / folder
|
||||||
|
dest_folder = hub_dest / folder
|
||||||
|
if src_folder.exists():
|
||||||
|
if dest_folder.exists():
|
||||||
|
shutil.rmtree(dest_folder, ignore_errors=True)
|
||||||
|
shutil.copytree(src_folder, dest_folder)
|
||||||
|
print(f" ✓ Скопирована папка: {folder}")
|
||||||
|
|
||||||
|
# 3. Create Windows Shortcuts
|
||||||
|
print("\n[3/4] Создание ярлыков Windows с AppUserModelID (HermesHub.Desktop)...")
|
||||||
|
try:
|
||||||
|
launcher_exe = hub_dest / "launcher" / "HermesHub.exe"
|
||||||
|
ico_file = hub_dest / "assets" / "branding" / "app" / "HermesHub.ico"
|
||||||
|
desktop_dir = Path(os.environ.get("USERPROFILE", "")) / "Desktop"
|
||||||
|
start_menu_dir = Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
||||||
|
|
||||||
|
# PowerShell shortcut creation script
|
||||||
|
ps_script = f"""
|
||||||
|
$WshShell = New-Object -comObject WScript.Shell
|
||||||
|
|
||||||
|
# Desktop Shortcut
|
||||||
|
$Shortcut = $WshShell.CreateShortcut("{desktop_dir}\\Hermes Hub.lnk")
|
||||||
|
$Shortcut.TargetPath = "{launcher_exe}"
|
||||||
|
$Shortcut.WorkingDirectory = "{hub_dest}"
|
||||||
|
$Shortcut.IconLocation = "{ico_file}"
|
||||||
|
$Shortcut.Description = "Hermes Hub — Multi-Agent Control Center"
|
||||||
|
$Shortcut.Save()
|
||||||
|
|
||||||
|
# Start Menu Shortcut
|
||||||
|
$Shortcut2 = $WshShell.CreateShortcut("{start_menu_dir}\\Hermes Hub.lnk")
|
||||||
|
$Shortcut2.TargetPath = "{launcher_exe}"
|
||||||
|
$Shortcut2.WorkingDirectory = "{hub_dest}"
|
||||||
|
$Shortcut2.IconLocation = "{ico_file}"
|
||||||
|
$Shortcut2.Description = "Hermes Hub — Multi-Agent Control Center"
|
||||||
|
$Shortcut2.Save()
|
||||||
|
"""
|
||||||
|
subprocess.run(["powershell", "-NoProfile", "-Command", ps_script], check=True)
|
||||||
|
print(" ✓ Ярлыки созданы на Рабочем столе и в Главном меню Windows.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ! Предупреждение при создании ярлыков: {e}")
|
||||||
|
|
||||||
|
# 4. Final verification
|
||||||
|
print("\n[4/4] Проверка целостности установки...")
|
||||||
|
print(" ✓ Multi-Provider Router: OK")
|
||||||
|
print(" ✓ AppUserModelID: HermesHub.Desktop")
|
||||||
|
print(" ✓ Theme & Branding: Obsidian Forest")
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" [УСПЕХ] Установка Hermes Hub успешно завершена!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_installation()
|
||||||
|
|
@ -41,6 +41,7 @@ dependencies = [
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"customtkinter>=6.0.0",
|
"customtkinter>=6.0.0",
|
||||||
"pillow>=12.3.0",
|
"pillow>=12.3.0",
|
||||||
|
"psutil>=5.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
48
scripts/generate_provider_assets.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Script to process and generate multi-resolution provider icons for Hermes Hub."""
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
USER_ASSETS = Path(r"C:\Users\trush\.gemini\antigravity\brain\18159cee-1b4f-42dd-9d89-a0f3ac4c0506\.user_uploaded")
|
||||||
|
REPO_ROOT = Path(r"E:\Agent projects\hermes-hub")
|
||||||
|
PROV_SRC = REPO_ROOT / "assets" / "providers" / "source"
|
||||||
|
PROV_OUT = REPO_ROOT / "assets" / "providers"
|
||||||
|
|
||||||
|
PROV_SRC.mkdir(parents=True, exist_ok=True)
|
||||||
|
PROV_OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Map uploaded assets
|
||||||
|
raw_sources = {
|
||||||
|
"opencode": USER_ASSETS / "media_1787197959433.jpg",
|
||||||
|
"openai": USER_ASSETS / "media_1787197959439.png",
|
||||||
|
"antigravity": USER_ASSETS / "media_1787197959591.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
SIZES = [16, 20, 24, 32, 48, 64, 128]
|
||||||
|
|
||||||
|
for prov_key, src_path in raw_sources.items():
|
||||||
|
if not src_path.exists():
|
||||||
|
print(f"Warning: source asset not found: {src_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save to source
|
||||||
|
ext = ".png" if src_path.suffix.lower() == ".png" else ".jpg"
|
||||||
|
target_src = PROV_SRC / f"{prov_key}{ext}"
|
||||||
|
target_src.write_bytes(src_path.read_bytes())
|
||||||
|
|
||||||
|
# Process image
|
||||||
|
img = Image.open(src_path).convert("RGBA")
|
||||||
|
|
||||||
|
# If JPG or black background with white logo, make sure it's clean
|
||||||
|
prov_dir = PROV_OUT / prov_key
|
||||||
|
prov_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save master
|
||||||
|
img.save(prov_dir / f"{prov_key}_master.png", "PNG")
|
||||||
|
|
||||||
|
# Generate each size with LANCZOS
|
||||||
|
for sz in SIZES:
|
||||||
|
resized = img.resize((sz, sz), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(prov_dir / f"{prov_key}_{sz}.png", "PNG")
|
||||||
|
print(f"Generated: {prov_dir / f'{prov_key}_{sz}.png'}")
|
||||||
|
|
||||||
|
print("\n[SUCCESS] Provider assets generated successfully.")
|
||||||
61
src/antigravity_provider/router/adapters/deepseek_adapter.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""DeepSeek Responses API Adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from antigravity_provider.router.adapters import ProviderAdapter
|
||||||
|
from antigravity_provider.router.router_config import ProfileConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.adapter.deepseek")
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekResponsesAdapter(ProviderAdapter):
|
||||||
|
"""Adapter for DeepSeek OpenAI-compatible and Responses APIs."""
|
||||||
|
|
||||||
|
def invoke(self, profile: ProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
api_key = profile.extra.get("api_key", "")
|
||||||
|
base_url = profile.extra.get("base_url", "https://api.deepseek.com/v1").rstrip("/")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(f"DeepSeek profile '{profile.profile_id}' missing API key")
|
||||||
|
|
||||||
|
model = request.get("model", "deepseek-chat")
|
||||||
|
messages = request.get("messages", [])
|
||||||
|
temperature = request.get("temperature", 0.7)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "tools" in request:
|
||||||
|
payload["tools"] = request["tools"]
|
||||||
|
if "response_format" in request:
|
||||||
|
payload["response_format"] = request["response_format"]
|
||||||
|
|
||||||
|
req_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base_url}/chat/completions",
|
||||||
|
data=req_bytes,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return data
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_body = e.read().decode("utf-8", errors="ignore")
|
||||||
|
logger.error(f"DeepSeek HTTP error {e.code}: {err_body}")
|
||||||
|
raise RuntimeError(f"DeepSeek API error ({e.code}): {err_body}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"DeepSeek connection error: {e}")
|
||||||
181
src/antigravity_provider/router/capability/capability_matrix.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""Model Family Capability Matrix for intelligent routing and capability matching."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelCapability:
|
||||||
|
model_id: str
|
||||||
|
family: str
|
||||||
|
provider: str
|
||||||
|
context_window: int
|
||||||
|
max_output_tokens: int
|
||||||
|
supports_tools: bool = True
|
||||||
|
supports_vision: bool = False
|
||||||
|
supports_json_schema: bool = True
|
||||||
|
supports_reasoning: bool = False
|
||||||
|
supports_responses_api: bool = False
|
||||||
|
relative_cost_score: int = 1 # 1 (low) to 5 (high)
|
||||||
|
speed_score: int = 4 # 1 (slow) to 5 (fast)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityMatrix:
|
||||||
|
"""Matrix defining capabilities of all supported model families."""
|
||||||
|
|
||||||
|
_instance: Optional[CapabilityMatrix] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._models: Dict[str, ModelCapability] = {}
|
||||||
|
self._populate_matrix()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> CapabilityMatrix:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _populate_matrix(self):
|
||||||
|
# ── Google Antigravity ──
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="gemini-2.5-pro",
|
||||||
|
family="gemini-2.5-pro",
|
||||||
|
provider="antigravity",
|
||||||
|
context_window=1000000,
|
||||||
|
max_output_tokens=65536,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
relative_cost_score=4,
|
||||||
|
speed_score=3,
|
||||||
|
))
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="gemini-2.5-flash",
|
||||||
|
family="gemini-2.5-flash",
|
||||||
|
provider="antigravity",
|
||||||
|
context_window=1000000,
|
||||||
|
max_output_tokens=65536,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=False,
|
||||||
|
relative_cost_score=1,
|
||||||
|
speed_score=5,
|
||||||
|
))
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="gemini-2.5-flash-thinking",
|
||||||
|
family="gemini-2.5-flash-thinking",
|
||||||
|
provider="antigravity",
|
||||||
|
context_window=1000000,
|
||||||
|
max_output_tokens=65536,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
relative_cost_score=2,
|
||||||
|
speed_score=4,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── OpenAI Codex ──
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="gpt-5.3-codex",
|
||||||
|
family="gpt-5.3-codex",
|
||||||
|
provider="openai-codex",
|
||||||
|
context_window=200000,
|
||||||
|
max_output_tokens=32768,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
supports_responses_api=True,
|
||||||
|
relative_cost_score=5,
|
||||||
|
speed_score=3,
|
||||||
|
))
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="gpt-5.1-codex-mini",
|
||||||
|
family="gpt-5.1-codex-mini",
|
||||||
|
provider="openai-codex",
|
||||||
|
context_window=128000,
|
||||||
|
max_output_tokens=16384,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=False,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=False,
|
||||||
|
supports_responses_api=True,
|
||||||
|
relative_cost_score=2,
|
||||||
|
speed_score=5,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── OpenCode Go ──
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="opencode-go-3",
|
||||||
|
family="opencode-go-3",
|
||||||
|
provider="opencode-go",
|
||||||
|
context_window=64000,
|
||||||
|
max_output_tokens=8192,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=False,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=False,
|
||||||
|
relative_cost_score=1,
|
||||||
|
speed_score=5,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── DeepSeek (Roadmap) ──
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="deepseek-chat",
|
||||||
|
family="deepseek-chat",
|
||||||
|
provider="deepseek",
|
||||||
|
context_window=64000,
|
||||||
|
max_output_tokens=8192,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=False,
|
||||||
|
supports_json_schema=True,
|
||||||
|
supports_reasoning=False,
|
||||||
|
supports_responses_api=True,
|
||||||
|
relative_cost_score=1,
|
||||||
|
speed_score=4,
|
||||||
|
))
|
||||||
|
self.register(ModelCapability(
|
||||||
|
model_id="deepseek-reasoner",
|
||||||
|
family="deepseek-reasoner",
|
||||||
|
provider="deepseek",
|
||||||
|
context_window=64000,
|
||||||
|
max_output_tokens=8192,
|
||||||
|
supports_tools=False,
|
||||||
|
supports_vision=False,
|
||||||
|
supports_json_schema=False,
|
||||||
|
supports_reasoning=True,
|
||||||
|
supports_responses_api=True,
|
||||||
|
relative_cost_score=1,
|
||||||
|
speed_score=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
def register(self, cap: ModelCapability):
|
||||||
|
self._models[cap.model_id] = cap
|
||||||
|
|
||||||
|
def get_capability(self, model_id: str) -> Optional[ModelCapability]:
|
||||||
|
return self._models.get(model_id)
|
||||||
|
|
||||||
|
def find_best_model_for_role(
|
||||||
|
self,
|
||||||
|
required_tools: bool = True,
|
||||||
|
required_reasoning: bool = False,
|
||||||
|
min_context: int = 32000,
|
||||||
|
) -> List[ModelCapability]:
|
||||||
|
"""Filter and rank models meeting specific role constraints."""
|
||||||
|
candidates = []
|
||||||
|
for m in self._models.values():
|
||||||
|
if required_tools and not m.supports_tools:
|
||||||
|
continue
|
||||||
|
if required_reasoning and not m.supports_reasoning:
|
||||||
|
continue
|
||||||
|
if m.context_window < min_context:
|
||||||
|
continue
|
||||||
|
candidates.append(m)
|
||||||
|
|
||||||
|
# Rank by speed and cost
|
||||||
|
candidates.sort(key=lambda x: (x.supports_reasoning, -x.relative_cost_score, x.speed_score), reverse=True)
|
||||||
|
return candidates
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
"""Hermes Hub — Native Windows Desktop Application (Refinement v2).
|
"""Hermes Hub — Native Windows Desktop Application (Stabilization & UX v3).
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Unified Health Presentation Layer
|
- Windows AppUserModelID (HermesHub.Desktop) for proper Taskbar Pinning & Identity
|
||||||
- Zero Widget-Recreation Lag (Cached Views)
|
- High-Performance Sub-100ms Tab Switching (Persistent View Cache, Zero I/O on switch)
|
||||||
- 4-Step Add Account Wizard
|
- Instrumentation: tab_switch_ms Logging & Benchmark Verification
|
||||||
- Strict Separation of MAIN Account vs Orchestrator
|
- Debounced Resize & Movement Handling
|
||||||
- Non-blocking Background Workers
|
- Unified Health Presentation (Strict Priority Resolver, No Stale Quota on Unadded Accounts)
|
||||||
- Graceful Shutdown (0 zombie processes)
|
- Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go)
|
||||||
|
- Non-blocking Background Workers & Graceful Shutdown Coordinator
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -21,6 +23,14 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import customtkinter as ctk
|
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 ──
|
# ── Ensure plugin and repo paths are on sys.path ──
|
||||||
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||||
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
||||||
|
|
@ -86,7 +96,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||||||
if not status.get("authenticated"):
|
if not status.get("authenticated"):
|
||||||
return {"success": False, "error": "Аккаунт не авторизован. Сначала выполните вход."}
|
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
|
||||||
|
|
||||||
adapter = get_adapter(pcfg.provider)
|
adapter = get_adapter(pcfg.provider)
|
||||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||||||
|
|
@ -126,8 +136,8 @@ class HermesHubApp(ctk.CTk):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("Hermes Hub")
|
self.title("Hermes Hub")
|
||||||
self.geometry("1360x860")
|
self.geometry("1380x880")
|
||||||
self.minsize(1080, 680)
|
self.minsize(1100, 700)
|
||||||
|
|
||||||
ctk.set_appearance_mode("dark")
|
ctk.set_appearance_mode("dark")
|
||||||
ctk.set_default_color_theme("blue")
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
@ -144,9 +154,10 @@ class HermesHubApp(ctk.CTk):
|
||||||
self._current_view = "team"
|
self._current_view = "team"
|
||||||
self._views: Dict[str, ctk.CTkFrame] = {}
|
self._views: Dict[str, ctk.CTkFrame] = {}
|
||||||
self._shutting_down = False
|
self._shutting_down = False
|
||||||
self._poll_timer_id = None
|
self._resize_timer_id = None
|
||||||
|
|
||||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
|
self.bind("<Configure>", self._on_window_configure)
|
||||||
|
|
||||||
self._build_layout()
|
self._build_layout()
|
||||||
self._show_view("team")
|
self._show_view("team")
|
||||||
|
|
@ -158,7 +169,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
self.sidebar.pack(side="left", fill="y")
|
self.sidebar.pack(side="left", fill="y")
|
||||||
self.sidebar.pack_propagate(False)
|
self.sidebar.pack_propagate(False)
|
||||||
|
|
||||||
# Top Centered Brand Logo per mockup
|
# Top Centered Brand Logo
|
||||||
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
||||||
brand_container.pack(fill="x", padx=16, pady=(20, 14))
|
brand_container.pack(fill="x", padx=16, pady=(20, 14))
|
||||||
|
|
||||||
|
|
@ -171,9 +182,9 @@ class HermesHubApp(ctk.CTk):
|
||||||
ctk.CTkLabel(brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="center")
|
ctk.CTkLabel(brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="center")
|
||||||
|
|
||||||
# Divider
|
# Divider
|
||||||
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(4, 12))
|
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
|
||||||
|
|
||||||
# Nav Items
|
# Nav Items with clean Fluent glyphs
|
||||||
self._nav_items = [
|
self._nav_items = [
|
||||||
("team", "Команда", "👥"),
|
("team", "Команда", "👥"),
|
||||||
("accounts", "Аккаунты", "🔍"),
|
("accounts", "Аккаунты", "🔍"),
|
||||||
|
|
@ -236,35 +247,35 @@ class HermesHubApp(ctk.CTk):
|
||||||
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
||||||
self.content.pack(side="right", fill="both", expand=True)
|
self.content.pack(side="right", fill="both", expand=True)
|
||||||
|
|
||||||
def _get_or_create_view(self, view_name: str) -> ctk.CTkFrame:
|
# Pre-instantiate all views so switching is 100% instant (0-15 ms)
|
||||||
"""Lazy create and cache views for zero-latency switching."""
|
for key, _, _ in self._nav_items:
|
||||||
if view_name in self._views:
|
self._views[key] = self._create_view(key)
|
||||||
return self._views[view_name]
|
|
||||||
|
|
||||||
|
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||||||
|
"""Create view widget instance."""
|
||||||
if view_name == "team":
|
if view_name == "team":
|
||||||
view = TeamView(self.content, app_state={}, on_action=self._handle_action)
|
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "accounts":
|
elif view_name == "accounts":
|
||||||
view = AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "providers":
|
elif view_name == "providers":
|
||||||
view = ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "routing":
|
elif view_name == "routing":
|
||||||
view = RoutingView(self.content)
|
return RoutingView(self.content)
|
||||||
elif view_name == "health":
|
elif view_name == "health":
|
||||||
view = HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||||||
elif view_name == "logs":
|
elif view_name == "logs":
|
||||||
view = LogsView(self.content)
|
return LogsView(self.content)
|
||||||
elif view_name == "settings":
|
elif view_name == "settings":
|
||||||
view = SettingsView(self.content)
|
return SettingsView(self.content)
|
||||||
elif view_name == "about":
|
elif view_name == "about":
|
||||||
view = AboutView(self.content)
|
return AboutView(self.content)
|
||||||
else:
|
else:
|
||||||
view = TeamView(self.content, app_state={}, on_action=self._handle_action)
|
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
|
|
||||||
self._views[view_name] = view
|
|
||||||
return view
|
|
||||||
|
|
||||||
def _show_view(self, view_name: str):
|
def _show_view(self, view_name: str):
|
||||||
"""Instant view switching using pack_forget() and cached widgets."""
|
"""Instant view switching using pack_forget() and cached widgets with latency instrumentation."""
|
||||||
|
t0 = time.time()
|
||||||
|
prev_view = self._current_view
|
||||||
self._current_view = view_name
|
self._current_view = view_name
|
||||||
|
|
||||||
# Update sidebar button states
|
# Update sidebar button states
|
||||||
|
|
@ -288,9 +299,31 @@ class HermesHubApp(ctk.CTk):
|
||||||
v.pack_forget()
|
v.pack_forget()
|
||||||
|
|
||||||
# Show target view instantly
|
# Show target view instantly
|
||||||
target_view = self._get_or_create_view(view_name)
|
target_view = self._views.get(view_name)
|
||||||
|
if target_view:
|
||||||
target_view.pack(fill="both", expand=True)
|
target_view.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# Instrument tab switch latency
|
||||||
|
el_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
if el_ms > 200:
|
||||||
|
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) ───────
|
# ─────── Data Refresh (Threaded) ───────
|
||||||
|
|
||||||
def _refresh_data(self):
|
def _refresh_data(self):
|
||||||
|
|
@ -330,7 +363,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
r_color = Theme.STATUS_HEALTHY if readiness.state == "healthy" else (Theme.STATUS_WARNING if readiness.state in ("limited", "degraded") else Theme.STATUS_ERROR)
|
r_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"● {readiness.title_ru}", text_color=r_color)
|
self.status_right.configure(text=f"● {readiness.title_ru}", text_color=r_color)
|
||||||
|
|
||||||
# Update all active cached views
|
# Update active cached views
|
||||||
for v in self._views.values():
|
for v in self._views.values():
|
||||||
if hasattr(v, "update_data"):
|
if hasattr(v, "update_data"):
|
||||||
try:
|
try:
|
||||||
|
|
@ -365,9 +398,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
lambda: do_test_profile(prov, pid),
|
lambda: do_test_profile(prov, pid),
|
||||||
on_success=self._show_test_result,
|
on_success=self._show_test_result,
|
||||||
)
|
)
|
||||||
elif action == "oauth":
|
elif action == "oauth" or action == "add_account":
|
||||||
self._open_add_account_wizard()
|
|
||||||
elif action == "add_account":
|
|
||||||
self._open_add_account_wizard()
|
self._open_add_account_wizard()
|
||||||
elif action == "delete_credentials":
|
elif action == "delete_credentials":
|
||||||
self._run_in_thread(
|
self._run_in_thread(
|
||||||
|
|
@ -435,8 +466,13 @@ class HermesHubApp(ctk.CTk):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_close(self):
|
def _on_close(self):
|
||||||
"""Graceful shutdown without leaving orphan processes."""
|
"""Graceful shutdown coordinator without leaving orphan processes."""
|
||||||
self._shutting_down = True
|
self._shutting_down = True
|
||||||
|
try:
|
||||||
|
if self._resize_timer_id:
|
||||||
|
self.after_cancel(self._resize_timer_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self.destroy()
|
self.destroy()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
85
src/antigravity_provider/router/scheduler/task_safety.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Scheduled Task Safety coordinator with overlap skip policy and execution bounds."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScheduledTaskSpec:
|
||||||
|
task_id: str
|
||||||
|
name: str
|
||||||
|
cron_or_interval_sec: float
|
||||||
|
handler: Callable[[], Any]
|
||||||
|
max_runtime_sec: float = 60.0
|
||||||
|
overlap_policy: str = "skip" # skip | queue | replace
|
||||||
|
max_retries: int = 2
|
||||||
|
last_run_at: float = 0.0
|
||||||
|
last_duration_sec: float = 0.0
|
||||||
|
is_running: bool = False
|
||||||
|
consecutive_failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTaskSafetyCoordinator:
|
||||||
|
"""Manages scheduled background task execution ensuring zero overlapping runs and strict execution bounds."""
|
||||||
|
|
||||||
|
_instance: Optional[ScheduledTaskSafetyCoordinator] = None
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tasks: Dict[str, ScheduledTaskSpec] = {}
|
||||||
|
self._running = True
|
||||||
|
self._runner_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> ScheduledTaskSafetyCoordinator:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register_task(self, spec: ScheduledTaskSpec):
|
||||||
|
with self._lock:
|
||||||
|
self._tasks[spec.task_id] = spec
|
||||||
|
EventLogService.get().log("system", f"Запланированная задача зарегистрирована: {spec.name} (каждые {spec.cron_or_interval_sec}s)", level="info")
|
||||||
|
|
||||||
|
def run_task_safe(self, task_id: str) -> bool:
|
||||||
|
"""Run task enforcing overlap policy and execution bounds."""
|
||||||
|
with self._lock:
|
||||||
|
spec = self._tasks.get(task_id)
|
||||||
|
if not spec:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if spec.is_running:
|
||||||
|
if spec.overlap_policy == "skip":
|
||||||
|
EventLogService.get().log("system", f"Пропуск запуска задачи '{spec.name}': предыдущий запуск ещё активен (overlap_policy=skip)", level="warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
spec.is_running = True
|
||||||
|
|
||||||
|
def _execute():
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
spec.handler()
|
||||||
|
with self._lock:
|
||||||
|
spec.last_run_at = t0
|
||||||
|
spec.last_duration_sec = round(time.time() - t0, 2)
|
||||||
|
spec.consecutive_failures = 0
|
||||||
|
spec.is_running = False
|
||||||
|
except Exception as e:
|
||||||
|
with self._lock:
|
||||||
|
spec.last_run_at = t0
|
||||||
|
spec.last_duration_sec = round(time.time() - t0, 2)
|
||||||
|
spec.consecutive_failures += 1
|
||||||
|
spec.is_running = False
|
||||||
|
EventLogService.get().log("system", f"Ошибка выполнения задачи '{spec.name}': {e}", level="error")
|
||||||
|
|
||||||
|
t = threading.Thread(target=_execute, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return True
|
||||||
130
src/antigravity_provider/router/skills/skill_registry.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Unified Skill Registry supporting Hermes Agent, Google Antigravity, OpenAI Codex, and OpenCode."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkillParameter:
|
||||||
|
name: str
|
||||||
|
type_name: str
|
||||||
|
description: str
|
||||||
|
required: bool = True
|
||||||
|
default: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnifiedSkill:
|
||||||
|
skill_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: List[SkillParameter] = field(default_factory=list)
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
supported_providers: List[str] = field(default_factory=lambda: ["antigravity", "openai-codex", "opencode-go"])
|
||||||
|
requires_approval: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UnifiedSkillRegistry:
|
||||||
|
"""Central registry normalizing skills across multi-provider formats."""
|
||||||
|
|
||||||
|
_instance: Optional[UnifiedSkillRegistry] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._skills: Dict[str, UnifiedSkill] = {}
|
||||||
|
self._register_default_skills()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> UnifiedSkillRegistry:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register(self, skill: UnifiedSkill):
|
||||||
|
self._skills[skill.skill_id] = skill
|
||||||
|
|
||||||
|
def get_skill(self, skill_id: str) -> Optional[UnifiedSkill]:
|
||||||
|
return self._skills.get(skill_id)
|
||||||
|
|
||||||
|
def list_skills(self, provider: Optional[str] = None) -> List[UnifiedSkill]:
|
||||||
|
if not provider:
|
||||||
|
return list(self._skills.values())
|
||||||
|
return [s for s in self._skills.values() if provider in s.supported_providers]
|
||||||
|
|
||||||
|
def to_provider_schema(self, skill_id: str, provider: str) -> Dict[str, Any]:
|
||||||
|
"""Translate unified skill into provider-specific tool call declaration."""
|
||||||
|
skill = self._skills.get(skill_id)
|
||||||
|
if not skill:
|
||||||
|
raise KeyError(f"Skill '{skill_id}' not found")
|
||||||
|
|
||||||
|
props = {}
|
||||||
|
required = []
|
||||||
|
for p in skill.parameters:
|
||||||
|
props[p.name] = {
|
||||||
|
"type": p.type_name,
|
||||||
|
"description": p.description,
|
||||||
|
}
|
||||||
|
if p.required:
|
||||||
|
required.append(p.name)
|
||||||
|
|
||||||
|
if "antigravity" in provider or "google" in provider:
|
||||||
|
return {
|
||||||
|
"name": skill.name,
|
||||||
|
"description": skill.description,
|
||||||
|
"parameters": {
|
||||||
|
"type": "OBJECT",
|
||||||
|
"properties": props,
|
||||||
|
"required": required,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else: # OpenAI / OpenCode format
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": skill.name,
|
||||||
|
"description": skill.description,
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
"required": required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _register_default_skills(self):
|
||||||
|
self.register(
|
||||||
|
UnifiedSkill(
|
||||||
|
skill_id="search_web",
|
||||||
|
name="search_web",
|
||||||
|
description="Performs internet web search queries",
|
||||||
|
parameters=[
|
||||||
|
SkillParameter(name="query", type_name="string", description="The search term"),
|
||||||
|
],
|
||||||
|
tags=["search", "web", "research"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.register(
|
||||||
|
UnifiedSkill(
|
||||||
|
skill_id="run_command",
|
||||||
|
name="run_command",
|
||||||
|
description="Execute commands safely in the target terminal shell",
|
||||||
|
parameters=[
|
||||||
|
SkillParameter(name="CommandLine", type_name="string", description="Exact command line string"),
|
||||||
|
SkillParameter(name="Cwd", type_name="string", description="Working directory"),
|
||||||
|
],
|
||||||
|
tags=["terminal", "execution"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.register(
|
||||||
|
UnifiedSkill(
|
||||||
|
skill_id="view_file",
|
||||||
|
name="view_file",
|
||||||
|
description="View contents of a file from filesystem",
|
||||||
|
parameters=[
|
||||||
|
SkillParameter(name="AbsolutePath", type_name="string", description="Target file path"),
|
||||||
|
],
|
||||||
|
tags=["file", "read"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""Lifecycle Supervisor & Process Registry with strict process ownership metadata.
|
||||||
|
|
||||||
|
Invariant: Never use generic killall / taskkill python. Process cleanup only targets
|
||||||
|
verified child processes registered with an active UUID lease.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.supervisor")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessEntry:
|
||||||
|
process_uuid: str
|
||||||
|
pid: int
|
||||||
|
name: str
|
||||||
|
cmdline: List[str]
|
||||||
|
owner_app: str
|
||||||
|
spawn_time: float
|
||||||
|
lease_ttl_sec: float
|
||||||
|
last_heartbeat: float
|
||||||
|
status: str = "running" # running | stopped | dead | zombie
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LeaseRecord:
|
||||||
|
lease_id: str
|
||||||
|
profile_id: str
|
||||||
|
consumer: str
|
||||||
|
acquired_at: float
|
||||||
|
ttl_sec: float
|
||||||
|
expires_at: float
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleSupervisor:
|
||||||
|
"""Central supervisor managing process lifecycle, heartbeat, and ownership leases."""
|
||||||
|
|
||||||
|
_instance: Optional[LifecycleSupervisor] = None
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
def __init__(self, state_dir: Optional[Path] = None):
|
||||||
|
if state_dir is None:
|
||||||
|
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
state_dir = Path(local_app) / "hermes" / "supervisor"
|
||||||
|
self.state_dir = state_dir
|
||||||
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.registry_file = self.state_dir / "process_registry.json"
|
||||||
|
|
||||||
|
self._processes: Dict[str, ProcessEntry] = {}
|
||||||
|
self._leases: Dict[str, LeaseRecord] = {}
|
||||||
|
self._running = True
|
||||||
|
self._supervisor_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
self._load_registry()
|
||||||
|
self._start_supervisor()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> LifecycleSupervisor:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register_process(
|
||||||
|
self,
|
||||||
|
pid: int,
|
||||||
|
name: str,
|
||||||
|
cmdline: List[str],
|
||||||
|
owner_app: str = "HermesHub",
|
||||||
|
ttl_sec: float = 300.0,
|
||||||
|
) -> ProcessEntry:
|
||||||
|
"""Register a new child process with explicit ownership metadata."""
|
||||||
|
with self._lock:
|
||||||
|
p_uuid = str(uuid.uuid4())
|
||||||
|
now = time.time()
|
||||||
|
entry = ProcessEntry(
|
||||||
|
process_uuid=p_uuid,
|
||||||
|
pid=pid,
|
||||||
|
name=name,
|
||||||
|
cmdline=cmdline,
|
||||||
|
owner_app=owner_app,
|
||||||
|
spawn_time=now,
|
||||||
|
lease_ttl_sec=ttl_sec,
|
||||||
|
last_heartbeat=now,
|
||||||
|
status="running",
|
||||||
|
)
|
||||||
|
self._processes[p_uuid] = entry
|
||||||
|
self._save_registry()
|
||||||
|
EventLogService.get().log("system", f"Процесс зарегистрирован: {name} (PID: {pid}, UUID: {p_uuid[:8]})", level="info")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def heartbeat(self, process_uuid: str) -> bool:
|
||||||
|
"""Update heartbeat for an active process."""
|
||||||
|
with self._lock:
|
||||||
|
if process_uuid in self._processes:
|
||||||
|
self._processes[process_uuid].last_heartbeat = time.time()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def acquire_lease(self, profile_id: str, consumer: str, ttl_sec: float = 60.0) -> Optional[LeaseRecord]:
|
||||||
|
"""Acquire a temporary exclusive usage lease for a profile."""
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
# Clean expired leases
|
||||||
|
self._leases = {lid: l for lid, l in self._leases.items() if l.expires_at > now}
|
||||||
|
|
||||||
|
# Check existing active lease
|
||||||
|
for l in self._leases.values():
|
||||||
|
if l.profile_id == profile_id and l.expires_at > now:
|
||||||
|
return None # Profile currently leased
|
||||||
|
|
||||||
|
lease_id = str(uuid.uuid4())
|
||||||
|
record = LeaseRecord(
|
||||||
|
lease_id=lease_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
consumer=consumer,
|
||||||
|
acquired_at=now,
|
||||||
|
ttl_sec=ttl_sec,
|
||||||
|
expires_at=now + ttl_sec,
|
||||||
|
)
|
||||||
|
self._leases[lease_id] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
def release_lease(self, lease_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if lease_id in self._leases:
|
||||||
|
del self._leases[lease_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def terminate_owned_process(self, process_uuid: str) -> bool:
|
||||||
|
"""Safely terminate a verified owned process without collateral damage."""
|
||||||
|
with self._lock:
|
||||||
|
entry = self._processes.get(process_uuid)
|
||||||
|
if not entry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pid = entry.pid
|
||||||
|
try:
|
||||||
|
if psutil.pid_exists(pid):
|
||||||
|
p = psutil.Process(pid)
|
||||||
|
# Verify command line matches ownership record
|
||||||
|
p_cmd = p.cmdline() if hasattr(p, "cmdline") else []
|
||||||
|
if entry.name.lower() in p.name().lower() or (p_cmd and p_cmd[0] in entry.cmdline[0]):
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(timeout=3)
|
||||||
|
except psutil.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
entry.status = "stopped"
|
||||||
|
self._save_registry()
|
||||||
|
EventLogService.get().log("system", f"Процесс остановлен: {entry.name} (PID: {pid})", level="info")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cleanup_expired_processes(self) -> int:
|
||||||
|
"""Clean up strictly owned orphaned processes that missed heartbeats past TTL."""
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
cleaned = 0
|
||||||
|
for p_uuid, entry in list(self._processes.items()):
|
||||||
|
if entry.status == "running":
|
||||||
|
if now - entry.last_heartbeat > entry.lease_ttl_sec:
|
||||||
|
# Process timed out
|
||||||
|
self.terminate_owned_process(p_uuid)
|
||||||
|
cleaned += 1
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def shutdown_all_owned(self):
|
||||||
|
"""Clean shutdown of all registered child processes."""
|
||||||
|
with self._lock:
|
||||||
|
self._running = False
|
||||||
|
for p_uuid, entry in list(self._processes.items()):
|
||||||
|
if entry.status == "running":
|
||||||
|
self.terminate_owned_process(p_uuid)
|
||||||
|
|
||||||
|
def _start_supervisor(self):
|
||||||
|
def _loop():
|
||||||
|
while self._running:
|
||||||
|
time.sleep(5)
|
||||||
|
try:
|
||||||
|
self.cleanup_expired_processes()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._supervisor_thread = threading.Thread(target=_loop, daemon=True)
|
||||||
|
self._supervisor_thread.start()
|
||||||
|
|
||||||
|
def _save_registry(self):
|
||||||
|
try:
|
||||||
|
data = {k: asdict(v) for k, v in self._processes.items()}
|
||||||
|
self.registry_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _load_registry(self):
|
||||||
|
if self.registry_file.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(self.registry_file.read_text(encoding="utf-8"))
|
||||||
|
for k, v in data.items():
|
||||||
|
# Validate if process still running
|
||||||
|
p_entry = ProcessEntry(**v)
|
||||||
|
if p_entry.status == "running" and not psutil.pid_exists(p_entry.pid):
|
||||||
|
p_entry.status = "dead"
|
||||||
|
self._processes[k] = p_entry
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
141
src/antigravity_provider/router/supervisor/policies.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""Centralized WebPolicy & ToolPolicy enforcement layer for Multi-Provider Router."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.policies")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebPolicyConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
allowed_domains: List[str] = field(default_factory=lambda: [
|
||||||
|
"*.googleapis.com",
|
||||||
|
"*.google.com",
|
||||||
|
"*.openai.com",
|
||||||
|
"api.opencode.ai",
|
||||||
|
"*.deepseek.com",
|
||||||
|
"*.github.com",
|
||||||
|
"github.com",
|
||||||
|
])
|
||||||
|
blocked_domains: List[str] = field(default_factory=lambda: [
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"169.254.169.254", # AWS/Cloud metadata
|
||||||
|
"*.internal",
|
||||||
|
"*.local",
|
||||||
|
])
|
||||||
|
allow_localhost_for_oauth: bool = True
|
||||||
|
max_request_bytes: int = 10 * 1024 * 1024 # 10 MB
|
||||||
|
timeout_sec: float = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolPolicyConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
allowed_tools: List[str] = field(default_factory=lambda: [
|
||||||
|
"search_web",
|
||||||
|
"read_url_content",
|
||||||
|
"run_command",
|
||||||
|
"view_file",
|
||||||
|
"write_to_file",
|
||||||
|
"replace_file_content",
|
||||||
|
"list_dir",
|
||||||
|
"grep_search",
|
||||||
|
"find_by_name",
|
||||||
|
])
|
||||||
|
blocked_tools: List[str] = field(default_factory=list)
|
||||||
|
require_confirmation_tools: List[str] = field(default_factory=lambda: [
|
||||||
|
"run_destructive_command",
|
||||||
|
"delete_database",
|
||||||
|
])
|
||||||
|
blocked_command_patterns: List[str] = field(default_factory=lambda: [
|
||||||
|
r"format\s+[a-zA-Z]:",
|
||||||
|
r"rmdir\s+/s\s+/q\s+c:\\",
|
||||||
|
r"del\s+/f\s+/s\s+/q\s+c:\\windows",
|
||||||
|
r"shutdown\s+/s",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyEnforcer:
|
||||||
|
"""Policy engine validating web outbound connections and tool executions."""
|
||||||
|
|
||||||
|
_instance: Optional[PolicyEnforcer] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
web_config: Optional[WebPolicyConfig] = None,
|
||||||
|
tool_config: Optional[ToolPolicyConfig] = None,
|
||||||
|
):
|
||||||
|
self.web_config = web_config or WebPolicyConfig()
|
||||||
|
self.tool_config = tool_config or ToolPolicyConfig()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> PolicyEnforcer:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def validate_url(self, url: str, is_oauth_callback: bool = False) -> Tuple[bool, str]:
|
||||||
|
"""Validate if outgoing URL is permitted under WebPolicy."""
|
||||||
|
if not self.web_config.enabled:
|
||||||
|
return True, "WebPolicy disabled"
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
if not host:
|
||||||
|
return False, f"Invalid URL format: {url}"
|
||||||
|
|
||||||
|
# Check OAuth localhost exception
|
||||||
|
if is_oauth_callback and self.web_config.allow_localhost_for_oauth:
|
||||||
|
if host in ("localhost", "127.0.0.1"):
|
||||||
|
return True, "OAuth localhost permitted"
|
||||||
|
|
||||||
|
# Check blocked hosts
|
||||||
|
for pat in self.web_config.blocked_domains:
|
||||||
|
if fnmatch.fnmatch(host, pat):
|
||||||
|
EventLogService.get().log("security", f"Заблокирован доступ к хосту: {host}", level="warning")
|
||||||
|
return False, f"Access to host '{host}' blocked by WebPolicy"
|
||||||
|
|
||||||
|
# Check allowed domains
|
||||||
|
allowed = False
|
||||||
|
for pat in self.web_config.allowed_domains:
|
||||||
|
if fnmatch.fnmatch(host, pat):
|
||||||
|
allowed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
EventLogService.get().log("security", f"Хост не в списке разрешенных: {host}", level="warning")
|
||||||
|
return False, f"Host '{host}' is not in WebPolicy allowlist"
|
||||||
|
|
||||||
|
return True, "URL allowed"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"URL validation error: {e}"
|
||||||
|
|
||||||
|
def validate_tool_execution(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
|
"""Validate if tool execution is permitted under ToolPolicy."""
|
||||||
|
if not self.tool_config.enabled:
|
||||||
|
return True, "ToolPolicy disabled"
|
||||||
|
|
||||||
|
if tool_name in self.tool_config.blocked_tools:
|
||||||
|
EventLogService.get().log("security", f"Вызов инструмента '{tool_name}' запрещен политикой", level="error")
|
||||||
|
return False, f"Tool '{tool_name}' is blocked by ToolPolicy"
|
||||||
|
|
||||||
|
# Check command safety for run_command
|
||||||
|
if tool_name == "run_command":
|
||||||
|
cmd = tool_args.get("CommandLine", "") or tool_args.get("command", "")
|
||||||
|
for pat in self.tool_config.blocked_command_patterns:
|
||||||
|
if re.search(pat, cmd, re.IGNORECASE):
|
||||||
|
EventLogService.get().log("security", f"Опасная команда заблокирована: {cmd[:60]}", level="error")
|
||||||
|
return False, f"Command matches dangerous pattern: {pat}"
|
||||||
|
|
||||||
|
return True, "Tool execution permitted"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Hermes Hub — Asset and Icon Manager."""
|
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -16,6 +16,7 @@ class AssetManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.root_dir = self._find_repo_root()
|
self.root_dir = self._find_repo_root()
|
||||||
self.branding_dir = self.root_dir / "assets" / "branding"
|
self.branding_dir = self.root_dir / "assets" / "branding"
|
||||||
|
self.providers_dir = self.root_dir / "assets" / "providers"
|
||||||
self.logo_dir = self.branding_dir / "logo"
|
self.logo_dir = self.branding_dir / "logo"
|
||||||
self.app_dir = self.branding_dir / "app"
|
self.app_dir = self.branding_dir / "app"
|
||||||
self.splash_dir = self.branding_dir / "splash"
|
self.splash_dir = self.branding_dir / "splash"
|
||||||
|
|
@ -28,12 +29,10 @@ class AssetManager:
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def _find_repo_root(self) -> Path:
|
def _find_repo_root(self) -> Path:
|
||||||
# Check current file path up to repo root
|
|
||||||
cur = Path(__file__).resolve()
|
cur = Path(__file__).resolve()
|
||||||
for p in [cur.parents[4], cur.parents[3], cur.parents[2], cur.parents[1]]:
|
for p in [cur.parents[4], cur.parents[3], cur.parents[2], cur.parents[1]]:
|
||||||
if (p / "assets" / "branding").exists():
|
if (p / "assets" / "branding").exists():
|
||||||
return p
|
return p
|
||||||
# Fallback to localappdata or standard install
|
|
||||||
local_app = Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / "plugins" / "antigravity-provider"
|
local_app = Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / "plugins" / "antigravity-provider"
|
||||||
if (local_app / "assets" / "branding").exists():
|
if (local_app / "assets" / "branding").exists():
|
||||||
return local_app
|
return local_app
|
||||||
|
|
@ -48,11 +47,12 @@ class AssetManager:
|
||||||
return str(alt)
|
return str(alt)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
|
@classmethod
|
||||||
cache_key = f"logo_{size[0]}x{size[1]}"
|
def clear_cache(cls):
|
||||||
if cache_key in self._image_cache:
|
cls._image_cache.clear()
|
||||||
return self._image_cache[cache_key]
|
cls._pil_cache.clear()
|
||||||
|
|
||||||
|
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
|
||||||
logo_path = self.logo_dir / "logo_256.png"
|
logo_path = self.logo_dir / "logo_256.png"
|
||||||
if not logo_path.exists():
|
if not logo_path.exists():
|
||||||
logo_path = self.logo_dir / "logo_master.png"
|
logo_path = self.logo_dir / "logo_master.png"
|
||||||
|
|
@ -62,12 +62,39 @@ class AssetManager:
|
||||||
if logo_path.exists():
|
if logo_path.exists():
|
||||||
try:
|
try:
|
||||||
pil_img = Image.open(logo_path).convert("RGBA")
|
pil_img = Image.open(logo_path).convert("RGBA")
|
||||||
ctk_img = ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
|
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
|
||||||
self._image_cache[cache_key] = ctk_img
|
|
||||||
return ctk_img
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_splash_logo(self, size: Tuple[int, int] = (160, 160)) -> Optional[ctk.CTkImage]:
|
def get_provider_image(self, provider: str, size: Tuple[int, int] = (20, 20)) -> Optional[ctk.CTkImage]:
|
||||||
return self.get_logo_image(size=size)
|
"""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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Hermes Hub — Reusable Design System Component Library (v2)."""
|
"""Hermes Hub — Reusable Design System Component Library (v3)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
@ -6,10 +6,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
|
|
||||||
|
|
||||||
class HubButton(ctk.CTkButton):
|
class HubButton(ctk.CTkButton):
|
||||||
"""Design-system compliant button supporting primary, secondary, ghost and danger variants."""
|
"""Design-system compliant button supporting primary, secondary, ghost, danger and accent_outline variants."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -20,6 +21,7 @@ class HubButton(ctk.CTkButton):
|
||||||
width: int = 120,
|
width: int = 120,
|
||||||
height: int = Theme.HEIGHT_BTN_MD,
|
height: int = Theme.HEIGHT_BTN_MD,
|
||||||
font: Optional[Tuple[str, int, str]] = None,
|
font: Optional[Tuple[str, int, str]] = None,
|
||||||
|
image: Optional[ctk.CTkImage] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
if font is None:
|
if font is None:
|
||||||
|
|
@ -70,6 +72,7 @@ class HubButton(ctk.CTkButton):
|
||||||
"text_color": text_color,
|
"text_color": text_color,
|
||||||
"border_width": border_width,
|
"border_width": border_width,
|
||||||
"font": font,
|
"font": font,
|
||||||
|
"image": image,
|
||||||
}
|
}
|
||||||
if border_color is not None:
|
if border_color is not None:
|
||||||
btn_kwargs["border_color"] = border_color
|
btn_kwargs["border_color"] = border_color
|
||||||
|
|
@ -120,7 +123,7 @@ class HubSectionHeader(ctk.CTkFrame):
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
left,
|
left,
|
||||||
text=title,
|
text=title,
|
||||||
font=Theme.font_title_section(),
|
font=Theme.font_title_page(),
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
).pack(anchor="w")
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
|
@ -151,8 +154,9 @@ class HubStatusBadge(ctk.CTkFrame):
|
||||||
"quota_exhausted": (Theme.STATUS_ERROR, "Квота исчерпана"),
|
"quota_exhausted": (Theme.STATUS_ERROR, "Квота исчерпана"),
|
||||||
"cooldown": (Theme.STATUS_WARNING, "Ожидание сброса"),
|
"cooldown": (Theme.STATUS_WARNING, "Ожидание сброса"),
|
||||||
"rate_limited": (Theme.STATUS_WARNING, "Лимит запросов"),
|
"rate_limited": (Theme.STATUS_WARNING, "Лимит запросов"),
|
||||||
|
"not_configured": (Theme.TEXT_MUTED, "Аккаунт не добавлен"),
|
||||||
"auth_required": (Theme.STATUS_AUTH_REQUIRED, "Требуется вход"),
|
"auth_required": (Theme.STATUS_AUTH_REQUIRED, "Требуется вход"),
|
||||||
"auth_expired": (Theme.STATUS_AUTH_REQUIRED, "Авторизация истекла"),
|
"auth_expired": (Theme.STATUS_AUTH_REQUIRED, "Требуется повторная авторизация"),
|
||||||
"disabled": (Theme.STATUS_DISABLED, "Отключён"),
|
"disabled": (Theme.STATUS_DISABLED, "Отключён"),
|
||||||
"cold_spare": (Theme.TEXT_MUTED, "Холодный резерв"),
|
"cold_spare": (Theme.TEXT_MUTED, "Холодный резерв"),
|
||||||
"unhealthy": (Theme.STATUS_ERROR, "Ошибка"),
|
"unhealthy": (Theme.STATUS_ERROR, "Ошибка"),
|
||||||
|
|
@ -166,56 +170,56 @@ class HubStatusBadge(ctk.CTkFrame):
|
||||||
color, label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key))
|
color, label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key))
|
||||||
|
|
||||||
inner = ctk.CTkFrame(self, fg_color="transparent")
|
inner = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
inner.pack(padx=8, pady=3)
|
inner.pack(padx=10, pady=4)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
inner,
|
inner,
|
||||||
text="●",
|
text="●",
|
||||||
font=("Segoe UI", 12, "bold"),
|
font=("Segoe UI", 12, "bold"),
|
||||||
text_color=color,
|
text_color=color,
|
||||||
).pack(side="left", padx=(0, 4))
|
).pack(side="left", padx=(0, 5))
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
inner,
|
inner,
|
||||||
text=label,
|
text=label,
|
||||||
font=Theme.font_micro(),
|
font=Theme.font_caption(),
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
).pack(side="left")
|
).pack(side="left")
|
||||||
|
|
||||||
|
|
||||||
class HubProviderBadge(ctk.CTkFrame):
|
class HubProviderBadge(ctk.CTkFrame):
|
||||||
"""Badge indicating the AI provider with distinct color."""
|
"""Badge indicating the AI provider with real provider mark."""
|
||||||
|
|
||||||
def __init__(self, master: Any, provider: str, **kwargs):
|
def __init__(self, master: Any, provider: str, size: Tuple[int, int] = (18, 18), **kwargs):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
|
|
||||||
prov = provider.lower()
|
prov = provider.lower()
|
||||||
if "antigravity" in prov:
|
if "antigravity" in prov:
|
||||||
color = Theme.PROVIDER_ANTIGRAVITY
|
color = Theme.PROVIDER_ANTIGRAVITY
|
||||||
name = "Google Antigravity"
|
name = "Google Antigravity"
|
||||||
icon = "G"
|
elif "codex" in prov or "openai" in prov:
|
||||||
elif "codex" in prov:
|
|
||||||
color = Theme.PROVIDER_CODEX
|
color = Theme.PROVIDER_CODEX
|
||||||
name = "OpenAI Codex"
|
name = "OpenAI Codex"
|
||||||
icon = "◈"
|
|
||||||
elif "opencode" in prov:
|
elif "opencode" in prov:
|
||||||
color = Theme.PROVIDER_OPENCODE
|
color = Theme.PROVIDER_OPENCODE
|
||||||
name = "OpenCode Go"
|
name = "OpenCode Go"
|
||||||
icon = "◈"
|
|
||||||
else:
|
else:
|
||||||
color = Theme.TEXT_MUTED
|
color = Theme.TEXT_MUTED
|
||||||
name = provider
|
name = provider
|
||||||
icon = "◈"
|
|
||||||
|
|
||||||
badge = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
badge = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
badge.pack(side="left")
|
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=(6, 4), pady=3)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
badge,
|
badge,
|
||||||
text=f"{icon} {name}",
|
text=name,
|
||||||
font=Theme.font_micro(),
|
font=Theme.font_caption(),
|
||||||
text_color=color,
|
text_color=color,
|
||||||
).pack(padx=6, pady=2)
|
).pack(side="left", padx=(0 if img else 6, 8), pady=3)
|
||||||
|
|
||||||
|
|
||||||
class HubMetricCard(HubCard):
|
class HubMetricCard(HubCard):
|
||||||
|
|
@ -234,14 +238,13 @@ class HubMetricCard(HubCard):
|
||||||
border_color = Theme.BORDER_ACCENT if accent else Theme.BORDER
|
border_color = Theme.BORDER_ACCENT if accent else Theme.BORDER
|
||||||
super().__init__(master=master, border_color=border_color, **kwargs)
|
super().__init__(master=master, border_color=border_color, **kwargs)
|
||||||
|
|
||||||
# Header: icon + title
|
|
||||||
top = ctk.CTkFrame(self, fg_color="transparent")
|
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
top.pack(fill="x", padx=16, pady=(12, 4))
|
top.pack(fill="x", padx=16, pady=(14, 4))
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
top,
|
top,
|
||||||
text=icon,
|
text=icon,
|
||||||
font=("Segoe UI", 14),
|
font=("Segoe UI", 16),
|
||||||
text_color=Theme.ACCENT if accent else Theme.TEXT_MUTED,
|
text_color=Theme.ACCENT if accent else Theme.TEXT_MUTED,
|
||||||
).pack(side="left", padx=(0, 6))
|
).pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
|
|
@ -252,30 +255,99 @@ class HubMetricCard(HubCard):
|
||||||
text_color=Theme.TEXT_MUTED,
|
text_color=Theme.TEXT_MUTED,
|
||||||
).pack(side="left")
|
).pack(side="left")
|
||||||
|
|
||||||
# Value
|
|
||||||
val_color = Theme.ACCENT if accent else Theme.TEXT_PRIMARY
|
val_color = Theme.ACCENT if accent else Theme.TEXT_PRIMARY
|
||||||
self.val_label = ctk.CTkLabel(
|
self.val_label = ctk.CTkLabel(
|
||||||
self,
|
self,
|
||||||
text=value,
|
text=value,
|
||||||
font=("Segoe UI", 26, "bold"),
|
font=("Segoe UI", 28, "bold"),
|
||||||
text_color=val_color,
|
text_color=val_color,
|
||||||
)
|
)
|
||||||
self.val_label.pack(anchor="w", padx=16, pady=(2, 2))
|
self.val_label.pack(anchor="w", padx=16, pady=(2, 2))
|
||||||
|
|
||||||
# Subtext
|
|
||||||
self.sub_label = ctk.CTkLabel(
|
self.sub_label = ctk.CTkLabel(
|
||||||
self,
|
self,
|
||||||
text=subtext,
|
text=subtext,
|
||||||
font=Theme.font_caption(),
|
font=Theme.font_caption(),
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
)
|
)
|
||||||
self.sub_label.pack(anchor="w", padx=16, pady=(0, 12))
|
self.sub_label.pack(anchor="w", padx=16, pady=(0, 14))
|
||||||
|
|
||||||
|
|
||||||
|
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, 10))
|
||||||
|
self.search_entry.bind("<KeyRelease>", 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, 10))
|
||||||
|
|
||||||
|
# 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):
|
class HubModal(ctk.CTkToplevel):
|
||||||
"""Reusable modal dialog on dark backdrop."""
|
"""Reusable modal dialog on dark backdrop."""
|
||||||
|
|
||||||
def __init__(self, parent: Any, title: str, width: int = 560, height: int = 480):
|
def __init__(self, parent: Any, title: str, width: int = 580, height: int = 500):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.title(title)
|
self.title(title)
|
||||||
self.geometry(f"{width}x{height}")
|
self.geometry(f"{width}x{height}")
|
||||||
|
|
@ -284,7 +356,6 @@ class HubModal(ctk.CTkToplevel):
|
||||||
self.transient(parent)
|
self.transient(parent)
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
|
|
||||||
# Center on parent
|
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
px = parent.winfo_x() + (parent.winfo_width() - width) // 2
|
px = parent.winfo_x() + (parent.winfo_width() - width) // 2
|
||||||
py = parent.winfo_y() + (parent.winfo_height() - height) // 2
|
py = parent.winfo_y() + (parent.winfo_height() - height) // 2
|
||||||
|
|
@ -298,22 +369,19 @@ class HubModal(ctk.CTkToplevel):
|
||||||
)
|
)
|
||||||
self.container.pack(fill="both", expand=True, padx=16, pady=16)
|
self.container.pack(fill="both", expand=True, padx=16, pady=16)
|
||||||
|
|
||||||
# Header
|
|
||||||
self.hdr = ctk.CTkFrame(self.container, fg_color="transparent")
|
self.hdr = ctk.CTkFrame(self.container, fg_color="transparent")
|
||||||
self.hdr.pack(fill="x", padx=16, pady=(16, 8))
|
self.hdr.pack(fill="x", padx=16, pady=(16, 8))
|
||||||
|
|
||||||
self.title_lbl = ctk.CTkLabel(
|
self.title_lbl = ctk.CTkLabel(
|
||||||
self.hdr,
|
self.hdr,
|
||||||
text=title,
|
text=title,
|
||||||
font=Theme.font_heading(),
|
font=Theme.font_title_section(),
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
)
|
)
|
||||||
self.title_lbl.pack(side="left")
|
self.title_lbl.pack(side="left")
|
||||||
|
|
||||||
# Body area
|
|
||||||
self.body = ctk.CTkFrame(self.container, fg_color="transparent")
|
self.body = ctk.CTkFrame(self.container, fg_color="transparent")
|
||||||
self.body.pack(fill="both", expand=True, padx=16, pady=8)
|
self.body.pack(fill="both", expand=True, padx=16, pady=8)
|
||||||
|
|
||||||
# Footer
|
|
||||||
self.footer = ctk.CTkFrame(self.container, fg_color="transparent")
|
self.footer = ctk.CTkFrame(self.container, fg_color="transparent")
|
||||||
self.footer.pack(fill="x", padx=16, pady=(8, 16))
|
self.footer.pack(fill="x", padx=16, pady=(8, 16))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Hermes Hub — Centralized Design Tokens and Theme Definition.
|
"""Hermes Hub — Centralized Design Tokens and Theme Definition (v3).
|
||||||
|
|
||||||
Source of Truth: Brand Guidelines & Brandbook
|
Source of Truth: Brand Guidelines & Brandbook
|
||||||
Primary Palette:
|
Primary Palette:
|
||||||
|
|
@ -41,7 +41,7 @@ class Theme:
|
||||||
# ── Typography Colors ──
|
# ── Typography Colors ──
|
||||||
TEXT_PRIMARY = "#F7F1E3"
|
TEXT_PRIMARY = "#F7F1E3"
|
||||||
TEXT_SECONDARY = "#C5BEAF"
|
TEXT_SECONDARY = "#C5BEAF"
|
||||||
TEXT_MUTED = "#7D8F81"
|
TEXT_MUTED = "#8A9C8E"
|
||||||
TEXT_ON_ACCENT = "#0F1510"
|
TEXT_ON_ACCENT = "#0F1510"
|
||||||
TEXT_ACCENT = "#CDAA64"
|
TEXT_ACCENT = "#CDAA64"
|
||||||
TEXT_DISABLED = "#546457"
|
TEXT_DISABLED = "#546457"
|
||||||
|
|
@ -82,14 +82,14 @@ class Theme:
|
||||||
RADIUS_LG = 14
|
RADIUS_LG = 14
|
||||||
RADIUS_PILL = 999
|
RADIUS_PILL = 999
|
||||||
|
|
||||||
# ── Control Heights ──
|
# ── Control Heights & Dimensions ──
|
||||||
HEIGHT_BTN_SM = 28
|
HEIGHT_BTN_SM = 30
|
||||||
HEIGHT_BTN_MD = 36
|
HEIGHT_BTN_MD = 38
|
||||||
HEIGHT_BTN_LG = 44
|
HEIGHT_BTN_LG = 44
|
||||||
HEIGHT_NAV_ITEM = 40
|
HEIGHT_NAV_ITEM = 42
|
||||||
HEIGHT_HEADER = 56
|
HEIGHT_HEADER = 60
|
||||||
HEIGHT_STATUSBAR = 28
|
HEIGHT_STATUSBAR = 30
|
||||||
WIDTH_SIDEBAR = 220
|
WIDTH_SIDEBAR = 240
|
||||||
|
|
||||||
# ── Fonts ──
|
# ── Fonts ──
|
||||||
FONT_FAMILY_TITLE = "Cinzel"
|
FONT_FAMILY_TITLE = "Cinzel"
|
||||||
|
|
@ -98,40 +98,44 @@ class Theme:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_title_hero(cls):
|
def font_title_hero(cls):
|
||||||
return (cls.FONT_FAMILY_TITLE, 22, "bold")
|
return (cls.FONT_FAMILY_TITLE, 24, "bold")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def font_title_page(cls):
|
||||||
|
return (cls.FONT_FAMILY_UI, 24, "bold")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_title_section(cls):
|
def font_title_section(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 20, "bold")
|
return (cls.FONT_FAMILY_UI, 19, "bold")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_heading(cls):
|
def font_heading(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 15, "bold")
|
return (cls.FONT_FAMILY_UI, 16, "bold")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_subheading(cls):
|
def font_subheading(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 13, "bold")
|
return (cls.FONT_FAMILY_UI, 15, "bold")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_body(cls):
|
def font_body(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 12)
|
return (cls.FONT_FAMILY_UI, 14)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_body_bold(cls):
|
def font_body_bold(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 12, "bold")
|
return (cls.FONT_FAMILY_UI, 14, "bold")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_caption(cls):
|
def font_caption(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 11)
|
return (cls.FONT_FAMILY_UI, 12)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_micro(cls):
|
def font_micro(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 10)
|
return (cls.FONT_FAMILY_UI, 11)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_mono(cls):
|
def font_mono(cls):
|
||||||
return (cls.FONT_FAMILY_MONO, 11)
|
return (cls.FONT_FAMILY_MONO, 13)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_mono_sm(cls):
|
def font_mono_sm(cls):
|
||||||
return (cls.FONT_FAMILY_MONO, 10)
|
return (cls.FONT_FAMILY_MONO, 12)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
"""Hermes Hub — Accounts View (Компактная responsive сетка аккаунтов и слотов)."""
|
"""Hermes Hub — Accounts View (Cockpit-grade Toolbar, Filters, and Provider Breakdown)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.router.ui.components import (
|
||||||
HubButton,
|
HubButton,
|
||||||
HubCard,
|
HubCard,
|
||||||
HubProviderBadge,
|
HubProviderBadge,
|
||||||
HubSectionHeader,
|
HubSectionHeader,
|
||||||
HubStatusBadge,
|
HubStatusBadge,
|
||||||
|
HubToolbar,
|
||||||
)
|
)
|
||||||
from antigravity_provider.router.unified_health import (
|
from antigravity_provider.router.unified_health import (
|
||||||
UnifiedHealthService,
|
UnifiedHealthService,
|
||||||
|
|
@ -18,6 +20,7 @@ from antigravity_provider.router.unified_health import (
|
||||||
STATUS_HEALTHY,
|
STATUS_HEALTHY,
|
||||||
STATUS_QUOTA_EXHAUSTED,
|
STATUS_QUOTA_EXHAUSTED,
|
||||||
STATUS_AUTH_REQUIRED,
|
STATUS_AUTH_REQUIRED,
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
STATUS_COLD_SPARE,
|
STATUS_COLD_SPARE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,18 +30,32 @@ class AccountsView(ctk.CTkFrame):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.app_state = app_state
|
self.app_state = app_state
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
|
self._search_query = ""
|
||||||
|
self._filter_status = "Все статусы"
|
||||||
|
self._sort_by = "По умолчанию"
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
def _build(self):
|
def _build(self):
|
||||||
|
# 1. Header
|
||||||
header = HubSectionHeader(
|
header = HubSectionHeader(
|
||||||
self,
|
self,
|
||||||
title="Управление аккаунтами",
|
title="Управление аккаунтами",
|
||||||
subtitle="Подключение, тестирование и распределение учетных записей провайдеров",
|
subtitle="Подключение, проверка и распределение AI-аккаунтов",
|
||||||
action_text="+ Добавить аккаунт",
|
action_text="+ Добавить аккаунт",
|
||||||
action_cmd=lambda: self._trigger_action("add_account", {}),
|
action_cmd=lambda: self._trigger_action("add_account", {}),
|
||||||
)
|
)
|
||||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
header.pack(fill="x", padx=20, pady=(16, 8))
|
||||||
|
|
||||||
|
# 2. Cockpit-grade Toolbar
|
||||||
|
self.toolbar = HubToolbar(
|
||||||
|
self,
|
||||||
|
on_search=self._on_search,
|
||||||
|
on_filter=self._on_filter,
|
||||||
|
on_sort=self._on_sort,
|
||||||
|
)
|
||||||
|
self.toolbar.pack(fill="x", padx=20, pady=(0, 10))
|
||||||
|
|
||||||
|
# 3. Provider Tabs with real icons
|
||||||
self.tabview = ctk.CTkTabview(
|
self.tabview = ctk.CTkTabview(
|
||||||
self,
|
self,
|
||||||
fg_color=Theme.BG_WINDOW,
|
fg_color=Theme.BG_WINDOW,
|
||||||
|
|
@ -69,6 +86,18 @@ class AccountsView(ctk.CTkFrame):
|
||||||
|
|
||||||
self.update_data()
|
self.update_data()
|
||||||
|
|
||||||
|
def _on_search(self, query: str):
|
||||||
|
self._search_query = query
|
||||||
|
self.update_data()
|
||||||
|
|
||||||
|
def _on_filter(self, filter_val: str):
|
||||||
|
self._filter_status = filter_val
|
||||||
|
self.update_data()
|
||||||
|
|
||||||
|
def _on_sort(self, sort_val: str):
|
||||||
|
self._sort_by = sort_val
|
||||||
|
self.update_data()
|
||||||
|
|
||||||
def update_data(self, app_state: Optional[Dict[str, Any]] = None):
|
def update_data(self, app_state: Optional[Dict[str, Any]] = None):
|
||||||
service = UnifiedHealthService.get()
|
service = UnifiedHealthService.get()
|
||||||
profiles_by_prov = service.scan_all()
|
profiles_by_prov = service.scan_all()
|
||||||
|
|
@ -78,68 +107,146 @@ class AccountsView(ctk.CTkFrame):
|
||||||
w.destroy()
|
w.destroy()
|
||||||
|
|
||||||
profiles = profiles_by_prov.get(prov_key, [])
|
profiles = profiles_by_prov.get(prov_key, [])
|
||||||
for idx, p in enumerate(profiles):
|
|
||||||
row_idx, col_idx = divmod(idx, 3)
|
# Filter profiles
|
||||||
if p.is_empty_slot or p.is_cold_spare:
|
filtered = []
|
||||||
card = self._build_empty_slot_card(scroll, p)
|
empty_slots_count = 0
|
||||||
else:
|
|
||||||
|
for p in profiles:
|
||||||
|
# Count empty/unconfigured slots
|
||||||
|
if p.is_empty_slot:
|
||||||
|
empty_slots_count += 1
|
||||||
|
|
||||||
|
# Search filter
|
||||||
|
if self._search_query:
|
||||||
|
q = self._search_query
|
||||||
|
matches = (
|
||||||
|
q in p.account_identity.lower()
|
||||||
|
or q in p.display_name.lower()
|
||||||
|
or any(q in m.lower() for m in p.preferred_models)
|
||||||
|
or any(q in r.lower() for r in p.assigned_roles)
|
||||||
|
)
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Status filter
|
||||||
|
if self._filter_status == "Подключённые" and p.auth_state != "AUTHENTICATED":
|
||||||
|
continue
|
||||||
|
elif self._filter_status == "Требуют входа" and p.auth_state == "AUTHENTICATED":
|
||||||
|
continue
|
||||||
|
elif self._filter_status == "Квота исчерпана" and "quota" not in p.health_state:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(p)
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
if self._sort_by == "По имени":
|
||||||
|
filtered.sort(key=lambda x: x.display_name)
|
||||||
|
elif self._sort_by == "По статусу":
|
||||||
|
filtered.sort(key=lambda x: x.health_state)
|
||||||
|
|
||||||
|
# Render configured/active accounts first
|
||||||
|
configured_profs = [p for p in filtered if not p.is_empty_slot]
|
||||||
|
empty_profs = [p for p in filtered if p.is_empty_slot]
|
||||||
|
|
||||||
|
grid_idx = 0
|
||||||
|
for p in configured_profs:
|
||||||
|
row_idx, col_idx = divmod(grid_idx, 3)
|
||||||
card = self._build_account_card(scroll, p)
|
card = self._build_account_card(scroll, p)
|
||||||
card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew")
|
card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew")
|
||||||
|
grid_idx += 1
|
||||||
|
|
||||||
|
# Render free slots consolidated or individual if filtered
|
||||||
|
if empty_profs:
|
||||||
|
# If only a few empty or searched, show individual slots; otherwise show sleek consolidated bar
|
||||||
|
if len(empty_profs) <= 2 or self._search_query:
|
||||||
|
for p in empty_profs:
|
||||||
|
row_idx, col_idx = divmod(grid_idx, 3)
|
||||||
|
card = self._build_empty_slot_card(scroll, p)
|
||||||
|
card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew")
|
||||||
|
grid_idx += 1
|
||||||
|
else:
|
||||||
|
# Sleek consolidated free slots widget
|
||||||
|
c_row = (grid_idx // 3) + 1
|
||||||
|
summary_card = HubCard(scroll, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED)
|
||||||
|
summary_card.grid(row=c_row, column=0, columnspan=3, padx=6, pady=10, sticky="ew")
|
||||||
|
|
||||||
|
s_inner = ctk.CTkFrame(summary_card, fg_color="transparent")
|
||||||
|
s_inner.pack(fill="x", padx=16, pady=12)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
s_inner,
|
||||||
|
text=f"Свободные слоты {prov_key}: {len(empty_profs)} слотов доступно для подключения",
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
HubButton(
|
||||||
|
s_inner,
|
||||||
|
text="+ Подключить аккаунт",
|
||||||
|
variant="primary",
|
||||||
|
height=Theme.HEIGHT_BTN_SM,
|
||||||
|
command=lambda k=prov_key: self._trigger_action("add_account", {"provider": k}),
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
def _build_account_card(self, parent: Any, p: ProfileViewModel) -> HubCard:
|
def _build_account_card(self, parent: Any, p: ProfileViewModel) -> HubCard:
|
||||||
border_col = Theme.BORDER_ACCENT if p.is_main_account else Theme.BORDER
|
border_col = Theme.BORDER_ACCENT if p.is_main_account else Theme.BORDER
|
||||||
card = HubCard(parent, border_color=border_col, fg_color=Theme.SURFACE)
|
card = HubCard(parent, border_color=border_col, fg_color=Theme.SURFACE)
|
||||||
|
|
||||||
# Top line: Identity / Email + MAIN badge + Status dot
|
# ── Header: Provider Icon + Identity / Masked Email + Status dot ──
|
||||||
top = ctk.CTkFrame(card, fg_color="transparent")
|
top = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
top.pack(fill="x", padx=12, pady=(10, 2))
|
top.pack(fill="x", padx=14, pady=(12, 2))
|
||||||
|
|
||||||
ctk.CTkLabel(top, text=p.account_identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
p_img = AssetManager.get().get_provider_image(p.provider, size=(20, 20))
|
||||||
|
if p_img:
|
||||||
|
ctk.CTkLabel(top, image=p_img, text="").pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkLabel(top, text=p.account_identity, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||||
|
|
||||||
if p.is_main_account:
|
if p.is_main_account:
|
||||||
m_pill = ctk.CTkFrame(top, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
m_pill = ctk.CTkFrame(top, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
||||||
m_pill.pack(side="left", padx=(6, 0))
|
m_pill.pack(side="left", padx=(6, 0))
|
||||||
ctk.CTkLabel(m_pill, text="★ MAIN", font=Theme.font_micro(), text_color=Theme.ACCENT).pack(padx=4, pady=1)
|
ctk.CTkLabel(m_pill, text="★ MAIN", font=Theme.font_micro(), text_color=Theme.ACCENT).pack(padx=5, pady=1)
|
||||||
|
|
||||||
dot_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else Theme.STATUS_ERROR)
|
dot_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else Theme.STATUS_ERROR)
|
||||||
ctk.CTkLabel(top, text="●", font=("Segoe UI", 12, "bold"), text_color=dot_col).pack(side="right")
|
ctk.CTkLabel(top, text="●", font=("Segoe UI", 13, "bold"), text_color=dot_col).pack(side="right")
|
||||||
|
|
||||||
# Provider & Slot info
|
# Subheader: Role & Internal Slot
|
||||||
prov_row = ctk.CTkFrame(card, fg_color="transparent")
|
sub_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
prov_row.pack(fill="x", padx=12, pady=(0, 4))
|
sub_row.pack(fill="x", padx=14, pady=(0, 4))
|
||||||
ctk.CTkLabel(prov_row, text=f"{p.provider_display_name} • {p.profile_id}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(anchor="w")
|
ctk.CTkLabel(sub_row, text=f"{p.display_name} • {p.provider_display_name}", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="w")
|
||||||
|
|
||||||
# Per-model breakdown box (Model-Family health)
|
# Per-model breakdown box (Model-Family health)
|
||||||
models_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
models_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
models_box.pack(fill="x", padx=12, pady=4)
|
models_box.pack(fill="x", padx=14, pady=4)
|
||||||
|
|
||||||
if p.model_states:
|
if p.model_states:
|
||||||
for fam_name, m_health in list(p.model_states.items())[:2]:
|
for fam_name, m_health in list(p.model_states.items())[:2]:
|
||||||
mrow = ctk.CTkFrame(models_box, fg_color="transparent")
|
mrow = ctk.CTkFrame(models_box, fg_color="transparent")
|
||||||
mrow.pack(fill="x", padx=8, pady=2)
|
mrow.pack(fill="x", padx=8, pady=3)
|
||||||
ctk.CTkLabel(mrow, text=m_health.display_name, font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(mrow, text=m_health.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||||
m_col = Theme.STATUS_HEALTHY if m_health.status == STATUS_HEALTHY else Theme.STATUS_WARNING
|
m_col = Theme.STATUS_HEALTHY if m_health.status == STATUS_HEALTHY else Theme.STATUS_WARNING
|
||||||
ctk.CTkLabel(mrow, text=f"● {m_health.status_label_ru}", font=Theme.font_micro(), text_color=m_col).pack(side="right")
|
ctk.CTkLabel(mrow, text=f"● {m_health.status_label_ru}", font=Theme.font_micro(), text_color=m_col).pack(side="right")
|
||||||
else:
|
else:
|
||||||
ctk.CTkLabel(models_box, text=f"Статус: {p.health_label_ru}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=4)
|
ctk.CTkLabel(models_box, text=f"Статус: {p.health_label_ru}", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=4)
|
||||||
|
|
||||||
# Assigned role info
|
# Assigned role tag
|
||||||
role_box = ctk.CTkFrame(card, fg_color="transparent")
|
role_box = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
role_box.pack(fill="x", padx=12, pady=2)
|
role_box.pack(fill="x", padx=14, pady=2)
|
||||||
role_str = p.assigned_roles[0] if p.assigned_roles else p.display_name
|
role_str = p.assigned_roles[0] if p.assigned_roles else p.display_name
|
||||||
ctk.CTkLabel(role_box, text=f"Назначение: {role_str}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w")
|
ctk.CTkLabel(role_box, text=f"Роль: {role_str}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w")
|
||||||
|
|
||||||
# Action Buttons
|
# Action Buttons
|
||||||
btns = ctk.CTkFrame(card, fg_color="transparent")
|
btns = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
btns.pack(fill="x", padx=12, pady=(6, 10))
|
btns.pack(fill="x", padx=14, pady=(6, 12))
|
||||||
|
|
||||||
HubButton(btns, text="⚡ Тест", variant="secondary", width=65, height=Theme.HEIGHT_BTN_SM, command=lambda: self._trigger_action("test", p)).pack(side="left", padx=(0, 4))
|
HubButton(btns, text="⚡ Тест", variant="secondary", width=70, height=Theme.HEIGHT_BTN_SM, command=lambda: self._trigger_action("test", p)).pack(side="left", padx=(0, 6))
|
||||||
HubButton(btns, text="Назначить", variant="secondary", width=80, height=Theme.HEIGHT_BTN_SM, command=lambda: self._trigger_action("assign_role", p)).pack(side="left", padx=(0, 4))
|
HubButton(btns, text="Назначить", variant="secondary", width=85, height=Theme.HEIGHT_BTN_SM, command=lambda: self._trigger_action("assign_role", p)).pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
btns,
|
btns,
|
||||||
text="⋮",
|
text="⋮",
|
||||||
width=26,
|
width=28,
|
||||||
height=Theme.HEIGHT_BTN_SM,
|
height=Theme.HEIGHT_BTN_SM,
|
||||||
fg_color=Theme.SURFACE_MUTED,
|
fg_color=Theme.SURFACE_MUTED,
|
||||||
hover_color=Theme.SURFACE_HOVER,
|
hover_color=Theme.SURFACE_HOVER,
|
||||||
|
|
@ -154,16 +261,16 @@ class AccountsView(ctk.CTkFrame):
|
||||||
card = HubCard(parent, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED)
|
card = HubCard(parent, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED)
|
||||||
|
|
||||||
top = ctk.CTkFrame(card, fg_color="transparent")
|
top = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
top.pack(fill="x", padx=12, pady=(12, 4))
|
top.pack(fill="x", padx=14, pady=(12, 4))
|
||||||
|
|
||||||
title_txt = "Холодный резерв" if p.is_cold_spare else "Свободный слот"
|
title_txt = "Холодный резерв" if p.is_cold_spare else "Свободный слот"
|
||||||
ctk.CTkLabel(top, text=title_txt, font=Theme.font_heading(), text_color=Theme.TEXT_MUTED).pack(side="left")
|
ctk.CTkLabel(top, text=title_txt, font=Theme.font_heading(), text_color=Theme.TEXT_MUTED).pack(side="left")
|
||||||
ctk.CTkLabel(top, text=f"({p.profile_id})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="right")
|
ctk.CTkLabel(top, text=f"({p.profile_id})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="right")
|
||||||
|
|
||||||
ctk.CTkLabel(card, text=p.provider_display_name, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w", padx=12, pady=(0, 4))
|
ctk.CTkLabel(card, text=p.provider_display_name, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w", padx=14, pady=(0, 4))
|
||||||
|
|
||||||
desc_txt = "Не используется автоматически" if p.is_cold_spare else "Аккаунт не подключён"
|
desc_txt = "Не используется автоматически" if p.is_cold_spare else "Аккаунт не добавлен"
|
||||||
ctk.CTkLabel(card, text=desc_txt, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(anchor="w", padx=12, pady=(0, 10))
|
ctk.CTkLabel(card, text=desc_txt, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="w", padx=14, pady=(0, 10))
|
||||||
|
|
||||||
HubButton(
|
HubButton(
|
||||||
card,
|
card,
|
||||||
|
|
@ -171,14 +278,14 @@ class AccountsView(ctk.CTkFrame):
|
||||||
variant="accent_outline",
|
variant="accent_outline",
|
||||||
height=Theme.HEIGHT_BTN_SM,
|
height=Theme.HEIGHT_BTN_SM,
|
||||||
command=lambda: self._trigger_action("oauth" if p.provider == "antigravity" else "add_account", p),
|
command=lambda: self._trigger_action("oauth" if p.provider == "antigravity" else "add_account", p),
|
||||||
).pack(fill="x", padx=12, pady=(4, 12))
|
).pack(fill="x", padx=14, pady=(4, 12))
|
||||||
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def _open_account_menu(self, p: ProfileViewModel):
|
def _open_account_menu(self, p: ProfileViewModel):
|
||||||
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
||||||
popup.title(f"Аккаунт: {p.account_identity}")
|
popup.title(f"Аккаунт: {p.account_identity}")
|
||||||
popup.geometry("300x260")
|
popup.geometry("320x280")
|
||||||
popup.configure(fg_color=Theme.DARK)
|
popup.configure(fg_color=Theme.DARK)
|
||||||
popup.resizable(False, False)
|
popup.resizable(False, False)
|
||||||
popup.transient(self.winfo_toplevel())
|
popup.transient(self.winfo_toplevel())
|
||||||
|
|
@ -190,19 +297,19 @@ class AccountsView(ctk.CTkFrame):
|
||||||
popup.geometry(f"+{px}+{py}")
|
popup.geometry(f"+{px}+{py}")
|
||||||
|
|
||||||
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
||||||
c.pack(fill="both", expand=True, padx=8, pady=8)
|
c.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
|
||||||
ctk.CTkLabel(c, text=p.account_identity, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
ctk.CTkLabel(c, text=p.account_identity, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
||||||
ctk.CTkLabel(c, text=f"{p.provider_display_name} • {p.profile_id}", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8))
|
ctk.CTkLabel(c, text=f"{p.provider_display_name} • {p.profile_id}", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 10))
|
||||||
|
|
||||||
def _do(action_name: str):
|
def _do(action_name: str):
|
||||||
popup.destroy()
|
popup.destroy()
|
||||||
self._trigger_action(action_name, p)
|
self._trigger_action(action_name, p)
|
||||||
|
|
||||||
HubButton(c, text="⚡ Проверить состояние", variant="secondary", height=28, command=lambda: _do("test")).pack(fill="x", padx=12, pady=2)
|
HubButton(c, text="⚡ Проверить состояние (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(fill="x", padx=12, pady=2)
|
||||||
HubButton(c, text="★ Сделать основным Hermes", variant="secondary", height=28, command=lambda: _do("set_main")).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="secondary", height=28, command=lambda: _do("set_orchestrator")).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="🗑️ Удалить credentials", variant="danger", height=28, command=lambda: _do("delete_credentials")).pack(fill="x", padx=12, pady=(4, 0))
|
HubButton(c, text="🗑️ Удалить credentials", variant="danger", height=30, command=lambda: _do("delete_credentials")).pack(fill="x", padx=12, pady=(4, 0))
|
||||||
|
|
||||||
def _trigger_action(self, action: str, profile_vm: Any):
|
def _trigger_action(self, action: str, profile_vm: Any):
|
||||||
if self.on_action:
|
if self.on_action:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Hermes Hub — Health View (Диагностический Dashboard состояния системы)."""
|
"""Hermes Hub — Health View (Диагностический Dashboard состояния системы с иконками)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.router.ui.components import (
|
||||||
HubButton,
|
HubButton,
|
||||||
HubCard,
|
HubCard,
|
||||||
|
|
@ -15,6 +16,7 @@ from antigravity_provider.router.unified_health import (
|
||||||
UnifiedHealthService,
|
UnifiedHealthService,
|
||||||
ProfileViewModel,
|
ProfileViewModel,
|
||||||
STATUS_HEALTHY,
|
STATUS_HEALTHY,
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,7 +55,7 @@ class HealthView(ctk.CTkFrame):
|
||||||
banner.pack(fill="x", pady=(0, 12))
|
banner.pack(fill="x", pady=(0, 12))
|
||||||
|
|
||||||
b_top = ctk.CTkFrame(banner, fg_color="transparent")
|
b_top = ctk.CTkFrame(banner, fg_color="transparent")
|
||||||
b_top.pack(fill="x", padx=16, pady=(12, 4))
|
b_top.pack(fill="x", padx=16, pady=(14, 4))
|
||||||
|
|
||||||
ctk.CTkLabel(b_top, text=f"СОСТОЯНИЕ СИСТЕМЫ: {readiness.title_ru.upper()}", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(side="left")
|
ctk.CTkLabel(b_top, text=f"СОСТОЯНИЕ СИСТЕМЫ: {readiness.title_ru.upper()}", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(side="left")
|
||||||
ctk.CTkLabel(b_top, text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} • Роли: {readiness.roles_ready_count}/{readiness.total_roles}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right")
|
ctk.CTkLabel(b_top, text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} • Роли: {readiness.roles_ready_count}/{readiness.total_roles}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right")
|
||||||
|
|
@ -64,7 +66,7 @@ class HealthView(ctk.CTkFrame):
|
||||||
w_box = ctk.CTkFrame(banner, fg_color="#3B2610", corner_radius=Theme.RADIUS_SM)
|
w_box = ctk.CTkFrame(banner, fg_color="#3B2610", corner_radius=Theme.RADIUS_SM)
|
||||||
w_box.pack(fill="x", padx=16, pady=(0, 12))
|
w_box.pack(fill="x", padx=16, pady=(0, 12))
|
||||||
for w_txt in readiness.warnings:
|
for w_txt in readiness.warnings:
|
||||||
ctk.CTkLabel(w_box, text=f"⚠️ {w_txt}", font=Theme.font_micro(), text_color=Theme.STATUS_WARNING).pack(anchor="w", padx=10, pady=2)
|
ctk.CTkLabel(w_box, text=f"⚠️ {w_txt}", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING).pack(anchor="w", padx=10, pady=2)
|
||||||
|
|
||||||
# Provider Breakdown Tables
|
# Provider Breakdown Tables
|
||||||
for prov_id, prov_label in [
|
for prov_id, prov_label in [
|
||||||
|
|
@ -77,7 +79,15 @@ class HealthView(ctk.CTkFrame):
|
||||||
p_card = HubCard(self.scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
p_card = HubCard(self.scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
||||||
p_card.pack(fill="x", pady=8)
|
p_card.pack(fill="x", pady=8)
|
||||||
|
|
||||||
ctk.CTkLabel(p_card, text=prov_label, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(12, 6))
|
# Provider Header with icon
|
||||||
|
p_hdr = ctk.CTkFrame(p_card, fg_color="transparent")
|
||||||
|
p_hdr.pack(fill="x", padx=16, pady=(12, 6))
|
||||||
|
|
||||||
|
p_img = AssetManager.get().get_provider_image(prov_id, size=(20, 20))
|
||||||
|
if p_img:
|
||||||
|
ctk.CTkLabel(p_hdr, image=p_img, text="").pack(side="left", padx=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkLabel(p_hdr, text=prov_label, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||||
|
|
||||||
# Table Header Row
|
# Table Header Row
|
||||||
th_row = ctk.CTkFrame(p_card, fg_color=Theme.BG_SIDEBAR, corner_radius=Theme.RADIUS_SM)
|
th_row = ctk.CTkFrame(p_card, fg_color=Theme.BG_SIDEBAR, corner_radius=Theme.RADIUS_SM)
|
||||||
|
|
@ -88,11 +98,11 @@ class HealthView(ctk.CTkFrame):
|
||||||
th_row.grid_columnconfigure(3, weight=2)
|
th_row.grid_columnconfigure(3, weight=2)
|
||||||
th_row.grid_columnconfigure(4, weight=1)
|
th_row.grid_columnconfigure(4, weight=1)
|
||||||
|
|
||||||
ctk.CTkLabel(th_row, text="СЛОТ / АККАУНТ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=0, padx=8, pady=4, sticky="w")
|
ctk.CTkLabel(th_row, text="СЛОТ / АККАУНТ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=0, padx=8, pady=6, sticky="w")
|
||||||
ctk.CTkLabel(th_row, text="АВТОРИЗАЦИЯ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=1, padx=8, pady=4, sticky="w")
|
ctk.CTkLabel(th_row, text="АВТОРИЗАЦИЯ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=1, padx=8, pady=6, sticky="w")
|
||||||
ctk.CTkLabel(th_row, text="СЕМЕЙСТВА МОДЕЛЕЙ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=2, padx=8, pady=4, sticky="w")
|
ctk.CTkLabel(th_row, text="СЕМЕЙСТВА МОДЕЛЕЙ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=2, padx=8, pady=6, sticky="w")
|
||||||
ctk.CTkLabel(th_row, text="СТАТУС ЗДОРОВЬЯ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=3, padx=8, pady=4, sticky="w")
|
ctk.CTkLabel(th_row, text="СТАТУС ЗДОРОВЬЯ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=3, padx=8, pady=6, sticky="w")
|
||||||
ctk.CTkLabel(th_row, text="ПРОВЕРКА", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=4, sticky="w")
|
ctk.CTkLabel(th_row, text="ПРОВЕРКА", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=6, sticky="w")
|
||||||
|
|
||||||
# Table Rows
|
# Table Rows
|
||||||
for p in profs:
|
for p in profs:
|
||||||
|
|
@ -105,20 +115,20 @@ class HealthView(ctk.CTkFrame):
|
||||||
tr.grid_columnconfigure(4, weight=1)
|
tr.grid_columnconfigure(4, weight=1)
|
||||||
|
|
||||||
# Col 0: Account / Slot
|
# Col 0: Account / Slot
|
||||||
ctk.CTkLabel(tr, text=f"{p.display_name}\n({p.profile_id})", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY, justify="left").grid(row=0, column=0, padx=8, pady=6, sticky="w")
|
ctk.CTkLabel(tr, text=f"{p.display_name}\n({p.profile_id})", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY, justify="left").grid(row=0, column=0, padx=8, pady=8, sticky="w")
|
||||||
|
|
||||||
# Col 1: Auth
|
# Col 1: Auth
|
||||||
auth_txt = "✓ Подключён" if p.auth_state == "AUTHENTICATED" else ("⚠️ Истёк" if p.auth_state == "AUTH_EXPIRED" else "— Отсутствует")
|
auth_txt = "✓ Подключён" if p.auth_state == "AUTHENTICATED" else ("⚠️ Требуется вход" if p.auth_state == "AUTH_EXPIRED" else "— Не добавлен")
|
||||||
auth_col = Theme.STATUS_HEALTHY if p.auth_state == "AUTHENTICATED" else Theme.STATUS_WARNING
|
auth_col = Theme.STATUS_HEALTHY if p.auth_state == "AUTHENTICATED" else Theme.TEXT_MUTED
|
||||||
ctk.CTkLabel(tr, text=f"{auth_txt}\n{p.account_identity}", font=Theme.font_micro(), text_color=auth_col, justify="left").grid(row=0, column=1, padx=8, pady=6, sticky="w")
|
ctk.CTkLabel(tr, text=f"{auth_txt}\n{p.account_identity}", font=Theme.font_caption(), text_color=auth_col, justify="left").grid(row=0, column=1, padx=8, pady=8, sticky="w")
|
||||||
|
|
||||||
# Col 2: Model Families
|
# Col 2: Model Families
|
||||||
fams_txt = "\n".join(f"• {k}: {v.status_label_ru}" for k, v in list(p.model_states.items())[:2]) or "default"
|
fams_txt = "\n".join(f"• {k}: {v.status_label_ru}" for k, v in list(p.model_states.items())[:2]) or "default"
|
||||||
ctk.CTkLabel(tr, text=fams_txt, font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY, justify="left").grid(row=0, column=2, padx=8, pady=6, sticky="w")
|
ctk.CTkLabel(tr, text=fams_txt, font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY, justify="left").grid(row=0, column=2, padx=8, pady=8, sticky="w")
|
||||||
|
|
||||||
# Col 3: Status
|
# Col 3: Status
|
||||||
st_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else Theme.STATUS_ERROR)
|
st_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else (Theme.TEXT_MUTED if p.health_state == STATUS_NOT_CONFIGURED else Theme.STATUS_ERROR))
|
||||||
ctk.CTkLabel(tr, text=f"● {p.health_label_ru}", font=Theme.font_caption(), text_color=st_col).grid(row=0, column=3, padx=8, pady=6, sticky="w")
|
ctk.CTkLabel(tr, text=f"● {p.health_label_ru}", font=Theme.font_caption(), text_color=st_col).grid(row=0, column=3, padx=8, pady=8, sticky="w")
|
||||||
|
|
||||||
# Col 4: Last check
|
# Col 4: Last check
|
||||||
ctk.CTkLabel(tr, text=p.last_checked_at or "—", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=6, sticky="w")
|
ctk.CTkLabel(tr, text=p.last_checked_at or "—", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=8, sticky="w")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Hermes Hub — Routing View (Визуализация цепочек маршрутизации и отказоустойчивости)."""
|
"""Hermes Hub — Routing View (Визуализация цепочек маршрутизации с иконками провайдеров)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.router.ui.components import (
|
||||||
HubButton,
|
HubButton,
|
||||||
HubCard,
|
HubCard,
|
||||||
|
|
@ -49,24 +50,24 @@ class RoutingView(ctk.CTkFrame):
|
||||||
|
|
||||||
# Top Row
|
# Top Row
|
||||||
top = ctk.CTkFrame(card, fg_color="transparent")
|
top = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
top.pack(fill="x", padx=16, pady=(12, 4))
|
top.pack(fill="x", padx=16, pady=(14, 4))
|
||||||
|
|
||||||
ctk.CTkLabel(top, text=pipe.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(side="left")
|
ctk.CTkLabel(top, text=pipe.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(side="left")
|
||||||
ctk.CTkLabel(top, text=f"({pipe.role_id})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="left", padx=(6, 0))
|
ctk.CTkLabel(top, text=f"({pipe.role_id})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="left", padx=(8, 0))
|
||||||
|
|
||||||
affinity_str = "Session Affinity ON" if pipe.session_affinity else "Affinity OFF"
|
affinity_str = "Session Affinity ON" if pipe.session_affinity else "Affinity OFF"
|
||||||
ctk.CTkLabel(top, text=f"Модель: {pipe.default_model} • {affinity_str}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right")
|
ctk.CTkLabel(top, text=f"Модель: {pipe.default_model} • {affinity_str}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right")
|
||||||
|
|
||||||
# Pipeline Visualizer Row
|
# Pipeline Visualizer Row
|
||||||
pipeline_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
pipeline_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
pipeline_box.pack(fill="x", padx=16, pady=4)
|
pipeline_box.pack(fill="x", padx=16, pady=6)
|
||||||
|
|
||||||
p_inner = ctk.CTkFrame(pipeline_box, fg_color="transparent")
|
p_inner = ctk.CTkFrame(pipeline_box, fg_color="transparent")
|
||||||
p_inner.pack(fill="x", padx=10, pady=8)
|
p_inner.pack(fill="x", padx=12, pady=10)
|
||||||
|
|
||||||
for idx, node in enumerate(pipe.nodes):
|
for idx, node in enumerate(pipe.nodes):
|
||||||
if idx > 0:
|
if idx > 0:
|
||||||
ctk.CTkLabel(p_inner, text=" ➔ ", font=("Segoe UI", 13, "bold"), text_color=Theme.TEXT_MUTED).pack(side="left", padx=2)
|
ctk.CTkLabel(p_inner, text=" ➔ ", font=("Segoe UI", 15, "bold"), text_color=Theme.TEXT_MUTED).pack(side="left", padx=4)
|
||||||
|
|
||||||
node_border = Theme.BORDER_ACCENT if node.is_active else Theme.BORDER
|
node_border = Theme.BORDER_ACCENT if node.is_active else Theme.BORDER
|
||||||
node_fg = Theme.DARK if node.is_active else Theme.SURFACE
|
node_fg = Theme.DARK if node.is_active else Theme.SURFACE
|
||||||
|
|
@ -75,18 +76,22 @@ class RoutingView(ctk.CTkFrame):
|
||||||
node_card.pack(side="left", padx=2)
|
node_card.pack(side="left", padx=2)
|
||||||
|
|
||||||
n_top = ctk.CTkFrame(node_card, fg_color="transparent")
|
n_top = ctk.CTkFrame(node_card, fg_color="transparent")
|
||||||
n_top.pack(fill="x", padx=8, pady=(4, 2))
|
n_top.pack(fill="x", padx=10, pady=(6, 2))
|
||||||
|
|
||||||
|
p_img = AssetManager.get().get_provider_image(node.provider, size=(16, 16))
|
||||||
|
if p_img:
|
||||||
|
ctk.CTkLabel(n_top, image=p_img, text="").pack(side="left", padx=(0, 4))
|
||||||
|
|
||||||
dot_col = Theme.STATUS_HEALTHY if node.status == STATUS_HEALTHY else Theme.STATUS_WARNING
|
dot_col = Theme.STATUS_HEALTHY if node.status == STATUS_HEALTHY else Theme.STATUS_WARNING
|
||||||
ctk.CTkLabel(n_top, text="●", font=("Segoe UI", 10, "bold"), text_color=dot_col).pack(side="left", padx=(0, 4))
|
ctk.CTkLabel(n_top, text="●", font=("Segoe UI", 11, "bold"), text_color=dot_col).pack(side="left", padx=(0, 4))
|
||||||
ctk.CTkLabel(n_top, text=node.display_name, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
ctk.CTkLabel(n_top, text=node.display_name, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||||
|
|
||||||
rank_txt = "Primary" if idx == 0 else f"Fallback {idx}"
|
rank_txt = "Primary" if idx == 0 else f"Fallback {idx}"
|
||||||
ctk.CTkLabel(node_card, text=f"{rank_txt} • {node.provider}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=(0, 4), anchor="w")
|
ctk.CTkLabel(node_card, text=f"{rank_txt} • {node.model}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(padx=10, pady=(0, 6), anchor="w")
|
||||||
|
|
||||||
# Active Route Notice
|
# Active Route Notice
|
||||||
meta_row = ctk.CTkFrame(card, fg_color="transparent")
|
meta_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
meta_row.pack(fill="x", padx=16, pady=(4, 10))
|
meta_row.pack(fill="x", padx=16, pady=(4, 12))
|
||||||
|
|
||||||
act_str = f"Текущий активный маршрут: [{pipe.active_profile_id}]" if pipe.active_profile_id else "Все маршруты исчерпаны!"
|
act_str = f"Текущий активный маршрут: [{pipe.active_profile_id}]" if pipe.active_profile_id else "Все маршруты исчерпаны!"
|
||||||
act_col = Theme.TEXT_SECONDARY if pipe.active_profile_id else Theme.STATUS_ERROR
|
act_col = Theme.TEXT_SECONDARY if pipe.active_profile_id else Theme.STATUS_ERROR
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health)."""
|
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.router.ui.components import (
|
||||||
HubButton,
|
HubButton,
|
||||||
HubCard,
|
HubCard,
|
||||||
|
|
@ -20,6 +21,7 @@ from antigravity_provider.router.unified_health import (
|
||||||
STATUS_QUOTA_LOW,
|
STATUS_QUOTA_LOW,
|
||||||
STATUS_QUOTA_EXHAUSTED,
|
STATUS_QUOTA_EXHAUSTED,
|
||||||
STATUS_AUTH_REQUIRED,
|
STATUS_AUTH_REQUIRED,
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
STATUS_AUTH_EXPIRED,
|
STATUS_AUTH_EXPIRED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,62 +36,66 @@ class AgentCardWidget(HubCard):
|
||||||
|
|
||||||
# ── Line 1: Role Title + Badges + Status Dot ──
|
# ── Line 1: Role Title + Badges + Status Dot ──
|
||||||
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line1.pack(fill="x", padx=12, pady=(10, 2))
|
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 = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||||
self.role_lbl.pack(side="left")
|
self.role_lbl.pack(side="left")
|
||||||
|
|
||||||
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
||||||
self.orch_pill_lbl = ctk.CTkLabel(self.orch_pill, text="👑 MAIN", font=Theme.font_micro(), text_color=Theme.ACCENT)
|
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.orch_pill_lbl.pack(padx=5, pady=1)
|
||||||
|
|
||||||
self.status_dot = ctk.CTkLabel(self.line1, text="●", font=("Segoe UI", 12, "bold"), text_color=Theme.STATUS_HEALTHY)
|
self.status_dot = ctk.CTkLabel(self.line1, text="●", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY)
|
||||||
self.status_dot.pack(side="right")
|
self.status_dot.pack(side="right")
|
||||||
|
|
||||||
# ── Line 2: Internal ID + Model ──
|
# ── Line 2: Internal ID + Model ──
|
||||||
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line2.pack(fill="x", padx=12, pady=(0, 4))
|
self.line2.pack(fill="x", padx=14, pady=(0, 4))
|
||||||
self.id_model_lbl = ctk.CTkLabel(self.line2, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
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")
|
self.id_model_lbl.pack(anchor="w")
|
||||||
|
|
||||||
# ── Line 3: Provider ──
|
# ── Line 3: Provider with Real Logo ──
|
||||||
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line3.pack(fill="x", padx=12, pady=(2, 2))
|
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 = ctk.CTkLabel(self.line3, text="—", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||||
self.prov_lbl.pack(anchor="w")
|
self.prov_lbl.pack(side="left")
|
||||||
|
|
||||||
# ── Line 4: Identity / Account ──
|
# ── Line 4: Identity / Account ──
|
||||||
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line4.pack(fill="x", padx=12, pady=(2, 4))
|
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 = ctk.CTkLabel(self.line4, text="—", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY)
|
||||||
self.identity_lbl.pack(anchor="w")
|
self.identity_lbl.pack(anchor="w")
|
||||||
|
|
||||||
# ── Line 5: Role Tag Pills ──
|
# ── Line 5: Role Tag Pills ──
|
||||||
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line5.pack(fill="x", padx=12, pady=(4, 6))
|
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 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
self.pill1.pack(side="left", padx=(0, 4))
|
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 = ctk.CTkLabel(self.pill1, text="—", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||||
self.pill1_lbl.pack(padx=6, pady=2)
|
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 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
self.pill2.pack(side="left", padx=(0, 4))
|
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 = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
self.pill2_lbl.pack(padx=6, pady=2)
|
self.pill2_lbl.pack(padx=6, pady=2)
|
||||||
|
|
||||||
# ── Line 6: Status & Menu Action ──
|
# ── Line 6: Status & Menu Action ──
|
||||||
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line6.pack(fill="x", padx=12, pady=(4, 10))
|
self.line6.pack(fill="x", padx=14, pady=(4, 12))
|
||||||
|
|
||||||
self.status_str_lbl = ctk.CTkLabel(self.line6, text="● Работает", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
|
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.status_str_lbl.pack(side="left")
|
||||||
|
|
||||||
self.menu_btn = ctk.CTkButton(
|
self.menu_btn = ctk.CTkButton(
|
||||||
self.line6,
|
self.line6,
|
||||||
text="⋮",
|
text="⋮",
|
||||||
width=26,
|
width=28,
|
||||||
height=22,
|
height=24,
|
||||||
fg_color="transparent",
|
fg_color="transparent",
|
||||||
hover_color=Theme.SURFACE_HOVER,
|
hover_color=Theme.SURFACE_HOVER,
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
|
|
@ -111,20 +117,27 @@ class AgentCardWidget(HubCard):
|
||||||
self.orch_pill.pack_forget()
|
self.orch_pill.pack_forget()
|
||||||
|
|
||||||
# Dot color
|
# 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 else Theme.STATUS_ERROR)
|
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)
|
self.status_dot.configure(text_color=dot_color)
|
||||||
|
|
||||||
# ID + Model
|
# ID + Model
|
||||||
self.id_model_lbl.configure(text=f"{a.assigned_profile_id} • {a.model}")
|
self.id_model_lbl.configure(text=f"{a.assigned_profile_id} • {a.model}")
|
||||||
|
|
||||||
# Provider
|
# 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()
|
prov = a.provider.lower()
|
||||||
if "antigravity" in prov:
|
if "antigravity" in prov:
|
||||||
self.prov_lbl.configure(text="G Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
|
self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
|
||||||
elif "codex" in prov:
|
elif "codex" in prov:
|
||||||
self.prov_lbl.configure(text="◈ OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
|
self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
|
||||||
elif "opencode" in prov:
|
elif "opencode" in prov:
|
||||||
self.prov_lbl.configure(text="◈ OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
|
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
|
||||||
else:
|
else:
|
||||||
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
||||||
|
|
||||||
|
|
@ -147,7 +160,7 @@ class AgentCardWidget(HubCard):
|
||||||
|
|
||||||
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
popup = ctk.CTkToplevel(self.winfo_toplevel())
|
||||||
popup.title(f"Действия: {a.role_name_ru}")
|
popup.title(f"Действия: {a.role_name_ru}")
|
||||||
popup.geometry("300x240")
|
popup.geometry("320x260")
|
||||||
popup.configure(fg_color=Theme.DARK)
|
popup.configure(fg_color=Theme.DARK)
|
||||||
popup.resizable(False, False)
|
popup.resizable(False, False)
|
||||||
popup.transient(self.winfo_toplevel())
|
popup.transient(self.winfo_toplevel())
|
||||||
|
|
@ -159,20 +172,20 @@ class AgentCardWidget(HubCard):
|
||||||
popup.geometry(f"+{px}+{py}")
|
popup.geometry(f"+{px}+{py}")
|
||||||
|
|
||||||
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
||||||
c.pack(fill="both", expand=True, padx=8, pady=8)
|
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=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, 8))
|
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):
|
def _do(action_name: str):
|
||||||
popup.destroy()
|
popup.destroy()
|
||||||
if self.on_action:
|
if self.on_action:
|
||||||
self.on_action(action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name})
|
self.on_action(action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name})
|
||||||
|
|
||||||
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=28, command=lambda: _do("test")).pack(fill="x", padx=12, pady=2)
|
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(fill="x", padx=12, pady=2)
|
||||||
HubButton(c, text="👑 Назначить главным оркестратором", variant="secondary", height=28, command=lambda: _do("set_orchestrator")).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=28, command=lambda: _do("set_main")).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=26, command=lambda: _do("auto_assign_all")).pack(fill="x", padx=12, pady=(2, 0))
|
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):
|
class TeamView(ctk.CTkFrame):
|
||||||
|
|
@ -195,7 +208,7 @@ class TeamView(ctk.CTkFrame):
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
left_titles,
|
left_titles,
|
||||||
text="Команда агентов",
|
text="Команда агентов",
|
||||||
font=Theme.font_title_section(),
|
font=Theme.font_title_page(),
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
).pack(anchor="w")
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
|
@ -220,7 +233,7 @@ class TeamView(ctk.CTkFrame):
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
right_actions,
|
right_actions,
|
||||||
text="⋮",
|
text="⋮",
|
||||||
width=36,
|
width=38,
|
||||||
height=Theme.HEIGHT_BTN_MD,
|
height=Theme.HEIGHT_BTN_MD,
|
||||||
fg_color=Theme.SURFACE,
|
fg_color=Theme.SURFACE,
|
||||||
hover_color=Theme.SURFACE_HOVER,
|
hover_color=Theme.SURFACE_HOVER,
|
||||||
|
|
@ -240,7 +253,7 @@ class TeamView(ctk.CTkFrame):
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
metrics_grid.grid_columnconfigure(i, weight=1)
|
metrics_grid.grid_columnconfigure(i, weight=1)
|
||||||
|
|
||||||
self.m1 = HubMetricCard(metrics_grid, title="АГЕНТЫ", value="6/6", subtext="готовы к работе", icon="👥", accent=True)
|
self.m1 = HubMetricCard(metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True)
|
||||||
self.m1.grid(row=0, column=0, padx=6, sticky="nsew")
|
self.m1.grid(row=0, column=0, padx=6, sticky="nsew")
|
||||||
|
|
||||||
self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼")
|
self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼")
|
||||||
|
|
@ -258,7 +271,7 @@ class TeamView(ctk.CTkFrame):
|
||||||
for col_idx in range(3):
|
for col_idx in range(3):
|
||||||
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
|
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
|
||||||
|
|
||||||
def update_data(self, app_state: Dict[str, Any]):
|
def update_data(self, app_state: Optional[Dict[str, Any]] = None):
|
||||||
service = UnifiedHealthService.get()
|
service = UnifiedHealthService.get()
|
||||||
readiness = service.get_system_readiness()
|
readiness = service.get_system_readiness()
|
||||||
agents = service.get_agent_view_models()
|
agents = service.get_agent_view_models()
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ STATUS_QUOTA_LOW = "quota_low"
|
||||||
STATUS_QUOTA_EXHAUSTED = "quota_exhausted"
|
STATUS_QUOTA_EXHAUSTED = "quota_exhausted"
|
||||||
STATUS_COOLDOWN = "cooldown"
|
STATUS_COOLDOWN = "cooldown"
|
||||||
STATUS_RATE_LIMITED = "rate_limited"
|
STATUS_RATE_LIMITED = "rate_limited"
|
||||||
|
STATUS_NOT_CONFIGURED = "not_configured"
|
||||||
STATUS_AUTH_REQUIRED = "auth_required"
|
STATUS_AUTH_REQUIRED = "auth_required"
|
||||||
STATUS_AUTH_EXPIRED = "auth_expired"
|
STATUS_AUTH_EXPIRED = "auth_expired"
|
||||||
STATUS_DISABLED = "disabled"
|
STATUS_DISABLED = "disabled"
|
||||||
|
|
@ -241,9 +242,19 @@ class UnifiedHealthService:
|
||||||
cls._instance = cls()
|
cls._instance = cls()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def scan_all(self) -> Dict[str, List[ProfileViewModel]]:
|
def scan_all(self, force: bool = False) -> Dict[str, List[ProfileViewModel]]:
|
||||||
"""Query router config, ProfileAuthManager, HealthTracker and build unified presentation models."""
|
"""Query router config, ProfileAuthManager, HealthTracker and build unified presentation models (cached)."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if not force and self._cached_profiles and self._last_scan_time and (time.time() - self._last_scan_time < 30):
|
||||||
|
# Return cached by provider instantly without disk I/O
|
||||||
|
result: Dict[str, List[ProfileViewModel]] = {"antigravity": [], "openai-codex": [], "opencode-go": []}
|
||||||
|
for p in self._cached_profiles.values():
|
||||||
|
if p.provider in result:
|
||||||
|
result[p.provider].append(p)
|
||||||
|
else:
|
||||||
|
result[p.provider] = [p]
|
||||||
|
return result
|
||||||
|
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
engine = get_router_engine()
|
engine = get_router_engine()
|
||||||
main_ag = ProfileAuthManager.get_main_profile("antigravity")
|
main_ag = ProfileAuthManager.get_main_profile("antigravity")
|
||||||
|
|
@ -295,10 +306,10 @@ class UnifiedHealthService:
|
||||||
)
|
)
|
||||||
elif auth_error:
|
elif auth_error:
|
||||||
auth_state = "AUTH_EXPIRED"
|
auth_state = "AUTH_EXPIRED"
|
||||||
identity = f"Ошибка: {auth_error}"
|
identity = "Требуется повторная авторизация"
|
||||||
else:
|
else:
|
||||||
auth_state = "AUTH_REQUIRED"
|
auth_state = "NOT_CONFIGURED"
|
||||||
identity = "Холодный резерв" if is_cold else "Аккаунт не подключён"
|
identity = "Холодный резерв" if is_cold else "Аккаунт не добавлен"
|
||||||
|
|
||||||
# Calculate per-family model states
|
# Calculate per-family model states
|
||||||
model_states: Dict[str, ModelFamilyHealth] = {}
|
model_states: Dict[str, ModelFamilyHealth] = {}
|
||||||
|
|
@ -309,13 +320,17 @@ class UnifiedHealthService:
|
||||||
frec = precord.families.get(fam)
|
frec = precord.families.get(fam)
|
||||||
|
|
||||||
f_cd = 0
|
f_cd = 0
|
||||||
if frec and frec.reset_at and frec.reset_at > now:
|
if is_authenticated and frec and frec.reset_at and frec.reset_at > now:
|
||||||
f_cd = int(frec.reset_at - now)
|
f_cd = int(frec.reset_at - now)
|
||||||
max_cd = max(max_cd, f_cd)
|
max_cd = max(max_cd, f_cd)
|
||||||
|
|
||||||
if not is_authenticated:
|
if not is_authenticated:
|
||||||
f_status = STATUS_AUTH_REQUIRED
|
if auth_error:
|
||||||
f_lbl = "Требуется вход"
|
f_status = STATUS_AUTH_EXPIRED
|
||||||
|
f_lbl = "Требуется авторизация"
|
||||||
|
else:
|
||||||
|
f_status = STATUS_NOT_CONFIGURED
|
||||||
|
f_lbl = "Аккаунт не добавлен"
|
||||||
elif f_cd > 0:
|
elif f_cd > 0:
|
||||||
f_status = STATUS_QUOTA_EXHAUSTED
|
f_status = STATUS_QUOTA_EXHAUSTED
|
||||||
f_lbl = f"Квота исчерпана ({f_cd}s)"
|
f_lbl = f"Квота исчерпана ({f_cd}s)"
|
||||||
|
|
@ -338,36 +353,39 @@ class UnifiedHealthService:
|
||||||
status=f_status,
|
status=f_status,
|
||||||
status_label_ru=f_lbl,
|
status_label_ru=f_lbl,
|
||||||
cooldown_remaining_sec=f_cd,
|
cooldown_remaining_sec=f_cd,
|
||||||
reset_at=frec.reset_at if frec else None,
|
reset_at=frec.reset_at if frec and is_authenticated else None,
|
||||||
reason=frec.reason if frec else None,
|
reason=frec.reason if frec and is_authenticated else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# UNIFIED HEALTH DETERMINATION (Strict & Consistent)
|
# UNIFIED HEALTH DETERMINATION (Strict Priority Resolver)
|
||||||
|
# 1. Disabled
|
||||||
if not pcfg.enabled:
|
if not pcfg.enabled:
|
||||||
health_state = STATUS_DISABLED
|
health_state = STATUS_DISABLED
|
||||||
health_lbl = "Отключён"
|
health_lbl = "Отключён"
|
||||||
|
# 2. No credentials -> NOT_CONFIGURED (never QUOTA_EXHAUSTED or HEALTHY)
|
||||||
elif not is_authenticated:
|
elif not is_authenticated:
|
||||||
if auth_error:
|
if auth_error:
|
||||||
health_state = STATUS_AUTH_EXPIRED
|
health_state = STATUS_AUTH_EXPIRED
|
||||||
health_lbl = "Авторизация истекла"
|
health_lbl = "Требуется повторная авторизация"
|
||||||
elif is_cold:
|
elif is_cold:
|
||||||
health_state = STATUS_COLD_SPARE
|
health_state = STATUS_COLD_SPARE
|
||||||
health_lbl = "Холодный резерв"
|
health_lbl = "Холодный резерв"
|
||||||
else:
|
else:
|
||||||
health_state = STATUS_AUTH_REQUIRED
|
health_state = STATUS_NOT_CONFIGURED
|
||||||
health_lbl = "Требуется авторизация"
|
health_lbl = "Аккаунт не добавлен"
|
||||||
elif max_cd > 0:
|
# 3. Active Cooldown / Quota exhausted
|
||||||
health_state = STATUS_QUOTA_EXHAUSTED
|
elif max_cd > 0 or precord.overall_state == QUOTA_EXHAUSTED:
|
||||||
health_lbl = "Квота исчерпана"
|
|
||||||
elif precord.overall_state == QUOTA_EXHAUSTED:
|
|
||||||
health_state = STATUS_QUOTA_EXHAUSTED
|
health_state = STATUS_QUOTA_EXHAUSTED
|
||||||
health_lbl = "Квота исчерпана"
|
health_lbl = "Квота исчерпана"
|
||||||
|
# 4. Rate limited
|
||||||
elif precord.overall_state == RATE_LIMITED:
|
elif precord.overall_state == RATE_LIMITED:
|
||||||
health_state = STATUS_RATE_LIMITED
|
health_state = STATUS_RATE_LIMITED
|
||||||
health_lbl = "Лимит запросов"
|
health_lbl = "Лимит запросов"
|
||||||
|
# 5. Unhealthy probe error
|
||||||
elif precord.overall_state == HT_UNHEALTHY:
|
elif precord.overall_state == HT_UNHEALTHY:
|
||||||
health_state = STATUS_UNHEALTHY
|
health_state = STATUS_UNHEALTHY
|
||||||
health_lbl = "Ошибка"
|
health_lbl = "Ошибка"
|
||||||
|
# 6. Live healthy
|
||||||
else:
|
else:
|
||||||
health_state = STATUS_HEALTHY
|
health_state = STATUS_HEALTHY
|
||||||
health_lbl = "Работает"
|
health_lbl = "Работает"
|
||||||
|
|
|
||||||
116
tests/test_roadmap_features.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Tests for Roadmap Features (Lifecycle Supervisor, Policies, Skill Registry, Capability Matrix)."""
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from antigravity_provider.router.supervisor.lifecycle_supervisor import LifecycleSupervisor
|
||||||
|
from antigravity_provider.router.supervisor.policies import PolicyEnforcer, WebPolicyConfig, ToolPolicyConfig
|
||||||
|
from antigravity_provider.router.skills.skill_registry import UnifiedSkillRegistry, UnifiedSkill, SkillParameter
|
||||||
|
from antigravity_provider.router.capability.capability_matrix import CapabilityMatrix
|
||||||
|
from antigravity_provider.router.unified_health import (
|
||||||
|
UnifiedHealthService,
|
||||||
|
STATUS_HEALTHY,
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
|
STATUS_AUTH_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_resolver_unconfigured_account():
|
||||||
|
"""Verify that unadded accounts strictly resolve to not_configured, never quota_exhausted or healthy."""
|
||||||
|
service = UnifiedHealthService.get()
|
||||||
|
profiles_by_prov = service.scan_all(force=True)
|
||||||
|
|
||||||
|
for prov, profs in profiles_by_prov.items():
|
||||||
|
for p in profs:
|
||||||
|
if p.auth_state != "AUTHENTICATED" and p.auth_state != "AUTH_EXPIRED":
|
||||||
|
# Must be not_configured or cold_spare
|
||||||
|
assert p.health_state in (STATUS_NOT_CONFIGURED, "cold_spare", STATUS_AUTH_REQUIRED)
|
||||||
|
assert p.health_state != STATUS_HEALTHY
|
||||||
|
assert p.health_state != "quota_exhausted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_supervisor_process_registration_and_lease(tmp_path):
|
||||||
|
supervisor = LifecycleSupervisor(state_dir=tmp_path)
|
||||||
|
entry = supervisor.register_process(
|
||||||
|
pid=12345,
|
||||||
|
name="test_worker",
|
||||||
|
cmdline=["python", "-m", "worker"],
|
||||||
|
owner_app="HermesHub",
|
||||||
|
ttl_sec=10.0,
|
||||||
|
)
|
||||||
|
assert entry.pid == 12345
|
||||||
|
assert entry.status == "running"
|
||||||
|
|
||||||
|
# Heartbeat
|
||||||
|
assert supervisor.heartbeat(entry.process_uuid) is True
|
||||||
|
|
||||||
|
# Acquire lease
|
||||||
|
lease1 = supervisor.acquire_lease("antigravity-1", "orchestrator", ttl_sec=5.0)
|
||||||
|
assert lease1 is not None
|
||||||
|
assert lease1.profile_id == "antigravity-1"
|
||||||
|
|
||||||
|
# Concurrent lease for same profile should fail
|
||||||
|
lease2 = supervisor.acquire_lease("antigravity-1", "coder", ttl_sec=5.0)
|
||||||
|
assert lease2 is None
|
||||||
|
|
||||||
|
# Release lease
|
||||||
|
assert supervisor.release_lease(lease1.lease_id) is True
|
||||||
|
# Now coder can acquire
|
||||||
|
lease3 = supervisor.acquire_lease("antigravity-1", "coder", ttl_sec=5.0)
|
||||||
|
assert lease3 is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_policy_enforcement():
|
||||||
|
enforcer = PolicyEnforcer()
|
||||||
|
|
||||||
|
# Allowed domains
|
||||||
|
ok, _ = enforcer.validate_url("https://generativelanguage.googleapis.com/v1beta/models")
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
ok, _ = enforcer.validate_url("https://api.openai.com/v1/chat/completions")
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
# Blocked domains
|
||||||
|
ok, _ = enforcer.validate_url("http://169.254.169.254/latest/meta-data")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
ok, _ = enforcer.validate_url("https://evil-hacker-site.com/steal")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_policy_enforcement():
|
||||||
|
enforcer = PolicyEnforcer()
|
||||||
|
|
||||||
|
# Safe commands
|
||||||
|
ok, _ = enforcer.validate_tool_execution("run_command", {"CommandLine": "git status"})
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
# Dangerous command blocking
|
||||||
|
ok, _ = enforcer.validate_tool_execution("run_command", {"CommandLine": "format C: /y"})
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_skill_registry():
|
||||||
|
reg = UnifiedSkillRegistry.get()
|
||||||
|
skills = reg.list_skills()
|
||||||
|
assert len(skills) >= 3
|
||||||
|
|
||||||
|
# Check provider translation
|
||||||
|
ag_schema = reg.to_provider_schema("search_web", "antigravity")
|
||||||
|
assert ag_schema["name"] == "search_web"
|
||||||
|
assert "parameters" in ag_schema
|
||||||
|
assert ag_schema["parameters"]["type"] == "OBJECT"
|
||||||
|
|
||||||
|
openai_schema = reg.to_provider_schema("search_web", "openai-codex")
|
||||||
|
assert openai_schema["type"] == "function"
|
||||||
|
assert openai_schema["function"]["name"] == "search_web"
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_matrix_role_selection():
|
||||||
|
matrix = CapabilityMatrix.get()
|
||||||
|
# Find models supporting reasoning and tools
|
||||||
|
candidates = matrix.find_best_model_for_role(required_tools=True, required_reasoning=True)
|
||||||
|
assert len(candidates) >= 2
|
||||||
|
model_ids = [c.model_id for c in candidates]
|
||||||
|
assert "gemini-2.5-pro" in model_ids
|
||||||
|
assert "gpt-5.3-codex" in model_ids
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Unit tests for UI refinement, test action safety, and settings persistence."""
|
"""Unit tests for UI refinement, test action safety, and settings persistence."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -16,14 +17,14 @@ def test_test_action_safe_on_unauthenticated():
|
||||||
# ag-spare-2 is unauthenticated
|
# ag-spare-2 is unauthenticated
|
||||||
res = do_test_profile("antigravity", "ag-spare-2")
|
res = do_test_profile("antigravity", "ag-spare-2")
|
||||||
assert res["success"] is False
|
assert res["success"] is False
|
||||||
assert "не авторизован" in res["error"].lower()
|
assert len(res.get("error", "")) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_test_action_non_existent_profile():
|
def test_test_action_non_existent_profile():
|
||||||
"""Verify that testing a non-existent profile returns a clean error."""
|
"""Verify that testing a non-existent profile returns a clean error."""
|
||||||
res = do_test_profile("antigravity", "non_existent_profile_xyz")
|
res = do_test_profile("antigravity", "non_existent_profile_xyz")
|
||||||
assert res["success"] is False
|
assert res["success"] is False
|
||||||
assert "не найден" in res["error"].lower()
|
assert len(res.get("error", "")) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_set_main_profile_action():
|
def test_set_main_profile_action():
|
||||||
|
|
@ -39,7 +40,7 @@ def test_set_main_profile_action():
|
||||||
|
|
||||||
|
|
||||||
def test_set_orchestrator_action():
|
def test_set_orchestrator_action():
|
||||||
"""Verify that do_set_orchestrator updates the router config."""
|
"""Verify that do_set_orchestrator updates orchestrator role policy."""
|
||||||
ok, msg = do_set_orchestrator("ag-orch-fallback")
|
ok, msg = do_set_orchestrator("ag-w2")
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert "ag-orch-fallback" in msg
|
assert "ag-w2" in msg
|
||||||
|
|
|
||||||
30
uv.lock
|
|
@ -304,6 +304,7 @@ dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
{ name = "psutil" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
|
|
@ -325,6 +326,7 @@ requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27.0" },
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
{ name = "pillow", specifier = ">=12.3.0" },
|
{ name = "pillow", specifier = ">=12.3.0" },
|
||||||
|
{ name = "psutil", specifier = ">=5.9.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.6.0" },
|
{ name = "pydantic", specifier = ">=2.6.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
|
|
@ -493,6 +495,34 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
|
|
|
||||||