Files
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
New router intelligence modules (26 files): alert_ingest/store, audit_store,
architecture_pressure, backlog_generator/store, cost_analyzer, data_governance,
dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment,
platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files),
signature_state_store, sofiia_auto_router, tool_governance

New services:
- sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static
- memory-service: integration_endpoints, integrations, voice_endpoints, static UI
- aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents)
- sofiia-supervisor: new supervisor service
- aistalk-bridge-lite: Telegram bridge lite
- calendar-service: CalDAV calendar service with reminders
- mlx-stt-service / mlx-tts-service: Apple Silicon speech services
- binance-bot-monitor: market monitor service
- node-worker: STT/TTS memory providers

New tools (9): agent_email, browser_tool, contract_tool, observability_tool,
oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault

New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus,
farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine,
session_context, style_adapter, telemetry)

Tests: 85+ test files for all new modules
Made-with: Cursor
2026-03-03 07:14:14 -08:00

1142 lines
56 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SOFIIA — Control Console</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a1f; --bg2: #27272a; --bg3: #0f0f12;
--border: #3f3f46; --text: #e0e0e0; --muted: #71717a;
--gold: #c9a87c; --gold2: #8b7355;
--ok: #22c55e; --warn: #f59e0b; --err: #ef4444; --accent: #a78bfa;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg3); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
header { background: var(--bg); border-bottom: 1px solid var(--border); padding: 10px 16px; display: flex; align-items: center; gap: 12px; }
.logo { font-size: 1.3rem; font-weight: 700; color: var(--gold); letter-spacing: 2px; }
.subtitle { font-size: 0.76rem; color: var(--muted); }
.hright { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.status-dot.ok { background: var(--ok); }
.status-dot.err { background: var(--err); }
#globalStatus { font-size: 0.75rem; color: var(--muted); }
#bffStatus { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; background: var(--bg2); border: 1px solid var(--border); }
#bffStatus.ok { color: var(--ok); border-color: var(--ok); }
#bffStatus.err { color: var(--err); border-color: var(--err); }
nav { background: var(--bg); border-bottom: 1px solid var(--border); display: flex; padding: 0 14px; gap: 2px; overflow-x: auto; }
nav button { background: none; border: none; color: var(--muted); padding: 9px 13px; cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent; white-space: nowrap; }
nav button:hover { color: var(--text); }
nav button.active { color: var(--gold); border-bottom-color: var(--gold); }
main { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.section { display: none; flex: 1; flex-direction: column; overflow: hidden; }
.section.active { display: flex; }
/* Chat toolbar */
.chat-toolbar { background: var(--bg); border-bottom: 1px solid var(--border); padding: 8px 14px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
select.model-select { padding: 5px 9px; background: var(--bg2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; font-size: 0.82rem; }
select.model-select:focus { outline: none; border-color: var(--gold); }
.toolbar-check { display: flex; align-items: center; gap: 5px; font-size: 0.82rem; cursor: pointer; }
.toolbar-check input[type=checkbox] { accent-color: var(--gold); width: 14px; height: 14px; }
#voiceStatus { font-size: 0.74rem; color: var(--muted); margin-left: auto; }
/* Chat log */
#chatLog { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
.msg { padding: 9px 13px; border-radius: 12px; max-width: 88%; line-height: 1.55; font-size: 0.87rem; word-break: break-word; }
.msg.user { background: rgba(201,168,124,0.17); align-self: flex-end; border-bottom-right-radius: 4px; }
.msg.ai { background: rgba(255,255,255,0.05); align-self: flex-start; border-bottom-left-radius: 4px; }
.msg.system { background: rgba(167,139,250,0.09); align-self: center; font-size: 0.74rem; color: var(--muted); border-radius: 6px; max-width: 100%; padding: 5px 11px; }
.msg-sender { font-size: 0.69rem; font-weight: 700; margin-bottom: 3px; color: var(--gold); text-transform: uppercase; letter-spacing: 0.4px; }
.msg.user .msg-sender { color: var(--gold2); }
.typing-indicator { display: inline-flex; gap: 4px; align-items: center; padding: 3px 0; }
.typing-indicator span { width: 5px; height: 5px; border-radius: 50%; background: var(--gold); animation: bounce 1.1s infinite; }
.typing-indicator span:nth-child(2) { animation-delay: 0.18s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.36s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); } 30% { transform: translateY(-5px); } }
/* Input bar */
.chat-input-bar { background: var(--bg); border-top: 1px solid var(--border); padding: 9px 12px; display: flex; gap: 7px; align-items: flex-end; }
.chat-input-bar textarea { flex: 1; padding: 8px 11px; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 0.87rem; resize: none; max-height: 120px; min-height: 36px; line-height: 1.45; font-family: inherit; }
.chat-input-bar textarea:focus { outline: none; border-color: var(--gold); }
/* Buttons */
.btn { padding: 8px 14px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.82rem; font-weight: 500; transition: opacity 0.15s; white-space: nowrap; }
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-gold { background: linear-gradient(135deg, var(--gold), var(--gold2)); color: #1a1a1f; }
.btn-ghost { background: var(--bg2); color: var(--text); border: 1px solid var(--border); }
.btn-sm { padding: 4px 9px; font-size: 0.76rem; }
.btn-record { background: var(--bg2); border: 1px solid var(--border); color: var(--text); width: 38px; padding: 8px; font-size: 0.92rem; }
.btn-record.recording { background: var(--err); border-color: var(--err); color: #fff; animation: pulse 0.9s infinite; }
.btn-record.muted { background: var(--bg2); border-color: var(--warn); color: var(--warn); }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 7px rgba(239,68,68,0); } }
/* Panels */
.panel { flex: 1; overflow-y: auto; padding: 13px 14px; display: flex; flex-direction: column; gap: 11px; }
.sec-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 3px; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px; }
.card h3 { font-size: 0.87rem; font-weight: 600; color: var(--gold); margin-bottom: 10px; }
.row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
.row:last-child { border-bottom: none; }
.row .label { color: var(--muted); }
.badge { padding: 2px 7px; border-radius: 4px; font-size: 0.71rem; font-weight: 600; }
.badge.ok { background: rgba(34,197,94,0.18); color: var(--ok); }
.badge.err { background: rgba(239,68,68,0.18); color: var(--err); }
.badge.warn{ background: rgba(245,158,11,0.18); color: var(--warn); }
/* Ops */
.ops-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(185px, 1fr)); gap: 7px; }
.ops-btn { padding: 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); cursor: pointer; text-align: left; font-size: 0.82rem; line-height: 1.4; transition: border-color 0.12s; }
.ops-btn:hover { border-color: var(--gold); background: var(--bg2); }
.ops-btn .ops-icon { font-size: 1.2rem; display: block; margin-bottom: 3px; }
.ops-btn .ops-name { font-weight: 600; display: block; }
.ops-btn .ops-desc { font-size: 0.72rem; color: var(--muted); }
.result-box { background: var(--bg); border: 1px solid var(--border); border-radius: 7px; padding: 11px; font-size: 0.79rem; white-space: pre-wrap; max-height: 360px; overflow-y: auto; font-family: "SF Mono","Fira Code",monospace; }
.running-row { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.82rem; }
.spinner { width: 13px; height: 13px; border: 2px solid var(--border); border-top-color: var(--gold); border-radius: 50%; animation: spin 0.6s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Nodes */
.nodes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 9px; }
.node-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px; }
.node-card.online { border-color: rgba(34,197,94,0.35); }
.node-card.offline { border-color: rgba(239,68,68,0.35); }
.node-header { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
.node-name { font-weight: 600; font-size: 0.9rem; }
.node-url { font-size: 0.72rem; color: var(--muted); }
.node-meta { font-size: 0.69rem; color: var(--muted); margin-top: 7px; display: flex; flex-wrap: wrap; gap: 5px; }
.node-meta span { padding: 2px 6px; background: var(--bg2); border-radius: 3px; border: 1px solid var(--border); }
.latency-pill { padding: 1px 5px; border-radius: 3px; font-size: 0.68rem; background: rgba(201,168,124,0.12); color: var(--gold); border: 1px solid rgba(201,168,124,0.25); }
.nodes-summary { display: flex; gap: 9px; margin-bottom: 9px; flex-wrap: wrap; }
.nodes-summary .sum-card { flex: 1; min-width: 90px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; padding: 8px 11px; text-align: center; }
.nodes-summary .sum-val { font-size: 1.4rem; font-weight: 700; color: var(--gold); }
.nodes-summary .sum-lbl { font-size: 0.68rem; color: var(--muted); }
/* Voice chips */
.voices-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.voice-chip { padding: 3px 9px; background: var(--bg2); border: 1px solid var(--border); border-radius: 20px; font-size: 0.74rem; cursor: pointer; }
.voice-chip:hover, .voice-chip.active { border-color: var(--gold); color: var(--gold); }
/* TTS stop button */
.btn-stop { background: rgba(239,68,68,0.18); border: 1px solid var(--err); color: var(--err); width: 32px; padding: 5px; font-size: 0.9rem; border-radius: 6px; cursor: pointer; display: none; }
.btn-stop.visible { display: inline-flex; align-items: center; justify-content: center; }
.btn-stop:hover { background: rgba(239,68,68,0.35); }
/* Speed slider */
.speed-control { display: flex; align-items: center; gap: 5px; font-size: 0.74rem; color: var(--muted); }
.speed-control input[type=range] { width: 72px; accent-color: var(--gold); cursor: pointer; }
.speed-control .speed-val { min-width: 28px; color: var(--gold); font-weight: 600; }
/* Settings panel */
.settings-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 0.83rem; }
.settings-row:last-child { border-bottom: none; }
.settings-row label { color: var(--muted); }
.settings-row input[type=text], .settings-row input[type=number] { padding: 5px 9px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 0.82rem; width: 220px; }
.settings-row input:focus { outline: none; border-color: var(--gold); }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Session/Project bar */
.session-bar { background: var(--bg3); border-bottom: 1px solid var(--border); padding: 4px 14px; display: flex; align-items: center; gap: 10px; font-size: 0.74rem; color: var(--muted); flex-wrap: wrap; }
.session-bar select { padding: 3px 7px; background: var(--bg2); border: 1px solid var(--border); color: var(--text); border-radius: 5px; font-size: 0.74rem; }
.session-bar .sess-id { font-family: "SF Mono","Fira Code",monospace; color: var(--accent); font-size: 0.68rem; }
.session-bar .btn-xs { padding: 2px 7px; font-size: 0.7rem; border: 1px solid var(--border); background: var(--bg2); color: var(--text); border-radius: 4px; cursor: pointer; }
.session-bar .btn-xs:hover { border-color: var(--gold); }
/* Live events panel */
.events-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.events-toolbar { background: var(--bg); border-bottom: 1px solid var(--border); padding: 7px 14px; display: flex; align-items: center; gap: 9px; }
.ws-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.ws-dot.ok { background: var(--ok); animation: wsPulse 2s infinite; }
.ws-dot.err { background: var(--err); }
@keyframes wsPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
#eventsLog { flex: 1; overflow-y: auto; font-family: "SF Mono","Fira Code",monospace; font-size: 0.73rem; padding: 8px 12px; display: flex; flex-direction: column; gap: 2px; }
.ev-line { padding: 3px 7px; border-radius: 4px; background: var(--bg); border-left: 3px solid var(--border); line-height: 1.4; }
.ev-line.chat { border-left-color: var(--gold); }
.ev-line.voice { border-left-color: var(--accent); }
.ev-line.ops { border-left-color: var(--ok); }
.ev-line.error { border-left-color: var(--err); }
.ev-line.nodes { border-left-color: var(--muted); }
.ev-ts { color: var(--muted); margin-right: 6px; font-size: 0.67rem; }
.ev-type { font-weight: 700; margin-right: 6px; }
</style>
</head>
<body>
<header>
<div>
<div class="logo">SOFIIA</div>
<div class="subtitle">CTO DAARION · AI Control Console</div>
</div>
<div class="hright">
<span class="status-dot" id="headerDot"></span>
<span id="globalStatus">Перевірка...</span>
<span id="bffStatus">BFF: —</span>
</div>
</header>
<nav>
<button class="active" data-tab="chat">💬 Чат</button>
<button data-tab="ops">⚙️ Ops</button>
<button data-tab="nodes">🖥 Ноди</button>
<button data-tab="memory">🧠 Пам'ять</button>
<button data-tab="events">📡 Events</button>
<button data-tab="settings">⚙ Налаштування</button>
</nav>
<!-- Session bar -->
<div class="session-bar">
<span>Проект:</span>
<select id="projectSelect" onchange="onProjectChange()">
<option value="default">default</option>
<option value="daarion">daarion</option>
<option value="aistalk">aistalk</option>
</select>
<span>Сесія:</span>
<span class="sess-id" id="sessionIdLabel"></span>
<button class="btn-xs" onclick="newSession()">+ Нова</button>
<span style="margin-left:auto;font-size:0.68rem;color:var(--muted);">user: console_user</span>
</div>
<main>
<!-- ── CHAT ── -->
<div class="section active" id="section-chat">
<div class="chat-toolbar">
<select class="model-select" id="modelSelect">
<optgroup label="🔮 Ollama (Local)">
<option value="ollama:qwen3:14b">Qwen3 14B (local)</option>
<option value="ollama:gemma3">Gemma 3 3.3B (local)</option>
<option value="ollama:mistral-nemo:12b">Mistral Nemo 12B (1M ctx) ⭐</option>
<option value="ollama:glm-4.7-flash:32k">GLM-4.7 Flash 32K (local)</option>
<option value="ollama:deepseek-r1:70b">DeepSeek R1 70B (local)</option>
<option value="ollama:deepseek-coder:33b">DeepSeek Coder 33B (local)</option>
<option value="ollama:llava:13b">LLaVA 13B (local)</option>
<option value="ollama:phi3:latest">Phi-3 3.8B (local)</option>
<option value="ollama:gpt-oss:latest">GPT-Oss 13B (local)</option>
<option value="ollama:starcoder2:3b">StarCoder2 3B (local)</option>
</optgroup>
<optgroup label="🌐 Router">
<option value="router:sofiia">Sofiia (via Router)</option>
</optgroup>
<optgroup label="🟢 GLM (Zhipu API)">
<option value="glm:glm-5">GLM-5 ⭐</option>
<option value="glm:glm-4.7">GLM-4.7</option>
<option value="glm:glm-4.6">GLM-4.6</option>
<option value="glm:glm-4.5">GLM-4.5</option>
<option value="glm:glm-4.5-air">GLM-4.5 Air 💸</option>
</optgroup>
<optgroup label="🧠 Grok 4 (xAI)">
<option value="grok:grok-4-1-fast-reasoning">Grok 4.1 Fast Reasoning ⚡</option>
<option value="grok:grok-4-1-fast-non-reasoning">Grok 4.1 Fast</option>
<option value="grok:grok-4-0709">Grok 4 (flagship)</option>
<option value="grok:grok-code-fast-1">Grok Code Fast</option>
<option value="grok:grok-3-mini">Grok 3 Mini 💸</option>
<option value="grok:grok-3">Grok 3</option>
</optgroup>
</select>
<label class="toolbar-check"><input type="checkbox" id="autoSpeak" checked> 🔊 TTS</label>
<button class="btn-stop" id="stopTtsBtn" onclick="stopTTS()" title="Зупинити озвучення"></button>
<div class="speed-control">
<span>🐢</span>
<input type="range" id="ttsSpeed" min="0.5" max="2.0" step="0.1" value="1.0" oninput="onSpeedChange(this)">
<span>🐇</span>
<span class="speed-val" id="ttsSpeedVal">1.0×</span>
</div>
<label class="toolbar-check"><input type="checkbox" id="contVoice"> 🎙️ Безперервний</label>
<span id="voiceStatus">Готовий</span>
</div>
<div id="chatLog"></div>
<div class="chat-input-bar">
<button class="btn btn-record" id="voiceBtn" onclick="toggleVoiceWithStop()" title="Голосовий ввід">🎤</button>
<textarea id="chatInput" placeholder="Напишіть повідомлення... (Enter — надіслати)" rows="1"></textarea>
<button class="btn btn-gold" id="sendBtn" onclick="sendMessage()">Надіслати</button>
</div>
</div>
<!-- ── OPS ── -->
<div class="section" id="section-ops">
<div class="panel">
<div class="sec-label">Governance операції</div>
<div class="ops-grid" id="opsGrid"><div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div></div>
<div id="opsRunning" style="display:none;" class="running-row">
<div class="spinner"></div><span id="opsRunningLabel">Виконую...</span>
</div>
<div id="opsResultWrap" style="display:none;">
<div class="sec-label">Результат</div>
<div class="result-box" id="opsResult"></div>
</div>
</div>
</div>
<!-- ── NODES ── -->
<div class="section" id="section-nodes">
<div class="panel">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:8px;">
<div class="sec-label" style="margin:0;">Стан мережі DAARION</div>
<button class="btn btn-ghost btn-sm" onclick="loadNodes(true)">↻ Оновити</button>
<span id="nodesCachedBadge" style="font-size:0.69rem;color:var(--muted);margin-left:auto;"></span>
</div>
<div class="nodes-summary" id="nodesSummary"></div>
<div class="nodes-grid" id="nodesGrid"><div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div></div>
</div>
</div>
<!-- ── MEMORY ── -->
<div class="section" id="section-memory">
<div class="panel">
<div style="display:flex;align-items:center;gap:7px;">
<div class="sec-label" style="margin:0;">Memory &amp; Voice</div>
<button class="btn btn-ghost btn-sm" onclick="loadMemoryStatus()">↻ Оновити</button>
</div>
<div class="card" id="memStatusCard">
<h3>🧠 Memory Service</h3>
<div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div>
</div>
<div class="card">
<h3>🎙️ Голос</h3>
<div class="row"><span class="label">STT</span><span>whisper-large-v3-turbo (mlx-audio)</span></div>
<div class="row"><span class="label">TTS</span><span>edge-tts · uk-UA-PolinaNeural / OstapNeural</span></div>
<div class="row"><span class="label">Fallback</span><span>macOS say (Milena / Yuri)</span></div>
<div class="row">
<span class="label">Голос TTS</span>
<div class="voices-list" id="voicesList">
<div class="voice-chip active" data-voice="default" onclick="selectVoice(this)">Polina</div>
<div class="voice-chip" data-voice="Ostap" onclick="selectVoice(this)">Ostap</div>
<div class="voice-chip" data-voice="Milena" onclick="selectVoice(this)">Milena</div>
<div class="voice-chip" data-voice="Yuri" onclick="selectVoice(this)">Yuri</div>
</div>
</div>
</div>
<div class="card">
<h3>🧪 Тест TTS</h3>
<div style="display:flex;gap:7px;align-items:flex-start;flex-wrap:wrap;">
<textarea id="ttsTestText" rows="2" style="flex:1;min-width:160px;padding:7px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:inherit;font-size:0.82rem;">Привіт! Я Sofiia, CTO DAARION. Система активна.</textarea>
<button class="btn btn-gold" onclick="testTTS()">▶ Озвучити</button>
</div>
</div>
</div>
</div>
<!-- ── EVENTS ── -->
<div class="section" id="section-events">
<div class="events-panel">
<div class="events-toolbar">
<div class="ws-dot" id="wsDot"></div>
<span style="font-size:0.78rem;" id="wsStatusLabel">WebSocket: відключено</span>
<button class="btn btn-ghost btn-sm" onclick="toggleWs()">Підключити</button>
<button class="btn btn-ghost btn-sm" onclick="clearEvents()">Очистити</button>
<label class="toolbar-check" style="margin-left:auto;">
<input type="checkbox" id="evAutoScroll" checked>
<span style="font-size:0.74rem;">Авто-скрол</span>
</label>
</div>
<div id="eventsLog"><div class="ev-line nodes"><span class="ev-ts"></span><span class="ev-type">system</span>Підключіться до /ws/events щоб бачити події в реальному часі.</div></div>
</div>
</div>
<!-- ── SETTINGS ── -->
<div class="section" id="section-settings">
<div class="panel">
<div class="sec-label">З'єднання</div>
<div class="card">
<h3>🔗 Control Plane (sofiia-console BFF)</h3>
<div class="settings-row">
<label>BFF URL</label>
<input type="text" id="settingBff" placeholder="http://localhost:8002">
</div>
<div class="settings-row">
<label>API Key</label>
<input type="text" id="settingApiKey" placeholder="залишити порожнім для dev">
</div>
<div style="margin-top:10px;display:flex;gap:7px;">
<button class="btn btn-gold btn-sm" onclick="saveSettings()">Зберегти</button>
<button class="btn btn-ghost btn-sm" onclick="testBffConnection()">Перевірити з'єднання</button>
</div>
</div>
<div class="card">
<h3>⏱ Голосовий режим</h3>
<div class="settings-row">
<label>Макс. тривалість сесії (хв)</label>
<input type="number" id="settingMaxVoiceMin" value="30" min="1" max="120">
</div>
<div class="settings-row">
<label>Пауза між репліками (сек)</label>
<input type="number" id="settingVoiceCooldown" value="1" min="0" max="10">
</div>
<div class="settings-row">
<label>Авто-зупинка після N реплік</label>
<input type="number" id="settingMaxTurns" value="50" min="1" max="200">
</div>
<div style="margin-top:10px;">
<button class="btn btn-gold btn-sm" onclick="saveSettings()">Зберегти</button>
</div>
</div>
<div class="card" id="settingsBffStatusCard">
<h3>🩺 Статус BFF</h3>
<div style="color:var(--muted);font-size:0.82rem;">Натисніть "Перевірити з'єднання"</div>
</div>
</div>
</div>
</main>
<script>
// ── Config (persisted in localStorage) ─────────────────────────────────────
const DEFAULT_BFF = 'http://localhost:8002';
const LS = {
bff: () => localStorage.getItem('sofiia_bff_url') || DEFAULT_BFF,
apiKey: () => localStorage.getItem('sofiia_api_key') || '',
voice: () => localStorage.getItem('sofiia_voice') || 'default',
maxVoice: () => parseInt(localStorage.getItem('sofiia_max_voice_min') || '30'),
cooldown: () => parseFloat(localStorage.getItem('sofiia_voice_cooldown') || '1'),
maxTurns: () => parseInt(localStorage.getItem('sofiia_max_turns') || '50'),
project: () => localStorage.getItem('sofiia_project_id') || 'default',
};
function BFF() { return LS.bff().replace(/\/$/, ''); }
function WS_URL() { return BFF().replace(/^http/, 'ws') + '/ws/events'; }
function headers(extra) {
const k = LS.apiKey();
return { 'Content-Type': 'application/json', ...(k ? { 'X-API-Key': k } : {}), ...(extra || {}) };
}
// ── Runtime identity ─────────────────────────────────────────────────────────
let _projectId = LS.project();
let _sessionId = genSessionId();
function genSessionId() {
return 'sess_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function newSession() {
_sessionId = genSessionId();
updateSessionLabel();
chatHistory = [];
// Only update label — no clutter in chat log
}
function onProjectChange() {
const sel = document.getElementById('projectSelect');
_projectId = sel.value;
localStorage.setItem('sofiia_project_id', _projectId);
// Only update label — no clutter in chat log
}
function updateSessionLabel() {
const el = document.getElementById('sessionIdLabel');
if (el) el.textContent = _sessionId;
}
// ── TTS speed ────────────────────────────────────────────────────────────────
function onSpeedChange(el) {
_ttsSpeed = parseFloat(el.value);
localStorage.setItem('sofiia_tts_speed', _ttsSpeed);
document.getElementById('ttsSpeedVal').textContent = _ttsSpeed.toFixed(1) + '×';
}
// ── State ───────────────────────────────────────────────────────────────────
let chatHistory = [];
let recording = false;
let voiceMode = false;
let _ttsSpeed = parseFloat(localStorage.getItem('sofiia_tts_speed') || '1.0');
let ttsPlaying = false; // echo-cancellation: true while TTS plays
let mediaRecorder = null;
let audioChunks = [];
let selectedVoice = LS.voice();
let currentAudio = null;
let voiceSessionStart = null;
let voiceTurnCount = 0;
let voiceCooldownTimer = null;
// ── WebSocket events ─────────────────────────────────────────────────────────
let _ws = null;
let _wsEnabled = false;
const MAX_EVENTS = 200;
function toggleWs() {
if (_ws && _ws.readyState < 2) { _ws.close(); return; }
_wsEnabled = true;
connectWs();
}
function connectWs() {
if (_ws && _ws.readyState < 2) return;
const url = WS_URL();
try { _ws = new WebSocket(url); } catch(e) { setWsStatus(false, 'Помилка: ' + e.message); return; }
_ws.onopen = () => {
setWsStatus(true, 'Підключено: ' + url);
document.querySelector('[data-tab="events"]').style.color = '';
};
_ws.onclose = () => {
setWsStatus(false, 'Відключено');
if (_wsEnabled) setTimeout(connectWs, 5000);
};
_ws.onerror = () => setWsStatus(false, 'Помилка з\'єднання');
_ws.onmessage = e => {
try { appendEvent(JSON.parse(e.data)); } catch(_) {}
};
}
function setWsStatus(ok, label) {
const dot = document.getElementById('wsDot');
const lbl = document.getElementById('wsStatusLabel');
const btn = document.querySelector('#section-events .btn');
if (dot) { dot.className = 'ws-dot ' + (ok ? 'ok' : 'err'); }
if (lbl) lbl.textContent = label;
if (btn) btn.textContent = ok ? 'Відключити' : 'Підключити';
}
function clearEvents() {
const log = document.getElementById('eventsLog');
if (log) log.innerHTML = '';
}
const EV_TYPE_CLASS = {
'chat.message': 'chat', 'chat.reply': 'chat',
'voice.stt': 'voice', 'voice.tts': 'voice',
'ops.run': 'ops',
'error': 'error',
'nodes.status': 'nodes',
'tool.called': 'ops', 'tool.result': 'ops',
};
function appendEvent(ev) {
const log = document.getElementById('eventsLog');
if (!log) return;
const cls = EV_TYPE_CLASS[ev.type] || 'nodes';
const ts = ev.ts ? ev.ts.slice(11, 23) : '';
const data = ev.data || {};
let detail = '';
if (ev.type === 'chat.message') detail = `"${(data.text||'').slice(0,60)}" [${data.provider}]`;
else if (ev.type === 'chat.reply') detail = `"${(data.text||'').slice(0,60)}" ${data.latency_ms}ms`;
else if (ev.type === 'voice.stt') detail = `${data.phase}${data.elapsed_ms ? ' '+data.elapsed_ms+'ms' : ''}`;
else if (ev.type === 'voice.tts') detail = `${data.phase} voice=${data.voice||''}${data.elapsed_ms ? ' '+data.elapsed_ms+'ms' : ''}`;
else if (ev.type === 'ops.run') detail = `${data.name} ok=${data.ok} ${data.elapsed_ms}ms`;
else if (ev.type === 'error') detail = `[${data.where}] ${data.message}`;
else if (ev.type === 'nodes.status') detail = data.message || `uptime=${data.bff_uptime_s}s ws=${data.ws_clients}`;
else detail = JSON.stringify(data).slice(0, 80);
const line = document.createElement('div');
line.className = 'ev-line ' + cls;
line.innerHTML = `<span class="ev-ts">${ts}</span><span class="ev-type">${ev.type}</span>${detail}`;
log.appendChild(line);
// Trim to MAX_EVENTS
while (log.children.length > MAX_EVENTS) log.removeChild(log.firstChild);
if (document.getElementById('evAutoScroll')?.checked) log.scrollTop = log.scrollHeight;
}
// ── Tabs ────────────────────────────────────────────────────────────────────
document.querySelectorAll('nav button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
btn.classList.add('active');
const sec = document.getElementById('section-' + btn.dataset.tab);
if (sec) sec.classList.add('active');
if (btn.dataset.tab === 'nodes') loadNodes();
if (btn.dataset.tab === 'memory') loadMemoryStatus();
if (btn.dataset.tab === 'ops') loadOpsActions();
if (btn.dataset.tab === 'settings') initSettings();
if (btn.dataset.tab === 'events' && (!_ws || _ws.readyState > 1)) { _wsEnabled = true; connectWs(); }
});
});
// ── Init ────────────────────────────────────────────────────────────────────
(async function init() {
// Restore voice chip
document.querySelectorAll('.voice-chip').forEach(c => {
if (c.dataset.voice === selectedVoice) c.classList.add('active');
else c.classList.remove('active');
});
// Restore project selector
const projSel = document.getElementById('projectSelect');
if (projSel) {
_projectId = LS.project();
projSel.value = _projectId;
if (!projSel.value) projSel.value = 'default';
}
// Restore TTS speed slider
const speedEl = document.getElementById('ttsSpeed');
const speedValEl = document.getElementById('ttsSpeedVal');
if (speedEl) {
speedEl.value = _ttsSpeed;
speedValEl.textContent = _ttsSpeed.toFixed(1) + '×';
}
// Show session id
updateSessionLabel();
await checkBffHealth();
setInterval(checkBffHealth, 30000);
const inp = document.getElementById('chatInput');
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
inp.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Auto-connect WS on events tab open
document.querySelector('[data-tab="events"]')?.addEventListener('click', () => {
if (!_ws || _ws.readyState > 1) { _wsEnabled = true; connectWs(); }
});
})();
async function checkBffHealth() {
const dot = document.getElementById('headerDot');
const status = document.getElementById('globalStatus');
const bffEl = document.getElementById('bffStatus');
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 5000);
try {
const r = await fetch(BFF() + '/api/memory/status', { signal: ctrl.signal });
clearTimeout(tid);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const pts = d.vector_store?.memories?.points_count ?? 0;
dot.className = 'status-dot ok';
status.textContent = `Memory: ${pts} · Voice: OK`;
bffEl.textContent = 'BFF: OK';
bffEl.className = 'ok';
} catch(e) {
clearTimeout(tid);
dot.className = 'status-dot err';
status.textContent = 'BFF недоступний';
bffEl.textContent = 'BFF: ✗';
bffEl.className = 'err';
}
}
// ── Chat ────────────────────────────────────────────────────────────────────
function addMsg(text, type) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg ' + type;
if (type !== 'system') {
const s = document.createElement('div'); s.className = 'msg-sender';
s.textContent = type === 'user' ? 'Ви' : 'Sofiia';
div.appendChild(s);
}
const c = document.createElement('div'); c.textContent = text;
div.appendChild(c);
log.appendChild(div);
log.scrollTop = log.scrollHeight;
return div;
}
function addTyping() {
const log = document.getElementById('chatLog');
const div = document.createElement('div'); div.className = 'msg ai'; div.id = 'typingDot';
const s = document.createElement('div'); s.className = 'msg-sender'; s.textContent = 'Sofiia';
const i = document.createElement('div'); i.className = 'typing-indicator';
i.innerHTML = '<span></span><span></span><span></span>';
div.appendChild(s); div.appendChild(i);
log.appendChild(div); log.scrollTop = log.scrollHeight;
}
function removeTyping() { const el = document.getElementById('typingDot'); if (el) el.remove(); }
async function sendMessage() {
const input = document.getElementById('chatInput');
const text = input.value.trim();
if (!text) return;
addMsg(text, 'user');
input.value = ''; input.style.height = 'auto';
document.getElementById('sendBtn').disabled = true;
chatHistory.push({ role: 'user', content: text });
addTyping();
try {
const r = await fetch(BFF() + '/api/chat/send', {
method: 'POST',
headers: headers(),
body: JSON.stringify({
message: text,
model: document.getElementById('modelSelect').value,
history: chatHistory.slice(-12),
project_id: _projectId,
session_id: _sessionId,
user_id: 'console_user',
})
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
// Update session_id from response (BFF may have generated one)
if (d.session_id && d.session_id !== _sessionId) {
_sessionId = d.session_id;
updateSessionLabel();
}
removeTyping();
const reply = d.response || '(порожня відповідь)';
addMsg(reply, 'ai');
chatHistory.push({ role: 'assistant', content: reply });
if (document.getElementById('autoSpeak').checked) await speakText(reply);
if (voiceMode && !recording && !ttsPlaying) scheduleNextListen();
} catch(e) {
removeTyping();
addMsg('⚠ ' + e.message, 'system');
console.error('Chat error:', e);
}
document.getElementById('sendBtn').disabled = false;
}
// ── Language detection ────────────────────────────────────────────────────────
// Returns array of {text, lang, voice} segments split by language
function splitByLanguage(text) {
// Split into tokens preserving spaces
const tokens = text.match(/[^\s,;.!?]+[\s,;.!?]*/g) || [text];
const segments = [];
let curLang = null, curText = '';
for (const token of tokens) {
// Latin characters (English): >50% of word chars are ASCII letters
const word = token.trim().replace(/[^a-zA-Zа-яА-ЯіІїЇєЄёЁ]/g, '');
if (!word) { curText += token; continue; }
const latinCount = (word.match(/[a-zA-Z]/g) || []).length;
const lang = latinCount / word.length > 0.5 ? 'en' : 'uk';
if (lang !== curLang && curText.trim()) {
segments.push({ text: curText.trim(), lang: curLang });
curText = token;
curLang = lang;
} else {
if (curLang === null) curLang = lang;
curText += token;
}
}
if (curText.trim()) segments.push({ text: curText.trim(), lang: curLang || 'uk' });
return segments.map(s => ({
...s,
voice: s.lang === 'en' ? 'en-US-GuyNeural' : (selectedVoice === 'Ostap' ? 'uk-UA-OstapNeural' : 'uk-UA-PolinaNeural'),
}));
}
// ── TTS stop ──────────────────────────────────────────────────────────────────
function stopTTS() {
if (currentAudio) {
currentAudio.pause();
currentAudio.src = '';
currentAudio = null;
}
ttsPlaying = false;
muteRecording(false);
document.getElementById('stopTtsBtn').classList.remove('visible');
if (voiceMode && !recording) scheduleNextListen();
}
// ── TTS (with echo-cancellation, language splitting, speed) ──────────────────
async function speakText(text) {
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
ttsPlaying = true;
muteRecording(true);
document.getElementById('stopTtsBtn').classList.add('visible');
try {
const segments = splitByLanguage(text.substring(0, 600));
for (const seg of segments) {
if (!ttsPlaying) break; // stopped mid-sentence
await _playSegment(seg.text, seg.voice);
}
} catch(e) {
console.warn('TTS error:', e);
} finally {
ttsPlaying = false;
muteRecording(false);
document.getElementById('stopTtsBtn').classList.remove('visible');
if (voiceMode && !recording) scheduleNextListen();
}
}
async function _playSegment(text, voice) {
return new Promise(async (resolve) => {
try {
const r = await fetch(BFF() + '/api/voice/tts', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ text, voice, speed: _ttsSpeed }),
});
if (!r.ok) { resolve(); return; }
const blob = await r.blob();
const audio = new Audio(URL.createObjectURL(blob));
audio.playbackRate = _ttsSpeed; // client-side speed also applied
currentAudio = audio;
audio.onended = () => { currentAudio = null; resolve(); };
audio.onerror = () => { currentAudio = null; resolve(); };
// If stopped externally while fetching
if (!ttsPlaying) { resolve(); return; }
await audio.play();
} catch(e) {
resolve();
}
});
}
async function testTTS() {
const t = document.getElementById('ttsTestText').value.trim();
if (t) await speakText(t);
}
function muteRecording(mute) {
const btn = document.getElementById('voiceBtn');
if (mute) btn.classList.add('muted');
else btn.classList.remove('muted');
}
function selectVoice(el) {
document.querySelectorAll('.voice-chip').forEach(c => c.classList.remove('active'));
el.classList.add('active');
selectedVoice = el.dataset.voice;
localStorage.setItem('sofiia_voice', selectedVoice);
}
// ── STT / Voice ─────────────────────────────────────────────────────────────
function setVoiceStatus(t) { document.getElementById('voiceStatus').textContent = t; }
function scheduleNextListen() {
if (!voiceMode) return;
const cooldown = LS.cooldown() * 1000;
if (voiceCooldownTimer) clearTimeout(voiceCooldownTimer);
voiceCooldownTimer = setTimeout(() => {
if (voiceMode && !recording && !ttsPlaying) startListening();
}, cooldown);
}
async function startListening() {
if (recording || ttsPlaying) return;
// Max session duration check
if (voiceSessionStart) {
const elapsed = (Date.now() - voiceSessionStart) / 60000;
if (elapsed > LS.maxVoice()) {
voiceMode = false;
document.getElementById('contVoice').checked = false;
setVoiceStatus('⏱ Сесія завершена (ліміт часу)');
addMsg(`Голосова сесія завершена: перевищено ліміт ${LS.maxVoice()} хв.`, 'system');
return;
}
}
// Max turns check
if (voiceTurnCount >= LS.maxTurns()) {
voiceMode = false;
document.getElementById('contVoice').checked = false;
setVoiceStatus('⏱ Сесія завершена (ліміт реплік)');
addMsg(`Голосова сесія завершена: ${LS.maxTurns()} реплік досягнуто.`, 'system');
return;
}
setVoiceStatus('🎙️ Слухаю...');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(audioChunks, { type: 'audio/webm' });
if (blob.size < 1000) {
setVoiceStatus('✓ Готовий');
if (voiceMode) scheduleNextListen();
return;
}
const fd = new FormData();
fd.append('audio', blob, 'audio.webm');
setVoiceStatus('🔄 Розпізнаю...');
try {
const apiKey = LS.apiKey();
const sttHeaders = apiKey ? { 'X-API-Key': apiKey } : {};
const sttUrl = BFF() + '/api/voice/stt?session_id=' + encodeURIComponent(_sessionId) + '&project_id=' + encodeURIComponent(_projectId);
const r = await fetch(sttUrl, { method: 'POST', headers: sttHeaders, body: fd });
if (!r.ok) throw new Error('STT HTTP ' + r.status);
const d = await r.json();
if (d.text && d.text.trim().length > 1) {
document.getElementById('chatInput').value = d.text;
setVoiceStatus('✓ Розпізнано');
voiceTurnCount++;
sendMessage();
} else {
setVoiceStatus('✓ Готовий');
if (voiceMode) scheduleNextListen();
}
} catch(e) {
console.error('STT error:', e);
setVoiceStatus('⚠ STT помилка');
if (voiceMode) scheduleNextListen();
}
};
mediaRecorder.start();
recording = true;
document.getElementById('voiceBtn').classList.add('recording');
// Auto-stop after 10s silence guard
setTimeout(stopListening, 10000);
} catch(e) {
setVoiceStatus('⚠ Немає мікрофону: ' + e.message);
console.error('Mic error:', e);
}
}
function stopListening() {
if (recording && mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
recording = false;
document.getElementById('voiceBtn').classList.remove('recording');
}
}
function toggleVoice() {
if (!recording) startListening();
else { stopListening(); setVoiceStatus('✓ Готовий'); }
}
// Voice button: stop TTS immediately, then listen
function toggleVoiceWithStop() {
if (ttsPlaying) {
stopTTS(); // agent stops speaking
setTimeout(() => {
if (!recording) startListening();
}, 100);
return;
}
toggleVoice();
}
document.getElementById('contVoice').addEventListener('change', function() {
voiceMode = this.checked;
if (voiceMode) {
voiceSessionStart = Date.now();
voiceTurnCount = 0;
addMsg('Голосовий режим увімкнено. Говоріть після сигналу.', 'system');
if (!recording && !ttsPlaying) startListening();
} else {
stopListening();
if (voiceCooldownTimer) clearTimeout(voiceCooldownTimer);
setVoiceStatus('✓ Готовий');
}
});
// ── Ops ─────────────────────────────────────────────────────────────────────
const OPS_META = {
risk_dashboard: { icon: '📊', desc: 'Risk dashboard сервісів' },
pressure_dashboard: { icon: '🏗️', desc: 'Architecture pressure' },
backlog_dashboard: { icon: '📋', desc: 'Backlog items' },
backlog_generate_weekly: { icon: '🔄', desc: 'Генерація weekly backlog' },
release_check: { icon: '🚀', desc: 'Release check (staging)' },
};
async function loadOpsActions() {
const grid = document.getElementById('opsGrid');
try {
const r = await fetch(BFF() + '/api/ops/actions');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
grid.innerHTML = '';
(d.actions || []).forEach(id => {
const m = OPS_META[id] || { icon: '⚡', desc: '' };
const btn = document.createElement('button');
btn.className = 'ops-btn';
btn.innerHTML = `<span class="ops-icon">${m.icon}</span><span class="ops-name">${id.replace(/_/g,' ')}</span><span class="ops-desc">${m.desc}</span>`;
btn.onclick = () => runOps(id);
grid.appendChild(btn);
});
if (!(d.actions && d.actions.length)) {
grid.innerHTML = '<div style="color:var(--muted);font-size:0.82rem;">Ops недоступний. Перевірте BFF URL у налаштуваннях.</div>';
}
} catch(e) {
grid.innerHTML = `<div style="color:var(--muted);font-size:0.82rem;">BFF недоступний (${e.message}). Налаштуйте URL у вкладці Налаштування.</div>`;
}
}
async function runOps(id) {
const runEl = document.getElementById('opsRunning'), resEl = document.getElementById('opsResult'), wrapEl = document.getElementById('opsResultWrap');
runEl.style.display = 'flex'; document.getElementById('opsRunningLabel').textContent = 'Виконую: ' + id.replace(/_/g,' ') + '...';
resEl.textContent = ''; wrapEl.style.display = 'none';
try {
const r = await fetch(BFF() + '/api/ops/run', {
method: 'POST', headers: headers(),
body: JSON.stringify({ action_id: id, node_id: 'NODA2', params: {} })
});
const d = await r.json();
runEl.style.display = 'none'; wrapEl.style.display = 'block';
resEl.textContent = JSON.stringify(d, null, 2);
} catch(e) {
runEl.style.display = 'none'; wrapEl.style.display = 'block';
resEl.textContent = 'Помилка: ' + e.message;
}
}
// ── Nodes ───────────────────────────────────────────────────────────────────
function _latencyPill(ms) {
if (ms == null) return '';
const cls = ms < 100 ? 'ok' : ms < 400 ? 'warn' : 'err';
const color = ms < 100 ? 'var(--ok)' : ms < 400 ? 'var(--warn)' : 'var(--err)';
return `<span style="font-size:0.68rem;color:${color};padding:1px 5px;border-radius:3px;background:rgba(0,0,0,0.2);">${ms}ms</span>`;
}
function _badge(ok, trueText, falseText) {
if (ok === null || ok === undefined) return '<span class="badge warn">—</span>';
return ok ? `<span class="badge ok">${trueText||'OK'}</span>` : `<span class="badge err">${falseText||'FAIL'}</span>`;
}
function _ago(isoTs) {
if (!isoTs) return '—';
try {
const diff = Math.round((Date.now() - new Date(isoTs).getTime()) / 1000);
if (diff < 60) return diff + 's тому';
if (diff < 3600) return Math.round(diff/60) + 'хв тому';
return Math.round(diff/3600) + 'год тому';
} catch(_) { return isoTs.slice(0,10); }
}
async function loadNodes(forceRefresh) {
const grid = document.getElementById('nodesGrid');
const summary = document.getElementById('nodesSummary');
const badge = document.getElementById('nodesCachedBadge');
grid.innerHTML = '<div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div>';
if (summary) summary.innerHTML = '';
try {
const url = BFF() + '/api/nodes/dashboard' + (forceRefresh ? '?refresh=true' : '');
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
// Summary bar
const s = d.summary || {};
if (summary && s.total) {
summary.innerHTML = `
<div class="sum-card"><div class="sum-val">${s.total}</div><div class="sum-lbl">Всього нод</div></div>
<div class="sum-card"><div class="sum-val" style="color:${s.online===s.total?'var(--ok)':'var(--warn)'}">${s.online||0}</div><div class="sum-lbl">Онлайн</div></div>
<div class="sum-card"><div class="sum-val" style="color:${s.router_ok===s.total?'var(--ok)':'var(--warn)'}">${s.router_ok||0}</div><div class="sum-lbl">Router OK</div></div>
`;
}
if (badge) badge.textContent = d.cached ? `кеш · ${_ago(d.ts)}` : 'свіже';
grid.innerHTML = '';
(d.nodes || []).forEach(n => {
const online = n.online !== false && n.router_ok;
const card = document.createElement('div');
card.className = 'node-card ' + (online ? 'online' : 'offline');
// Backends pills
const be = n.backends || {};
const bePills = Object.entries(be).map(([k, v]) =>
`<span title="${k}">${k.replace('_',' ')}: <b>${v||'?'}</b></span>`
).join('');
// Artifacts
const art = n.last_artifacts || {};
const artRows = Object.entries(art).map(([k,v]) =>
`<div class="row"><span class="label">${k.replace(/_/g,' ')}</span><span style="font-size:0.72rem;">${v||'—'}</span></div>`
).join('');
// SLO
const slo = n.alerts_loop_slo;
const sloHtml = slo ? `
<div class="row"><span class="label">Alert SLO p95</span><span>${slo.p95_ms != null ? slo.p95_ms+'ms' : '—'}</span></div>
<div class="row"><span class="label">Failed rate</span><span>${slo.failed_rate != null ? (slo.failed_rate*100).toFixed(1)+'%' : '—'}</span></div>
` : '';
card.innerHTML = `
<div class="node-header">
<div class="status-dot ${online ? 'ok' : 'err'}"></div>
<div style="flex:1;">
<div class="node-name">${n.label || n.node_id}</div>
<div class="node-url">${n.router_url || ''}</div>
</div>
<div style="font-size:0.72rem;color:${online?'var(--ok)':'var(--err)'};">${online?'ONLINE':'OFFLINE'}</div>
</div>
<div class="row">
<span class="label">Router</span>
<span>${_badge(n.router_ok)} ${_latencyPill(n.router_latency_ms)}</span>
</div>
${n.gateway_ok !== undefined && n.gateway_ok !== null ? `
<div class="row">
<span class="label">Gateway</span>
<span>${_badge(n.gateway_ok)} ${_latencyPill(n.gateway_latency_ms)}</span>
</div>` : ''}
${n.heartbeat_age_s != null ? `
<div class="row"><span class="label">Monitor heartbeat</span><span>${n.heartbeat_age_s}s тому</span></div>` : ''}
${n.open_incidents != null ? `
<div class="row">
<span class="label">Відкриті інциденти</span>
<span class="${n.open_incidents > 0 ? 'badge warn' : 'badge ok'}">${n.open_incidents}</span>
</div>` : ''}
${sloHtml}
${artRows}
${bePills ? `<div class="node-meta">${bePills}</div>` : ''}
<div style="margin-top:6px;font-size:0.68rem;color:var(--muted);">
джерело: ${n.monitor_source||'—'} · ${_ago(n.ts)}
</div>
`;
grid.appendChild(card);
});
if (!d.nodes || !d.nodes.length) grid.innerHTML = '<div style="color:var(--muted);font-size:0.82rem;">Ноди не налаштовані. Перевірте config/nodes_registry.yml</div>';
} catch(e) {
grid.innerHTML = `<div style="color:var(--err);font-size:0.82rem;">Помилка: ${e.message}</div>`;
}
}
// ── Memory ───────────────────────────────────────────────────────────────────
async function loadMemoryStatus() {
const card = document.getElementById('memStatusCard');
try {
const r = await fetch(BFF() + '/api/memory/status');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const pts = d.vector_store?.memories?.points_count ?? 0;
card.innerHTML = `
<h3>🧠 Memory Service</h3>
<div class="row"><span class="label">Статус</span><span class="badge ${d.ok ? 'ok' : 'err'}">${d.ok ? 'Активний' : 'Недоступний'}</span></div>
<div class="row"><span class="label">URL</span><span style="font-size:0.73rem;">${d.memory_url || '—'}</span></div>
<div class="row"><span class="label">Векторів</span><span>${pts}</span></div>
<div class="row"><span class="label">STT</span><span>${d.stt || '—'}</span></div>
<div class="row"><span class="label">TTS</span><span>${d.tts || '—'}</span></div>
`;
} catch(e) {
card.innerHTML = `<h3>🧠 Memory Service</h3><div style="color:var(--err);font-size:0.82rem;">Помилка: ${e.message}</div>`;
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
function initSettings() {
document.getElementById('settingBff').value = LS.bff();
document.getElementById('settingApiKey').value = LS.apiKey();
document.getElementById('settingMaxVoiceMin').value = LS.maxVoice();
document.getElementById('settingVoiceCooldown').value = LS.cooldown();
document.getElementById('settingMaxTurns').value = LS.maxTurns();
}
function saveSettings() {
const bff = document.getElementById('settingBff').value.trim();
if (bff) localStorage.setItem('sofiia_bff_url', bff);
localStorage.setItem('sofiia_api_key', document.getElementById('settingApiKey').value.trim());
localStorage.setItem('sofiia_max_voice_min', document.getElementById('settingMaxVoiceMin').value);
localStorage.setItem('sofiia_voice_cooldown', document.getElementById('settingVoiceCooldown').value);
localStorage.setItem('sofiia_max_turns', document.getElementById('settingMaxTurns').value);
addMsg('✓ Налаштування збережені. BFF: ' + (bff || LS.bff()), 'system');
checkBffHealth();
}
async function testBffConnection() {
const card = document.getElementById('settingsBffStatusCard');
card.innerHTML = '<h3>🩺 Статус BFF</h3><div class="running-row"><div class="spinner"></div> Перевіряю...</div>';
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 5000);
try {
const r = await fetch(BFF() + '/api/health', { signal: ctrl.signal });
clearTimeout(tid);
const d = await r.json();
// BFF is alive if we got a JSON response — router ok is secondary
const alive = r.ok && d.service === 'sofiia-console';
card.innerHTML = `
<h3>🩺 Статус BFF</h3>
<div class="row"><span class="label">URL</span><span style="font-size:0.73rem;">${BFF()}</span></div>
<div class="row"><span class="label">BFF сервіс</span><span class="badge ok">Активний</span></div>
<div class="row"><span class="label">Версія</span><span>${d.version || '—'}</span></div>
<div class="row"><span class="label">Env</span><span>${d.env || '—'}</span></div>
<div class="row"><span class="label">Router (NODA2)</span><span class="badge ${d.router?.ok ? 'ok' : 'warn'}">${d.router?.ok ? 'OK' : 'Офлайн'}</span></div>
`;
} catch(e) {
clearTimeout(tid);
card.innerHTML = `<h3>🩺 Статус BFF</h3><div class="row"><span class="label">Помилка</span><span class="badge err">${e.message}</span></div>
<div style="color:var(--muted);font-size:0.75rem;margin-top:8px;">Переконайтесь що sofiia-console запущений на ${BFF()}</div>`;
}
}
</script>
</body>
</html>