fix(web): маршрутизация читала несуществующий ключ снапшота

Экран маршрутизации брал профили из 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 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-31 02:45:14 +07:00
parent e6bbd60a36
commit e7194d3220
3 changed files with 39 additions and 6 deletions

View file

@ -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() {
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(prof.provider || '')}</div>
</div>
<div style="width:40px;">
<div class="cell-bar-track"><div class="cell-bar-fill" style="width:80%; background:var(--status-healthy);"></div></div>
<!-- Здесь стояла полоса с зашитым width:80% одинаковая у всех
аккаунтов и ни на чём не основанная. Показываем измеренное
состояние здоровья, а не выдуманный процент. -->
<span class="status-dot ${healthDotClass(prof.health_state)}" title="${escapeHtml(prof.health_label_ru || 'Н/Д: состояние не проверялось')}"></span>
</div>
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;" onclick="quickAddProfile('${escapeHtml(pid)}')"><i class="fa-solid fa-plus"></i></button>
</div>

View file

@ -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); }

View file

@ -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) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: модели не обнаружены</option>';
// Список моделей даёт провайдер. 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) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: список моделей ещё не получен от провайдера</option>';
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;