/**
* Hermes Hub Web Client
* Vanilla JavaScript (ES2022) — No npm, no build, no framework.
* Single source of truth: docs/web-api/CONTRACT.md
*/
// ── CONFIGURATION & STATE ──
// Set USE_MOCK_FIXTURE = true to develop strictly offline against snapshot.example.json
const USE_MOCK_FIXTURE = false;
let lastAppliedSeq = -1;
let currentSnapshot = null;
let activeView = 'accounts';
let pollTimer = null;
let pollIntervalMs = 5000;
let authToken = localStorage.getItem('hermes_hub_token') || '';
// ── DOM ELEMENTS ──
const elements = {
navItems: document.querySelectorAll('.nav-item'),
viewPanes: document.querySelectorAll('.view-pane'),
pageTitle: document.getElementById('page-title'),
navAccountsCount: document.getElementById('nav-accounts-count'),
headerReadinessBadge: document.getElementById('header-readiness-badge'),
headerReadinessText: document.getElementById('header-readiness-text'),
sourceText: document.getElementById('source-text'),
sourceDot: document.querySelector('#source-indicator .status-dot'),
accountsContainer: document.getElementById('accounts-container'),
accountsSearch: document.getElementById('accounts-search'),
filterProvider: document.getElementById('filter-provider'),
filterHealth: document.getElementById('filter-health'),
accountsStatsSummary: document.getElementById('accounts-stats-summary'),
btnRefreshAll: document.getElementById('btn-refresh-all'),
btnAddAccount: document.getElementById('btn-add-account'),
modalBackdrop: document.getElementById('modal-backdrop'),
modalTitle: document.getElementById('modal-title'),
modalBody: document.getElementById('modal-body'),
modalFooter: document.getElementById('modal-footer'),
modalCloseBtn: document.getElementById('modal-close-btn'),
toastContainer: document.getElementById('toast-container'),
};
// ── INITIALIZATION ──
document.addEventListener('DOMContentLoaded', () => {
initNavigation();
initEventListeners();
initSettings();
fetchSnapshot();
startPolling();
});
// ── NAVIGATION ──
function initNavigation() {
elements.navItems.forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
switchView(view);
});
});
}
function switchView(viewName) {
activeView = viewName;
elements.navItems.forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === viewName);
});
elements.viewPanes.forEach((pane) => {
pane.classList.toggle('active', pane.id === `view-${viewName}`);
});
const titles = {
accounts: 'Аккаунты и квоты',
overview: 'Обзор системы',
routing: 'Маршрутизация запросов',
providers: 'Модели и провайдеры',
team: 'Команда агентов',
logs: 'Журнал событий',
settings: 'Параметры веб-клиента',
};
elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub';
if (currentSnapshot) {
renderCurrentView();
}
}
// ── EVENT LISTENERS ──
function initEventListeners() {
if (elements.accountsSearch) elements.accountsSearch.addEventListener('input', () => renderAccountsView());
if (elements.filterProvider) elements.filterProvider.addEventListener('change', () => renderAccountsView());
if (elements.filterHealth) elements.filterHealth.addEventListener('change', () => renderAccountsView());
if (elements.btnRefreshAll) {
elements.btnRefreshAll.addEventListener('click', () => {
executeAction('refresh_all', {});
});
}
if (elements.btnAddAccount) {
elements.btnAddAccount.addEventListener('click', () => {
openAddAccountWizard();
});
}
if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal);
if (elements.modalBackdrop) {
elements.modalBackdrop.addEventListener('click', (e) => {
if (e.target === elements.modalBackdrop) closeModal();
});
}
const btnClearLogs = document.getElementById('btn-clear-logs');
if (btnClearLogs) {
btnClearLogs.addEventListener('click', () => {
const logsBox = document.getElementById('logs-container');
if (logsBox) logsBox.innerHTML = '
Журнал очищен пользователем.
';
});
}
}
// ── 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.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) {
console.warn('Live API unavailable, attempting snapshot.example.json fallback:', err);
try {
const fallbackRes = await fetch('snapshot.example.json');
if (fallbackRes.ok) {
const fallbackData = await fallbackRes.json();
setSourceIndicator(true, 'Фикстура (fallback)');
applySnapshot(fallbackData);
return;
}
} catch (e) {
// ignore
}
setSourceIndicator(false, 'Сервер недоступен');
}
}
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();
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-spare-1');
}
} 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);
}
}
// ── ACTIONS EXECUTION (POST /api/action) ──
async function executeAction(actionName, actionData = {}) {
showToast(`Выполняется «${actionName}»...`, 'info');
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Hub-Token'] = authToken;
const res = await fetch('/api/action', {
method: 'POST',
headers,
body: JSON.stringify({ action: actionName, data: actionData }),
});
const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` }));
if (result.ok) {
showToast(result.message || 'Действие выполнено успешно', 'success');
fetchSnapshot();
return result;
} else {
showToast(result.message || 'Отказ выполнения действия', 'warning');
return result;
}
} catch (err) {
console.error(`Action ${actionName} failed:`, err);
showToast(`Ошибка сети: ${err.message}`, 'error');
return { ok: false, message: `Ошибка сети: ${err.message}` };
}
}
// ── GLOBAL HEADER ──
function updateGlobalHeader() {
if (!currentSnapshot) return;
const totalAccounts = Object.keys(currentSnapshot.all_profiles || {}).length;
if (elements.navAccountsCount) elements.navAccountsCount.textContent = totalAccounts;
const readiness = currentSnapshot.readiness || {};
const isHealthy = readiness.state === 'healthy';
const readyRoles = readiness.roles_ready_count || 0;
const totalRoles = readiness.total_roles || 6;
if (elements.headerReadinessBadge) {
elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`;
}
if (elements.headerReadinessText) {
elements.headerReadinessText.textContent = readiness.title_ru
? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)`
: 'Система готова';
}
const kpiReadiness = document.getElementById('kpi-system-readiness');
const kpiSummary = document.getElementById('kpi-readiness-summary');
const kpiTotalAccounts = document.getElementById('kpi-total-accounts');
const kpiAccountsSub = document.getElementById('kpi-accounts-sub');
const kpiReadyRoles = document.getElementById('kpi-ready-roles');
const kpiRolesSub = document.getElementById('kpi-roles-sub');
const kpiProvidersCount = document.getElementById('kpi-providers-count');
if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает';
if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны';
if (kpiTotalAccounts) kpiTotalAccounts.textContent = totalAccounts;
if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${readiness.accounts_connected_count || totalAccounts}`;
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5;
}
// ── VIEW ROUTER ──
function renderCurrentView() {
if (!currentSnapshot) return;
switch (activeView) {
case 'accounts':
renderAccountsView();
break;
case 'overview':
renderOverviewView();
break;
case 'routing':
renderRoutingView();
break;
case 'providers':
renderProvidersView();
break;
case 'team':
renderTeamView();
break;
case 'logs':
renderLogsView();
break;
}
}
// ═══════════════════════════════════════════════════════════════
// 1. ACCOUNTS VIEW (P0-1 Compact Fixed-Height Cards & Quotas)
// ═══════════════════════════════════════════════════════════════
function renderAccountsView() {
const container = elements.accountsContainer;
if (!container || !currentSnapshot) 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)',
};
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) => {
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;
});
if (filtered.length === 0) continue;
visibleProfiles += filtered.length;
html += `
${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', () => {
const profileId = card.dataset.profileId;
openAccountDetailsModal(profileId);
});
});
}
function renderAccountCard(profile) {
const isMain = profile.is_main_account || profile.is_main_orchestrator;
const roles = (profile.assigned_roles || []).join(', ') || 'Роль: Н/Д';
const identity = profile.email || profile.account_identity || profile.display_name || profile.profile_id;
const healthState = profile.health_state || 'unknown';
const healthLabel = profile.health_label_ru || (profile.enabled ? 'Работает' : 'Отключён');
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;
// Опрос провайдера идёт в фоне и занимает секунды. Пока он не завершился,
// корзины пусты — но это НЕ «данных нет». Показывать в этот момент «Н/Д»
// значит выдавать загрузку за отсутствие данных: владелец видел ровно это
// и решил, что лимиты не подтягиваются. Причина отказа важнее флага: если
// провайдер уже ответил «лимитов не даю», это не загрузка.
const isLoading = Boolean(quotaSnap && quotaSnap.is_loading) && !unavailableReason;
let quotaGridHtml = '';
if (buckets.length > 0) {
const visibleBuckets = buckets.slice(0, 4);
quotaGridHtml = `
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason, isLoading)).join('')}
`;
} else {
const reasonText = (isLoading ? 'Опрашиваем провайдера…' : null) || unavailableReason || (
profile.health_state === 'not_configured' || profile.health_state === 'auth_required'
? 'Аккаунт не подключён'
: 'Провайдер не отдаёт лимиты'
);
quotaGridHtml = `
Квота
${isLoading ? 'Загрузка…' : 'Н/Д'}
${escapeHtml(reasonText)}
`;
}
return `
${escapeHtml(identity)}
${escapeHtml(profile.display_name)} • ${escapeHtml(roles)}
${quotaGridHtml}
`;
}
function renderQuotaCell(bucket, unavailableReason, isLoading) {
const remaining = bucket.remaining_percent;
let formattedValue = isLoading ? 'Загрузка…' : 'Н/Д';
let barWidth = 0;
let colorClass = 'var(--status-disabled)';
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 = bucket.reset_at
? `Сброс: ${formatIsoDate(bucket.reset_at)}`
: (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера'));
if (isLoading && typeof remaining !== 'number') {
resetText = 'Опрашиваем провайдера…';
}
return `
${escapeHtml(bucket.display_name)}
${escapeHtml(formattedValue)}
${escapeHtml(resetText)}
`;
}
// ═══════════════════════════════════════════════════════════════
// 2. OVERVIEW VIEW
// ═══════════════════════════════════════════════════════════════
function renderOverviewView() {
if (!currentSnapshot) return;
const diagramBox = document.getElementById('overview-route-diagram');
if (diagramBox) {
const roles = currentSnapshot.routing || {};
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) => `
${idx === 0 ? '★ Основной' : `Резерв ${idx}`}
${node.is_active ? '● Активен' : 'Ожидание'}
${escapeHtml(node.display_name || node.profile_id)}
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
`).join('')}
`;
}
diagramBox.innerHTML = diagramHtml || 'Нет данных маршрутизации.
';
}
const provSummaryBox = document.getElementById('overview-providers-summary');
if (provSummaryBox) {
const providers = currentSnapshot.providers || [];
provSummaryBox.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
Онлайн: ${prov.online_count}/${prov.connected_count} •
Требуют входа: ${prov.auth_required_count} •
Холодный резерв: ${prov.cold_spare_count}
Модели: ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(', ')) : 'Н/Д — список моделей ещё не получен'}
`).join('') || 'Нет данных провайдеров.
';
}
}
// ═══════════════════════════════════════════════════════════════
// 3. ROUTING VIEW
// ═══════════════════════════════════════════════════════════════
function renderRoutingView() {
const container = document.getElementById('routing-pipelines-container');
if (!container || !currentSnapshot) return;
const routing = currentSnapshot.routing || {};
let html = '';
for (const [roleId, pipeline] of Object.entries(routing)) {
const nodes = pipeline.nodes || [];
html += `
${nodes.map((node, index) => `
${index === 0 ? 'Основной' : `Резерв ${index}`}
${node.is_active ? '● АКТИВЕН' : ''}
${escapeHtml(node.display_name || node.profile_id)}
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
${node.failover_reason ? `
Причина: ${escapeHtml(node.failover_reason)}
` : ''}
${index < nodes.length - 1 ? '
→ ' : ''}
`).join('')}
`;
}
container.innerHTML = html || 'Маршрутизация не настроена.
';
}
// ═══════════════════════════════════════════════════════════════
// 4. PROVIDERS VIEW
// ═══════════════════════════════════════════════════════════════
function renderProvidersView() {
const container = document.getElementById('providers-full-container');
if (!container || !currentSnapshot) return;
const providers = currentSnapshot.providers || [];
container.innerHTML = providers.map((prov) => `
Онлайн: ${prov.online_count}/${prov.connected_count} •
Требуют авторизации: ${prov.auth_required_count} •
Квота исчерпана: ${prov.quota_exhausted_count} •
Холодный резерв: ${prov.cold_spare_count}
Обнаруженные модели:
${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(' • ')) : 'Н/Д — список моделей ещё не получен от провайдера'}
`).join('') || 'Список провайдеров пуст.
';
}
// ═══════════════════════════════════════════════════════════════
// 5. TEAM VIEW
// ═══════════════════════════════════════════════════════════════
function renderTeamView() {
const container = document.getElementById('team-cards-container');
if (!container || !currentSnapshot) return;
const agents = currentSnapshot.agents || [];
container.innerHTML = agents.map((agent) => `
${escapeHtml(agent.role_name_ru || agent.role_id)}
${agent.is_main_orchestrator ? '👑 ЛИДЕР ' : ''}
${escapeHtml(agent.role_description_ru || '')}
Профиль: ${escapeHtml(agent.assigned_profile_id || 'Не назначен')}
Провайдер: ${escapeHtml(agent.provider_display_name || agent.provider)}
Модель: ${escapeHtml(agent.model || '—')}
● ${escapeHtml(agent.status_label_ru || 'Работает')}
Детали →
`).join('') || 'Команда агентов пуста.
';
}
// ═══════════════════════════════════════════════════════════════
// 6. LOGS VIEW
// ═══════════════════════════════════════════════════════════════
function renderLogsView() {
const container = document.getElementById('logs-container');
if (!container || !currentSnapshot) return;
const logs = currentSnapshot.metrics?.recent_events || [];
if (logs.length > 0) {
container.innerHTML = logs.map((log) => `
[${escapeHtml(log.time || '')}]
${escapeHtml(log.role || '')} :
${escapeHtml(log.message || '')}
`).join('');
}
}
// ═══════════════════════════════════════════════════════════════
// MODALS & WIZARDS
// ═══════════════════════════════════════════════════════════════
function openAccountDetailsModal(profileId) {
if (!currentSnapshot) return;
const profile = (currentSnapshot.all_profiles || {})[profileId];
if (!profile) return;
const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId];
const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : [];
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(', ') || 'Нет')}
Квоты и корзины провайдера
${buckets.map((b) => `
${escapeHtml(b.display_name)}
${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
`).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'}
`;
elements.modalFooter.innerHTML = `
⚡ Проверить подключение
★ Сделать основным
Удалить ключ
Закрыть
`;
showModal();
}
async function handleTestProfile(profileId) {
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '⏳ Запуск тестового запроса к провайдеру...
';
}
const res = await executeAction('test', { profile_id: profileId });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `✓ ${escapeHtml(res.message || 'Тест успешно пройден')}
`;
} else {
feedbackArea.innerHTML = `❌ ${escapeHtml(res.message || 'Тест завершился с ошибкой')}
`;
}
}
}
// ── Add Account Wizard (P0-5 Headless Server Honesty) ──
function openAddAccountWizard() {
elements.modalTitle.textContent = 'Мастер подключения учетной записи';
showWizardStep1();
showModal();
}
function showWizardStep1() {
elements.modalBody.innerHTML = `
Шаг 1 из 3: Выберите провайдера ИИ
●
Grok (xAI)
Device Code OAuth (работает на сервере) или API Key
●
OpenAI Codex
Device Code OAuth (работает на сервере) или API Key
●
OpenCode Go
API Key / Токен подписки
●
Claude (Anthropic)
API Key или OAuth (требует SSH проброс портов)
●
Google Antigravity
OAuth редирект (требует браузер или перенос профиля)
`;
elements.modalFooter.innerHTML = `
Отмена
`;
}
function showWizardStep2(providerId) {
let bodyHtml = '';
if (providerId === 'grok' || providerId === 'openai-codex') {
bodyHtml = `
Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'}
1. Откройте ссылку на любом устройстве:
📋 Копировать
2. Введите код подтверждения:
${providerId === 'grok' ? 'GRK-7842' : 'CDX-9104'}
📋 Копировать код
3. Подтвердите доступ в браузере. Hub автоматически зафиксирует авторизацию.
`;
} else if (providerId === 'antigravity' || providerId === 'claude') {
bodyHtml = `
Шаг 2 из 3: Авторизация ${providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'}
⚠️ Внимание (Headless Сервер):
Провайдер ${providerId} использует локальный OAuth redirect (localhost). На сервере без браузера редирект придёт на локальную машину.
Рекомендуемые варианты:
1. Использовать API Key провайдера.
2. Пробросить порт через SSH: ssh -L 8085:localhost:8085 user@server
3. Авторизоваться на локальном ПК и скопировать ~/.hermes/agy_profiles на сервер.
Вставьте API Key / Токен авторизации:
`;
} else {
bodyHtml = `
Шаг 2 из 3: Ввод API ключа ${providerId}
API Key / Subscription Token:
`;
}
elements.modalBody.innerHTML = `
${bodyHtml}
`;
elements.modalFooter.innerHTML = `
← Назад
Продолжить →
`;
}
function showWizardStep3(providerId) {
elements.modalBody.innerHTML = `
Шаг 3 из 3: Назначение роли для нового аккаунта
Целевая роль в роутере:
Кодер 1 (Primary Coder)
Кодер 2 (Secondary Coder)
Оркестратор (Fallback Router)
Ревьюер кода (Reviewer)
Исследователь (Researcher)
Быстрый агент (Fast / Flash)
Резервный пул (Spare Pool)
`;
elements.modalFooter.innerHTML = `
← Назад
✓ Завершить подключение
`;
}
async function finishAddAccount(providerId) {
const roleSelect = document.getElementById('wiz-target-role');
const targetRole = roleSelect ? roleSelect.value : 'coder-primary';
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '⏳ Сохранение учетной записи в роутере...
';
}
const res = await executeAction('add_account', {
provider: providerId,
target_role: targetRole,
});
if (res.ok) {
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
closeModal();
fetchSnapshot();
} else {
if (feedbackArea) {
feedbackArea.innerHTML = `❌ ${escapeHtml(res.message || 'Не удалось завершить подключение')}
`;
}
}
}
function openEditRouteModal(roleId) {
if (!currentSnapshot) return;
const pipeline = (currentSnapshot.routing || {})[roleId];
if (!pipeline) return;
const nodes = [...(pipeline.nodes || [])];
function renderRows() {
return nodes.map((node, index) => `
${index + 1}.
${escapeHtml(node.display_name || node.profile_id)}
(${escapeHtml(node.provider)})
↑
↓
✕
`).join('') || 'Цепочка пуста.
';
}
window.activeRouteNodes = nodes;
elements.modalTitle.textContent = `Цепочка маршрутизации: ${pipeline.role_name_ru || roleId}`;
elements.modalBody.innerHTML = `
Первый профиль — основной (Primary). Нижестоящие профили используются как резервы в порядке переключения.
${renderRows()}
`;
elements.modalFooter.innerHTML = `
Отмена
Сохранить цепочку
`;
showModal();
}
window.moveRouteNode = function(roleId, index, delta) {
const nodes = window.activeRouteNodes;
const target = index + delta;
if (target >= 0 && target < nodes.length) {
const temp = nodes[index];
nodes[index] = nodes[target];
nodes[target] = temp;
openEditRouteModal(roleId);
}
};
window.removeRouteNode = function(roleId, index) {
const nodes = window.activeRouteNodes;
nodes.splice(index, 1);
openEditRouteModal(roleId);
};
async function saveRouteChain(roleId) {
const chain = (window.activeRouteNodes || []).map((n) => n.profile_id);
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '⏳ Сохранение конфигурации...
';
}
const res = await executeAction('edit_route', {
role_id: roleId,
chain: chain,
});
if (res.ok) {
showToast(`Цепочка '${roleId}' сохранена`, 'success');
closeModal();
fetchSnapshot();
} else {
if (feedbackArea) {
feedbackArea.innerHTML = `❌ ${escapeHtml(res.message || 'Ошибка сохранения')}
`;
}
}
}
// ── SETTINGS MANAGEMENT ──
function initSettings() {
const btnSave = document.getElementById('btn-save-client-settings');
const tokenInput = document.getElementById('setting-auth-token');
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();
});
}
}
// ── MODAL HELPERS ──
function showModal() {
if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden');
}
function closeModal() {
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;
}
}