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
1142 lines
56 KiB
HTML
1142 lines
56 KiB
HTML
<!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 & 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>
|