/** * Hermes Hub Web Client * Vanilla JavaScript (ES2022) — No npm, no build, no framework. * Single source of truth: docs/web-api/CONTRACT.md */ // ── CONFIGURATION & STATE ── const USE_MOCK_FIXTURE = false; let lastAppliedSeq = -1; // Какой профиль сейчас открыт в окне аккаунта: нужно, чтобы перерисовывать // его по свежему снапшоту, а не оставлять с заглушкой. let _openAccountModalProfile = null; let currentSnapshot = null; let activeView = 'overview'; let pollTimer = null; let pollIntervalMs = 5000; let authToken = localStorage.getItem('hermes_hub_token') || ''; let cachedEvents = []; let currentSettings = {}; let currentDragState = null; let latestUpdateInfo = null; const CANONICAL_ROLE_DESCRIPTIONS = { orchestrator: 'Главный оркестратор команды и маршрутизатор запросов', 'coder-primary': 'Основная разработка кода и реализация задач', 'coder-secondary': 'Вспомогательная разработка и параллельные задачи', reviewer: 'Ревью кода, аудит изменений и контроль качества', research: 'Read-only поиск в кодовой базе и сбор фактов', fast: 'Оперативные вызовы, быстрые проверки и тесты', }; function getProviderIdFromName(name) { if (!name) return ''; const n = name.toLowerCase(); if (n.includes('antigravity') || n.includes('google')) return 'antigravity'; if (n.includes('codex') || n.includes('openai')) return 'openai-codex'; if (n.includes('opencode') || n.includes('go')) return 'opencode-go'; if (n.includes('claude') || n.includes('anthropic')) return 'claude'; if (n.includes('grok') || n.includes('xai')) return 'grok'; return name; } // ── DOM ELEMENTS ── const elements = { navItems: document.querySelectorAll('.nav-item'), viewPanes: document.querySelectorAll('.view-pane'), pageTitle: document.getElementById('page-title'), navAccountsCount: document.getElementById('nav-accounts-count'), headerReadinessBadge: document.getElementById('header-readiness-badge'), headerReadinessText: document.getElementById('header-readiness-text'), sourceText: document.getElementById('source-text'), sourceDot: document.querySelector('#source-indicator .status-dot'), accountsContainer: document.getElementById('accounts-container'), accountsSearch: document.getElementById('accounts-search'), filterProvider: document.getElementById('filter-provider'), filterHealth: document.getElementById('filter-health'), accountsStatsSummary: document.getElementById('accounts-stats-summary'), btnRefreshAll: document.getElementById('btn-refresh-all'), btnAddAccount: document.getElementById('btn-add-account'), modalBackdrop: document.getElementById('modal-backdrop'), modalTitle: document.getElementById('modal-title'), modalBody: document.getElementById('modal-body'), modalFooter: document.getElementById('modal-footer'), modalCloseBtn: document.getElementById('modal-close-btn'), toastContainer: document.getElementById('toast-container'), }; // ── INITIALIZATION ── document.addEventListener('DOMContentLoaded', () => { initNavigation(); initEventListeners(); initSettings(); fetchSnapshot(); startPolling(); checkUpdates(true); }); // ── NAVIGATION ── function initNavigation() { document.querySelectorAll('.nav-item').forEach((btn) => { btn.addEventListener('click', () => { const view = btn.dataset.view; switchView(view); }); }); } function switchView(viewName) { activeView = viewName; document.body.dataset.view = viewName; document.querySelectorAll('.nav-item').forEach((btn) => { btn.classList.toggle('active', btn.dataset.view === viewName); }); document.querySelectorAll('.view-pane').forEach((pane) => { pane.classList.toggle('active', pane.id === `view-${viewName}`); }); const titles = { overview: 'Обзор системы', accounts: 'Аккаунты и квоты', routing: 'Маршрутизация', analytics: 'Аналитика и телеметрия', health: 'Состояние системы', logs: 'Журнал событий', settings: 'Настройки Hermes Hub', }; if (elements.pageTitle) { elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub'; } if (currentSnapshot) { renderCurrentView(); } } // ── EVENT LISTENERS ── function initEventListeners() { if (elements.accountsSearch) elements.accountsSearch.addEventListener('input', () => renderAccountsView()); if (elements.filterProvider) elements.filterProvider.addEventListener('change', () => renderAccountsView()); if (elements.filterHealth) elements.filterHealth.addEventListener('change', () => renderAccountsView()); if (elements.btnRefreshAll) { elements.btnRefreshAll.addEventListener('click', () => { executeAction('refresh_all', {}); }); } if (elements.btnAddAccount) { elements.btnAddAccount.addEventListener('click', () => { openAddAccountWizard(); }); } const btnAutoAssign = document.getElementById('btn-auto-assign'); if (btnAutoAssign) { btnAutoAssign.addEventListener('click', () => { executeAction('auto_assign_all', {}); }); } if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal); if (elements.modalBackdrop) { elements.modalBackdrop.addEventListener('click', (e) => { if (e.target === elements.modalBackdrop) closeModal(); }); } // Logs view filters const logsSearch = document.getElementById('logs-search'); const logsFilterLevel = document.getElementById('logs-filter-level'); const logsFilterCategory = document.getElementById('logs-filter-category'); const btnRefreshLogs = document.getElementById('btn-refresh-logs'); if (logsSearch) logsSearch.addEventListener('input', () => renderLogsList()); if (logsFilterLevel) logsFilterLevel.addEventListener('change', () => renderLogsList()); if (logsFilterCategory) logsFilterCategory.addEventListener('change', () => renderLogsList()); if (btnRefreshLogs) btnRefreshLogs.addEventListener('click', () => fetchLogs()); // Settings view buttons const btnSaveHub = document.getElementById('btn-save-hub-settings'); if (btnSaveHub) btnSaveHub.addEventListener('click', () => saveHubServerSettings()); const themeSel = document.getElementById('setting-theme'); if (themeSel) { themeSel.addEventListener('change', () => { applyTheme(themeSel.value); }); } // Updates event listeners const btnHeaderUpdate = document.getElementById('header-update-badge'); if (btnHeaderUpdate) { btnHeaderUpdate.addEventListener('click', () => openUpdateModal()); } const btnCheckUpdates = document.getElementById('btn-check-updates'); if (btnCheckUpdates) { btnCheckUpdates.addEventListener('click', () => checkUpdates(false)); } const btnApplyUpdate = document.getElementById('btn-apply-update'); if (btnApplyUpdate) { btnApplyUpdate.addEventListener('click', () => applyUpdate()); } // Preflight check listener const btnPreflight = document.getElementById('btn-run-preflight'); if (btnPreflight) { btnPreflight.addEventListener('click', () => runPreflightChecks()); } // Reset router config listener (P0-4) const btnResetConfig = document.getElementById('btn-reset-router-config'); if (btnResetConfig) { btnResetConfig.addEventListener('click', () => openResetConfigModal()); } } // ── SNAPSHOT INGESTION & MONOTONIC SEQ ── async function fetchSnapshot() { const urlParams = new URLSearchParams(window.location.search); const forceFixture = USE_MOCK_FIXTURE || urlParams.get('fixture') === '1' || window.location.protocol === 'file:'; if (forceFixture) { try { const res = await fetch('snapshot.example.json'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setSourceIndicator(true, 'Фикстура (snapshot.example.json)'); applySnapshot(data); return; } catch (err) { console.error('Failed to load snapshot.example.json:', err); setSourceIndicator(false, 'Ошибка фикстуры'); return; } } try { const headers = {}; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/snapshot', { headers }); if (res.status === 401) { handleUnauthorized(); return; } if (!res.ok) { if (res.status === 404 || res.status === 502 || res.status === 503) { throw new Error(`Server returned ${res.status}`); } const errBody = await res.json().catch(() => ({})); showToast(errBody.error || `Ошибка сервера: ${res.status}`, 'error'); setSourceIndicator(false, `Ошибка /api/snapshot (${res.status})`); return; } const data = await res.json(); setSourceIndicator(true, 'Live API (/api/snapshot)'); applySnapshot(data); } catch (err) { // Раньше здесь при недоступном API молча подставлялась // snapshot.example.json — 63 чужих профиля с почтами user@example.test — // и индикатор ставился в зелёное. Экран выглядел здоровой панелью, // которая не имеет отношения к этому серверу: при работе через SSH-туннель // достаточно промахнуться портом или потерять туннель, чтобы принять // пример за свои данные. Осознанная работа с фикстурой осталась — // ?fixture=1 и открытие файлом, — но подставлять её вместо ответа сервера // нельзя: отсутствие данных должно читаться как отсутствие данных. console.warn('Live API unavailable:', err); setSourceIndicator(false, 'Сервер недоступен'); showToast('Сервер не отвечает. Данные на экране могут быть устаревшими.', 'error'); } } function applySnapshot(snapshot) { if (!snapshot || typeof snapshot !== 'object') return; // Monotonic seq check: reject out-of-order stale responses if (typeof snapshot.seq === 'number') { if (snapshot.seq < lastAppliedSeq) { console.warn(`[Hub] Stale snapshot rejected: seq ${snapshot.seq} < lastAppliedSeq ${lastAppliedSeq}`); return; } lastAppliedSeq = snapshot.seq; } const isFirstLoad = !currentSnapshot; currentSnapshot = snapshot; updateGlobalHeader(); // Открытое окно аккаунта рисовалось один раз и на опрос не реагировало. // Если его открыть до того, как придут живые квоты, оно навсегда // оставалось с заглушкой («Grok 2h — Н/Д») и со статусом «Не проверялся» // даже после успешной проверки. Перерисовываем по свежему снапшоту. if (_openAccountModalProfile) { try { openAccountDetailsModal(_openAccountModalProfile, true); } catch (e) { console.warn('[Hub] Не удалось обновить окно аккаунта:', e); } } if (isFirstLoad) { const params = new URLSearchParams(window.location.search); const targetView = params.get('view'); const targetModal = params.get('modal'); const targetProfile = params.get('profile'); if (targetView) { switchView(targetView); } else { renderCurrentView(); } if (targetModal === 'grok_wizard') { openAddAccountWizard(); showWizardStep2('grok'); } else if (targetModal === 'antigravity_wizard') { openAddAccountWizard(); showWizardStep2('antigravity'); } else if (targetModal === 'account_details') { openAccountDetailsModal(targetProfile || 'ag-w1'); } else if (targetModal === 'agent_model') { const targetRole = params.get('role') || 'coder-primary'; const ag = (currentSnapshot.agents || []).find(a => a.role_id === targetRole) || (currentSnapshot.agents || [])[1]; if (ag) openAgentModelModal(ag.role_id, ag.assigned_profile_id); } } else { renderCurrentView(); } } function setSourceIndicator(healthy, text) { if (elements.sourceDot) { elements.sourceDot.className = `status-dot ${healthy ? 'healthy' : 'error'}`; } if (elements.sourceText) { elements.sourceText.textContent = text; } } function startPolling() { if (pollTimer) clearInterval(pollTimer); if (pollIntervalMs > 0) { pollTimer = setInterval(fetchSnapshot, pollIntervalMs); } } // Подключён ли профиль на самом деле. // // A26 определял это как «health_state не равен not_configured», а поле // authenticated в модели вообще отсутствует, поэтому первая половина условия // была мертва. Через фильтр проходили холодный резерв (health_state // "disabled") и непроверенные пустые слоты: при нуле настоящих аккаунтов // страница показывала три карточки «Холодный резерв», а «Обзор» предлагал // назначать роли на пустые слоты — то самое мышление слотами, ради отмены // которого задание и делалось. // // Authoritative признак — auth_state: у подключённого AUTHENTICATED, у // пустого слота и у холодного резерва 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(); if (st) return st !== 'NOT_CONFIGURED'; // Запасной путь, если поле не пришло: судим по наличию опознанного аккаунта. return Boolean(p.email); } // ── КОПИРОВАНИЕ В БУФЕР ── // // navigator.clipboard существует только в защищённом контексте: HTTPS или // localhost. Когда хаб открыт по сети (http://192.168.1.81:5800), его нет // вовсе, и кнопки «Копировать» молча не работали — хуже того, показывали // «Ссылка скопирована», потому что промис никто не проверял. // Запасной путь — execCommand('copy'), он работает и по HTTP. async function copyToClipboard(text, okMessage) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); showToast(okMessage || 'Скопировано', 'success'); return true; } } catch (err) { console.warn('clipboard API недоступен:', err); } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '-1000px'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); if (ok) { showToast(okMessage || 'Скопировано', 'success'); return true; } } catch (err) { console.warn('execCommand copy не сработал:', err); } // Молчать нельзя: владелец решит, что скопировалось, и вставит старое. showToast('Скопировать не удалось — выделите текст в поле и нажмите Ctrl+C', 'warning'); return false; } // ── ЗАПРОС ТОКЕНА ПРИ 401 ── // // Сервер, привязанный не к localhost, требует X-Hub-Token. Раньше клиент этого // случая не знал вовсе: 401 попадал в общую ветку ошибок и превращался в // красный тост «Ошибка сервера: 401», который повторялся на каждом опросе. // Владелец видел пустую панель и не имел ни одной подсказки, что нужен токен // и где его взять. Теперь спрашиваем прямо, один раз. let _tokenPromptOpen = false; function handleUnauthorized() { if (_tokenPromptOpen) return; _tokenPromptOpen = true; // Опрос останавливаем, иначе окно ввода будет перекрываться новыми 401 // каждые несколько секунд. Отдельной stopPolling в клиенте нет — таймер // гасится напрямую, как это делает startPolling перед перезапуском. if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } setSourceIndicator(false, 'Требуется токен доступа'); elements.modalTitle.textContent = 'Требуется токен доступа'; elements.modalBody.innerHTML = `
Токен сохранится в этом браузере и больше спрашиваться не будет.
`; elements.modalFooter.innerHTML = ` `; showModal(); setTimeout(() => { const el = document.getElementById('auth-token-input'); if (el) el.focus(); }, 50); } async function saveAuthTokenFromPrompt() { const input = document.getElementById('auth-token-input'); const feedback = document.getElementById('auth-token-feedback'); if (!input) return; const value = (input.value || '').trim(); if (!value) { feedback.innerHTML = 'Поле пустое.'; return; } // Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает // TypeError и обработчик умирает целиком, не показав ничего: так бывает, // если вместе с токеном скопировали русский текст или лишний символ. if (!/^[!-~]+$/.test(value)) { feedback.innerHTML = 'В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.'; return; } feedback.textContent = 'Проверяем…'; let res; try { res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } }); } catch (err) { feedback.innerHTML = `Не удалось обратиться к серверу: ${escapeHtml(err.message)}`; return; } if (res.status === 401) { feedback.innerHTML = 'Токен не подошёл. Проверьте, что скопирован целиком.'; return; } if (!res.ok) { feedback.innerHTML = `Сервер ответил ${res.status}.`; return; } authToken = value; localStorage.setItem('hermes_hub_token', authToken); const tokenInput = document.getElementById('setting-client-token-input'); if (tokenInput) tokenInput.value = authToken; _tokenPromptOpen = false; closeModal(); showToast('Токен принят', 'success'); startPolling(); fetchSnapshot(); } // ── ACTIONS EXECUTION (POST /api/action) ── async function executeAction(actionName, actionData = {}) { showToast(`Выполняется «${actionName}»...`, 'info'); try { const headers = { 'Content-Type': 'application/json' }; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/action', { method: 'POST', headers, body: JSON.stringify({ action: actionName, data: actionData }), }); if (res.status === 401) { handleUnauthorized(); return { ok: false, message: 'Требуется токен доступа' }; } const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); if (result.ok) { showToast(result.message || 'Действие выполнено успешно', 'success'); fetchSnapshot(); return result; } else { showToast(result.message || 'Отказ выполнения действия', 'warning'); return result; } } catch (err) { console.error(`Action ${actionName} failed:`, err); showToast(`Ошибка сети: ${err.message}`, 'error'); return { ok: false, message: `Ошибка сети: ${err.message}` }; } } // ── GLOBAL HEADER ── function updateGlobalHeader() { if (!currentSnapshot) return; const readiness = currentSnapshot.readiness || {}; renderAccountSummary(currentSnapshot); const allProfiles = Object.values(currentSnapshot.all_profiles || {}); const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( (p) => isConnectedProfile(p) ).length; if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; const isHealthy = readiness.state === 'healthy'; const readyRoles = readiness.roles_ready_count || 0; const totalRoles = readiness.total_roles ?? 0; if (elements.headerReadinessBadge) { elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`; } if (elements.headerReadinessText) { elements.headerReadinessText.textContent = readiness.title_ru ? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)` : 'Н/Д: состояние ещё не измерено'; } const kpiReadiness = document.getElementById('kpi-system-readiness'); const kpiSummary = document.getElementById('kpi-readiness-summary'); const kpiTotalAccounts = document.getElementById('kpi-total-accounts'); const kpiAccountsSub = document.getElementById('kpi-accounts-sub'); const kpiReadyRoles = document.getElementById('kpi-ready-roles'); const kpiRolesSub = document.getElementById('kpi-roles-sub'); const kpiProvidersCount = document.getElementById('kpi-providers-count'); if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает'; if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны'; if (kpiTotalAccounts) kpiTotalAccounts.textContent = connectedAccounts; if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${connectedAccounts}`; if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length; } // ── VIEW ROUTER ── function renderCurrentView() { if (!currentSnapshot) return; switch (activeView) { case 'overview': renderOverviewView(); break; case 'accounts': renderAccountsView(); break; case 'routing': renderRoutingView(); break; case 'analytics': renderAnalyticsView(); break; case 'health': renderHealthView(); break; case 'logs': renderLogsView(); break; case 'settings': renderSettingsView(); break; } } // ═══════════════════════════════════════════════════════════════ // 1. ACCOUNTS VIEW (P0-2 Only Connected Accounts & Empty State) // ═══════════════════════════════════════════════════════════════ function renderAccountsView() { const container = elements.accountsContainer; if (!container || !currentSnapshot) return; const allProfiles = Object.values(currentSnapshot.all_profiles || {}); const totalConnectedInSystem = allProfiles.filter( (p) => isConnectedProfile(p) ).length; if (totalConnectedInSystem === 0) { container.innerHTML = `
👥

Нет подключённых аккаунтов

Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.

`; if (elements.accountsStatsSummary) { elements.accountsStatsSummary.innerHTML = 'Показано: 0 из 0 подключённых аккаунтов'; } return; } const searchQuery = (elements.accountsSearch ? elements.accountsSearch.value : '').trim().toLowerCase(); const providerFilter = elements.filterProvider ? elements.filterProvider.value : 'all'; const healthFilter = elements.filterHealth ? elements.filterHealth.value : 'all'; const providerNames = { antigravity: 'Google Antigravity', 'openai-codex': 'OpenAI Codex', 'opencode-go': 'OpenCode Go', claude: 'Claude (Anthropic)', grok: 'Grok (xAI)', local: 'Local LLM', 'local-llm': 'Local LLM', 'llama.cpp': 'Local LLM (llama.cpp)', ollama: 'Ollama', vllm: 'vLLM', }; const profilesByProv = currentSnapshot.profiles_by_provider || {}; let totalProfiles = 0; let visibleProfiles = 0; let html = ''; for (const [providerId, profiles] of Object.entries(profilesByProv)) { if (providerFilter !== 'all' && providerFilter !== providerId) continue; const filtered = profiles.filter((p) => { const isConnected = isConnectedProfile(p); if (!isConnected) return false; totalProfiles++; const matchesSearch = !searchQuery || (p.display_name && p.display_name.toLowerCase().includes(searchQuery)) || (p.account_identity && p.account_identity.toLowerCase().includes(searchQuery)) || (p.email && p.email.toLowerCase().includes(searchQuery)) || (p.profile_id && p.profile_id.toLowerCase().includes(searchQuery)) || (p.assigned_roles && p.assigned_roles.some((r) => r.toLowerCase().includes(searchQuery))) || (p.preferred_models && p.preferred_models.some((m) => m.toLowerCase().includes(searchQuery))); const matchesHealth = healthFilter === 'all' || p.health_state === healthFilter || (healthFilter === 'disabled' && (p.is_cold_spare || !p.enabled || p.health_state === 'disabled')); return matchesSearch && matchesHealth; }); filtered.sort((a, b) => { const rank = (p) => { if (p.health_state === 'not_configured') return 5; if (p.is_cold_spare || !p.enabled || p.health_state === 'disabled') return 4; if (p.health_state === 'not_tested') return 3; if (p.health_state === 'auth_required' || p.health_state === 'auth_expired') return 2; if (p.health_state === 'healthy') return 0; return 1; }; const d = rank(a) - rank(b); if (d !== 0) return d; return (a.display_name || '').localeCompare(b.display_name || '', 'ru'); }); if (filtered.length === 0) continue; visibleProfiles += filtered.length; html += `
${providerNames[providerId] || providerId}
${filtered.length} аккаунт(ов)
${filtered.map((p) => renderAccountCard(p)).join('')}
`; } container.innerHTML = html || '
Аккаунты по заданным фильтрам не найдены.
'; if (elements.accountsStatsSummary) { elements.accountsStatsSummary.innerHTML = `Показано: ${visibleProfiles} из ${totalProfiles} подключённых аккаунтов`; } container.querySelectorAll('.account-card').forEach((card) => { card.addEventListener('click', (e) => { // If click was on refresh button, skip modal if (e.target.closest('.btn-ghost')) return; const profileId = card.dataset.profileId; openAccountDetailsModal(profileId); }); }); } function renderAccountCard(profile) { const isMain = profile.is_main_account || profile.is_main_orchestrator; const roles = (profile.assigned_roles || []).join(', ') || 'Роль: Н/Д'; const identity = profile.email || profile.account_identity || profile.display_name || profile.profile_id; const healthState = profile.health_state || 'unknown'; const healthLabel = profile.health_label_ru || 'Н/Д: состояние не проверялось'; const plan = profile.plan_code && profile.plan_code !== 'UNKNOWN' ? profile.plan_code : ''; const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profile.profile_id]; const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : []; const unavailableReason = quotaSnap ? quotaSnap.unavailable_reason : null; let quotaGridHtml = ''; if (buckets.length > 0) { const visibleBuckets = buckets.slice(0, 4); quotaGridHtml = `
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
`; } else { let reasonText = unavailableReason; if (!unavailableReason && quotaSnap && quotaSnap.is_loading) { reasonText = 'Загрузка квот…'; } else if (!reasonText) { reasonText = (profile.health_state === 'not_configured' || profile.health_state === 'auth_required') ? 'Аккаунт не подключён' : 'Провайдер не отдаёт лимиты'; } quotaGridHtml = `
Квота Н/Д
${escapeHtml(reasonText)}
`; } return `
${quotaGridHtml}
`; } function renderQuotaCell(bucket, unavailableReason) { const remaining = bucket.remaining_percent; let formattedValue = 'Н/Д'; let barWidth = 0; let colorClass = 'var(--status-disabled)'; const isUnlimited = bucket.status === 'unlimited' || bucket.period === 'unlimited' || (unavailableReason && unavailableReason.includes('Без ограничений')); if (isUnlimited) { formattedValue = 'Без ограничений'; barWidth = 100; colorClass = 'var(--status-healthy)'; } else if (typeof remaining === 'number') { formattedValue = `${remaining.toFixed(1)}%`; barWidth = Math.max(0, Math.min(100, remaining)); if (remaining <= 0) colorClass = 'var(--status-error)'; else if (remaining < 20) colorClass = 'var(--status-warning)'; else colorClass = 'var(--status-healthy)'; } else if (unavailableReason) { formattedValue = 'Н/Д'; } let resetText = isUnlimited ? (unavailableReason || 'Без ограничений') : (bucket.reset_at ? `Сброс: ${formatIsoDate(bucket.reset_at)}` : (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера'))); return `
${escapeHtml(bucket.display_name)} ${formattedValue}
${escapeHtml(resetText)}
`; } // ═══════════════════════════════════════════════════════════════ // 1. OVERVIEW VIEW (P0-3, P0-4 Diagram Model Select & Counters) // ═══════════════════════════════════════════════════════════════ function renderOverviewView() { if (typeof renderWorkflowOverview === 'function') { renderWorkflowOverview(currentSnapshot); return; } if (!currentSnapshot) return; const providers = currentSnapshot.providers || []; const readiness = currentSnapshot.readiness || {}; let totalSlots = 0; let connectedSlots = 0; let onlineSlots = 0; let authRequiredSlots = 0; providers.forEach((p) => { totalSlots += p.total_slots || 0; connectedSlots += p.connected_count || 0; onlineSlots += p.online_count || 0; authRequiredSlots += p.auth_required_count || 0; }); const kpiSystem = document.getElementById('kpi-system-readiness'); const kpiSystemSub = document.getElementById('kpi-readiness-summary'); if (kpiSystem) { kpiSystem.textContent = readiness.title_ru || 'Система готова'; kpiSystem.className = `kpi-value ${readiness.state === 'healthy' ? 'text-healthy' : (readiness.state === 'warning' ? 'text-warning' : 'text-error')}`; } if (kpiSystemSub) { kpiSystemSub.textContent = readiness.summary_ru || 'Все ключевые роли обеспечены'; } const kpiAccounts = document.getElementById('kpi-total-accounts'); const kpiAccountsSub = document.getElementById('kpi-accounts-sub'); if (kpiAccounts) { kpiAccounts.textContent = `${connectedSlots}/${totalSlots} слотов`; } if (kpiAccountsSub) { kpiAccountsSub.textContent = `Онлайн: ${onlineSlots} • Требуют входа: ${authRequiredSlots}`; } const kpiRoles = document.getElementById('kpi-ready-roles'); const kpiRolesSub = document.getElementById('kpi-roles-sub'); if (kpiRoles) { kpiRoles.textContent = `${readiness.roles_ready_count || 0}/${readiness.total_roles || 6}`; } if (kpiRolesSub) { kpiRolesSub.textContent = (readiness.roles_ready_count >= readiness.total_roles) ? 'Все роли обеспечены' : 'Требуется подключение аккаунтов'; } const kpiProviders = document.getElementById('kpi-providers-count'); const kpiProvidersSub = document.getElementById('kpi-providers-sub'); if (kpiProviders) { const connectedProviders = providers.filter((p) => (p.connected_count || 0) > 0).length; kpiProviders.textContent = `${connectedProviders}/${providers.length}`; } if (kpiProvidersSub) { kpiProvidersSub.textContent = 'Подключено провайдеров ИИ'; } const diagramBox = document.getElementById('overview-route-diagram'); if (diagramBox) { const roles = currentSnapshot.routing || {}; const allConnectedProfiles = Object.values(currentSnapshot.all_profiles || {}).filter( (p) => isConnectedProfile(p) ); let diagramHtml = ''; for (const [roleId, pipeline] of Object.entries(roles)) { const nodes = pipeline.nodes || []; diagramHtml += `
${escapeHtml(pipeline.role_name_ru || roleId)}
${nodes.map((node, idx) => { const profile = (currentSnapshot.all_profiles || {})[node.profile_id]; const provId = profile?.provider || getProviderIdFromName(node.provider); const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === provId || p.provider_name === node.provider); const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : []; const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || ''; const hasCurrentInConnected = allConnectedProfiles.some((p) => p.profile_id === node.profile_id); let accountControlHtml = ''; if (allConnectedProfiles.length > 0) { accountControlHtml = ` `; } let modelControlHtml = ''; if (discoveredModels.length > 0) { modelControlHtml = ` `; } else { modelControlHtml = `
Список моделей ещё не получен
`; } return `
${idx === 0 ? '★ Основной' : `Резерв ${idx}`} ${node.is_active ? '● Активен' : 'Ожидание'}
${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}
${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})
${accountControlHtml} ${modelControlHtml}
`; }).join('') || '
Цепочка не задана
'}
`; } diagramBox.innerHTML = diagramHtml || '
Нет данных маршрутизации.
'; } const provSummaryBox = document.getElementById('overview-providers-summary'); if (provSummaryBox) { provSummaryBox.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
Всего слотов: ${prov.total_slots} • Подключено: ${prov.connected_count} • Онлайн: ${prov.online_count} • Требуют входа: ${prov.auth_required_count} • Холодный резерв: ${prov.cold_spare_count}
Модели: ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(', ')) : 'Н/Д — список моделей ещё не получен'}
`).join('') || '
Нет данных провайдеров.
'; } } // ═══════════════════════════════════════════════════════════════ // 2. ROUTING VIEW (P0-1, P0-2 Main Routing Control Center) // ═══════════════════════════════════════════════════════════════ function getProviderIcon(provider) { const map = { 'openai-codex': 'codex.png', 'google-antigravity': 'антигравити.png', 'opencode-go': 'opencode.png', 'anthropic-claude': 'claude.png', 'deepseek': 'deepseek.png', 'grok': 'grok.jfif' }; return map[provider] || 'llama.png'; } function renderRoutingView() { renderAccountRouting(); } function quickAddProfile(pid) { const routing = currentSnapshot.routing || {}; let role = 'manager'; if (!routing[role]) role = Object.keys(routing)[0]; if (!role) return; addProfileToChain(role, pid); } function setupDragAndDrop() { let draggedEl = null; let dragPid = null; let sourceRole = null; document.querySelectorAll('.draggable-item').forEach(el => { el.addEventListener('dragstart', (e) => { draggedEl = el; dragPid = el.dataset.pid; sourceRole = el.dataset.role || null; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragPid); setTimeout(() => el.style.opacity = '0.5', 0); }); el.addEventListener('dragend', (e) => { el.style.opacity = '1'; document.querySelectorAll('.drag-over').forEach(d => d.classList.remove('drag-over')); draggedEl = null; }); }); // Drop zones (the "add to end" zones) document.querySelectorAll('.drop-zone').forEach(zone => { zone.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', (e) => { zone.classList.remove('drag-over'); }); zone.addEventListener('drop', (e) => { e.preventDefault(); zone.classList.remove('drag-over'); const targetRole = zone.dataset.role; if (!targetRole || !dragPid) return; handleDropMove(dragPid, sourceRole, targetRole, -1); }); }); // Reordering inside role-chain-list document.querySelectorAll('.account-row').forEach(row => { row.addEventListener('dragover', (e) => { e.preventDefault(); row.classList.add('drag-over'); }); row.addEventListener('dragleave', (e) => { row.classList.remove('drag-over'); }); row.addEventListener('drop', (e) => { e.preventDefault(); row.classList.remove('drag-over'); const targetRole = row.dataset.role; if (!targetRole || !dragPid) return; const list = row.parentNode; const children = Array.from(list.children); const insertIndex = children.indexOf(row); handleDropMove(dragPid, sourceRole, targetRole, insertIndex); }); }); } function handleDropMove(pid, sourceRole, targetRole, insertIndex) { const routing = currentSnapshot.routing; if (!routing || !routing[targetRole]) return; const targetChain = (routing[targetRole].nodes || []).map(node => node.profile_id); if (sourceRole && sourceRole === targetRole) { const oldIndex = targetChain.indexOf(pid); if (oldIndex > -1) { targetChain.splice(oldIndex, 1); } if (insertIndex === -1) { targetChain.push(pid); } else { let idx = insertIndex; if (oldIndex > -1 && oldIndex < insertIndex) idx--; targetChain.splice(idx, 0, pid); } updateRoleChain(targetRole, targetChain); } else { if (targetChain.includes(pid)) { showToast(`Аккаунт ${pid} уже есть в роли ${targetRole}`, 'warning'); return; } if (insertIndex === -1) { targetChain.push(pid); } else { targetChain.splice(insertIndex, 0, pid); } updateRoleChain(targetRole, targetChain); } } async function updateRoleChain(roleId, newChain) { const result = await executeAction('save_chain', { role_id: roleId, chain: newChain }); if (result.ok) await fetchSnapshot(); } async function removeProfileFromChain(roleId, pid) { const routing = currentSnapshot.routing; if (!routing || !routing[roleId]) return; const chain = (routing[roleId].nodes || []).map(node => node.profile_id); const idx = chain.indexOf(pid); if (idx > -1) { chain.splice(idx, 1); await updateRoleChain(roleId, chain); } } function openAddNodeToChainModal(roleId) { if (!currentSnapshot) return; const pipeline = (currentSnapshot.routing || {})[roleId]; if (!pipeline) return; const currentChain = (pipeline.nodes || []).map((n) => n.profile_id); const allProfiles = currentSnapshot.all_profiles || {}; const available = Object.values(allProfiles).filter((p) => isConnectedProfile(p) && !currentChain.includes(p.profile_id)); elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`; if (available.length === 0) { elements.modalBody.innerHTML = `
Все зарегистрированные профили уже включены в эту цепочку.
`; elements.modalFooter.innerHTML = ``; } else { elements.modalBody.innerHTML = `
Выберите доступный профиль для включения в цепочку отказоустойчивости:
`; elements.modalFooter.innerHTML = ` `; } showModal(); } async function handleAddNodeToChain(roleId) { const sel = document.getElementById('add-node-profile-select'); if (!sel) return; const newProfileId = sel.value; if (!newProfileId) return; const pipeline = (currentSnapshot.routing || {})[roleId]; const currentChain = (pipeline?.nodes || []).map((n) => n.profile_id); const newChain = [...currentChain, newProfileId]; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('save_chain', { role_id: roleId, chain: newChain }); if (res.ok) { showToast(`Профиль добавлен в цепочку '${pipeline?.role_name_ru || roleId}'`, 'success'); closeModal(); fetchSnapshot(); } else { if (feedbackArea) { feedbackArea.innerHTML = ``; } } } // ── ANALYTICS VIEW ── function renderAnalyticsView() { if (!currentSnapshot) return; const metrics = currentSnapshot.metrics || {}; const telemetry = metrics.telemetry || {}; const global = telemetry.global || {}; const totalCallsEl = document.getElementById('analytics-total-calls'); const callsBreakdownEl = document.getElementById('analytics-calls-breakdown'); const errorRateEl = document.getElementById('analytics-error-rate'); const errorRateSubEl = document.getElementById('analytics-error-rate-sub'); const latencyP50El = document.getElementById('analytics-latency-p50'); const latencySubEl = document.getElementById('analytics-latency-sub'); const tokensTotalEl = document.getElementById('analytics-tokens-total'); const tokensSubEl = document.getElementById('analytics-tokens-sub'); if (global.total_calls !== undefined) { if (totalCallsEl) totalCallsEl.textContent = new Intl.NumberFormat('ru-RU').format(global.total_calls); if (callsBreakdownEl) callsBreakdownEl.textContent = `Успешно: ${global.successful_calls ?? 0} · Сбоев: ${global.failed_calls ?? 0}`; const errRate = global.total_calls > 0 && global.failed_calls != null ? ((global.failed_calls / global.total_calls) * 100).toFixed(1) : null; if (errorRateEl) errorRateEl.textContent = errRate === null ? 'Н/Д' : `${errRate}%`; if (errorRateSubEl) errorRateSubEl.textContent = `${global.failed_calls ?? 0} сбоев из ${global.total_calls} вызовов`; } else { if (totalCallsEl) totalCallsEl.textContent = '—'; if (callsBreakdownEl) callsBreakdownEl.textContent = 'Нет данных за 24 ч'; if (errorRateEl) errorRateEl.textContent = '—'; if (errorRateSubEl) errorRateSubEl.textContent = 'Нет зарегистрированных сбоев'; } if (Number.isFinite(global.latency_p50_ms)) { if (latencyP50El) latencyP50El.textContent = `${global.latency_p50_ms} мс`; if (latencySubEl) latencySubEl.textContent = `p95: ${global.latency_p95_ms ?? '—'} мс · max: ${global.latency_max_ms ?? '—'} мс`; } else { if (latencyP50El) latencyP50El.textContent = '—'; if (latencySubEl) latencySubEl.textContent = 'Задержка не измерена'; } if (global.total_tokens !== undefined && global.total_tokens !== null) { if (tokensTotalEl) tokensTotalEl.textContent = new Intl.NumberFormat('ru-RU').format(global.total_tokens); if (tokensSubEl) tokensSubEl.textContent = `Вход: ${new Intl.NumberFormat('ru-RU').format(global.total_prompt_tokens ?? 'Н/Д')} · Выход: ${new Intl.NumberFormat('ru-RU').format(global.total_completion_tokens ?? 'Н/Д')}`; } else { if (tokensTotalEl) tokensTotalEl.textContent = 'Н/Д'; if (tokensSubEl) tokensSubEl.textContent = 'Н/Д: провайдеры не отдают usage'; } renderAnalyticsCharts(telemetry); // Providers telemetry table const providersContainer = document.getElementById('analytics-providers-table'); const providersData = telemetry.by_provider || {}; if (providersContainer) { const provKeys = Object.keys(providersData); if (provKeys.length === 0) { providersContainer.innerHTML = '
Нет накопленной телеметрии по провайдерам
'; } else { let tableHtml = ` `; provKeys.forEach((pKey) => { const item = providersData[pKey] || {}; tableHtml += ` `; }); tableHtml += '
Провайдер Всего вызовов Успешных Ошибок p50 задержка Расход токенов
${escapeHtml(pKey)} ${item.total_calls ?? 0} ${item.successful_calls ?? 0} ${item.failed_calls ?? 0} ${Number.isFinite(item.latency_p50_ms) ? `${item.latency_p50_ms} мс` : '—'} ${item.total_tokens != null ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'}
'; providersContainer.innerHTML = tableHtml; } } // Roles telemetry table const rolesContainer = document.getElementById('analytics-roles-table'); const rolesData = telemetry.by_role || {}; if (rolesContainer) { const roleKeys = Object.keys(rolesData); if (roleKeys.length === 0) { rolesContainer.innerHTML = '
Нет накопленной телеметрии по ролям
'; } else { let tableHtml = ` `; roleKeys.forEach((rKey) => { const item = rolesData[rKey] || {}; tableHtml += ` `; }); tableHtml += '
Роль Всего вызовов Успешных Ошибок p50 задержка
${escapeHtml(rKey)} ${item.total_calls ?? 0} ${item.successful_calls ?? 0} ${item.failed_calls ?? 0} ${Number.isFinite(item.latency_p50_ms) ? `${item.latency_p50_ms} мс` : '—'}
'; rolesContainer.innerHTML = tableHtml; } } } // ── HEALTH VIEW ── function renderHealthView() { if (!currentSnapshot) return; const readiness = currentSnapshot.readiness || {}; const banner = document.getElementById('health-readiness-banner'); if (banner) { const stateClass = readiness.state === 'HEALTHY' ? 'ready' : (readiness.state === 'LIMITED' ? 'warning' : 'not-ready'); banner.className = `readiness-banner ${stateClass}`; banner.innerHTML = `

${escapeHtml(readiness.title_ru || 'Состояние готовности')}

${escapeHtml(readiness.summary_ru || 'Проверка состояния маршрутизатора')}

Готовых ролей: ${readiness.roles_ready_count ?? 0} из ${readiness.total_roles ?? 'Н/Д'} Подключенных аккаунтов: ${(currentSnapshot.all_profiles ? Object.values(currentSnapshot.all_profiles).filter(isConnectedProfile).length : 0)}
`; } const resContainer = document.getElementById('health-host-resources'); if (resContainer) { renderHostResources(resContainer, currentSnapshot.metrics?.host || {}); } renderHealthPanels(currentSnapshot); const warningsContainer = document.getElementById('health-warnings-list'); if (warningsContainer) { const warnings = readiness.warnings || []; if (warnings.length === 0) { warningsContainer.innerHTML = '
✓ Критических предупреждений и деградаций не обнаружено
'; } else { warningsContainer.innerHTML = warnings.map((w) => `
⚠ ${escapeHtml(w.title || 'Предупреждение')}: ${escapeHtml(w.message || w)}
`).join(''); } } } // ── LOGS VIEW ── let cachedLogs = []; async function fetchLogs() { if (!currentSnapshot) return; cachedLogs = [...(currentSnapshot.workflow?.events || [])].reverse(); renderLogsList(cachedLogs); } function renderLogsView() { fetchLogs(); } function renderLogsList(events) { const listToRender = events || cachedLogs; const container = document.getElementById('logs-container'); if (!container) return; const searchEl = document.getElementById('logs-search'); const levelEl = document.getElementById('logs-filter-level'); const catEl = document.getElementById('logs-filter-category'); const q = searchEl ? searchEl.value.toLowerCase().trim() : ''; const levelFilter = levelEl ? levelEl.value : 'all'; const catFilter = catEl ? catEl.value : 'all'; const filtered = listToRender.filter((ev) => { if (levelFilter !== 'all' && (ev.level || '').toLowerCase() !== levelFilter) return false; if (catFilter !== 'all' && (ev.type || '').toLowerCase() !== catFilter) return false; if (q) { const matchText = `${ev.message || ''} ${ev.details || ''} ${ev.type || ''} ${ev.account || ''}`.toLowerCase(); if (!matchText.includes(q)) return false; } return true; }); if (filtered.length === 0) { renderLogDetail(null); container.innerHTML = '
События не найдены в текущем снапшоте
'; return; } container.innerHTML = ` ${filtered.map((ev, index) => { const lvl = (ev.level || 'info').toLowerCase(); const lvlClass = lvl === 'error' ? 'error' : (lvl === 'warning' || lvl === 'warn' ? 'warning' : (lvl === 'success' ? 'healthy' : 'info')); return ` `; }).join('')}
Время Уровень Категория Сообщение
${escapeHtml(ev.timestamp || '—')} ${escapeHtml(ev.level || 'INFO')} ${escapeHtml(ev.type || 'Н/Д')}
${escapeHtml(ev.message || '')}
${ev.details ? `
${escapeHtml(typeof ev.details === 'object' ? JSON.stringify(ev.details) : ev.details)}
` : ''}
`; container.querySelectorAll('[data-event-index]').forEach(row => { const open = () => renderLogDetail(filtered[Number(row.dataset.eventIndex)]); row.addEventListener('click', open); row.addEventListener('keydown', event => { if (event.key === 'Enter') open(); }); }); renderLogDetail(filtered[0]); } // ── SETTINGS MANAGEMENT ── function renderSettingsView() { if (!currentSnapshot) return; const paths = {}; // HubSnapshot does not expose filesystem paths. const s = currentSettings; const elHome = document.getElementById('path-hermes-home'); const elConfig = document.getElementById('path-config-dir'); const elLog = document.getElementById('path-log-file'); if (elHome) elHome.textContent = paths.hermes_home || 'Н/Д: API не передаёт путь'; if (elConfig) elConfig.textContent = paths.config_dir || 'Н/Д: API не передаёт путь'; if (elLog) elLog.textContent = paths.log_file || 'Н/Д: API не передаёт путь'; const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const emailMaskingSel = document.getElementById('setting-email-masking-mode'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); } if (quotaActionSel && s.quota_threshold_action) { quotaActionSel.value = s.quota_threshold_action; } if (emailMaskingSel && s.email_masking_mode) { emailMaskingSel.value = s.email_masking_mode; } if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { monitorIntervalInput.value = s.monitoring_interval_seconds; } } async function saveHubServerSettings() { const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const emailMaskingSel = document.getElementById('setting-email-masking-mode'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const newSettings = {}; if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value); if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value; if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value; if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value); if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; } showToast('Сохранение настроек сервера...', 'info'); const res = await executeAction('save_settings', newSettings); if (res.ok) { showToast('Настройки сервера успешно сохранены', 'success'); fetchSnapshot(); } else { showToast(res.message || 'Ошибка сохранения настроек сервера', 'error'); } } // ── RESET CONFIGURATION (P0-2 & P0-4) ── function openResetConfigModal() { elements.modalTitle.textContent = 'Начать настройку заново'; elements.modalBody.innerHTML = `
Будет сброшено:
Будет сохранено (НЕ затрагивается):
`; elements.modalFooter.innerHTML = ` `; showModal(); } async function confirmResetConfig() { const btn = document.getElementById('btn-modal-confirm-reset'); const feedback = document.getElementById('reset-config-feedback-area'); if (btn) btn.disabled = true; if (feedback) { feedback.innerHTML = ''; } try { const res = await executeAction('reset_router_config', {}); if (res && res.ok) { if (feedback) { feedback.innerHTML = ``; } setTimeout(() => { closeModal(); fetchSnapshot(); }, 1000); } else { if (btn) btn.disabled = false; if (feedback) { feedback.innerHTML = ``; } } } catch (err) { if (btn) btn.disabled = false; if (feedback) { feedback.innerHTML = ``; } } } // ── PREFLIGHT READINESS CHECKS ── async function runPreflightChecks() { const container = document.getElementById('preflight-results-container'); const btn = document.getElementById('btn-run-preflight'); if (btn) btn.disabled = true; if (container) { container.innerHTML = '
⏳ Запуск zero-quota проверки зависимостей и окружения...
'; } try { const res = await executeAction('run_preflight', {}); if (!res) throw new Error('Сервер не вернул ответ'); const report = res.data || {}; renderPreflightReport(report, container); showToast(res.message || 'Проверка готовности завершена', res.ok ? 'success' : 'warning'); } catch (err) { if (container) { container.innerHTML = ``; } showToast('Ошибка при запуске проверки готовности', 'error'); } finally { if (btn) btn.disabled = false; } } function renderPreflightReport(report, container) { if (!container) return; const checks = report.checks || []; const passed = report.passed_count || 0; const failed = report.failed_count || 0; const warn = report.warn_count || 0; const statusBadge = `${failed === 0 ? 'Все проверки пройдены' : `Обнаружено ошибок: ${failed}`}`; let html = `
Результат: ${statusBadge}
Пройдено: ${passed} • Ошибок: ${failed} • Предупреждений: ${warn}
`; checks.forEach((item) => { const badgeClass = item.status === 'PASS' ? 'healthy' : (item.status === 'WARN' ? 'warning' : 'error'); const icon = item.status === 'PASS' ? '✓' : (item.status === 'WARN' ? '⚠' : '✕'); html += `
${icon} ${escapeHtml(item.status)} ${escapeHtml(item.name || item.check_id)}
${escapeHtml(item.message || '')}
${item.remediation ? `
💡 Рекомендация: ${escapeHtml(item.remediation)}
` : ''}
`; }); html += '
'; container.innerHTML = html; } function initSettings() { const btnSave = document.getElementById('btn-save-client-settings'); const tokenInput = document.getElementById('setting-client-token-input'); const pollSelect = document.getElementById('setting-poll-interval'); if (tokenInput && authToken) { tokenInput.value = authToken; } if (btnSave) { btnSave.addEventListener('click', () => { if (tokenInput) { authToken = tokenInput.value.trim(); localStorage.setItem('hermes_hub_token', authToken); } if (pollSelect) { pollIntervalMs = parseInt(pollSelect.value, 10); startPolling(); } showToast('Параметры веб-клиента сохранены', 'success'); fetchSnapshot(); }); } } function openAccountDetailsModal(profileId, isRedraw = false) { _openAccountModalProfile = profileId; if (!currentSnapshot) return; const allProfiles = currentSnapshot.all_profiles || {}; const profile = allProfiles[profileId]; if (!profile) return; const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider); const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; const qs = profile.quota_snapshot; const buckets = (qs && qs.buckets) ? qs.buckets : []; let modelBlockHtml = ''; if (discoveredModels.length > 0) { modelBlockHtml = `
`; } else { modelBlockHtml = `
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
`; } // Local request options section let requestOptionsHtml = ''; if (profile.provider === 'local') { const rawOptions = profile.request_options || {}; const formattedJson = JSON.stringify(rawOptions, null, 2); requestOptionsHtml = `
✓ JSON валиден
Произвольные параметры, подмешиваемые в тело запроса (например, {"chat_template_kwargs": {"enable_thinking": false}}).
`; } let quotasHtml = ''; if (profile.provider !== 'local') { quotasHtml = `

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

${buckets.map((b) => `
${escapeHtml(b.display_name)} ${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
`).join('') || '
Данные о квотах отсутствуют.
'}
`; } elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; elements.modalBody.innerHTML = `
${escapeHtml(profile.account_identity || profile.email || profileId)}
Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • Тариф: ${escapeHtml(profile.plan || 'Неизвестen')} • Статус: ${escapeHtml(profile.health_label_ru || 'Работает')}
Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}
${modelBlockHtml} ${requestOptionsHtml} ${quotasHtml} `; elements.modalFooter.innerHTML = ` `; if (!isRedraw) { showModal(); } if (profile.provider === 'local') { updateRequestOptionsPreview(profileId); } } function updateRequestOptionsPreview(profileId) { const input = document.getElementById('modal-request-options-input'); const statusEl = document.getElementById('modal-options-validation-status'); const previewContent = document.getElementById('modal-payload-preview-content'); if (!input) return; const raw = input.value.trim(); let parsed = {}; let isValid = true; let errorMsg = ''; if (raw) { try { parsed = JSON.parse(raw); if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { isValid = false; errorMsg = 'JSON должен быть объектом {...}'; } } catch (e) { isValid = false; errorMsg = e.message; } } if (statusEl) { if (isValid) { statusEl.style.color = 'var(--status-healthy)'; statusEl.textContent = '✓ JSON валиден'; } else { statusEl.style.color = 'var(--status-error)'; statusEl.textContent = `⚠ Ошибка: ${errorMsg}`; } } if (previewContent) { const profile = (currentSnapshot && currentSnapshot.all_profiles) ? currentSnapshot.all_profiles[profileId] : null; const model = (profile && profile.preferred_models && profile.preferred_models[0]) || 'default'; const samplePayload = { model: model, messages: [{ role: 'user', content: 'Тестовое сообщение' }], temperature: 0.7, max_tokens: 1500, }; if (isValid && typeof parsed === 'object' && parsed !== null) { Object.assign(samplePayload, parsed); } previewContent.textContent = JSON.stringify(samplePayload, null, 2); } } function toggleRequestOptionsPreview() { const box = document.getElementById('modal-payload-preview-box'); if (box) { box.style.display = box.style.display === 'none' ? 'block' : 'none'; } } async function handleSaveRequestOptions(profileId) { const input = document.getElementById('modal-request-options-input'); const feedbackArea = document.getElementById('modal-feedback-area'); if (!input) return; const raw = input.value.trim(); let parsed = {}; if (raw) { try { parsed = JSON.parse(raw); if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { throw new Error('Параметры должны быть JSON-объектом {...}'); } } catch (e) { if (feedbackArea) { feedbackArea.innerHTML = ``; } return; } } if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('save_request_options', { profile_id: profileId, request_options: parsed, }); if (feedbackArea) { if (res && res.ok) { feedbackArea.innerHTML = ``; showToast('Параметры запроса сохранены', 'success'); if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].request_options = parsed; } } else { feedbackArea.innerHTML = ``; } } } async function handleTestProfile(profileId) { const feedbackArea = document.getElementById('modal-feedback-area'); const btn = document.getElementById('btn-modal-test-profile'); if (btn) btn.disabled = true; if (feedbackArea) { feedbackArea.innerHTML = ''; } const profile = (currentSnapshot && currentSnapshot.all_profiles) ? currentSnapshot.all_profiles[profileId] : null; const prov = profile ? profile.provider : ''; const res = await executeAction('test', { profile_id: profileId, provider: prov, }); if (btn) btn.disabled = false; if (feedbackArea) { const data = (res && res.data) || {}; const dur = data.duration_sec ? ` (${data.duration_sec}с)` : ''; if (res && res.ok) { feedbackArea.innerHTML = ``; showToast('Проверка подключения успешна', 'success'); } else { const errMsg = (res && (res.message || (res.data && res.data.error))) || 'Ошибка подключения'; feedbackArea.innerHTML = ``; showToast(`Сбой проверки: ${errMsg}`, 'error'); } } } async function handleSaveProfileModel(profileId) { const sel = document.getElementById('modal-model-select'); if (!sel) return; const model = sel.value; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('set_model', { profile_id: profileId, model: model }); if (feedbackArea) { if (res && res.ok) { feedbackArea.innerHTML = ``; if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [model]; } } else { feedbackArea.innerHTML = ``; } } } async function handleRefreshProviderModels(provider, profileId) { const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('refresh_models', { provider: provider }); if (res && res.ok) { showToast('Список моделей обновлен', 'success'); await fetchSnapshot(); if (_openAccountModalProfile) { openAccountDetailsModal(_openAccountModalProfile, true); } } else { if (feedbackArea) { feedbackArea.innerHTML = ``; } } } // ── MODAL HELPERS ── function showModal() { if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden'); } function closeModal() { _openAccountModalProfile = null; stopDeviceAuthPolling(); // Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно. stopRedirectAuthPolling(); if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden'); } // ── TOAST NOTIFICATIONS ── function showToast(message, type = 'info') { if (!elements.toastContainer) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; elements.toastContainer.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s ease'; setTimeout(() => toast.remove(), 300); }, 4000); } // ── UTILITIES ── function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatIsoDate(isoStr) { if (!isoStr) return ''; try { const d = new Date(isoStr); if (isNaN(d.getTime())) return isoStr; return d.toLocaleString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch (e) { return isoStr; } } // ── THEME SWITCHER ── function applyTheme(theme) { // Тем три, а не две. // // Medium была полностью описана в style.css, но здесь не обрабатывалась и в // список выбора не попадала — выбрать её было нельзя. По брендбуку это // отдельная тема, а не осветлённая Dark: тёмно-зелёный холст #1A2A1F со // светлыми кремовыми карточками #F7F1E3. Проверено подстановкой атрибута // вручную — отрисовывается верно и отличается от Dark и фоном, и карточками. const known = ['light', 'medium', 'dark']; if (known.includes(theme)) { document.body.setAttribute('data-theme', theme); document.body.classList.toggle('theme-light', theme === 'light'); } else { // Системная: отдаём выбор prefers-color-scheme. document.body.removeAttribute('data-theme'); document.body.classList.remove('theme-light'); } } // ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ── async function checkUpdates(silent = false) { if (!silent) { showToast('Проверка обновлений...', 'info'); } try { const res = await executeAction('check_updates', {}); if (res && res.ok && res.data) { latestUpdateInfo = res.data; renderUpdateUI(); if (!silent) { if (res.data.update_available) { const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); showToast(`Доступно обновление (сборка ${c})`, 'info'); } else { showToast(res.data.message || 'Установлена последняя сборка', 'success'); } } } else { if (res && res.data) { latestUpdateInfo = res.data; renderUpdateUI(); } if (!silent) { showToast((res && res.message) || 'Ошибка проверки обновлений', 'error'); } } } catch (err) { if (!silent) { showToast(`Ошибка проверки обновлений: ${err.message}`, 'error'); } } } function renderUpdateUI() { const badge = document.getElementById('header-update-badge'); const badgeText = document.getElementById('header-update-text'); const commitTag = document.getElementById('commit-tag'); const installedCommit = (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') ? latestUpdateInfo.installed_commit : (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : ''); if (commitTag) { commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —'; } if (badge && badgeText) { if (latestUpdateInfo && latestUpdateInfo.update_available) { badge.classList.remove('hidden'); const c = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || 'new'); badgeText.textContent = `Доступно обновление (${c})`; } else { badge.classList.add('hidden'); } } const updateInfoDesc = document.getElementById('update-installed-info'); const statusBadge = document.getElementById('update-status-badge'); const lastCheckedDesc = document.getElementById('update-last-checked-desc'); const btnApply = document.getElementById('btn-apply-update'); const detailsBlock = document.getElementById('update-details-block'); const releaseTitle = document.getElementById('update-release-title'); const releaseMeta = document.getElementById('update-release-meta'); const releaseNotes = document.getElementById('update-release-notes'); const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '0.1.1'; const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; if (updateInfoDesc) { updateInfoDesc.textContent = `Hermes Hub v${curVer} (сборка: ${cDisplay})`; } if (statusBadge) { if (latestUpdateInfo && latestUpdateInfo.error) { statusBadge.textContent = 'Ошибка проверки'; statusBadge.className = 'badge badge-status warning'; statusBadge.title = latestUpdateInfo.error; } else if (latestUpdateInfo && latestUpdateInfo.update_available) { statusBadge.textContent = 'Доступно обновление'; statusBadge.className = 'badge badge-status warning'; statusBadge.title = ''; } else if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { statusBadge.textContent = 'Актуально'; statusBadge.className = 'badge healthy'; statusBadge.title = ''; } else { statusBadge.textContent = 'Не проверялось'; statusBadge.className = 'badge'; statusBadge.title = ''; } } if (lastCheckedDesc) { if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { const tStr = new Date(latestUpdateInfo.checked_at * 1000).toLocaleTimeString('ru-RU'); const errNote = latestUpdateInfo.error ? ` — Ошибка: ${latestUpdateInfo.error}` : ''; lastCheckedDesc.textContent = `Последняя проверка: сегодня в ${tStr}${errNote}`; } else { lastCheckedDesc.textContent = 'Последняя проверка: еще не выполнялась'; } } if (btnApply) { btnApply.disabled = !(latestUpdateInfo && latestUpdateInfo.update_available); } if (detailsBlock && releaseTitle && releaseMeta && releaseNotes) { if (latestUpdateInfo && latestUpdateInfo.update_available) { detailsBlock.classList.remove('hidden'); const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : '—'; releaseTitle.textContent = `Релиз: ${latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || 'Новая сборка'} (коммит: ${latC})`; releaseMeta.textContent = latestUpdateInfo.published_at ? `Опубликован: ${latestUpdateInfo.published_at}` : ''; releaseNotes.textContent = latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.'; } else { detailsBlock.classList.add('hidden'); } } } function openUpdateModal() { if (!latestUpdateInfo) { checkUpdates(false); return; } const instC = (latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') ? latestUpdateInfo.installed_commit.slice(0, 7) : 'неизвестно'; const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; if (elements.modalBody) { elements.modalBody.innerHTML = `
Текущая сборка:
${escapeHtml(instC)}
Новая сборка:
${escapeHtml(latC)}
Тег: ${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')} ${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
Список изменений (Release Notes):
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
`; } if (elements.modalFooter) { elements.modalFooter.innerHTML = ` `; } showModal(); } async function handleInstallUpdateFromModal() { const btn = document.getElementById('btn-modal-install-update'); if (btn) { btn.disabled = true; btn.textContent = 'Установка...'; } await applyUpdate(); closeModal(); } async function applyUpdate() { showToast('Загрузка и запуск обновления...', 'info'); try { const res = await executeAction('apply_update', {}); if (res && res.ok) { showToast(res.message || 'Обновление запущено успешно!', 'success'); } else { showToast((res && res.message) || 'Ошибка установки обновления', 'error'); } } catch (err) { showToast(`Ошибка установки: ${err.message}`, 'error'); } } // ── OVERVIEW & ROUTING NODE HANDLERS ── async function handleNodeAccountChange(roleId, profileId, isPrimary = true) { if (!roleId || !profileId) return; showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info'); const res = await executeAction('assign_role', { role_id: roleId, profile_id: profileId, is_primary: isPrimary, }); if (res && res.ok) { showToast(`Аккаунт '${profileId}' успешно назначен`, 'success'); fetchSnapshot(); } else { showToast((res && res.message) || 'Ошибка назначения аккаунта', 'error'); } } async function handleNodeModelChange(roleId, profileId, newModel) { if (!newModel) return; showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info'); const res = await executeAction('set_model', { profile_id: profileId, model: newModel, role_id: roleId }); if (res && res.ok) { showToast(`Модель '${newModel}' успешно сохранена`, 'success'); if (currentSnapshot) { if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [newModel]; } if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { currentSnapshot.routing[roleId].default_model = newModel; const node = (currentSnapshot.routing[roleId].nodes || []).find((n) => n.profile_id === profileId); if (node) node.model = newModel; } } renderCurrentView(); fetchSnapshot(); } else { showToast((res && res.message) || 'Ошибка сохранения модели', 'error'); } } async function handleRefreshProviderModels(providerId, profileId = null) { showToast(`Запрос списка моделей для ${providerId}...`, 'info'); const res = await executeAction('refresh_models', { provider: providerId }); if (res && res.ok) { showToast('Запрос обновления моделей отправлен', 'success'); if (profileId) { setTimeout(() => openAccountDetailsModal(profileId, true), 500); } else { fetchSnapshot(); } } else { showToast((res && res.message) || 'Ошибка обновления моделей', 'error'); } } // ── ACCOUNT & AGENT MODALS ── function openAccountDetailsModal(profileId, isRefresh = false) { _openAccountModalProfile = profileId; if (!currentSnapshot) return; const profile = (currentSnapshot.all_profiles || {})[profileId]; if (!profile) return; const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider); const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; const qs = profile.quota_snapshot; const buckets = (qs && qs.buckets) ? qs.buckets : []; let modelBlockHtml = ''; if (discoveredModels.length > 0) { modelBlockHtml = `
`; } else { modelBlockHtml = `
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
`; } elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; elements.modalBody.innerHTML = `
${escapeHtml(profile.account_identity || profile.email || profileId)}
Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • Тариф: ${escapeHtml(profile.plan || 'Неизвестен')} • Статус: ${escapeHtml(profile.health_label_ru || 'Работает')}
Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}
${modelBlockHtml}
Лимиты и квоты провайдера:
${buckets.map((b) => { const isUnlimited = b.status === 'unlimited' || b.period === 'unlimited'; const remDisplay = isUnlimited ? 'Без ограничений' : (b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.round(b.remaining_percent) + '%' : 'Н/Д'); const fillPct = isUnlimited ? 100 : (b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.max(0, Math.min(100, b.remaining_percent)) : 0); const barColor = isUnlimited ? 'var(--status-healthy)' : ((b.remaining_percent !== null && b.remaining_percent < 20) ? 'var(--status-warning)' : 'var(--status-healthy)'); const resetLabel = isUnlimited ? 'Без ограничений (локальная модель)' : (b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')); return `
${escapeHtml(b.bucket_name || b.name || b.display_name || 'Квота')} ${escapeHtml(remDisplay)}
${escapeHtml(resetLabel)}
`; }).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'}
`; elements.modalFooter.innerHTML = ` `; if (!isRefresh) { showModal(); } } async function handleSaveProfileModel(profileId) { const sel = document.getElementById('modal-model-select'); if (!sel) return; const model = sel.value; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('set_model', { profile_id: profileId, model: model }); if (feedbackArea) { if (res && res.ok) { feedbackArea.innerHTML = ``; if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [model]; } fetchSnapshot(); } else { feedbackArea.innerHTML = ``; } } } function openAgentModelModal(roleId, profileId) { if (!currentSnapshot) return; const profile = (currentSnapshot.all_profiles || {})[profileId]; if (!profile) return; const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === profile.provider); const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId; elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`; elements.modalBody.innerHTML = `
Профиль агента: ${escapeHtml(profile.display_name)} (${profileId}) • Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)}
${discoveredModels.length > 0 ? `
` : `
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
`} `; elements.modalFooter.innerHTML = ` ${discoveredModels.length > 0 ? `` : ''} `; showModal(); } async function handleSaveRoleModel(roleId, profileId) { const sel = document.getElementById('role-model-select'); if (!sel) return; const model = sel.value; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId }); if (feedbackArea) { if (res && res.ok) { feedbackArea.innerHTML = ``; if (currentSnapshot) { if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [model]; } if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { currentSnapshot.routing[roleId].default_model = model; } if (currentSnapshot.agents) { const ag = currentSnapshot.agents.find((a) => a.role_id === roleId); if (ag) ag.model = model; } } setTimeout(() => { closeModal(); renderCurrentView(); fetchSnapshot(); }, 700); } else { feedbackArea.innerHTML = ``; } } } async function handleTestProfile(profileId) { const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('test', { profile_id: profileId }); if (feedbackArea) { if (res && res.ok) { feedbackArea.innerHTML = ``; } else { feedbackArea.innerHTML = ``; } } } // ── ADD ACCOUNT WIZARD (P0-1) ── function openAddAccountWizard() { window._wiz_device_profile = undefined; window._wiz_device_session = undefined; window._wiz_redirect_session = undefined; window._wiz_redirect_provider = undefined; window._wiz_redirect_slot_id = undefined; window._wiz_base_url = undefined; window._wiz_token = undefined; if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; showWizardStep1(); showModal(); } function showWizardStep1() { if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; elements.modalBody.innerHTML = `
Шаг 1 из 3: Выберите провайдера ИИ
`; elements.modalFooter.innerHTML = ` `; } function showWizardStep2(providerId) { let bodyHtml = ''; let footerHtml = ''; if (providerId === 'grok' || providerId === 'openai-codex') { const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; bodyHtml = `
Шаг 2 из 3: Авторизация ${providerName} по коду устройства
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
Выберите слот и нажмите «Начать авторизацию».
`; footerHtml = ` `; } else if (providerId === 'antigravity' || providerId === 'claude') { const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'; bodyHtml = `
Шаг 2 из 3: Авторизация ${providerName}
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
Выберите слот и нажмите «Получить ссылку».
`; footerHtml = ` `; } else if (providerId === 'ollama') { window._wiz_device_profile = undefined; bodyHtml = `
Шаг 2 из 3: Настройка Ollama
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
`; footerHtml = ` `; } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'vllm') { // P0-1: reset stale wizard slot so local add_account does not reuse grok/antigravity slot window._wiz_device_profile = undefined; bodyHtml = `
Шаг 2 из 3: Настройка локального сервера (Local LLM)
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
`; footerHtml = ` `; } else if (providerId === 'openrouter' || providerId === 'nvidia') { const providerName = providerId === 'openrouter' ? 'OpenRouter' : 'NVIDIA NIM'; bodyHtml = `
Шаг 2 из 3: Подключение ${providerName}
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
Оставьте пустым для значения по умолчанию.
`; footerHtml = ` `; } else { bodyHtml = `
Шаг 2 из 3: Ввод API ключа ${escapeHtml(providerId)}
`; footerHtml = ` `; } elements.modalBody.innerHTML = ` ${bodyHtml} `; elements.modalFooter.innerHTML = footerHtml; } function proceedToWizardStep3(providerId) { const baseInput = document.getElementById('wiz-base-url-input'); if (baseInput) { window._wiz_base_url = baseInput.value.trim(); } const tokenInput = document.getElementById('wiz-token-input'); if (tokenInput) { window._wiz_token = tokenInput.value.trim(); } // P0-1 BUG-1: persist owner-selected slot BEFORE showWizardStep3 destroys the select elements // Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot, // antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements. const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex'; const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia'; if (isDeviceAuthFlow) { const deviceSlot = document.getElementById('wiz-device-slot'); if (deviceSlot && deviceSlot.value) { window._wiz_device_profile = deviceSlot.value; } } else if (isRedirectAuthFlow) { const redirectSlot = document.getElementById('wiz-redirect-slot'); if (redirectSlot && redirectSlot.value) { window._wiz_device_profile = redirectSlot.value; } } // For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements showWizardStep3(providerId); } function showWizardStep3(providerId) { // GAP-3: динамически строим options ролей из currentSnapshot.routing const routing = currentSnapshot && currentSnapshot.routing ? currentSnapshot.routing : {}; const roleIds = Object.keys(routing); let roleOptionsHtml = ''; if (roleIds.length === 0) { // Fallback: минимальный набор если snapshot ещё не загружен roleOptionsHtml = ` `; } else { roleOptionsHtml = roleIds.map((roleId) => { const pipeline = routing[roleId] || {}; const label = pipeline.role_name_ru || roleId; const desc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || pipeline.role_description_ru || ''; return ``; }).join(''); } elements.modalBody.innerHTML = `
Шаг 3 из 3: Назначение роли для нового аккаунта
`; elements.modalFooter.innerHTML = ` `; } async function finishAddAccount(providerId) { const roleSelect = document.getElementById('wiz-target-role'); const targetRole = roleSelect ? roleSelect.value : 'coder-primary'; // GAP-2: owner-selected slot — читаем выбранный профиль из UI // Local providers (local, local-llm, llama.cpp, ollama, vllm) have no slot selector; // do NOT read stale DOM slot elements from previous flows in the same test session. const isLocalProvider = providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm'; let selectedProfileId; if (isLocalProvider) { selectedProfileId = ''; } else { const deviceSlot = document.getElementById('wiz-device-slot'); const redirectSlot = document.getElementById('wiz-redirect-slot'); // Use nullish coalescing: if _wiz_device_profile was explicitly set (not null/undefined), // it wins over any stale DOM element value (e.g. from a previous grok flow in the same test) selectedProfileId = window._wiz_device_profile ?? (deviceSlot?.value || redirectSlot?.value || ''); } const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const payload = { provider: providerId, target_role: targetRole, // GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner profile_id: selectedProfileId, }; if (window._wiz_base_url) { payload.base_url = window._wiz_base_url; } if (window._wiz_token) { payload.token = window._wiz_token; } const res = await executeAction('add_account', payload); if (res && res.ok) { showToast('Аккаунт успешно добавлен в маршрутизацию', 'success'); closeModal(); fetchSnapshot(); } else { if (feedbackArea) { feedbackArea.innerHTML = ``; } } } // ───────────────────────────────────────────────────────────── // Авторизация по коду устройства (Grok, OpenAI Codex) // ───────────────────────────────────────────────────────────── let _deviceAuthTimer = null; function stopDeviceAuthPolling() { if (_deviceAuthTimer) { clearInterval(_deviceAuthTimer); _deviceAuthTimer = null; } } async function startDeviceAuth(providerId) { stopDeviceAuthPolling(); const box = document.getElementById('device-auth-box'); if (!box) return; // GAP-1: owner-selected slot — показать выбранное в UI до начала auth const slotSelect = document.getElementById('wiz-device-slot'); const selectedSlot = slotSelect ? slotSelect.value : ''; if (selectedSlot) { window._wiz_device_profile = selectedSlot; } box.innerHTML = `
Запрашиваем код у провайдера…
`; // P0-1 BUG-2: send profile_id so server knows which slot the owner chose const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot }); if (!res || !res.ok) { box.innerHTML = ``; return; } const d = res.data || {}; window._wiz_device_session = d.session_id; // P0-1 BUG-3: owner-selected slot wins over server-assigned profile_id window._wiz_device_profile = selectedSlot || d.profile_id; box.innerHTML = `
1. Откройте ссылку:
2. Введите код:
${escapeHtml(d.code || '')}
3. Подтвердите доступ — окно обновится само. Код живёт недолго, не откладывайте.
`; _deviceAuthTimer = setInterval(() => pollDeviceAuth(providerId), 3000); } async function pollDeviceAuth(providerId) { const status = document.getElementById('device-auth-status'); if (!status) { stopDeviceAuthPolling(); return; } const res = await executeAction('poll_device_auth', { provider: providerId, session_id: window._wiz_device_session, }); if (!res) return; const state = (res.data || {}).status; if (res.ok && state === 'completed') { stopDeviceAuthPolling(); status.innerHTML = 'Аккаунт подключён'; showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } if (!res.ok) { // Отказ и просроченный код — конечные исходы, а не ожидание. stopDeviceAuthPolling(); status.innerHTML = `${escapeHtml(res.message || 'Авторизация не завершена')}`; } } // Профили, реально участвующие в цепочках маршрутизации. // // Судить по assigned_roles нельзя: холодный резерв и ag-spare-2 значатся с // ролью "spare", которой среди шести маршрутизируемых ролей нет. Поэтому // берём состав самих цепочек — это точно и не зависит от названий ролей. function profilesInRouting() { const routing = (currentSnapshot || {}).routing || {}; const ids = new Set(); Object.values(routing).forEach((pipeline) => { ((pipeline && pipeline.nodes) || []).forEach((n) => { if (n && n.profile_id) ids.add(n.profile_id); }); }); return ids; } // Список слотов провайдера из снимка: занятые помечены, свободные идут первыми. function buildSlotOptions(providerId) { const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || []; if (!profiles.length) { return ''; } // Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты // ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый // в них аккаунт честно не появляется ни в «Обзоре», ни в «Маршрутизации» — // и выглядит это как пропажа. const routed = profilesInRouting(); const free = []; const used = []; const idle = []; profiles.forEach((p) => { const isFree = p.health_state === 'not_configured'; const inRouting = routed.has(p.profile_id); const roles = (p.assigned_roles || []).join(', '); const who = p.email || p.account_identity || ''; const state = isFree ? 'свободен' : `занят${who ? ': ' + who : ''}`; const label = inRouting ? `${p.profile_id} — ${state} · ${roles || 'в маршрутизации'}` : `${p.profile_id} — ${state} · не участвует в маршрутизации`; const opt = ``; if (!inRouting) idle.push(opt); else if (isFree) free.push(opt); else used.push(opt); }); // Свободные слоты с ролью — первыми: именно они дают работающий маршрут. return free.concat(used, idle).join(''); } let _redirectAuthTimer = null; function stopRedirectAuthPolling() { if (_redirectAuthTimer) { clearInterval(_redirectAuthTimer); _redirectAuthTimer = null; } } // Вход по ссылке для Antigravity и Claude. // // Смысл: браузер не обязан быть на той машине, где работает Hub. Владелец // открывает ссылку у себя, подтверждает доступ и возвращает результат сюда. // Antigravity кладёт код в адресную строку (браузер при этом покажет ошибку // соединения, если слушатель на другой машине — это нормально, адрес всё // равно годен). Claude показывает код прямо на странице. async function startRedirectAuth(providerId) { stopRedirectAuthPolling(); const box = document.getElementById('redirect-auth-box'); if (!box) return; // Слот выбирает владелец, а не догадка сервера. // // find_free_slot определяет занятость по файлу учётных данных, но agy на // Windows хранит их в keyring — файла нет ни у одного слота, поэтому все // десять считаются свободными и всегда возвращается первый, ag-orch-fallback. // Вход в него затёр бы работающий аккаунт. Список ниже строится из снимка, // который знает настоящее состояние, и показывает, что занято. const slot = document.getElementById('wiz-redirect-slot'); const chosen = slot ? slot.value : ''; const res = await executeAction('start_redirect_auth', { provider: providerId, profile_id: chosen || undefined, }); if (!res || !res.ok) { box.innerHTML = ``; return; } const d = res.data || {}; window._wiz_redirect_session = d.session_id; window._wiz_redirect_provider = providerId; window._wiz_redirect_slot_id = d.profile_id; const pastesUrl = d.paste_kind !== 'code'; const label = pastesUrl ? 'Вставьте адрес из адресной строки браузера целиком:' : 'Вставьте код, показанный на странице:'; const placeholder = pastesUrl ? 'http://127.0.0.1:…/oauth-callback?code=…' : 'Код со страницы провайдера'; // Если хаб открыт не с этой же машины, браузер после подтверждения уйдёт на // 127.0.0.1:<порт> СВОЕЙ машины, где никто не слушает: получается тупик, // из которого адрес с кодом ещё надо как-то выковырять. Проброс этого порта // убирает проблему целиком — возврат попадает прямо в слушатель хаба и вход // завершается сам. Показываем готовую команду, а вставку оставляем запасным // путём. const host = window.location.hostname; const isRemote = host !== '127.0.0.1' && host !== 'localhost' && host !== ''; const cbPort = d.port || 0; const tunnelNote = pastesUrl && isRemote && cbPort ? `` : ''; const localNote = pastesUrl && d.redirect_uri ? `
Без проброса браузер после подтверждения уйдёт на ${escapeHtml(d.redirect_uri)} и покажет «страница недоступна» — это ожидаемо, хаб на другой машине. Скопируйте из адресной строки весь адрес целиком либо только значение code= — принимается и то, и другое.
` : ''; box.innerHTML = `
1. Откройте ссылку — на любой машине, где есть браузер:
2. ${escapeHtml(label)}
${tunnelNote} ${localNote}
Слот: ${escapeHtml(d.profile_id || '—')}. Ссылка действует 20 минут.
`; // Если браузер открыт на этой же машине, слушатель поймает возврат сам — // тогда вставлять ничего не придётся. _redirectAuthTimer = setInterval(pollRedirectAuth, 3000); } async function submitRedirectCallback() { const input = document.getElementById('wiz-redirect-paste'); const status = document.getElementById('redirect-auth-status'); if (!input || !status) return; const value = (input.value || '').trim(); if (!value) { status.innerHTML = 'Поле пустое — вставьте значение из браузера.'; return; } status.innerHTML = 'Проверяем…'; const res = await executeAction('submit_redirect_callback', { session_id: window._wiz_redirect_session, provider: window._wiz_redirect_provider, callback_url: value, }); if (res && res.ok) { stopRedirectAuthPolling(); status.innerHTML = 'Аккаунт подключён' + redirectSlotRoleNote(); showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } // Промах при вставке не заканчивает сессию: ссылка ещё годна, можно // вставить снова. Поэтому опрос не останавливаем. status.innerHTML = `${escapeHtml((res && res.message) || 'Не удалось завершить вход')}`; } // Если слот не входит ни в одну цепочку, аккаунт не появится ни в «Обзоре», // ни в «Маршрутизации» — и это выглядит как пропажа. Говорим об этом сразу. function redirectSlotRoleNote() { const pid = window._wiz_redirect_slot_id; if (!pid) return ''; const all = (currentSnapshot || {}).all_profiles || []; const prof = all.find((p) => p.profile_id === pid); const roles = prof ? (prof.assigned_roles || []) : []; if (profilesInRouting().has(pid)) { return `
Роль: ${escapeHtml(roles.join(', '))}
`; } return ``; } async function pollRedirectAuth() { const status = document.getElementById('redirect-auth-status'); if (!status) { stopRedirectAuthPolling(); return; } const res = await executeAction('poll_redirect_auth', { session_id: window._wiz_redirect_session, provider: window._wiz_redirect_provider, }); if (!res) return; if (res.ok && (res.data || {}).status === 'completed') { stopRedirectAuthPolling(); status.innerHTML = 'Аккаунт подключён'; showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } if (!res.ok) { // Конечный отказ провайдера — например, доступ отклонён. stopRedirectAuthPolling(); status.innerHTML = `${escapeHtml(res.message || 'Авторизация не завершена')}`; } } async function handleDeleteCredentials(profileId) { // Подтверждение обязательно: действие необратимо, аккаунт придётся // подключать заново. if (!confirm(`Удалить учётные данные профиля ${profileId}? Аккаунт придётся подключить заново.`)) { return; } const res = await executeAction('delete_credentials', { profile_id: profileId }); if (res && res.ok) { closeModal(); fetchSnapshot(); } } // ── P0-3: Local LLM server discovery ────────────────────────────────────── async function discoverLocalServers() { const btn = document.getElementById('wiz-discover-btn'); const statusEl = document.getElementById('wiz-discover-status'); const resultsEl = document.getElementById('wiz-discover-results'); if (!btn || !statusEl || !resultsEl) return; btn.disabled = true; btn.textContent = '⏳ Поиск...'; statusEl.textContent = ''; resultsEl.innerHTML = ''; // wiz-base-url-input: filled by selectDiscoveredServer when user picks a server const res = await executeAction('discover_local_models', {}); btn.disabled = false; btn.textContent = '🔍 Найти на этом компьютере'; const servers = (res && res.data && res.data.servers) ? res.data.servers : []; if (!servers.length) { statusEl.textContent = res && res.message ? res.message : 'Ничего не найдено. Запустите Ollama, LM Studio или llama.cpp, либо введите адрес вручную.'; statusEl.style.color = 'var(--text-muted)'; return; } // Show message if any servers have errors (occupied by other service) const errorServers = servers.filter(s => s.error); if (errorServers.length && errorServers.length === servers.length) { statusEl.textContent = 'Найдены серверы, но подключиться к ним не удалось:'; statusEl.style.color = 'var(--text-secondary)'; } else if (errorServers.length) { statusEl.textContent = `Найдено ${servers.length - errorServers.length} рабочих серверов (+${errorServers.length} с ошибкой). Выберите рабочий сервер:`; statusEl.style.color = 'var(--text-secondary)'; } else { statusEl.textContent = `Найдено серверов: ${servers.length}. Выберите:`; statusEl.style.color = 'var(--text-secondary)'; } // Render server list let html = '
'; servers.forEach((srv, idx) => { const hasError = !!srv.error; const modelCount = srv.models && srv.models.length ? srv.models.length : 0; const modelLabel = modelCount ? `, ${modelCount} модель${modelCount === 1 ? '' : modelCount < 5 ? 'ели' : 'елей'}` : ''; const errorTitle = hasError ? ` title="${escapeHtml(srv.error)}"` : ''; const rowStyle = hasError ? 'padding:8px 10px; background:#2a1a1a; cursor:not-allowed; opacity:0.7;' : 'padding:8px 10px; cursor:pointer;'; const clickAttr = hasError ? '' : ` onclick="selectDiscoveredServer('${escapeHtml(srv.base_url)}')"`; const icon = hasError ? '⚠️' : '🖥️'; html += `
${icon} ${escapeHtml(srv.name)}
${escapeHtml(srv.base_url)}${modelLabel}${hasError ? ` — ⚠ ${escapeHtml(srv.error)}` : ''}
`; }); html += '
'; resultsEl.innerHTML = html; } function selectDiscoveredServer(baseUrl) { const input = document.getElementById('wiz-base-url-input'); if (input) { input.value = baseUrl; // Scroll input into view input.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // Clear results to keep UI clean const resultsEl = document.getElementById('wiz-discover-results'); if (resultsEl) resultsEl.innerHTML = ''; const statusEl = document.getElementById('wiz-discover-status'); if (statusEl) { statusEl.textContent = `Выбран: ${baseUrl}`; statusEl.style.color = 'var(--status-healthy)'; } } // ── Quota & Limits Export ───────────────────────────────────────────────── async function exportQuotas(format = 'json') { const fmt = (format || 'json').toLowerCase(); showToast(`Формирование выгрузки лимитов (${fmt.toUpperCase()})...`, 'info'); try { const token = (typeof getWebToken === 'function' ? getWebToken() : (localStorage.getItem('hermes_hub_token') || '')); const headers = {}; if (token) { headers['X-Hub-Token'] = token; } const resp = await fetch(`/api/quotas/export?format=${fmt}`, { headers }); if (!resp.ok) { throw new Error(`Ошибка сервера: ${resp.status}`); } const blob = await resp.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `hermes_quotas_export.${fmt}`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(`Выгрузка лимитов (${fmt.toUpperCase()}) успешно скачана`, 'success'); } catch (err) { showToast(`Не удалось выгрузить лимиты: ${err.message}`, 'error'); } } function openExportQuotasModal() { if (elements.modalTitle) elements.modalTitle.textContent = '📥 Экспорт лимитов и квот'; elements.modalBody.innerHTML = `
Выберите формат для выгрузки актуального отчета по лимитам, корзинам и статусам всех профилей:
`; elements.modalFooter.innerHTML = ` `; showModal(); }