From e7194d322074438862db22a93cdf6e3b8daf6285 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Mon, 31 Aug 2026 02:45:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B0=20=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран маршрутизации брал профили из currentSnapshot.profiles, тогда как /api/snapshot отдаёт dataclasses.asdict(HubSnapshot), где поле называется all_profiles. Ключа profiles в ответе нет — проверено перечислением полей датакласса. Это было единственное такое место в файле: остальные девять обращений уже читали all_profiles. Следствия, которые чинятся разом: - правая колонка «доступные аккаунты» была всегда пуста; - «0 аккаунтов» оставалось литералом из разметки, счётчик не переписывался; - строки цепочек получали пустой профиль, provider становился 'unknown', и все аккаунты рисовались одной иконкой-заглушкой. Для строк цепочек берётся полный список профилей, для колонки «доступные» — только подключённые (правило A26). Убрана полоса квоты с зашитым width:80%: она была одинаковой у всех аккаунтов и ни на чём не основана. Вместо неё индикатор измеренного состояния; неизвестное состояние остаётся серым, а не выдаёт себя за здоровое. Подписи связей на холсте центрируются (text-anchor отсутствовал, поэтому подпись уходила вправо от середины связи и обрезалась о край холста) и получают обводку, чтобы читаться поверх линии. Инспектор агента показывал модели из preferred_models — это настроенный список предпочтений профиля, а не то, что даёт провайдер; model_states строится перебором того же preferred_models, поэтому запасная ветка давала тот же набор. Источником стал discovered_models провайдера. Настроенная у агента модель теперь всегда присутствует в списке: раньше, если её там не было, ни один option не получал selected, показывался первый вариант, и сохранение конфигурации молча подменяло модель агента. 486 passed, ruff clean. Co-Authored-By: Claude Opus 5 --- .../router/web/static/app.js | 29 +++++++++++++++++-- .../router/web/static/workflow.css | 2 +- .../router/web/static/workflow.js | 14 +++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 8f4742c..9fd0091 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -336,6 +336,16 @@ function startPolling() { // пустого слота и у холодного резерва NOT_CONFIGURED. Состояния // AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен // повторный вход, — их показываем. +// Цвет индикатора по измеренному состоянию. Неизвестное состояние остаётся +// серым (базовый .status-dot), а не выдаёт себя за здоровое. +function healthDotClass(state) { + const st = String(state || '').toLowerCase(); + if (st === 'healthy') return 'healthy'; + if (['quota_exhausted', 'rate_limited', 'auth_required', 'auth_expired'].includes(st)) return 'warning'; + if (['error', 'unhealthy'].includes(st)) return 'error'; + return ''; +} + function isConnectedProfile(p) { if (!p) return false; const st = String(p.auth_state || '').toUpperCase(); @@ -990,7 +1000,17 @@ function renderRoutingView() { const routing = currentSnapshot.routing || {}; const agents = currentSnapshot.agents || []; - const profiles = currentSnapshot.profiles || {}; + // Снапшот отдаётся как dataclasses.asdict(HubSnapshot): профили лежат в + // all_profiles. Ключа profiles в ответе нет — правая колонка была всегда + // пуста, счётчик оставался литеральным «0 аккаунтов» из разметки, а строки + // цепочек получали пустой профиль и рисовались иконкой-заглушкой. + // Показываем только подключённые аккаунты — правило A26. + // Для строк цепочек берём полный список: цепочка может ссылаться на + // аккаунт, который сейчас не подключён, и его надо показать как есть, + // а не подменять пустым профилем. + const profiles = currentSnapshot.all_profiles || {}; + // В колонку «доступные» идут только подключённые — правило A26. + const availableProfiles = Object.entries(profiles).filter(([, p]) => isConnectedProfile(p)); // Render Left Column (Roles) let rolesHtml = ''; @@ -1089,7 +1109,7 @@ function renderRoutingView() { const q = searchEl ? searchEl.value.toLowerCase() : ''; let count = 0; - for (const [pid, prof] of Object.entries(profiles)) { + for (const [pid, prof] of availableProfiles) { if (q && !pid.toLowerCase().includes(q) && !(prof.provider||'').toLowerCase().includes(q)) continue; count++; const icon = getProviderIcon(prof.provider); @@ -1101,7 +1121,10 @@ function renderRoutingView() {
${escapeHtml(prof.provider || '')}
-
+ +
diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css index 7c00f01..19de83e 100644 --- a/src/antigravity_provider/router/web/static/workflow.css +++ b/src/antigravity_provider/router/web/static/workflow.css @@ -31,7 +31,7 @@ .workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); } .workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); } .workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; } -.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; } +.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; text-anchor:middle; paint-order:stroke; stroke:var(--bg-base); stroke-width:3px; stroke-linejoin:round; } .workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; } .workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; } .workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); } diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js index 334141a..a22c80c 100644 --- a/src/antigravity_provider/router/web/static/workflow.js +++ b/src/antigravity_provider/router/web/static/workflow.js @@ -386,8 +386,18 @@ function renderAgentModelTab(content, snapshot, agent) { }; const refreshModels = () => { const profile = profiles.find((item) => item.profile_id === accountSelect.value); - const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean); - modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : ''; + // Список моделей даёт провайдер. preferred_models — это настроенный владельцем + // список предпочтений профиля, а model_states строится перебором того же + // preferred_models (unified_health.py), поэтому обе прежние ветки показывали + // один и тот же куцый набор, и владелец видел «модели уже распределены». + const summary = (snapshot.providers || []).find((item) => item.provider_id === (profile?.provider || providerSelect.value)); + const discovered = summary?.discovered_models || []; + const models = discovered.length ? [...discovered] : [...(profile?.preferred_models || [])]; + // Настроенная у агента модель обязана присутствовать в списке. Иначе ни один + // option не получал selected, браузер показывал первый вариант, и нажатие + // «Изменить конфигурацию» молча записывало агенту не ту модель. + if (cfg.model && !models.includes(cfg.model)) models.unshift(cfg.model); + modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : ''; }; providerSelect.onchange = refreshAccounts; accountSelect.onchange = refreshModels;