/**
* 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 = `
Этот хаб открыт по сети, поэтому запросы к нему требуют токен.
Он был показан при выполнении enable_lan_access.py и хранится
на сервере в ~/.hermes/hub_settings.json.
Токен сохранится в этом браузере и больше спрашиваться не будет.
`;
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.
Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.
`;
}
// Каталог 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 `
Модели: ${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 = `
Все зарегистрированные профили уже включены в эту цепочку.
⚠️ Проверка доступности Antigravity активна: аккаунты Google могут отклоняться по региону («not currently available in your location»). Примените патч в Настройках или используйте прокси.
По умолчанию указан адрес на машине с 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 {
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 = `
⏳ ${escapeHtml(providerId)}: сохраняем аккаунт…
`;
}
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 = `
❌ ${escapeHtml((res && res.message) || 'Не удалось завершить подключение')}
`;
}
}
}
// ─────────────────────────────────────────────────────────────
// Авторизация по коду устройства (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 = `
${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}
🟢 Терминал запущен (${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 = `
${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}
`;
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
? `
Проще всего — пробросить порт возврата. Выполните у себя
в терминале, до подтверждения доступа:
Тогда вход завершится сам и вставлять ничего не придётся.
Без проброса браузер после подтверждения уйдёт на ${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 `
Слот ${escapeHtml(pid)} не входит ни в одну цепочку, поэтому в
«Обзоре» и «Маршрутизации» аккаунт не появится. Добавьте его нужной роли
в разделе «Маршрутизация» кнопкой «+ Добавить».
`;
}
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 = '
Выберите субагента, которому будет назначен навык ${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);