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;