/** * 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(); fetchSettings(); 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: 'Маршрутизация', skills: 'Реестр навыков (Agent Skills · SkillDoctor)', analytics: 'Аналитика и телеметрия', health: 'Состояние системы', logs: 'Журнал событий', settings: 'Настройки Hermes Hub', }; if (elements.pageTitle) { elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub'; } if (viewName === 'skills') { fetchSkills(); } if (viewName === 'settings') { fetchSettings(); // Состояние сжатия спрашиваем при открытии экрана, а не при каждой // отрисовке настроек: раньше опрос запускался на каждом обновлении // снапшота и замыкал круг сам на себя. checkCompressionStatus(); } 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', () => openUpdateModal('details')); } // 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()); } // Antigravity eligibility actions (A58) const btnRefreshAgyElig = document.getElementById('btn-refresh-agy-eligibility'); if (btnRefreshAgyElig) { btnRefreshAgyElig.addEventListener('click', async () => { showToast('Проверка состояния agy...', 'info'); const res = await executeAction('refresh_agy_eligibility'); await fetchSettings(); await fetchSnapshot(); if (res && res.ok) showToast(res.message || 'Состояние обновлено', 'success'); }); } const btnRunAgyUpdate = document.getElementById('btn-run-agy-update'); if (btnRunAgyUpdate) { btnRunAgyUpdate.addEventListener('click', async () => { if (!confirm('Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч. Запустить agy update в терминале?')) return; const res = await executeAction('run_agy_update'); await fetchSettings(); await fetchSnapshot(); }); } const btnRunAgyPatch = document.getElementById('btn-run-agy-patch-script'); if (btnRunAgyPatch) { btnRunAgyPatch.addEventListener('click', async () => { const pathVal = (document.getElementById('setting-agy-patch-script-path')?.value || '').trim(); if (!pathVal && !currentSettings?.agy_patch_script_path) { showToast('Н/Д: путь к сценарию патча не указан в настройках', 'warning'); return; } const res = await executeAction('run_agy_patch_script'); await fetchSettings(); await fetchSnapshot(); }); } // Skills view event listeners const skillsSearch = document.getElementById('skills-search'); const filterSkillsSource = document.getElementById('filter-skills-source'); const filterSkillsStatus = document.getElementById('filter-skills-status'); const btnRefreshSkills = document.getElementById('btn-refresh-skills'); const btnDoctorAll = document.getElementById('btn-doctor-all-skills'); if (skillsSearch) skillsSearch.addEventListener('input', () => renderSkillsView()); if (filterSkillsSource) filterSkillsSource.addEventListener('change', () => renderSkillsView()); if (filterSkillsStatus) filterSkillsStatus.addEventListener('change', () => renderSkillsView()); if (btnRefreshSkills) btnRefreshSkills.addEventListener('click', () => fetchSkills()); if (btnDoctorAll) btnDoctorAll.addEventListener('click', () => runDoctorAllSkills()); // Obsidian Vault event listeners const btnCheckVault = document.getElementById('btn-check-obsidian-vault'); if (btnCheckVault) { btnCheckVault.addEventListener('click', () => { const p = document.getElementById('setting-obsidian-vault-path')?.value; checkObsidianVault(p); }); } const btnSetupMemory = document.getElementById('btn-setup-memory-structure'); if (btnSetupMemory) { btnSetupMemory.addEventListener('click', () => { const p = document.getElementById('setting-obsidian-vault-path')?.value; setupMemoryStructure(p); }); } } // ── 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) ── // Действия-опросы: их запускает сам интерфейс, а не владелец. // // Показывать их тостами нельзя: опрос состояния сжатия шёл при каждом // обновлении снапшота, и владелец не видел за уведомлениями самой программы. // Хуже того, успешное действие вызывало fetchSnapshot, тот перерисовывал // настройки, а перерисовка снова запускала опрос — круг замыкался и кормил // сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот. const SILENT_ACTIONS = new Set([ 'get_compression_status', 'get_update_progress', 'cancel_update', 'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth', 'poll_redirect_auth', 'poll_device_auth', ]); async function executeAction(actionName, actionData = {}) { const silent = SILENT_ACTIONS.has(actionName); if (!silent) 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) { if (!silent) { showToast(result.message || 'Действие выполнено успешно', 'success'); fetchSnapshot(); } return result; } else { // Отказ опроса показываем в том месте, которое его запросило: у мастера // входа для этого своя область сообщений. Тостом заливать нельзя. if (!silent) showToast(result.message || 'Отказ выполнения действия', 'warning'); return result; } } catch (err) { console.error(`Action ${actionName} failed:`, err); if (!silent) showToast(`Ошибка сети: ${err.message}`, 'error'); return { ok: false, message: `Ошибка сети: ${err.message}` }; } } // ── GLOBAL HEADER ── // Версия между сборками не меняется намеренно, а коммит — строка из // шестнадцатеричных цифр. Дата установки отвечает на вопрос «старая сборка // загрузилась или новая» сразу и без сверки коммитов. function versionTagText(curVer) { const installedAt = currentSettings && currentSettings.installed_at; let stamp = ''; if (installedAt) { const d = new Date(installedAt); if (!isNaN(d.getTime())) { stamp = ' · ' + d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); } } return curVer ? `Hermes Hub Web v${curVer}${stamp}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; } function updateGlobalHeader() { if (!currentSnapshot) return; const readiness = currentSnapshot.readiness || {}; renderAccountSummary(currentSnapshot); const allProfiles = Object.values(currentSnapshot.all_profiles || {}); // Одно число — одно определение. Готовность считает строго AUTHENTICATED, // а страница аккаунтов — всё, что не NOT_CONFIGURED. Из-за двух определений // значок в меню показывал 9, а карточка на той же странице — 3. // Берём то же правило, что и страница аккаунтов: расходиться они не должны. const connectedAccounts = 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 hermesCfg = (currentSnapshot.metrics || {}).hermes_config; const hermesBadge = document.getElementById('header-hermes-config-badge'); if (hermesBadge) { if (hermesCfg && hermesCfg.exists && hermesCfg.model) { hermesBadge.innerHTML = `🤖 В Hermes: ${escapeHtml(hermesCfg.model)} (${escapeHtml(hermesCfg.provider || 'default')})`; hermesBadge.classList.remove('hidden'); } else if (hermesCfg && hermesCfg.exists) { hermesBadge.innerHTML = `🤖 В Hermes: (модель не выбрана)`; hermesBadge.classList.remove('hidden'); } else { hermesBadge.innerHTML = `🤖 В Hermes: конфигурация не найдена`; hermesBadge.classList.remove('hidden'); } } 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; const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || ''; const versionTag = document.getElementById('version-tag'); if (versionTag) { versionTag.textContent = versionTagText(curVer); } } // ── VIEW ROUTER ── function renderCurrentView() { if (!currentSnapshot) return; switch (activeView) { case 'overview': renderOverviewView(); break; case 'accounts': renderAccountsView(); break; case 'routing': renderRoutingView(); break; case 'skills': renderSkillsView(); 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)', nvidia: 'NVIDIA NIM', openrouter: 'OpenRouter', local: 'llama.cpp', 'local-llm': 'llama.cpp', 'llama.cpp': '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 isAssigned = profile.assigned_roles && profile.assigned_roles.length > 0; const roles = isAssigned ? 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)}
`; } const unassignedBadge = !isAssigned ? 'Не назначен' : ''; let agyEligibilityHtml = ''; if (['antigravity', 'google-antigravity'].includes(String(profile.provider || '').toLowerCase())) { const agyElig = (currentSnapshot && currentSnapshot.agy_eligibility) || {}; let badgeClass = ''; let badgeText = agyElig.status_label_ru || 'Н/Д'; if (agyElig.status === 'check_removed') { badgeClass = 'healthy'; badgeText = '✓ Проверка снята'; } else if (agyElig.status === 'check_active') { badgeClass = 'warning'; badgeText = '⚠️ Проверка на месте'; } const badgeHtml = `${escapeHtml(badgeText)}`; let warningHtml = ''; if (agyElig.status === 'check_active') { warningHtml = `
⚠️ Проверка доступности Antigravity активна. Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси.
`; } agyEligibilityHtml = `
Доступность agy: ${badgeHtml}
${warningHtml} `; } return `
Предпочитаемые:${(profile.preferred_models || []).map(modelBrandLabel).join('')}
${agyEligibilityHtml} ${renderAccountCheck(profile)} ${quotaGridHtml}
`; } function renderAccountCheck(profile) { const check = profile.connection_check || {}; const meta = profile.model_discovery || {}; const checking = check.state === 'checking'; const models = meta.models || []; const timestamp = meta.discovered_at ? new Date(meta.discovered_at * 1000).toLocaleString('ru-RU') : ''; const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен'; return `
${checking ? `

${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.

` : ''}

${escapeHtml(check.message || "Подключение ещё не проверялось")}

${escapeHtml(modelStatus)}

Каталог моделей (${models.length})
${profile.provider === 'ollama' ? `

Выше — модели указанного сервера Ollama.

Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}

Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.

` : ''} ${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase()) ? `` : ''}
`; } // Каталог NVIDIA и OpenRouter публичный: он одинаков у всех и о правах // аккаунта ничего не говорит. Доступность выясняется опросом моделей, а он // тратит вызовы — поэтому только по явному нажатию, и результат сохраняется. async function handleProbeAccountModels(profileId, provider) { if (!confirm('Хаб опросит каталог провайдера, чтобы выяснить, какие модели доступны этому аккаунту. Каталог общий для всех и о правах не сообщает, поэтому каждая модель проверяется отдельным запросом. Недоступная отвечает отказом и ничего не стоит, доступная расходует один токен. Продолжить?')) return; showToast('Опрос моделей начат. Это может занять около минуты.', 'info'); const res = await executeAction('probe_account_models', { profile_id: profileId, provider: provider }); showToast(res?.message || 'Нет ответа от сервера', res?.ok ? 'success' : 'error'); await fetchSnapshot(); } async function handleAccountProbe(profileId) { await executeAction('check_account', {profile_id: profileId}); await fetchSnapshot(); } 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':'openai.png', openai:'openai.png', codex:'openai.png', antigravity:'antigravity.png', 'google-antigravity':'antigravity.png', 'opencode-go':'opencode.png', opencode:'opencode.png', claude:'claude-color.svg', 'anthropic-claude':'claude-color.svg', anthropic:'claude-color.svg', grok:'grok.svg', xai:'grok.svg', ollama:'ollama.svg', nvidia:'nvidia-color.svg', openrouter:'openrouter.svg', deepseek:'deepseek-color.svg', vllm:'vllm-color.svg', lmstudio:'lmstudio.svg', local:'unknown.svg', 'llama.cpp':'unknown.svg', }; return 'brands/'+(map[String(provider || '').toLowerCase()] || 'unknown.svg'); } function getModelIcon(model) { const name=String(model || '').toLowerCase().split('/').at(-1); const family=[ [/^gemini(?:[-.:]|$)/,'gemini-color.svg'], [/^claude(?:[-.:]|$)/,'claude-color.svg'],[/^(?:gpt|chatgpt|codex|o[134])(?:[-.:0-9]|$)/,'openai.png'], [/^grok(?:[-.:]|$)/,'grok.svg'],[/^deepseek(?:[-.:]|$)/,'deepseek-color.svg'], [/^qwen(?:[-.:0-9]|$)/,'qwen-color.svg'],[/^(?:llama|meta-llama)(?:[-.:0-9]|$)/,'meta-color.svg'], [/^(?:mistral|mixtral|codestral|devstral|magistral)(?:[-.:0-9]|$)/,'mistral-color.svg'], ].find(([pattern])=>pattern.test(name)); return 'brands/'+(family?.[1] || 'unknown.svg'); } function modelBrandLabel(model) { return `${escapeHtml(model || 'Модель: Н/Д')}`; } 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 = ``; } } } async function openAutoAssignPreviewModal() { if (elements.modalTitle) elements.modalTitle.textContent = '⚡ Предварительный просмотр авто-распределения'; elements.modalBody.innerHTML = ''; elements.modalFooter.innerHTML = ''; showModal(); const res = await executeAction('preview_auto_assign', {}); if (!res || !res.ok || !res.data || !res.data.success) { elements.modalBody.innerHTML = ``; return; } const data = res.data; const changes = data.changes || []; if (changes.length === 0) { elements.modalBody.innerHTML = '
Нет изменений для применения. Все роли уже распределены оптимально.
'; return; } let tableHtml = `
Будет распределено ${data.total_authenticated} подключённых аккаунтов по ролям:
`; changes.forEach((ch) => { const cur = (ch.current_chain && ch.current_chain.length) ? ch.current_chain.join(' → ') : 'пусто'; const prop = (ch.proposed_chain && ch.proposed_chain.length) ? ch.proposed_chain.join(' → ') : 'пусто'; tableHtml += ` `; }); tableHtml += '
Роль Текущая цепочка Предлагаемая цепочка
${escapeHtml(ch.role_name_ru || ch.role)} ${cur} ${prop}
'; elements.modalBody.innerHTML = tableHtml; elements.modalFooter.innerHTML = ` `; } async function applyAutoAssign() { const btn = document.getElementById('btn-apply-auto-assign'); if (btn) { btn.disabled = true; btn.textContent = 'Применение...'; } const res = await executeAction('auto_assign_all', {}); if (res && res.ok) { showToast('Авто-распределение успешно применено', 'success'); closeModal(); await fetchSnapshot(); } else { showToast((res && res.message) || 'Ошибка применения', 'error'); } } // ── 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); const routedCount = global.routed_calls_count ?? (global.total_calls - (global.bypassed_calls_count ?? 0)); const bypassCount = global.bypassed_calls_count ?? 0; if (callsBreakdownEl) callsBreakdownEl.textContent = `Через хаб: ${routedCount} · Мимо хаба (Bypass): ${bypassCount}`; 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} вызовов (Bypass: ${bypassCount})`; } 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 || {}); } if (banner) { const probe = currentSnapshot.account_probe || {}; banner.insertAdjacentHTML('beforeend', `

Автопроверка: ${probe.enabled ? 'работает' : 'остановлена'}. Последний обход: ${probe.last_tick ? escapeHtml(new Date(probe.last_tick * 1000).toLocaleString()) : 'ещё не выполнялся'}. ${escapeHtml(probe.error || '')}

`); } renderHealthPanels(currentSnapshot); const warningsContainer = document.getElementById('health-warnings-list'); if (warningsContainer) { const warnings = readiness.warnings || []; const agyElig = currentSnapshot.agy_eligibility || {}; const agyWarnHtml = (agyElig.status === 'check_active') ? `
⚠️ Проверка доступности Antigravity активна: аккаунты Google могут отклоняться по региону («not currently available in your location»). Примените патч в Настройках или используйте прокси.
` : ''; if (warnings.length === 0 && !agyWarnHtml) { warningsContainer.innerHTML = '
✓ Критических предупреждений и деградаций не обнаружено
'; } else { const regularWarnings = warnings.map((w) => `
⚠ ${escapeHtml(w.title || 'Предупреждение')}: ${escapeHtml(w.message || w)}
`).join(''); warningsContainer.innerHTML = agyWarnHtml + regularWarnings; } } } // ── 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 ── async function fetchSettings() { try { const headers = authToken ? { 'X-Hub-Token': authToken } : {}; const res = await fetch('/api/settings', { headers }); if (res.ok) { currentSettings = await res.json(); if (activeView === 'settings') { renderSettingsView(); } return currentSettings; } } catch (err) { console.error('Failed to fetch settings:', err); } return null; } function renderSettingsView() { const s = currentSettings || {}; const sysPaths = s.system_paths || (currentSnapshot && currentSnapshot.system_paths) || {}; const elHome = document.getElementById('path-hermes-home'); const elConfig = document.getElementById('path-config-dir'); const elLog = document.getElementById('path-log-file'); const homePath = sysPaths.hermes_home || s.hermes_home; const configPath = sysPaths.config_dir || s.config_dir; const logPath = sysPaths.log_file || s.log_file; if (elHome) elHome.textContent = homePath || 'Н/Д: путь не передан сервером'; if (elConfig) elConfig.textContent = configPath || 'Н/Д: путь не передан сервером'; if (elLog) elLog.textContent = logPath || 'Н/Д: путь не передан сервером'; // Server Host & Port const hostInput = document.getElementById('setting-server-host'); const portInput = document.getElementById('setting-server-port'); if (hostInput && s.web_api_host !== undefined) { hostInput.value = s.web_api_host; } if (portInput && s.web_api_port !== undefined) { portInput.value = s.web_api_port; } // Token Badge const tokenBadge = document.getElementById('setting-token-status-badge'); if (tokenBadge) { const isTokenSet = Boolean(s.web_api_token_configured || s.web_api_token); if (isTokenSet) { tokenBadge.textContent = '✓ Задан'; tokenBadge.className = 'badge healthy'; } else { tokenBadge.textContent = 'Не задан'; tokenBadge.className = 'badge'; } } // Account Check Interval const accountIntervalInput = document.getElementById('setting-account-check-interval'); if (accountIntervalInput) { const accVal = s.account_check_interval_seconds ?? s.account_interval; if (accVal !== undefined && Number.isFinite(Number(accVal))) { accountIntervalInput.value = accVal; accountIntervalInput.disabled = false; accountIntervalInput.placeholder = '300'; } else { accountIntervalInput.placeholder = 'Н/Д: не передан сервером'; } } // Прокси провайдеров: пустая строка — осмысленное значение «без прокси», // поэтому отличаем её от «сервер не передал». const proxyInput = document.getElementById('setting-provider-proxy-url'); if (proxyInput) { if (s.provider_proxy_url !== undefined) { proxyInput.value = s.provider_proxy_url || ''; proxyInput.placeholder = 'без прокси'; } else { proxyInput.placeholder = 'Н/Д: не передан сервером'; } } // Quota Interval const quotaIntervalSel = document.getElementById('setting-quota-interval'); if (quotaIntervalSel) { const qVal = s.quota_refresh_interval_sec ?? s.quota_interval ?? s.account_check_interval_seconds; if (qVal !== undefined) { quotaIntervalSel.value = String(qVal); } } // Quota Threshold Percent const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); } // Quota Threshold Action const quotaActionSel = document.getElementById('setting-quota-threshold-action'); if (quotaActionSel && s.quota_threshold_action) { quotaActionSel.value = s.quota_threshold_action; } // Email Masking Mode const emailMaskingSel = document.getElementById('setting-email-masking-mode'); if (emailMaskingSel && s.email_masking_mode) { emailMaskingSel.value = s.email_masking_mode; } // Default Role const defaultRoleSel = document.getElementById('setting-default-role'); if (defaultRoleSel) { const currentDef = s.default_role || (currentSnapshot && currentSnapshot.metrics && currentSnapshot.metrics.default_role) || 'manager'; defaultRoleSel.value = currentDef; } // Theme const themeSel = document.getElementById('setting-theme'); if (themeSel && s.theme) { themeSel.value = s.theme; } // Monitor Interval const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) { monitorIntervalInput.value = s.monitoring_interval_seconds; } // Obsidian Vault Path const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); if (vaultPathInput) { vaultPathInput.value = s.obsidian_vault_path || '/srv/projects/AI-Memory'; } // Context Compression Settings (A56) populateCompressorProfiles(s); const compThresholdSel = document.getElementById('setting-compression-threshold-percent'); if (compThresholdSel && s.compression_threshold_percent !== undefined) { compThresholdSel.value = String(Math.round(s.compression_threshold_percent)); } const compKeepRecentSel = document.getElementById('setting-compression-keep-recent'); if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) { compKeepRecentSel.value = String(s.compression_keep_recent_messages); } // Antigravity CLI & Eligibility Settings (A58) const agyPatchInput = document.getElementById('setting-agy-patch-script-path'); if (agyPatchInput) { agyPatchInput.value = s.agy_patch_script_path || ''; } const agyElig = s.agy_eligibility || (currentSnapshot && currentSnapshot.agy_eligibility) || {}; const agyBadge = document.getElementById('agy-eligibility-badge'); const agyDesc = document.getElementById('agy-version-path-desc'); const agyDetails = document.getElementById('agy-eligibility-details'); if (agyBadge) { if (agyElig.status === 'check_removed') { agyBadge.textContent = '✓ Проверка снята'; agyBadge.className = 'badge healthy'; } else if (agyElig.status === 'check_active') { agyBadge.textContent = '⚠️ Проверка на месте'; agyBadge.className = 'badge warning'; } else { agyBadge.textContent = agyElig.status_label_ru || 'Н/Д: не определено'; agyBadge.className = 'badge'; } } if (agyDesc) { const ver = agyElig.version || 'Н/Д'; const bin = agyElig.binary_path || 'Н/Д'; agyDesc.innerHTML = `Версия: ${escapeHtml(ver)} • Путь: ${escapeHtml(bin)}`; } if (agyDetails) { if (agyElig.binary_sha256) { agyDetails.textContent = `SHA-256: ${agyElig.binary_sha256}\nРазмер: ${agyElig.binary_size_bytes || 0} байт\nОписание: ${agyElig.detail_ru || ''}`; } else { agyDetails.textContent = agyElig.detail_ru || ''; } } } function populateCompressorProfiles(s) { const compProfileSel = document.getElementById('setting-compressor-profile'); if (!compProfileSel) return; const currentVal = (s && s.compressor_profile_id) || compProfileSel.value || 'none'; const profiles = (currentSnapshot && (currentSnapshot.all_profiles || currentSnapshot.profiles)) || {}; let optionsHtml = ''; for (const [pid, p] of Object.entries(profiles)) { const prov = p.provider || 'unknown'; const name = p.display_name || pid; const model = (p.preferred_models && p.preferred_models[0]) || ''; const endpoint = p.custom_base_url || (p.auth_config && p.auth_config.base_url) || ''; const label = `${name} [${prov}]${model ? ' — ' + model : ''}${endpoint ? ' (' + endpoint + ')' : ''}`; optionsHtml += ``; } compProfileSel.innerHTML = optionsHtml; if (currentVal) compProfileSel.value = currentVal; } async function saveHubServerSettings() { const hostInput = document.getElementById('setting-server-host'); const portInput = document.getElementById('setting-server-port'); const tokenInput = document.getElementById('setting-server-token-input'); 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 quotaIntervalSel = document.getElementById('setting-quota-interval'); const monitorIntervalInput = document.getElementById('setting-monitoring-interval'); const vaultPathInput = document.getElementById('setting-obsidian-vault-path'); const compProfileSel = document.getElementById('setting-compressor-profile'); const compThresholdSel = document.getElementById('setting-compression-threshold-percent'); const compKeepRecentSel = document.getElementById('setting-compression-keep-recent'); const accountIntervalInput = document.getElementById('setting-account-check-interval'); const defaultRoleSel = document.getElementById('setting-default-role'); const themeSel = document.getElementById('setting-theme'); const agyPatchInput = document.getElementById('setting-agy-patch-script-path'); const proxyInputSave = document.getElementById('setting-provider-proxy-url'); const newSettings = {}; // Пустое поле — это выбор «без прокси», а не отсутствие значения: // отправляем его тоже, иначе прокси нельзя было бы убрать. if (proxyInputSave) newSettings.provider_proxy_url = proxyInputSave.value.trim(); if (agyPatchInput) newSettings.agy_patch_script_path = agyPatchInput.value.trim(); if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim(); if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value); if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim(); if (accountIntervalInput && accountIntervalInput.value) newSettings.account_check_interval_seconds = Math.max(60, Number(accountIntervalInput.value)); if (quotaIntervalSel && quotaIntervalSel.value) newSettings.quota_refresh_interval_sec = Number(quotaIntervalSel.value); if (quotaThresholdSel && quotaThresholdSel.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value); if (quotaActionSel && quotaActionSel.value) newSettings.quota_threshold_action = quotaActionSel.value; if (emailMaskingSel && emailMaskingSel.value) newSettings.email_masking_mode = emailMaskingSel.value; if (monitorIntervalInput && monitorIntervalInput.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value); if (vaultPathInput && vaultPathInput.value.trim()) newSettings.obsidian_vault_path = vaultPathInput.value.trim(); if (compProfileSel) newSettings.compressor_profile_id = (compProfileSel.value === 'none' || !compProfileSel.value) ? null : compProfileSel.value; if (compThresholdSel && compThresholdSel.value) newSettings.compression_threshold_percent = Number(compThresholdSel.value); if (compKeepRecentSel && compKeepRecentSel.value) newSettings.compression_keep_recent_messages = Number(compKeepRecentSel.value); if (defaultRoleSel && defaultRoleSel.value) newSettings.default_role = defaultRoleSel.value; if (themeSel && themeSel.value) newSettings.theme = themeSel.value; if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; } showToast('Сохранение настроек сервера...', 'info'); const res = await executeAction('save_settings', newSettings); if (res && res.ok) { showToast('Настройки сервера успешно сохранены', 'success'); await fetchSettings(); fetchSnapshot(); } else { showToast((res && 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 = profile.model_discovery?.models || ((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.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
`; } modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml; // 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('') || '
Данные о квотах отсутствуют.
'}
`; } // Antigravity eligibility section let agyModalBlockHtml = ''; if (['antigravity', 'google-antigravity'].includes(String(profile.provider || '').toLowerCase())) { const agyElig = (currentSnapshot && currentSnapshot.agy_eligibility) || {}; let badgeClass = ''; let badgeText = agyElig.status_label_ru || 'Н/Д'; if (agyElig.status === 'check_removed') { badgeClass = 'healthy'; badgeText = '✓ Проверка снята'; } else if (agyElig.status === 'check_active') { badgeClass = 'warning'; badgeText = '⚠️ Проверка на месте'; } agyModalBlockHtml = `
Состояние проверки доступности agy: ${escapeHtml(badgeText)}
${escapeHtml(agyElig.detail_ru || 'Состояние проверки доступности утилиты agy.')}
${agyElig.binary_path ? `
Файл: ${escapeHtml(agyElig.binary_path)} (v${escapeHtml(agyElig.version || 'Н/Д')})
` : ''} ${agyElig.status === 'check_active' ? `
⚠️ Внимание: Проверка доступности активна. Аккаунт может отклоняться Google по региону. Примените патч в Настройках или настройте прокси.
` : ''}
`; } modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml; 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 && profile.assigned_roles.length > 0) ? profile.assigned_roles.join(', ') : 'Не назначен')}
${agyModalBlockHtml} ${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(); stopUpdateProgressPolling(); 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 (A59 / In-App Updates) ── let updateProgressInterval = null; function stopUpdateProgressPolling() { if (updateProgressInterval) { clearInterval(updateProgressInterval); updateProgressInterval = null; } } function checkPostUpdateNotification() { const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update); if (!applied || !applied.new_commit) return; const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version); if (!localStorage.getItem(key)) { const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—'; const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—'; const newV = applied.new_version || '0.1.3'; showToast( `Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`, 'success', 10000 ); localStorage.setItem(key, '1'); } } 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 (res.data.update_available) { const versionKey = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.latest_version || res.data.release_tag || ''); if (silent) { const dismissed = localStorage.getItem('hermes_dismissed_update_' + versionKey); if (!dismissed) { openUpdateModal('details'); } } else { const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); showToast(`Доступно обновление (сборка ${c})`, 'info'); openUpdateModal('details'); } } else { if (!silent) { 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'); // Первым источником — снапшот работающего сервера: он приходит всегда, а // панель обновлений заполняется только при её открытии. На Linux строка // сборки поэтому оставалась пустой, и понять, дошло ли обновление, было // нельзя. // // Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit // читается с диска при каждом запросе, и переживший обновление процесс // рапортует им свежий номер при старом поведении. const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || ''; const installedCommit = runningCommit || (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown' ? latestUpdateInfo.installed_commit : (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : '')); if (commitTag) { if (!installedCommit) { commitTag.textContent = 'Сборка: Н/Д (сервер не передал номер)'; } else { const startedAt = currentSnapshot && currentSnapshot.started_at; const since = startedAt ? ` · запущен ${new Date(startedAt * 1000).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}` : ''; commitTag.textContent = `Сборка: ${installedCommit.slice(0, 7)}${since}`; } } 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'); // Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в // запасном значении: подъём версии в коде до интерфейса не доходил, и // владелец видел старый номер при новой сборке. const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || ''; const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; if (updateInfoDesc) { updateInfoDesc.textContent = curVer ? `Hermes Hub v${curVer} (сборка: ${cDisplay})` : `Hermes Hub (сборка: ${cDisplay}) — Н/Д: версия не передана сервером`; } const versionTag = document.getElementById('version-tag'); if (versionTag) { versionTag.textContent = versionTagText(curVer); } 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'); } } checkPostUpdateNotification(); } function openUpdateModal(mode = 'details') { 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 || '—'); const curVer = latestUpdateInfo.current_version || (currentSettings && currentSettings.version) || '0.1.3'; const newVer = latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || curVer; const versionKey = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || ''); // Блок «Что нового»: брать из changelog / release_notes; если пусто — выводить Н/Д: описание не приложено const rawNotes = (latestUpdateInfo.changelog || latestUpdateInfo.release_notes || '').trim(); const notesText = rawNotes || 'Н/Д: описание не приложено'; // Размер загрузки: размер файла из assets / заголовков; если неизвестен — Н/Д: размер не указан let sizeText = 'Н/Д: размер не указан'; if (latestUpdateInfo.download_size && latestUpdateInfo.download_size > 0) { sizeText = (latestUpdateInfo.download_size / 1048576).toFixed(1) + ' МБ'; } else if (latestUpdateInfo.asset_sizes) { const sizes = Object.values(latestUpdateInfo.asset_sizes); if (sizes.length > 0 && sizes[0] > 0) { sizeText = (sizes[0] / 1048576).toFixed(1) + ' МБ'; } } if (elements.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub'; if (mode === 'details') { if (elements.modalBody) { elements.modalBody.innerHTML = `
Текущая сборка:
${escapeHtml(curVer)} (${escapeHtml(instC)})
Новая версия:
${escapeHtml(newVer)} (${escapeHtml(latC)})
Размер загрузки: ${escapeHtml(sizeText)}
${latestUpdateInfo.published_at ? `
Дата: ${escapeHtml(latestUpdateInfo.published_at.slice(0, 10))}
` : ''}
Что нового:
${escapeHtml(notesText)}
`; } if (elements.modalFooter) { elements.modalFooter.innerHTML = ` `; } } else if (mode === 'progress') { if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; renderUpdateProgressView({ status: 'downloading', filename: '', downloaded_bytes: 0, total_bytes: null, progress_percent: null, message: 'Подготовка к загрузке пакета обновления...', }); } showModal(); } function dismissUpdateModal(versionKey) { if (versionKey) { localStorage.setItem('hermes_dismissed_update_' + versionKey, '1'); } closeModal(); } async function startUpdateProcess() { openUpdateModal('progress'); executeAction('apply_update', {}); stopUpdateProgressPolling(); updateProgressInterval = setInterval(pollUpdateProgress, 500); } async function pollUpdateProgress() { try { const res = await executeAction('get_update_progress', {}); if (res && res.ok && res.data) { const p = res.data; renderUpdateProgressView(p); if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') { stopUpdateProgressPolling(); } } } catch (err) { console.debug('Failed polling update progress:', err); } } // Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу // заменяем бегущим отрезком, а не заполняем целиком. const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']); function renderUpdateProgressView(p) { if (!elements.modalBody) return; const status = p.status || 'downloading'; const filename = p.filename || 'Пакет обновления'; const downloaded = p.downloaded_bytes || 0; const total = p.total_bytes; const percent = p.progress_percent; const msg = p.message || ''; const error = p.error; let progressDetail = ''; let barWidth = '0%'; let isIndeterminate = false; const dlMb = (downloaded / 1048576).toFixed(1); if (total && total > 0) { const totMb = (total / 1048576).toFixed(1); const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1); progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`; barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`; } else { // Размер неизвестен — доля не вычисляется. Полоса при этом не должна // изображать процент: полная полоса читается как «готово». Активные этапы // показываем бегущим отрезком, завершённые — сплошной полосой. progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`; isIndeterminate = true; barWidth = '100%'; } let statusBadge = `Загрузка`; if (status === 'checking') statusBadge = `Проверка обновлений`; else if (status === 'verifying') statusBadge = `Проверка SHA-256`; else if (status === 'installing') statusBadge = `Установка`; else if (status === 'restarting') statusBadge = `Перезапуск`; else if (status === 'completed') statusBadge = `Завершено`; else if (status === 'failed') statusBadge = `Ошибка`; else if (status === 'cancelled') statusBadge = `Отменено`; // Inject indeterminate animation style if not already present if (isIndeterminate && !document.getElementById('indeterminate-bar-style')) { const style = document.createElement('style'); style.id = 'indeterminate-bar-style'; style.textContent = ` @keyframes indeterminate-bar { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .indeterminate-bar { position: absolute; top: 0; left: 0; height: 100%; width: 30%; background: var(--accent); animation: indeterminate-bar 1.5s ease-in-out infinite; opacity: 0.8; } `; document.head.appendChild(style); } elements.modalBody.innerHTML = `
${escapeHtml(filename)}
${statusBadge}
${escapeHtml(msg)}
${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
` : `
`}
${escapeHtml(progressDetail)}
${error ? `
❌ ${escapeHtml(error)}
` : ''} ${status === 'restarting' || status === 'completed' ? `
✓ ${escapeHtml(msg || 'Обновление успешно установлено!')}
` : ''}
`; if (elements.modalFooter) { if (status === 'downloading') { elements.modalFooter.innerHTML = ` `; } else if (status === 'failed' || status === 'cancelled') { elements.modalFooter.innerHTML = ` `; } else if (status === 'restarting' || status === 'installing' || status === 'verifying') { elements.modalFooter.innerHTML = ` `; } else { elements.modalFooter.innerHTML = ` `; } } } async function cancelUpdateProcess() { stopUpdateProgressPolling(); const cancelRes = await executeAction('cancel_update', {}); // Отмену могли не принять: после начала установки отменять уже нечего. // Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и // владелец решит, что установка встала. const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted; if (!accepted) { showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning'); updateProgressInterval = setInterval(pollUpdateProgress, 500); } const res = await executeAction('get_update_progress', {}); if (res && res.ok && res.data) { renderUpdateProgressView(res.data); } } // ── 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, profile_id: profileId || '' }); if (res && res.ok) { showToast(res.message, 'success'); await fetchSnapshot(); 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 = profile.model_discovery?.models || ((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.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (profile.provider_display_name || profile.provider))}
`; } modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml; 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 = profile.model_discovery?.models || ((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.model_discovery?.error ? "Сервер отказал: " + profile.model_discovery.error : profile.connection_check?.state === "checking" ? "Идёт запрос списка моделей…" : "Список моделей ещё не получен от провайдера " + (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('check_account', { 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_native_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; window._wiz_models = undefined; if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; showWizardStep1(); showModal(); } function showWizardStep1() { window._wiz_models = undefined; stopDeviceAuthPolling(); stopNativeAuthPolling(); stopRedirectAuthPolling(); for (const key of ['device_profile', 'device_session', 'native_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined; window._wiz_provider = undefined; if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; elements.modalBody.innerHTML = `
Шаг 1 из 3: Выберите провайдера ИИ
`; elements.modalFooter.innerHTML = ` `; } function showWizardStep2(providerId) { if (window._wiz_provider !== providerId) { window._wiz_device_profile = undefined; window._wiz_base_url = undefined; window._wiz_token = undefined; window._wiz_models = undefined; } window._wiz_provider = 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') { bodyHtml = `
Шаг 2 из 3: Авторизация Google Antigravity
Вход выполняет сама утилита agy в окне терминала с изолированным каталогом профиля.
`; footerHtml = ` `; } else if (providerId === 'claude') { bodyHtml = `
Шаг 2 из 3: Авторизация Claude
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
1. Откройте ссылку:
2. Вставьте код со страницы:
`; footerHtml = ` `; } else if (providerId === 'ollama') { window._wiz_device_profile = undefined; bodyHtml = `
Шаг 2 из 3: Настройка Ollama
По умолчанию указан адрес на машине с Hub (127.0.0.1:11434). Если Ollama работает на сервере или другом компьютере (например, http://192.168.1.81:11434), укажите его сетевой адрес.
`; 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; if (providerId === 'antigravity' || providerId === 'claude') { startRedirectAuth(providerId); } } async function testOllamaConnection() { const urlInput = document.getElementById('wiz-base-url-input'); const tokenInput = document.getElementById('wiz-token-input'); const resultEl = document.getElementById('wiz-ollama-check-result'); const btn = document.getElementById('wiz-test-ollama-btn'); if (!urlInput || !resultEl) return; const baseUrl = (urlInput.value || '').trim() || 'http://127.0.0.1:11434'; const token = tokenInput ? tokenInput.value.trim() : ''; if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверка…'; } resultEl.innerHTML = 'Проверяем подключение…'; try { const res = await executeAction('validate_connection', { provider: 'ollama', base_url: baseUrl, token: token, }); if (res && res.ok) { const models = (res.data && res.data.models) || []; window._wiz_models = models; window._wiz_base_url = baseUrl; resultEl.innerHTML = `✓ Подключение успешно. Моделей: ${models.length}`; showToast('Ollama подключена успешно', 'success'); } else { const errMsg = (res && res.message) || 'Не удалось подключиться к Ollama'; resultEl.innerHTML = `${escapeHtml(errMsg)}`; } } catch (err) { resultEl.innerHTML = `Ошибка: ${escapeHtml(String(err))}`; } finally { if (btn) { btn.disabled = false; btn.textContent = 'Проверить адрес'; } } } async function proceedToWizardStep3(providerId) { if (window._wiz_validating) return; window._wiz_validating = true; const nextButton = elements.modalFooter?.querySelector('.btn-primary'); if (nextButton) nextButton.disabled = true; try { 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'; if (isDeviceAuthFlow) { const deviceSlot = document.getElementById('wiz-device-slot'); window._wiz_device_profile = window._wiz_device_profile || deviceSlot?.value || ''; } else if (isRedirectAuthFlow) { const redirectSlot = document.getElementById('wiz-redirect-slot'); window._wiz_device_profile = window._wiz_redirect_slot_id || window._wiz_device_profile || redirectSlot?.value || ''; } else if (providerId === 'openrouter' || providerId === 'nvidia') { const redirectSlot = document.getElementById('wiz-redirect-slot'); window._wiz_device_profile = redirectSlot?.value || ''; } // For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements if (tokenInput || baseInput) { const feedback = document.getElementById('modal-feedback-area'); if (feedback) feedback.textContent = 'Проверка подключения и запрос моделей…'; // Ключ читаем из поля прямо сейчас, а не из глобальной переменной: // она переживает предыдущие попытки подключения и может оказаться пустой // или чужой. Провайдер тогда отвечает «Missing Authentication header» при // заполненном поле, и владелец не понимает, в чём дело. const liveToken = (tokenInput && tokenInput.value.trim()) || window._wiz_token || ''; const liveBase = (baseInput && baseInput.value.trim()) || window._wiz_base_url || ''; window._wiz_token = liveToken; window._wiz_base_url = liveBase; const result = await executeAction('validate_connection', {provider: providerId, token: liveToken, base_url: liveBase}); if (!result?.ok) { if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера'; return; } window._wiz_models = result.data.models; } showWizardStep3(providerId); } finally { window._wiz_validating = false; if (nextButton) nextButton.disabled = false; } } 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: Назначение роли для нового аккаунта
${window._wiz_models?.length ? `` : ''}
`; elements.modalFooter.innerHTML = ` `; } async function finishAddAccount(providerId) { if (window._wiz_saving) return; window._wiz_saving = true; const finishButton = elements.modalFooter?.querySelector(".btn-primary"); if (finishButton) finishButton.disabled = true; 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 if (providerId === 'antigravity' || providerId === 'claude') { selectedProfileId = window._wiz_redirect_slot_id || window._wiz_device_profile || document.getElementById('wiz-native-slot')?.value || document.getElementById('wiz-redirect-slot')?.value || ''; } 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) { // Обещать «до минуты на этап» было неправдой: проверка Antigravity через // CLI занимала до четырёх минут, и мастер выглядел зависшим. Теперь // действие возвращается сразу, а проверка идёт в фоне. feedbackArea.innerHTML = ``; } const payload = { provider: providerId, target_role: targetRole, preferred_model: document.getElementById('wiz-preferred-model')?.value || '', // 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; } let res; try { res = await executeAction('add_account', payload); } finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; } if (res && res.ok) { showToast(res.message, '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 (window._wiz_provider !== providerId) return; 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(''); } // ───────────────────────────────────────────────────────────── // Авторизация Google Antigravity через agy в терминале (A57) // ───────────────────────────────────────────────────────────── let _nativeAuthTimer = null; function stopNativeAuthPolling() { if (_nativeAuthTimer) { clearInterval(_nativeAuthTimer); _nativeAuthTimer = null; } } function toggleAntigravityAuthMode(mode) { const termBox = document.getElementById('ag-auth-mode-terminal'); const browserBox = document.getElementById('ag-auth-mode-browser'); const termBtn = document.getElementById('ag-tab-btn-terminal'); const browserBtn = document.getElementById('ag-tab-btn-browser'); if (!termBox || !browserBox) return; if (mode === 'browser') { termBox.style.display = 'none'; browserBox.style.display = 'block'; if (termBtn) termBtn.className = 'btn btn-ghost btn-sm'; if (browserBtn) browserBtn.className = 'btn btn-primary btn-sm'; startRedirectAuth('antigravity'); } else { termBox.style.display = 'block'; browserBox.style.display = 'none'; if (termBtn) termBtn.className = 'btn btn-primary btn-sm'; if (browserBtn) browserBtn.className = 'btn btn-ghost btn-sm'; stopRedirectAuthPolling(); } } async function startNativeAuth(providerId, force = false) { stopNativeAuthPolling(); const box = document.getElementById('native-auth-box'); if (!box) return; const slotSelect = document.getElementById('wiz-native-slot'); const selectedSlot = slotSelect ? slotSelect.value : ''; box.innerHTML = `
⏳ Запуск терминала…
`; const res = await executeAction('start_native_auth', { provider: providerId, profile_id: selectedSlot || undefined, force: force, }); if (window._wiz_provider !== providerId) return; if (res && res.data && res.data.confirmation_required) { box.innerHTML = `
`; return; } if (!res || !res.ok) { const errorDetails = (res && res.data && res.data.checked_terminals) ? `
Проверено: ${escapeHtml(res.data.checked_terminals.join(', '))}
` : ''; box.innerHTML = `
`; return; } const d = res.data || {}; window._wiz_native_session = d.session_id; window._wiz_device_profile = d.profile_id; window._wiz_redirect_slot_id = d.profile_id; box.innerHTML = `
🟢 Терминал запущен (${escapeHtml(d.terminal_cmd || 'терминал')}) для слота ${escapeHtml(d.profile_id)}
Пройдите авторизацию в открывшемся окне терминала на сервере. После успешного входа agy сохранит учётные данные, и мастер продолжит настройку.
⏳ Ожидание завершения авторизации в терминале...
`; _nativeAuthTimer = setInterval(() => pollNativeAuth(providerId), 1500); } async function pollNativeAuth(providerId) { const statusEl = document.getElementById('native-auth-status'); if (!statusEl || !window._wiz_native_session) { stopNativeAuthPolling(); return; } const res = await executeAction('poll_native_auth', { session_id: window._wiz_native_session, }); if (!res) return; const d = res.data || {}; if (res.ok && d.status === 'completed') { stopNativeAuthPolling(); window._wiz_device_profile = d.profile_id; window._wiz_redirect_slot_id = d.profile_id; statusEl.innerHTML = ` ✓ Авторизация успешно завершена (${escapeHtml(d.email || 'Google Account')}) `; showToast('Аккаунт Antigravity успешно подключён через agy', 'success'); fetchSnapshot(); setTimeout(() => { if (window._wiz_provider === 'antigravity') { proceedToWizardStep3('antigravity'); } }, 800); return; } if (!res.ok || d.status === 'timeout' || d.status === 'failed') { stopNativeAuthPolling(); statusEl.innerHTML = ` ❌ ${escapeHtml(res.message || 'Время ожидания истекло или произошла ошибка')} `; return; } if (d.status === 'pending') { const elapsed = d.elapsed_sec ? ` (${d.elapsed_sec}с)` : ''; statusEl.innerHTML = `⏳ Ожидание завершения авторизации в терминале${elapsed}...`; } } 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 (window._wiz_provider !== providerId) return; 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; window._wiz_device_profile = 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(); window._wiz_device_profile = window._wiz_redirect_slot_id; 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(); window._wiz_device_profile = window._wiz_redirect_slot_id; 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(); } // ═══════════════════════════════════════════════════════════════ // SKILLS VIEW & SKILL DOCTOR // ═══════════════════════════════════════════════════════════════ let currentSkills = []; let skillsUsageData = null; async function fetchSkills() { try { const headers = {}; if (authToken) headers['X-Hub-Token'] = authToken; const [resSkills, resUsage] = await Promise.all([ fetch('/api/skills', { headers }).then(r => r.json()).catch(() => ({ skills: [] })), fetch('/api/skills/usage', { headers }).then(r => r.json()).catch(() => null), ]); currentSkills = resSkills.skills || []; skillsUsageData = resUsage; const navBadge = document.getElementById('nav-skills-count'); if (navBadge) navBadge.textContent = currentSkills.length; renderSkillsView(); } catch (err) { console.error('Error fetching skills:', err); } } function renderSkillsView() { const container = document.getElementById('skills-cards-container'); if (!container) return; const searchQuery = (document.getElementById('skills-search')?.value || '').toLowerCase().trim(); const filterSource = document.getElementById('filter-skills-source')?.value || 'all'; const filterStatus = document.getElementById('filter-skills-status')?.value || 'all'; const sourceSel = document.getElementById('filter-skills-source'); if (sourceSel) { const currentVal = sourceSel.value; const sources = Array.from(new Set(currentSkills.map(s => s.source_dir).filter(Boolean))); sourceSel.innerHTML = '' + sources.map(src => ``).join(''); if (sources.includes(currentVal)) sourceSel.value = currentVal; } const usageBadge = document.getElementById('skills-usage-summary-badge'); if (usageBadge) { if (skillsUsageData && skillsUsageData.has_usage && skillsUsageData.total_calls > 0) { usageBadge.textContent = `${skillsUsageData.total_calls} вызовов зарегистрировано`; usageBadge.className = 'badge healthy'; } else { usageBadge.textContent = 'Н/Д: вызовы со скиллами ещё не регистрировались'; usageBadge.className = 'badge'; } } const filtered = currentSkills.filter(s => { if (searchQuery) { const matchName = s.name.toLowerCase().includes(searchQuery); const matchDesc = (s.description || '').toLowerCase().includes(searchQuery); const matchTags = (s.tags || []).some(t => t.toLowerCase().includes(searchQuery)); const matchAgents = (s.assigned_agents || []).some(a => a.toLowerCase().includes(searchQuery)); if (!matchName && !matchDesc && !matchTags && !matchAgents) return false; } if (filterSource !== 'all' && s.source_dir !== filterSource) return false; if (filterStatus === 'valid' && !s.is_valid) return false; if (filterStatus === 'invalid' && s.is_valid) return false; if (filterStatus === 'assigned' && (!s.assigned_agents || !s.assigned_agents.length)) return false; if (filterStatus === 'unassigned' && s.assigned_agents && s.assigned_agents.length > 0) return false; return true; }); const statsSummary = document.getElementById('skills-stats-summary'); if (statsSummary) { statsSummary.textContent = `Показано ${filtered.length} из ${currentSkills.length} скиллов`; } if (!filtered.length) { container.innerHTML = `

Скиллы не найдены

Проверьте строку поиска, фильтры или добавьте файлы SKILL.md в ~/.hermes/skills/, ~/.claude/skills/ или .agents/skills/.

`; return; } container.innerHTML = filtered.map(s => { const isValid = s.is_valid; const statusBadge = isValid ? '🟢 Валиден' : `🔴 Ошибки (${s.critical_errors?.length || 1})`; const tagsHtml = (s.tags || []).map(t => `#${escapeHtml(t)}`).join(''); const assignedHtml = (s.assigned_agents && s.assigned_agents.length) ? s.assigned_agents.map(a => ` 👤 ${escapeHtml(a)} × `).join('') : 'Не назначен ни одному субагенту'; const callsText = (s.usage_count > 0) ? `Вызовы: ${s.usage_count} (успешно: ${s.success_count})` : 'Н/Д: ещё не вызывался'; return `
${escapeHtml(s.name)}
${escapeHtml(s.path)}
${statusBadge}
${escapeHtml(s.description || 'Описание отсутствует')}
${tagsHtml ? `
${tagsHtml}
` : ''}
Субагенты: ${assignedHtml}
${callsText} ${s.last_used_at ? `Последний: ${formatTimeAgo(s.last_used_at)}` : ''}
`; }).join(''); } async function openSkillDoctorModal(skillName, filepath) { elements.modalTitle.textContent = `🩺 Скилл-доктор: ${skillName}`; elements.modalBody.innerHTML = '
Диагностика файла SKILL.md...
'; elements.modalFooter.innerHTML = ''; showModal(); try { const headers = { 'Content-Type': 'application/json' }; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/skills/diagnose', { method: 'POST', headers, body: JSON.stringify({ skill_name: skillName, path: filepath }), }); const result = await res.json(); if (!result.ok || !result.diagnosis) { elements.modalBody.innerHTML = ``; return; } const diag = result.diagnosis; const isVal = diag.is_valid; const statusBadge = isVal ? '🟢 ВАЛИДЕН' : '🔴 ОБНАРУЖЕНЫ КРИТИЧЕСКИЕ ОШИБКИ'; const checksHtml = Object.values(diag.checks || {}).map(c => `
${c.passed ? '✅' : '❌'}
${escapeHtml(c.name)}: ${escapeHtml(c.details)}
`).join(''); const errorsHtml = (diag.critical_errors || []).length ? ` ` : ''; const warningsHtml = (diag.warnings || []).length ? ` ` : ''; const posQueries = (diag.test_queries?.positive || []).map(q => `
  • «${escapeHtml(q)}»
  • `).join(''); const negQueries = (diag.test_queries?.negative || []).map(q => `
  • «${escapeHtml(q)}»
  • `).join(''); elements.modalBody.innerHTML = `
    ${escapeHtml(diag.skill_name)}
    ${escapeHtml(diag.file_path || diag.file_name)}
    ${statusBadge}
    ${errorsHtml} ${warningsHtml}

    Результаты чек-листа:

    ${checksHtml}

    5 контрольных запросов (тестовые сценарии):

    Позитивные триггеры (скилл ДОЛЖЕН запускаться):
      ${posQueries}
    Негативные триггеры (скилл НЕ ДОЛЖЕН запускаться):
      ${negQueries}

    Рекомендованный исправленный однострочный description:

    ${escapeHtml(diag.fixed_description)}
    `; elements.modalFooter.innerHTML = ` `; document.getElementById('btn-copy-fixed-desc')?.addEventListener('click', () => { if (diag.fixed_description) { navigator.clipboard.writeText(diag.fixed_description); showToast('Исправленный description скопирован в буфер обмена', 'success'); } }); } catch (err) { elements.modalBody.innerHTML = ``; } } async function runDoctorAllSkills() { if (!currentSkills.length) { showToast('Скиллы не найдены', 'warning'); return; } showToast(`Запуск диагностики ${currentSkills.length} скиллов...`, 'info'); await fetchSkills(); showToast('Диагностика всех скиллов завершена', 'success'); } function openAssignSkillModal(skillName) { const agents = currentSnapshot?.workflow?.agents || [ { id: 'manager', name: 'Оркестратор (Manager)' }, { id: 'developer-1', name: 'Кодер 1' }, { id: 'developer-2', name: 'Кодер 2' }, { id: 'code-reviewer', name: 'Ревьюер кода' }, { id: 'tester', name: 'Тестировщик' }, { id: 'tech-writer', name: 'Технический писатель' }, { id: 'skill-doctor', name: 'Скилл-доктор' }, ]; elements.modalTitle.textContent = `Назначить скилл: ${skillName}`; elements.modalBody.innerHTML = `
    Выберите субагента, которому будет назначен навык ${escapeHtml(skillName)}. Навык будет добавлен в конфигурацию инструментов агента в workflow_state.json.
    `; elements.modalFooter.innerHTML = ` `; document.getElementById('btn-modal-confirm-assign')?.addEventListener('click', async () => { const selectedAgent = document.getElementById('modal-assign-agent-select')?.value; if (!selectedAgent) return; const res = await executeAction('assign_skill', { skill_name: skillName, agent_id: selectedAgent }); if (res && res.ok) { closeModal(); await fetchSkills(); } }); showModal(); } async function unassignSkillFromAgent(skillName, agentId) { const res = await executeAction('unassign_skill', { skill_name: skillName, agent_id: agentId }); if (res && res.ok) { await fetchSkills(); } } // ═══════════════════════════════════════════════════════════════ // OBSIDIAN VAULT & SHARED MEMORY // ═══════════════════════════════════════════════════════════════ async function checkObsidianVault(path) { const badge = document.getElementById('obsidian-vault-status-badge'); const details = document.getElementById('obsidian-vault-details'); const p = path || document.getElementById('setting-obsidian-vault-path')?.value || '/srv/projects/AI-Memory'; if (badge) { badge.textContent = 'Проверка...'; badge.className = 'badge'; } if (details) details.textContent = 'Выполняется проверка хранилища Obsidian...'; const res = await executeAction('check_obsidian_vault', { obsidian_vault_path: p }); if (res && res.ok) { if (badge) { badge.textContent = 'Доступно'; badge.className = 'badge healthy'; } if (details) details.innerHTML = `✓ ${escapeHtml(res.message)} (Заметок: ${res.data?.notes_count || 0})`; } else { if (badge) { badge.textContent = 'Недоступно'; badge.className = 'badge error'; } if (details) details.innerHTML = `⚠️ ${escapeHtml(res.message || 'Ошибка проверки хранилища')}`; } } async function setupMemoryStructure(path) { const details = document.getElementById('obsidian-vault-details'); const p = path || document.getElementById('setting-obsidian-vault-path')?.value || '/srv/projects/AI-Memory'; if (details) details.textContent = 'Развёртывание структуры памяти...'; const res = await executeAction('setup_memory', { obsidian_vault_path: p, project_name: 'hermes-hub' }); if (res && res.ok) { if (details) { details.innerHTML = `✓ ${escapeHtml(res.message)}
    Создано папок: ${(res.data?.created_dirs || []).join(', ') || 'все существовали'}`; } showToast(res.message || 'Структура памяти развёрнута', 'success'); } else { if (details) details.innerHTML = `⚠️ ${escapeHtml(res.message || 'Ошибка развёртывания структуры')}`; showToast(res.message || 'Ошибка развёртывания памяти', 'error'); } } async function handleClearAccounts() { const preview = await executeAction('clear_accounts', {}); if (!preview?.ok) return; const targets = preview.data.targets; const names = targets.map(item => item.profile_id).join('\n'); if (!targets.length) { showToast('Нет аккаунтов для очистки. Antigravity защищён.', 'info'); return; } if (!confirm(`Удалить ключи этих аккаунтов?\n${names}\n\nAntigravity не будет затронут. Повторное подключение остальных аккаунтов потребует ключей.`)) return; const result = await executeAction('clear_accounts', {confirmed: true, targets}); showToast(result?.message || 'Нет ответа от сервера', result?.ok ? 'success' : 'error'); await fetchSnapshot(); } // ═══════════════════════════════════════════════════════════════ // CONTEXT COMPRESSION UI (A56) // ═══════════════════════════════════════════════════════════════ async function checkCompressionStatus() { const badge = document.getElementById('compression-status-badge'); const details = document.getElementById('compression-details-box'); const res = await executeAction('get_compression_status', {}); if (res && res.ok && res.data) { const d = res.data; if (badge) { if (!d.configured || d.status === 'unconfigured') { badge.textContent = 'Н/Д: модель для сжатия не выбрана'; badge.className = 'badge'; } else if (d.status === 'ready') { badge.textContent = `🟢 Готов: ${d.model || d.profile_id} (порог ${d.threshold_percent}%)`; badge.className = 'badge healthy'; } else { badge.textContent = `⚠️ Недоступен: ${d.endpoint || d.profile_id}`; badge.className = 'badge warning'; } } if (details) { if (!d.configured || d.status === 'unconfigured') { details.innerHTML = 'Сжатие отключено. Выберите профиль модели для сжатия контекста в настройках.'; } else { const stats = `Эндпоинт: ${d.endpoint}\nМодель: ${d.model}\nИзмеренный контекст: ${d.n_ctx} токенов\nПорог запуска: ${d.threshold_percent}%\nСвежих сообщений без сжатия: ${d.keep_recent_messages}\nСжатий выполнено: ${d.history_count} (Сэкономлено токенов: ${d.total_saved_tokens})`; details.textContent = stats; } } } } async function testCompression() { const details = document.getElementById('compression-details-box'); const btn = document.getElementById('btn-test-compression'); if (btn) btn.disabled = true; if (details) details.textContent = '⏳ Выполняется тестовое сжатие контекста на сервере...'; try { const sel = document.getElementById('setting-compressor-profile'); const profileId = sel ? sel.value : null; const res = await executeAction('test_compression', { profile_id: profileId }); if (res && res.ok && res.data) { const outcome = res.data.outcome || {}; const factsRetained = outcome.facts_retained || 0; const factsTotal = outcome.facts_total || 0; const pct = outcome.retention_percent || 100; const text = `✓ ${res.message}\n` + `├─ Токены: ${outcome.tokens_before} → ${outcome.tokens_after} (${outcome.compression_ratio}x, экономия ${outcome.saved_tokens} токенов)\n` + `├─ Время выполнения: ${outcome.duration_sec}с\n` + `├─ Удержание фактов: ${factsRetained}/${factsTotal} (${pct}%)\n` + `└─ Сохранённые факты:\n${(outcome.retained_facts || []).map(f => ' • ' + f).join('\n')}`; if (details) details.textContent = text; showToast('Тест сжатия успешно выполнен: 100% фактов сохранено', 'success'); await checkCompressionStatus(); } else { if (details) details.textContent = `❌ Ошибка: ${(res && res.message) || 'Не удалось выполнить тестовое сжатие'}`; showToast((res && res.message) || 'Ошибка тестирования сжатия', 'error'); } } catch (err) { if (details) details.textContent = `❌ Исключение: ${err.message || String(err)}`; showToast('Ошибка при вызове теста сжатия', 'error'); } finally { if (btn) btn.disabled = false; } } // Attach event listeners for compression buttons document.getElementById('btn-check-compression-status')?.addEventListener('click', checkCompressionStatus); document.getElementById('btn-test-compression')?.addEventListener('click', testCompression);