Files
microdao-daarion/services/sofiia-console/static/index.html
Apple e9dedffa48 feat(production): sync all modified production files to git
Includes updates across gateway, router, node-worker, memory-service,
aurora-service, swapper, sofiia-console UI and node2 infrastructure:

- gateway-bot: Dockerfile, http_api.py, druid/aistalk prompts, doc_service
- services/router: main.py, router-config.yml, fabric_metrics, memory_retrieval,
  offload_client, prompt_builder
- services/node-worker: worker.py, main.py, config.py, fabric_metrics
- services/memory-service: Dockerfile, database.py, main.py, requirements
- services/aurora-service: main.py (+399), kling.py, quality_report.py
- services/swapper-service: main.py, swapper_config_node2.yaml
- services/sofiia-console: static/index.html (console UI update)
- config: agent_registry, crewai_agents/teams, router_agents
- ops/fabric_preflight.sh: updated preflight checks
- router-config.yml, docker-compose.node2.yml: infra updates
- docs: NODA1-AGENT-ARCHITECTURE, fabric_contract updated

Made-with: Cursor
2026-03-03 07:13:29 -08:00

9287 lines
447 KiB
HTML
Raw 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 ── */
header { background: var(--bg); border-bottom: 1px solid var(--border); padding: 14px 20px; display: flex; align-items: center; gap: 14px; }
header .logo { font-size: 1.4rem; font-weight: 700; color: var(--gold); letter-spacing: 2px; }
header .subtitle { font-size: 0.8rem; color: var(--muted); }
header .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); margin-left: auto; }
header .status-dot.ok { background: var(--ok); }
header .status-dot.err { background: var(--err); }
#globalStatus { font-size: 0.78rem; color: var(--muted); }
/* ── Nav tabs ── */
nav { background: var(--bg); border-bottom: 1px solid var(--border); display: flex; padding: 0 20px; gap: 4px; }
nav button { background: none; border: none; color: var(--muted); padding: 10px 16px; cursor: pointer; font-size: 0.88rem; border-bottom: 2px solid transparent; transition: color 0.2s; }
nav button:hover { color: var(--text); }
nav button.active { color: var(--gold); border-bottom-color: var(--gold); }
/* ── Sections ── */
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 section ── */
#section-chat { padding: 0; }
.chat-toolbar { background: var(--bg); border-bottom: 1px solid var(--border); padding: 10px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
select.model-select { padding: 6px 10px; background: var(--bg2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
select.model-select:focus { outline: none; border-color: var(--gold); }
.toolbar-check { display: flex; align-items: center; gap: 5px; font-size: 0.85rem; cursor: pointer; color: var(--text); }
.toolbar-check input[type=checkbox] { accent-color: var(--gold); width: 15px; height: 15px; }
#voiceStatus { font-size: 0.78rem; color: var(--muted); margin-left: auto; }
#chatLog { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.msg { padding: 10px 14px; border-radius: 12px; max-width: 88%; line-height: 1.55; font-size: 0.9rem; word-break: break-word; }
.msg.user { background: rgba(201,168,124,0.18); 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.1); align-self: center; font-size: 0.78rem; color: var(--muted); border-radius: 6px; max-width: 100%; }
.msg .sender { font-size: 0.72rem; font-weight: 600; margin-bottom: 4px; color: var(--gold); }
.msg.user .sender { color: var(--gold2); }
.msg.system .sender { display: none; }
.typing-indicator { display: inline-flex; gap: 4px; align-items: center; }
.typing-indicator span { width: 6px; height: 6px; border-radius: 50%; background: var(--gold); animation: bounce 1.2s infinite; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); } 30% { transform: translateY(-6px); } }
.chat-input-bar { background: var(--bg); border-top: 1px solid var(--border); padding: 12px 16px; display: flex; gap: 8px; align-items: flex-end; }
.chat-input-bar textarea { flex: 1; padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 0.9rem; resize: none; max-height: 120px; min-height: 40px; line-height: 1.45; font-family: inherit; }
.chat-input-bar textarea:focus { outline: none; border-color: var(--gold); }
.btn { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: opacity 0.15s; }
.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-sm { padding: 3px 8px; font-size: 0.76rem; }
.tab-active { background: rgba(255,200,60,0.18); border-color: var(--gold); color: var(--gold); }
.btn-ghost { background: var(--bg2); color: var(--text); border: 1px solid var(--border); }
.btn-record { background: var(--bg2); color: var(--text); border: 1px solid var(--border); width: 42px; padding: 10px; font-size: 1rem; }
.btn-record.recording { background: var(--err); border-color: var(--err); color: #fff; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 8px rgba(239,68,68,0); } }
/* ── Ops section ── */
#section-ops { padding: 16px; overflow-y: auto; gap: 12px; }
.section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 8px; }
.ops-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-bottom: 16px; }
.ops-btn { padding: 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); cursor: pointer; text-align: left; font-size: 0.85rem; line-height: 1.4; transition: border-color 0.15s, background 0.15s; }
.ops-btn:hover { border-color: var(--gold); background: var(--bg2); }
.ops-btn .ops-icon { font-size: 1.3rem; display: block; margin-bottom: 4px; }
.ops-btn .ops-name { font-weight: 600; display: block; }
.ops-btn .ops-desc { font-size: 0.75rem; color: var(--muted); }
.ops-result { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; font-size: 0.82rem; white-space: pre-wrap; max-height: 400px; overflow-y: auto; font-family: "SF Mono", "Fira Code", monospace; color: var(--text); }
.ops-running { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.85rem; padding: 10px 0; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--gold); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Nodes section ── */
#section-hub { padding: 16px; overflow-y: auto; gap: 12px; }
#section-nodes { padding: 16px; overflow-y: auto; gap: 12px; }
.nodes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.node-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
.node-card .node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.node-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.node-dot.ok { background: var(--ok); }
.node-dot.err { background: var(--err); }
.node-name { font-weight: 600; font-size: 0.95rem; }
.node-url { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.node-detail { font-size: 0.8rem; color: var(--muted); margin-top: 6px; }
.node-detail span { color: var(--text); }
.refresh-btn { margin-left: auto; padding: 5px 10px; font-size: 0.78rem; }
/* ── Memory section ── */
#section-memory { padding: 16px; overflow-y: auto; gap: 14px; }
.memory-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.memory-card h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--gold); }
.memory-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
.memory-row:last-child { border-bottom: none; }
.memory-row .label { color: var(--muted); }
.memory-row .value { font-weight: 500; }
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.badge.ok { background: rgba(34,197,94,0.2); color: var(--ok); }
.badge.err { background: rgba(239,68,68,0.2); color: var(--err); }
.badge.warn { background: rgba(245,158,11,0.2); color: var(--warn); }
.voices-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.voice-chip { padding: 4px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 20px; font-size: 0.78rem; cursor: pointer; transition: border-color 0.15s; }
.voice-chip:hover, .voice-chip.active { border-color: var(--gold); color: var(--gold); }
/* ── Aurora section ── */
#section-aurora { padding: 16px; overflow-y: auto; gap: 12px; }
.aurora-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.aurora-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
.aurora-title { font-size: 0.86rem; font-weight: 700; color: var(--gold); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.aurora-dropzone {
border: 1px dashed var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
min-height: 130px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
text-align: center;
padding: 14px;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.aurora-dropzone:hover,
.aurora-dropzone.drag {
border-color: var(--gold);
color: var(--text);
background: rgba(201,168,124,0.08);
}
.aurora-mode-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.aurora-mode-btn {
padding: 7px 12px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg2);
color: var(--muted);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.aurora-mode-btn.active {
color: var(--text);
border-color: var(--gold);
background: rgba(201,168,124,0.12);
}
.aurora-mode-btn.forensic.active {
border-color: #ff5f5f;
color: #ffc7c7;
background: rgba(239,68,68,0.14);
}
.aurora-kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.04); }
.aurora-kv:last-child { border-bottom: none; }
.aurora-kv .k { color: var(--muted); }
.aurora-kv .v { color: var(--text); font-weight: 500; text-align: right; word-break: break-word; }
.aurora-progress-wrap { margin-top: 8px; }
.aurora-progress-bar {
height: 8px;
width: 100%;
border-radius: 999px;
background: var(--bg2);
border: 1px solid var(--border);
overflow: hidden;
}
.aurora-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #00c67a, #00ff9d);
transition: width 0.25s ease;
}
.aurora-progress-bar.processing .aurora-progress-fill {
background: linear-gradient(90deg, #00c67a 0%, #00ff9d 50%, #00c67a 100%);
background-size: 200% 100%;
animation: aurora-shimmer 1.8s ease-in-out infinite;
}
@keyframes aurora-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes klingPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.aurora-thumb-preview {
margin-top: 8px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg2);
position: relative;
}
.aurora-thumb-preview img, .aurora-thumb-preview video {
display: block;
width: 100%;
max-height: 180px;
object-fit: contain;
background: #000;
}
.aurora-thumb-label {
position: absolute;
bottom: 6px;
left: 8px;
font-size: 0.65rem;
background: rgba(0,0,0,0.7);
color: var(--text);
padding: 2px 6px;
border-radius: 4px;
}
.aurora-clip-picker {
margin-top: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg2);
padding: 8px;
display: none;
gap: 8px;
}
.aurora-clip-head {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.74rem;
color: var(--muted);
align-items: center;
}
.aurora-clip-head strong {
color: var(--text);
font-weight: 600;
}
.aurora-clip-range-row {
display: grid;
grid-template-columns: 54px 1fr 62px;
align-items: center;
gap: 8px;
font-size: 0.73rem;
color: var(--muted);
}
.aurora-clip-range-row input[type="range"] {
width: 100%;
accent-color: var(--gold);
cursor: pointer;
}
.aurora-clip-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.aurora-clip-btn {
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.7rem;
cursor: pointer;
}
.aurora-clip-btn:hover {
border-color: var(--gold);
color: var(--text);
}
.aurora-compare-wrap {
position: relative;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--border);
cursor: col-resize;
user-select: none;
-webkit-user-select: none;
}
.aurora-compare-wrap img {
display: block;
width: 100%;
max-height: 320px;
object-fit: contain;
background: #000;
}
.aurora-compare-after {
position: absolute;
top: 0; left: 0; bottom: 0;
overflow: hidden;
border-right: 2px solid var(--gold);
}
.aurora-compare-after img { width: var(--full-w); max-height: 320px; object-fit: contain; }
.aurora-compare-label {
position: absolute;
top: 6px;
font-size: 0.65rem;
background: rgba(0,0,0,0.7);
color: var(--text);
padding: 2px 6px;
border-radius: 4px;
}
.aurora-export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.aurora-export-grid label {
font-size: 0.76rem;
color: var(--muted);
display: flex;
flex-direction: column;
gap: 3px;
}
.aurora-export-grid select, .aurora-export-grid input {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 5px;
padding: 4px 6px;
font-size: 0.76rem;
}
.aurora-live-log {
max-height: 120px;
overflow-y: auto;
font-family: monospace;
font-size: 0.68rem;
line-height: 1.4;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
color: var(--muted);
margin-top: 6px;
}
.aurora-live-log .log-new { color: #00ff9d; }
.aurora-job-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 3px;
}
.aurora-job-meta .chip {
font-size: 0.62rem;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
white-space: nowrap;
}
.aurora-job-meta .chip.mps { border-color: rgba(0,198,122,0.3); color: #00c67a; }
.aurora-job-meta .chip.cpu { border-color: rgba(255,165,0,0.3); color: #ffa500; }
.aurora-job-meta .chip.done { border-color: rgba(0,198,122,0.3); color: #00c67a; }
.aurora-job-meta .chip.failed { border-color: rgba(239,68,68,0.3); color: #ef4444; }
.aurora-job-meta .chip.cancelled { border-color: rgba(255,165,0,0.3); color: #ffa500; }
.aurora-links { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.aurora-links a { color: var(--gold); font-size: 0.82rem; text-decoration: none; word-break: break-all; }
.aurora-links a:hover { text-decoration: underline; }
.aurora-note { font-size: 0.74rem; color: var(--muted); margin-top: 6px; line-height: 1.45; }
.aurora-checkline {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--text);
margin-top: 6px;
}
.aurora-checkline input[type="checkbox"] { accent-color: var(--gold); }
.aurora-priority-wrap {
margin-top: 10px;
padding: 8px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
background: var(--bg2);
}
.aurora-priority-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.72rem;
color: var(--muted);
margin-bottom: 6px;
}
.aurora-priority-wrap input[type="range"] {
width: 100%;
margin: 0;
accent-color: var(--gold);
}
.aurora-preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.aurora-preset-btn {
border: 1px solid var(--border);
background: var(--bg2);
color: var(--muted);
border-radius: 7px;
padding: 6px 10px;
font-size: 0.74rem;
cursor: pointer;
}
.aurora-preset-btn.active {
border-color: var(--gold);
color: var(--text);
background: rgba(201,168,124,0.12);
}
.aurora-quality-report {
margin: 10px 0;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.08);
background: var(--bg2);
}
.aurora-quality-group {
border-top: 1px solid rgba(255,255,255,0.06);
padding-top: 8px;
margin-top: 8px;
}
.aurora-quality-group:first-child {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.aurora-quality-head {
font-size: 0.78rem;
color: var(--gold);
margin-bottom: 5px;
font-weight: 600;
}
.aurora-quality-line {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
color: var(--text);
padding: 2px 0;
}
.aurora-quality-line span:first-child { color: var(--muted); }
.aurora-detect-wrap {
margin: 10px 0;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.08);
background: var(--bg2);
}
.aurora-detect-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
margin-top: 8px;
}
.aurora-detect-card {
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 8px;
background: rgba(255,255,255,0.02);
}
.aurora-detect-stage {
position: relative;
overflow: hidden;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08);
background: #000;
margin-top: 6px;
}
.aurora-detect-stage img {
display: block;
width: 100%;
height: auto;
max-height: 240px;
object-fit: contain;
background: #000;
}
.aurora-detect-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.aurora-bbox {
position: absolute;
border: 2px solid #00c67a;
border-radius: 4px;
box-sizing: border-box;
box-shadow: 0 0 0 1px rgba(0,0,0,0.35) inset;
}
.aurora-bbox.face { border-color: #00c67a; }
.aurora-bbox.plate { border-color: #f5a623; }
.aurora-bbox-label {
position: absolute;
left: 0;
top: 0;
transform: translateY(-100%);
font-size: 0.63rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: rgba(0,0,0,0.8);
color: #fff;
border-radius: 4px;
padding: 1px 5px;
white-space: nowrap;
border: 1px solid rgba(255,255,255,0.2);
}
.aurora-chat-log {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg2);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.aurora-chat-row {
font-size: 0.78rem;
line-height: 1.4;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
white-space: pre-wrap;
}
.aurora-chat-row.user {
background: rgba(201,168,124,0.12);
border-color: rgba(201,168,124,0.35);
align-self: flex-end;
max-width: 90%;
}
.aurora-chat-row.assistant {
background: rgba(255,255,255,0.02);
align-self: flex-start;
max-width: 95%;
}
.aurora-chat-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ── AISTALK section ── */
#section-aistalk { padding: 16px; overflow-y: auto; gap: 12px; }
/* ── Media Gen section ── */
#section-media-gen { padding: 16px; overflow-y: auto; gap: 12px; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.media-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
.media-title { font-size: 0.86rem; font-weight: 700; color: var(--gold); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.media-kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.04); }
.media-kv:last-child { border-bottom: none; }
.media-kv .k { color: var(--muted); }
.media-kv .v { color: var(--text); font-weight: 500; text-align: right; word-break: break-word; }
.media-input { width: 100%; min-height: 86px; background: var(--bg2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px; font-size: 0.82rem; }
.media-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
.media-job { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: var(--bg2); font-size: 0.76rem; }
.media-job b { color: var(--gold); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Projects sidebar ── */
.chat-with-sidebar { display: flex; flex: 1; overflow: hidden; }
.sidebar { width: 240px; min-width: 200px; background: var(--bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; transition: width 0.2s; }
.sidebar.collapsed { width: 36px; min-width: 36px; }
.sidebar-header { padding: 10px 12px; font-size: 0.8rem; font-weight: 700; color: var(--gold); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
.sidebar-content { flex: 1; overflow-y: auto; padding: 6px; }
.sidebar.collapsed .sidebar-content { display: none; }
.project-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; display: flex; align-items: center; gap: 6px; transition: background 0.15s; }
.project-item:hover { background: var(--bg2); }
.project-item.active { background: rgba(201,168,124,0.15); color: var(--gold); }
.project-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-count { font-size: 0.7rem; color: var(--muted); }
.sidebar-btn { width: 100%; padding: 7px; margin-top: 4px; border: 1px dashed var(--border); background: none; color: var(--muted); border-radius: 6px; cursor: pointer; font-size: 0.8rem; transition: border-color 0.15s, color 0.15s; }
.sidebar-btn:hover { border-color: var(--gold); color: var(--gold); }
.chat-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ── Projects section ── */
#section-projects { padding: 16px; overflow-y: auto; gap: 14px; }
.projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.project-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; cursor: pointer; transition: border-color 0.2s; }
.project-card:hover { border-color: var(--gold); }
.project-card h3 { font-size: 0.95rem; font-weight: 600; color: var(--gold); margin-bottom: 6px; }
.project-card p { font-size: 0.8rem; color: var(--muted); }
.project-card .meta { font-size: 0.75rem; color: var(--muted); margin-top: 10px; }
.tab-row { display: flex; gap: 6px; padding: 10px 0; border-bottom: 1px solid var(--border); margin-bottom: 14px; }
.tab-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border); background: none; color: var(--muted); font-size: 0.82rem; cursor: pointer; transition: all 0.15s; }
.tab-btn.active { background: rgba(201,168,124,0.15); color: var(--gold); border-color: var(--gold2); }
.doc-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 0.84rem; }
.doc-icon { font-size: 1.1rem; }
.doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 0.74rem; color: var(--muted); }
.session-row { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 0.83rem; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); margin-bottom: 6px; transition: border-color 0.15s; }
.session-row:hover { border-color: var(--gold2); }
.session-row .s-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-row .s-meta { font-size: 0.72rem; color: var(--muted); white-space: nowrap; }
/* ── Dialog Map ── */
.dialog-map { overflow-y: auto; padding: 8px; }
.map-node { margin: 4px 0; }
.map-node summary { cursor: pointer; padding: 6px 10px; border-radius: 6px; font-size: 0.8rem; display: flex; align-items: center; gap: 8px; }
.map-node summary:hover { background: var(--bg2); }
.map-node.user summary { border-left: 2px solid var(--accent); }
.map-node.assistant summary { border-left: 2px solid var(--gold2); }
.map-node-preview { font-size: 0.8rem; color: var(--text); }
.map-node-ts { font-size: 0.7rem; color: var(--muted); margin-left: auto; }
.map-children { margin-left: 20px; border-left: 1px solid var(--border); padding-left: 8px; }
.fork-btn { font-size: 0.72rem; padding: 2px 7px; border-radius: 4px; border: 1px solid var(--border); background: none; color: var(--muted); cursor: pointer; }
.fork-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Upload ── */
.upload-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 6px; border: 1px solid var(--border); background: none; color: var(--muted); cursor: pointer; font-size: 1rem; transition: all 0.15s; flex-shrink: 0; }
.upload-btn:hover { border-color: var(--gold); color: var(--gold); }
#uploadProgress { font-size: 0.78rem; color: var(--muted); padding: 0 6px; display: none; }
/* ── Kanban ── */
.kanban-col { background: var(--bg2); border-radius: 8px; border: 1px solid var(--border); padding: 10px; min-height: 200px; }
.kanban-col-header { font-size: 0.82rem; font-weight: 700; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.kanban-tasks { display: flex; flex-direction: column; gap: 8px; }
.task-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s; }
.task-card:hover { border-color: var(--gold); }
.task-card .task-title { font-weight: 600; margin-bottom: 4px; }
.task-card .task-meta { display: flex; gap: 6px; flex-wrap: wrap; }
.task-priority-low { border-left: 3px solid #69b578; }
.task-priority-normal { border-left: 3px solid var(--accent); }
.task-priority-high { border-left: 3px solid #f4a261; }
.task-priority-urgent { border-left: 3px solid #e63946; }
.task-label { font-size: 0.7rem; padding: 2px 6px; border-radius: 10px; background: var(--bg2); border: 1px solid var(--border); color: var(--muted); }
.task-status-btn { font-size: 0.7rem; padding: 2px 7px; border-radius: 4px; border: 1px solid var(--border); background: none; color: var(--muted); cursor: pointer; margin-top: 6px; }
.task-status-btn:hover { border-color: var(--gold); color: var(--gold); }
/* ── Meetings ── */
.meeting-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; font-size: 0.85rem; }
.meeting-card .meeting-title { font-weight: 700; color: var(--gold); margin-bottom: 4px; }
.meeting-card .meeting-meta { color: var(--muted); font-size: 0.78rem; display: flex; gap: 12px; flex-wrap: wrap; }
/* ── Dialog Graph SVG ── */
.graph-node { cursor: pointer; }
.graph-node circle { transition: r 0.15s; }
.graph-node:hover circle { r: 12; }
.graph-node text { font-size: 11px; fill: var(--text, #e0e0e0); pointer-events: none; }
.graph-edge { stroke: #444; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
.graph-edge.derives_task { stroke: #69b578; }
.graph-edge.references { stroke: #888; }
.graph-edge.updates_doc { stroke: #a8d8ea; }
.graph-edge.schedules_meeting { stroke: #f4a261; }
.graph-edge.resolves { stroke: #d4e09b; }
.graph-edge.produced_by { stroke: var(--gold, #e2a72e); }
/* ── Modal ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 20px; min-width: 320px; max-width: 480px; width: 90%; }
.modal-box h3 { margin: 0 0 14px; color: var(--gold); font-size: 1rem; }
.modal-box label { display: block; font-size: 0.82rem; color: var(--muted); margin-bottom: 3px; margin-top: 10px; }
.modal-box input, .modal-box select, .modal-box textarea { width: 100%; padding: 7px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 0.85rem; box-sizing: border-box; }
.modal-box textarea { min-height: 70px; resize: vertical; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
</style>
</head>
<body>
<!-- ── Login overlay ─────────────────────────────────────────────────────── -->
<div id="loginOverlay" style="display:flex;position:fixed;inset:0;z-index:9999;background:rgba(10,12,16,0.97);align-items:center;justify-content:center;flex-direction:column;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:36px 32px;max-width:380px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,0.6);">
<div style="font-size:1.5rem;font-weight:700;letter-spacing:0.08em;margin-bottom:4px;">SOFIIA</div>
<div style="font-size:0.75rem;color:var(--muted);margin-bottom:24px;">Network Control Panel · DAARION</div>
<label style="font-size:0.78rem;color:var(--muted);display:block;margin-bottom:6px;">API ключ доступу</label>
<input
id="loginKeyInput"
type="password"
placeholder="Введіть ключ…"
autocomplete="current-password"
style="width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px 12px;color:var(--text);font-size:0.9rem;outline:none;margin-bottom:10px;"
onkeydown="if(event.key==='Enter') submitLogin()"
/>
<div id="loginError" style="display:none;color:var(--err);font-size:0.75rem;margin-bottom:10px;"></div>
<button
id="loginBtn"
onclick="submitLogin()"
style="width:100%;padding:10px;background:var(--accent);border:none;border-radius:6px;color:#fff;font-size:0.9rem;font-weight:600;cursor:pointer;letter-spacing:0.03em;"
>Увійти</button>
<div style="margin-top:16px;font-size:0.68rem;color:var(--muted);text-align:center;">
Ключ знаходиться у файлі <code style="color:var(--gold);">.env.console.local</code><br>
або у змінній середовища <code style="color:var(--gold);">SOFIIA_CONSOLE_API_KEY</code> на сервері.
</div>
</div>
</div>
<header>
<div>
<div class="logo">SOFIIA</div>
<div class="subtitle">CTO DAARION · AI Control Console</div>
</div>
<span style="font-size:0.62rem;color:var(--muted);margin-left:auto;margin-right:10px;" id="buildVersionBadge" title="Build version">loading…</span>
<span class="status-dot" id="headerDot"></span>
<span id="globalStatus">Перевірка...</span>
<button onclick="logoutConsole()" title="Змінити ключ" style="margin-left:8px;background:none;border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:0.65rem;padding:2px 7px;cursor:pointer;">🔑</button>
</header>
<nav>
<button class="active" data-tab="chat">💬 Чат</button>
<button data-tab="projects">📁 Проєкти</button>
<button data-tab="aurora">🎞 Aurora</button>
<button data-tab="aistalk">🧠 AISTALK</button>
<button data-tab="media-gen">🧪 Media Gen</button>
<button data-tab="ops">⚙️ Ops</button>
<button data-tab="hub">🧭 Хаб</button>
<button data-tab="nodes">🖥 Ноди</button>
<button data-tab="memory">🧠 Пам'ять</button>
<button data-tab="cto">🎯 CTO</button>
<button data-tab="portfolio">🌐 Portfolio</button>
<button data-tab="budget">💰 Бюджет</button>
</nav>
<main>
<!-- ── Chat ── -->
<div class="section active" id="section-chat">
<div class="chat-with-sidebar">
<!-- Projects sidebar -->
<div class="sidebar" id="projectSidebar">
<div class="sidebar-header" onclick="toggleSidebar()" title="Розгорнути/Згорнути">
📁 <span class="sidebar-label">Проєкти</span>
</div>
<div class="sidebar-content">
<div id="sidebarProjectList"></div>
<button class="sidebar-btn" onclick="showNewProjectDialog()">+ Новий проєкт</button>
</div>
</div>
<!-- Main chat area -->
<div class="chat-main">
<div class="chat-toolbar">
<select class="model-select" id="modelSelect">
<optgroup label="🧠 Grok / xAI — Sofiia primary (AGENTS.md)">
<option value="grok:grok-4-1-fast-reasoning" selected>Grok 4.1 Fast Reasoning ← складні задачі</option>
<option value="grok:grok-4-1-fast">Grok 4.1 Fast — кодинг</option>
<option value="grok:grok-2-1212">Grok 2 (stable)</option>
</optgroup>
<optgroup label="⚡ GLM-5 — швидкі задачі (AGENTS.md)">
<option value="glm:glm-5">GLM-5 ← quick tasks</option>
<option value="glm:glm-4-flash">GLM-4 Flash</option>
</optgroup>
<optgroup label="🏠 НОДА2 Ollama (домівка Sofiia)">
<option value="ollama:qwen3.5:35b-a3b">Qwen3.5 35B A3B</option>
<option value="ollama:qwen3:14b">Qwen3 14B</option>
<option value="ollama:gpt-oss:latest">GPT-OSS</option>
<option value="ollama:gemma3:latest">Gemma 3</option>
<option value="ollama:mistral-nemo:12b">Mistral Nemo 12B</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>
</optgroup>
<optgroup label="🌐 Router DAARION (НОДА1 / Telegram)">
<option value="router:sofiia">Sofiia → Router (Grok via НОДА1)</option>
<option value="router:sofiia|qwen3.5:35b-a3b">Sofiia → Router Qwen3.5</option>
</optgroup>
</select>
<label class="toolbar-check">
<input type="checkbox" id="autoSpeak" checked> 🔊 TTS
</label>
<label class="toolbar-check">
<input type="checkbox" id="contVoice"> 🎙️ Безперервний
</label>
<label class="toolbar-check" title="Швидко: gemma3/qwen3:8b — ≤9s. Якісно: qwen3.5:35b — ≤11s">
<input type="checkbox" id="voiceQuality"> ✨ Якісно
</label>
<label class="toolbar-check" title="Phase 2: ранній TTS на першому реченні (~3-5s до першого звуку)">
<input type="checkbox" id="streamMode" checked> ⚡ Stream
</label>
<span id="voiceStatus">Готовий</span>
<span id="voiceDegradBadge" title="" style="
display: none;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: rgba(239,68,68,0.15);
border: 1px solid rgba(239,68,68,0.5);
color: #ef4444;
cursor: help;
"></span>
<span id="voiceRemoteBadge" title="" style="
display: none;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.5);
color: #818cf8;
cursor: help;
">🌐 Remote</span>
</div>
<div id="chatLog"></div>
<div class="chat-input-bar">
<button class="btn btn-record" id="voiceBtn" onclick="toggleVoice()" title="Голосовий ввід">🎤</button>
<button class="btn" id="stopVoiceBtn" onclick="stopVoice()" title="Зупинити голос"
style="display:none; background:rgba(239,68,68,0.2); border-color:#ef4444; color:#ef4444; transition: opacity 0.2s"></button>
<!-- File upload button -->
<button class="upload-btn" onclick="triggerFileUpload()" title="Завантажити файл (зображення, PDF, документ)">📎</button>
<input type="file" id="fileUploadInput" style="display:none"
accept=".pdf,.doc,.docx,.txt,.md,.csv,.xls,.xlsx,.json,.jpg,.jpeg,.png,.gif,.webp,.mp4,.webm"
onchange="handleFileUpload(this)">
<span id="uploadProgress"></span>
<textarea id="chatInput" placeholder="Напишіть повідомлення... (Enter — надіслати, Shift+Enter — новий рядок)" rows="1"></textarea>
<button class="btn btn-gold" id="sendBtn" onclick="sendMessage()">Надіслати</button>
</div>
</div><!-- .chat-main -->
</div><!-- .chat-with-sidebar -->
</div>
<!-- ── Aurora ── -->
<div class="section" id="section-aurora">
<div class="aurora-grid">
<div class="aurora-card">
<div class="aurora-title">Media Forensics Upload</div>
<div class="aurora-mode-row">
<button id="auroraModeTactical" class="aurora-mode-btn active" onclick="auroraSetMode('tactical')">⚡ Tactical</button>
<button id="auroraModeForensic" class="aurora-mode-btn forensic" onclick="auroraSetMode('forensic')">⚖ Forensic</button>
</div>
<div id="auroraDropzone" class="aurora-dropzone" onclick="auroraPickFile()">
<div style="font-size:1.2rem;">📂</div>
<div>Перетягніть файл або натисніть для вибору</div>
<div style="font-size:0.72rem;">Video: MP4/AVI/MOV · Audio: MP3/WAV/FLAC · Photo: JPG/PNG/TIFF</div>
</div>
<input id="auroraFileInput" type="file" style="display:none" multiple
accept=".mp4,.avi,.mov,.mkv,.webm,.mp3,.wav,.flac,.m4a,.aac,.ogg,.jpg,.jpeg,.png,.tiff,.tif,.webp"
onchange="auroraOnFilePicked(this)">
<div id="auroraThumbPreview" class="aurora-thumb-preview" style="display:none;"></div>
<div id="auroraClipPicker" class="aurora-clip-picker">
<div class="aurora-clip-head">
<strong>🎚 Фрагмент На Прев'ю</strong>
<span id="auroraClipSummary"></span>
</div>
<div class="aurora-clip-range-row">
<span>Start</span>
<input id="auroraClipStartRange" type="range" min="0" max="0" step="0.1" value="0">
<span id="auroraClipStartLabel">0s</span>
</div>
<div class="aurora-clip-range-row">
<span>End</span>
<input id="auroraClipEndRange" type="range" min="0" max="0" step="0.1" value="0">
<span id="auroraClipEndLabel">0s</span>
</div>
<div class="aurora-clip-actions">
<button type="button" class="aurora-clip-btn" id="auroraClipSetStartBtn">Start = поточний кадр</button>
<button type="button" class="aurora-clip-btn" id="auroraClipSetEndBtn">End = поточний кадр</button>
<button type="button" class="aurora-clip-btn" id="auroraClipFullBtn">Повне відео</button>
</div>
</div>
<div class="aurora-kv" style="margin-top:10px;">
<span class="k">Файл</span><span class="v" id="auroraSelectedFile"></span>
</div>
<div id="auroraBatchInfo" class="aurora-note" style="display:none; margin-top:4px;"></div>
<div class="aurora-kv">
<span class="k">Сервіс Aurora</span><span class="v" id="auroraServiceState">checking...</span>
</div>
<details id="auroraExportDetails" style="margin-top:8px;">
<summary style="cursor:pointer; font-size:0.78rem; color:var(--gold);">⚙ Export options</summary>
<div class="aurora-export-grid">
<label>Outscale
<select id="auroraOptOutscale">
<option value="auto" selected>Auto</option>
<option value="1">1x (original)</option>
<option value="2">2x</option>
<option value="3">3x</option>
<option value="4">4x</option>
</select>
</label>
<label>Codec
<select id="auroraOptCodec">
<option value="auto" selected>Auto</option>
<option value="h264_videotoolbox">H.264 (HW)</option>
<option value="hevc_videotoolbox">HEVC (HW)</option>
<option value="libx264">H.264 (SW)</option>
</select>
</label>
<label>Quality
<select id="auroraOptQuality">
<option value="balanced" selected>Balanced</option>
<option value="quality">Quality (slower)</option>
<option value="fast">Fast (lower quality)</option>
</select>
</label>
<label>Face model
<select id="auroraOptFaceModel">
<option value="auto" selected>Auto</option>
<option value="gfpgan">GFPGAN v1.4</option>
<option value="codeformer">CodeFormer</option>
</select>
</label>
<label>Clip start (sec)
<input id="auroraOptClipStart" type="number" min="0" step="0.1" placeholder="0">
</label>
<label>Clip duration (sec)
<input id="auroraOptClipDuration" type="number" min="0.1" step="0.1" placeholder="5">
</label>
</div>
</details>
<div style="margin-top:10px; border:1px solid rgba(255,255,255,0.08); border-radius:8px; padding:8px; background:var(--bg2);">
<div class="aurora-note" style="margin-top:0;">Smart Orchestrator (Dual Stack)</div>
<label class="aurora-checkline" style="margin-top:5px;">
<input type="checkbox" id="auroraSmartEnabled" checked>
Auto (Aurora + Kling when needed)
</label>
<div class="aurora-export-grid" style="margin-top:8px;">
<label>Strategy
<select id="auroraSmartStrategy">
<option value="auto" selected>Auto</option>
<option value="local_only">Local only</option>
<option value="local_then_kling">Local then Kling</option>
</select>
</label>
<label>Budget
<select id="auroraSmartBudget">
<option value="low">Low</option>
<option value="normal" selected>Normal</option>
<option value="high">High</option>
</select>
</label>
</div>
<label class="aurora-checkline" style="margin-top:6px;">
<input type="checkbox" id="auroraSmartPreferQuality" checked>
Prefer quality over speed
</label>
<div id="auroraSmartHint" class="aurora-note">policy: standby</div>
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
<button id="auroraAnalyzeBtn" class="btn btn-ghost" onclick="auroraAnalyze()" disabled>🔍 Аналіз</button>
<button id="auroraAudioProcessBtn" class="btn btn-ghost" style="display:none;" onclick="auroraStartAudio()">🎧 Audio process</button>
<button id="auroraStartBtn" class="btn btn-gold" style="flex:1;" onclick="auroraStart()" disabled>Почати обробку</button>
<button id="auroraCancelBtn" class="btn btn-ghost" style="display:none;" onclick="auroraCancel()">Зупинити</button>
</div>
</div>
<div class="aurora-card">
<div class="aurora-title">Job Status</div>
<div class="aurora-kv"><span class="k">Job ID</span><span class="v" id="auroraJobId"></span></div>
<div class="aurora-kv"><span class="k">Smart Run</span><span class="v" id="auroraSmartRunId"></span></div>
<div class="aurora-kv"><span class="k">Smart Policy</span><span class="v" id="auroraSmartPolicy"></span></div>
<div class="aurora-kv"><span class="k">Статус</span><span class="v" id="auroraJobStatus">idle</span></div>
<div class="aurora-kv"><span class="k">Етап</span><span class="v" id="auroraJobStage"></span></div>
<div class="aurora-kv"><span class="k">Черга</span><span class="v" id="auroraQueuePos"></span></div>
<div class="aurora-kv"><span class="k">Минуло</span><span class="v" id="auroraElapsed"></span></div>
<div class="aurora-kv"><span class="k">ETA</span><span class="v" id="auroraEta"></span></div>
<div class="aurora-kv"><span class="k">FPS (confidence)</span><span class="v" id="auroraLivePerf"></span></div>
<div class="aurora-kv"><span class="k">Збереження</span><span class="v" id="auroraStoragePath"></span></div>
<div class="media-row" style="margin-top:8px;">
<a id="auroraFolderLink" class="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener" style="display:none;">📁 Відкрити папку</a>
<button id="auroraRevealFolderBtn" class="btn btn-ghost btn-sm" onclick="auroraRevealFolder()" disabled>🗂 Reveal in Finder</button>
</div>
<div class="aurora-progress-wrap">
<div class="aurora-progress-bar" id="auroraProgressBar">
<div id="auroraProgressFill" class="aurora-progress-fill"></div>
</div>
<div id="auroraProgressText" class="aurora-note">0%</div>
</div>
<div id="auroraLiveLog" class="aurora-live-log" style="display:none;"></div>
</div>
</div>
<div class="aurora-card" id="auroraAnalysisCard" style="display:none;">
<div class="aurora-title">Pre-analysis</div>
<div class="aurora-kv"><span class="k">Тип</span><span class="v" id="auroraAnalysisType"></span></div>
<div class="aurora-kv"><span class="k">Обличчя</span><span class="v" id="auroraAnalysisFaces">0</span></div>
<div class="aurora-kv"><span class="k">Номерні знаки</span><span class="v" id="auroraAnalysisPlates">0</span></div>
<div class="aurora-kv"><span class="k">Якість</span><span class="v" id="auroraAnalysisQuality"></span></div>
<div class="aurora-kv"><span class="k">Рекоменд. пріоритет</span><span class="v" id="auroraAnalysisPriority">balanced</span></div>
<div class="aurora-note" style="margin-top:8px;">Рекомендації Aurora:</div>
<div id="auroraAnalysisRecs" class="aurora-links" style="margin-top:6px;"></div>
<div class="aurora-note" style="margin-top:10px;">Параметри обробки:</div>
<label class="aurora-checkline"><input type="checkbox" id="auroraCtrlDenoise"> Enable denoise (FastDVDnet/SCUNet)</label>
<label class="aurora-checkline"><input type="checkbox" id="auroraCtrlFaceRestore"> Run face restoration (GFPGAN)</label>
<label class="aurora-checkline"><input type="checkbox" id="auroraCtrlPlateRoi"> License-plate ROI enhancement</label>
<label class="aurora-checkline"><input type="checkbox" id="auroraCtrlMaxFace"> Max face quality (повільніше, але краще для облич)</label>
<label class="aurora-note" style="display:block; margin-top:8px;">Фокус задачі:</label>
<select id="auroraFocusProfile" style="width:100%; margin-top:4px;">
<option value="auto" selected>Auto</option>
<option value="max_faces">Max faces</option>
<option value="text_readability">Text / logos readability</option>
<option value="plates">License plates</option>
</select>
<input id="auroraTaskHint" type="text" style="width:100%; margin-top:8px;" placeholder="Ціль Aurora: напр. Прочитати напис на кепці персонажа (00:12-00:18)">
<div class="aurora-priority-wrap">
<div class="aurora-priority-head">
<span>Пріоритет: Обличчя</span>
<span>Номерні знаки</span>
</div>
<input type="range" id="auroraPriorityBias" min="-100" max="100" value="0" step="5" oninput="auroraUpdatePriorityLabel()">
<div class="aurora-note" id="auroraPriorityLabel">Баланс: рівномірно</div>
</div>
<div class="aurora-note" style="margin-top:8px;">Пресет швидкості:</div>
<div class="aurora-preset-row">
<button class="aurora-preset-btn" data-aurora-preset="turbo" onclick="auroraSetPreset('turbo')">⚡ Turbo</button>
<button class="aurora-preset-btn active" data-aurora-preset="balanced" onclick="auroraSetPreset('balanced')">⚖ Balanced</button>
<button class="aurora-preset-btn" data-aurora-preset="max_quality" onclick="auroraSetPreset('max_quality')">🔍 Max quality</button>
</div>
<div class="media-row" style="margin-top:10px;">
<button id="auroraStartFromAnalysisBtn" class="btn btn-gold btn-sm" onclick="auroraStartFromAnalysis()" disabled>Запустити обробку з вибраними опціями</button>
</div>
</div>
<div class="aurora-card" id="auroraAudioAnalysisCard" style="display:none;">
<div class="aurora-title">Audio Analysis</div>
<div class="aurora-kv"><span class="k">Duration</span><span class="v" id="auroraAudioDuration"></span></div>
<div class="aurora-kv"><span class="k">Sample rate</span><span class="v" id="auroraAudioSampleRate"></span></div>
<div class="aurora-kv"><span class="k">Channels</span><span class="v" id="auroraAudioChannels"></span></div>
<div class="aurora-kv"><span class="k">Codec</span><span class="v" id="auroraAudioCodec"></span></div>
<div class="aurora-kv"><span class="k">Bitrate</span><span class="v" id="auroraAudioBitrate"></span></div>
<div class="aurora-kv"><span class="k">Suggested priority</span><span class="v" id="auroraAudioPriority">speech</span></div>
<div class="aurora-note" style="margin-top:8px;">Рекомендації Aurora:</div>
<div id="auroraAudioRecs" class="aurora-links" style="margin-top:6px;"></div>
</div>
<div class="aurora-card" id="auroraResultCard" style="display:none;">
<div class="aurora-title">Результат обробки</div>
<div id="auroraCompareWrap" style="display:none; margin-bottom:10px;"></div>
<div id="auroraCompareTable" style="display:none; margin-bottom:12px;">
<table style="width:100%; font-size:0.78rem; border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid var(--border);">
<th style="text-align:left; padding:4px 6px; color:var(--muted); font-weight:500;">Параметр</th>
<th style="text-align:right; padding:4px 6px; color:var(--muted); font-weight:500;">До (input)</th>
<th style="text-align:right; padding:4px 6px; color:var(--muted); font-weight:500;">Після (output)</th>
</tr>
</thead>
<tbody id="auroraCompareRows"></tbody>
</table>
</div>
<div id="auroraQualityWrap" class="aurora-quality-report" style="display:none;">
<div class="aurora-note" style="margin-top:0; margin-bottom:6px;">Quality Report</div>
<div id="auroraQualityContent"></div>
</div>
<div id="auroraDetectionsWrap" class="aurora-detect-wrap" style="display:none;">
<div class="aurora-note" style="margin-top:0;">Detections (faces + plates)</div>
<div class="aurora-detect-grid">
<div class="aurora-detect-card">
<div class="aurora-note" style="margin-top:0;">Original frame</div>
<div id="auroraDetectionsBefore"></div>
</div>
<div class="aurora-detect-card">
<div class="aurora-note" style="margin-top:0;">Aurora enhanced frame</div>
<div id="auroraDetectionsAfter"></div>
</div>
</div>
</div>
<div id="auroraFacesRow" class="aurora-kv" style="display:none;">
<span class="k">Виявлено облич</span><span class="v" id="auroraFacesCount">0</span>
</div>
<div id="auroraStepsWrap" style="display:none; margin:8px 0;">
<div class="aurora-note" style="margin-bottom:4px;">Етапи обробки:</div>
<div id="auroraStepsList" style="font-size:0.72rem;"></div>
</div>
<div class="aurora-kv"><span class="k">Mode</span><span class="v" id="auroraResultMode"></span></div>
<div class="aurora-kv"><span class="k">Input hash</span><span class="v" id="auroraInputHash"></span></div>
<div class="aurora-kv"><span class="k">Digital signature</span><span class="v" id="auroraDigitalSignature"></span></div>
<div class="aurora-kv" id="auroraForensicReportRow" style="display:none;">
<span class="k">Forensic report</span><span class="v"><a id="auroraForensicReportLink" href="#" target="_blank" rel="noopener">download PDF</a></span>
</div>
<div class="aurora-links" id="auroraOutputLinks"></div>
<div style="display:flex; gap:8px; margin-top:10px; flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" id="auroraDownloadResultBtn" style="display:none;" onclick="auroraDownloadResult()">Завантажити результат</button>
<button class="btn btn-ghost btn-sm" id="auroraOpenFolderBtn" onclick="auroraRevealFolder()">Відкрити папку</button>
<button id="auroraReprocessBtn" class="btn btn-ghost btn-sm" onclick="auroraReprocess()" disabled>Повторна обробка ×1</button>
<select id="auroraReprocessPasses" class="btn btn-ghost btn-sm" style="min-width:92px;" onchange="auroraUpdateReprocessLabel()">
<option value="1" selected>1 прохід</option>
<option value="2">2 проходи</option>
<option value="3">3 проходи</option>
<option value="4">4 проходи</option>
</select>
<label class="aurora-checkline" style="margin:0;"><input type="checkbox" id="auroraReprocessSecondPass" checked> chain second-pass</label>
</div>
<div id="auroraForensicLogWrap" style="display:none; margin-top:10px;">
<div class="aurora-note" style="margin-top:0;">Forensic log</div>
<pre id="auroraForensicLog" style="margin-top:6px; max-height:220px; overflow:auto; border:1px solid var(--border); border-radius:8px; background:var(--bg2); padding:10px; font-size:0.72rem; line-height:1.35;"></pre>
</div>
</div>
<!-- ── Plates card ── -->
<div class="aurora-card" id="auroraPlatesCard" style="display:none;">
<div class="aurora-title" style="display:flex; align-items:center; gap:8px;">
🚗 Номерні знаки (ALPR)
<span id="auroraPlatesCount" style="font-size:0.75rem; background:var(--accent); color:#fff; border-radius:10px; padding:1px 8px; font-weight:600;">0</span>
</div>
<div id="auroraPlatesList" style="font-size:0.78rem; margin-top:6px;"></div>
<div id="auroraPlatesNote" class="aurora-note" style="margin-top:6px; display:none;"></div>
</div>
<!-- ── Kling AI card ── -->
<div class="aurora-card" id="auroraKlingCard" style="display:none;">
<div class="aurora-title" style="display:flex; align-items:center; gap:8px;">
✨ Kling AI — Generative Enhancement
<span id="auroraKlingStatus" style="font-size:0.72rem; color:var(--muted); font-weight:400; margin-left:auto;">not submitted</span>
</div>
<div class="aurora-note" style="margin-bottom:8px; color:var(--accent-soft);">Паралельне покращення відео через генеративний AI (відео→відео + image-to-video)</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:8px;">
<div>
<label style="font-size:0.72rem; color:var(--muted); display:block; margin-bottom:3px;">Mode</label>
<select id="klingMode" style="width:100%; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 8px; font-size:0.78rem;">
<option value="pro">Pro (краща якість)</option>
<option value="std">Standard</option>
</select>
</div>
<div>
<label style="font-size:0.72rem; color:var(--muted); display:block; margin-bottom:3px;">Тривалість</label>
<select id="klingDuration" style="width:100%; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 8px; font-size:0.78rem;">
<option value="5">5 секунд</option>
<option value="10">10 секунд</option>
</select>
</div>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:0.72rem; color:var(--muted); display:block; margin-bottom:3px;">Prompt (що покращити)</label>
<input id="klingPrompt" type="text" value="enhance video quality, improve sharpness, clarity and details"
style="width:100%; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:6px 8px; font-size:0.78rem; box-sizing:border-box;">
</div>
<div style="margin-bottom:10px;">
<label style="font-size:0.72rem; color:var(--muted); display:block; margin-bottom:3px;">Negative prompt</label>
<input id="klingNegative" type="text" value="noise, blur, artifacts, distortion"
style="width:100%; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:6px 8px; font-size:0.78rem; box-sizing:border-box;">
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="btn btn-gold btn-sm" id="klingSubmitBtn" onclick="auroraKlingSubmit()">🚀 Надіслати в Kling AI</button>
<button class="btn btn-ghost btn-sm" id="klingCheckBtn" onclick="auroraKlingCheck()" style="display:none;">↻ Перевірити статус</button>
</div>
<div id="klingResultWrap" style="display:none; margin-top:10px;">
<div class="aurora-note" style="margin-bottom:4px;">Результат Kling AI:</div>
<div id="klingResultInfo" style="font-size:0.78rem;"></div>
<video id="klingResultVideo" controls style="width:100%; border-radius:8px; margin-top:8px; display:none;"></video>
</div>
<div id="auroraKlingProgress" style="margin-top:8px; display:none;">
<div style="background:var(--bg2); border-radius:6px; height:4px; overflow:hidden; margin-bottom:4px;">
<div id="klingProgressBar" style="height:100%; background: linear-gradient(90deg,#f5a623,#e07b00); width:0%; transition:width 0.5s; animation: klingPulse 1.5s ease-in-out infinite;"></div>
</div>
<div id="klingProgressText" class="aurora-note" style="text-align:center;"></div>
</div>
</div>
<div class="aurora-card">
<div class="aurora-title">Recent Jobs</div>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span id="auroraRecentJobsCount" class="aurora-note" style="margin:0;">loading...</span>
<button class="btn btn-ghost btn-sm" onclick="auroraRefreshJobs()">↻ Оновити</button>
<button class="btn btn-ghost btn-sm" onclick="auroraPurgeTerminalJobs()">🧹 Очистити завершені</button>
<button class="btn btn-ghost btn-sm" onclick="auroraClearActiveJob()">Очистити active</button>
</div>
<div id="auroraRecentJobs" style="display:flex; flex-direction:column; gap:6px;">
<div class="aurora-note">Завантаження історії job...</div>
</div>
</div>
<div class="aurora-card" id="auroraChatCard">
<div class="aurora-title">Aurora Operator Chat</div>
<div id="auroraChatLog" class="aurora-chat-log"></div>
<div class="aurora-chat-actions" id="auroraChatActions"></div>
<div style="display:flex; gap:8px; margin-top:10px;">
<input id="auroraChatInput" type="text" placeholder="Запитайте Aurora про статус, ETA, reprocess..." style="flex:1; min-width:0; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:7px; padding:8px 10px; font-size:0.8rem;"
onkeydown="if(event.key==='Enter'){event.preventDefault(); auroraSendChat();}">
<button id="auroraChatSendBtn" class="btn btn-gold" onclick="auroraSendChat()">Надіслати</button>
</div>
</div>
</div>
<!-- ── AISTALK ── -->
<div class="section" id="section-aistalk">
<div class="media-grid">
<div class="media-card">
<div class="media-title">AISTALK Status</div>
<div class="media-kv"><span class="k">Adapter</span><span class="v" id="aistalkAdapterState">checking...</span></div>
<div class="media-kv"><span class="k">Relay</span><span class="v" id="aistalkRelayState"></span></div>
<div class="media-kv"><span class="k">Supervisor</span><span class="v" id="aistalkSupervisorState">checking...</span></div>
<div class="media-kv"><span class="k">Graphs</span><span class="v" id="aistalkGraphsState"></span></div>
<div class="media-kv"><span class="k">Aurora</span><span class="v" id="aistalkAuroraState"></span></div>
<div class="media-kv"><span class="k">CPU / RAM</span><span class="v" id="aistalkResourceState"></span></div>
<div class="media-kv"><span class="k">Parallel Team Runs</span><span class="v" id="aistalkRunLimitState"></span></div>
<div class="media-kv"><span class="k">Parallel Chat</span><span class="v" id="aistalkChatLimitState"></span></div>
<div class="media-row">
<label class="aurora-note" style="margin:0;">Team</label>
<input id="aistalkLimitTeamInput" type="number" min="1" max="4" value="1" style="width:72px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;">
<label class="aurora-note" style="margin:0;">Chat</label>
<input id="aistalkLimitChatInput" type="number" min="1" max="8" value="2" style="width:72px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;">
<button class="btn btn-ghost btn-sm" onclick="aistalkSaveLimits()">💾 Save limits</button>
</div>
<div class="aurora-note" id="aistalkRuleText" style="margin-top:6px;">rule: loading...</div>
<div class="media-row">
<button class="btn btn-ghost btn-sm" onclick="aistalkRefreshStatus()">↻ Оновити статус</button>
<button class="btn btn-ghost btn-sm" onclick="aistalkRelayTest()">📡 Relay test</button>
<a class="btn btn-ghost btn-sm" href="/docs/aistalk/contract.md" target="_blank" rel="noopener">📄 Contract</a>
<a class="btn btn-ghost btn-sm" href="/docs/supervisor/langgraph_supervisor.md" target="_blank" rel="noopener">🧭 Supervisor</a>
</div>
</div>
<div class="media-card">
<div class="media-title">AISTALK Capabilities</div>
<div id="aistalkCapabilityDomains" class="aurora-links">
<div class="aurora-note">loading domains...</div>
</div>
<div class="aurora-note" style="margin-top:8px;">
AISTALK: crypto-detective & network-security command unit (OSINT, threat analysis, red/blue/purple teaming, risk, forensic via Aurora).
</div>
</div>
<div class="media-card">
<div class="media-title">Subagents Team</div>
<div id="aistalkAgentsGrid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:8px;">
<div class="aurora-note">loading subagents...</div>
</div>
</div>
<div class="media-card">
<div class="media-title">AISTALK Chat</div>
<div id="aistalkChatLog" class="aurora-chat-log"></div>
<div class="media-row" style="margin-top:8px;">
<label class="aurora-note" style="margin:0;">Agent</label>
<select id="aistalkChatAgentSelect" style="min-width:220px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;"></select>
<button class="btn btn-ghost btn-sm" onclick="aistalkChatUseAssignedModel()">Use assigned model</button>
</div>
<div class="media-row" style="margin-top:8px;">
<label class="aurora-note" style="margin:0;">Model</label>
<select id="aistalkChatModelSelect" style="min-width:220px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;"></select>
</div>
<textarea id="aistalkChatInput" class="media-input" style="min-height:88px;margin-top:8px;" placeholder="Пиши запит до AISTALK (мережева безпека, crypto detective, triage...)"></textarea>
<div class="media-row">
<button class="btn btn-gold" id="aistalkChatSendBtn" onclick="aistalkSendChat()">Надіслати</button>
<button class="btn btn-ghost" onclick="aistalkClearChat()">Очистити чат</button>
</div>
</div>
<div class="media-card">
<div class="media-title">Memory Timeline</div>
<div class="media-row">
<button class="btn btn-ghost btn-sm" onclick="aistalkRefreshMemoryTimeline()">↻ Оновити timeline</button>
<span class="aurora-note" id="aistalkMemoryTimelineMeta" style="margin:0;">session: —</span>
</div>
<div id="aistalkMemoryTimeline" style="display:flex; flex-direction:column; gap:8px; max-height:300px; overflow:auto; margin-top:8px;">
<div class="aurora-note">loading timeline...</div>
</div>
</div>
<div class="media-card">
<div class="media-title">Run AISTALK Team (LangGraph)</div>
<div class="aurora-note" style="margin-top:0;">AISTALK orchestration через Supervisor. Для пари з Aurora можна взяти поточний Aurora job як objective.</div>
<div class="media-row">
<label class="aurora-note" style="margin:0;">Graph</label>
<select id="aistalkGraphSelect" style="min-width:220px; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:7px; padding:6px 8px; font-size:0.78rem;">
<option value="incident_triage">incident_triage</option>
<option value="release_check">release_check</option>
<option value="postmortem_draft">postmortem_draft</option>
<option value="alert_triage">alert_triage</option>
</select>
</div>
<div class="media-row">
<button class="btn btn-ghost btn-sm" onclick="aistalkApplyTemplate('aurora_triage')">📌 Aurora Triage</button>
<button class="btn btn-ghost btn-sm" onclick="aistalkApplyTemplate('aurora_release')">📌 Aurora Release Check</button>
<button class="btn btn-ghost btn-sm" onclick="aistalkApplyTemplate('alerts_sweep')">📌 Alerts Sweep</button>
<button class="btn btn-ghost btn-sm" onclick="aistalkApplyTemplate('postmortem')">📌 Postmortem</button>
</div>
<textarea id="aistalkObjective" class="media-input" placeholder="Опишіть objective для команди AISTALK..."></textarea>
<textarea id="aistalkInputJson" class="media-input" style="min-height:92px; margin-top:8px;" placeholder='Опційно: input JSON, наприклад {"incident_id":"inc_123","service":"aurora-service"}'></textarea>
<div class="media-row">
<button class="btn btn-ghost btn-sm" onclick="aistalkUseAuroraContext()">🎞 Взяти з Aurora job</button>
</div>
<div class="media-row">
<button class="btn btn-gold" id="aistalkStartBtn" onclick="aistalkStartRun()">▶ Запустити</button>
<button class="btn btn-ghost" id="aistalkCancelBtn" onclick="aistalkCancelRun()" disabled>⏹ Скасувати</button>
</div>
<div class="media-kv"><span class="k">Run ID</span><span class="v" id="aistalkRunId"></span></div>
<div class="media-kv"><span class="k">Status</span><span class="v" id="aistalkRunStatus">idle</span></div>
</div>
<div class="media-card">
<div class="media-title">Run Output</div>
<pre id="aistalkRunOutput" style="margin:0; max-height:360px; overflow:auto; border:1px solid var(--border); border-radius:8px; background:var(--bg2); padding:10px; font-size:0.72rem; line-height:1.35;">Очікування запуску...</pre>
</div>
</div>
</div>
<!-- ── Media Gen ── -->
<div class="section" id="section-media-gen">
<div class="media-grid">
<div class="media-card">
<div class="media-title">Service Status</div>
<div class="media-kv"><span class="k">Router</span><span class="v" id="mediaHealthRouter">checking...</span></div>
<div class="media-kv"><span class="k">Comfy Agent</span><span class="v" id="mediaHealthComfy">checking...</span></div>
<div class="media-kv"><span class="k">ComfyUI</span><span class="v" id="mediaHealthComfyUi">checking...</span></div>
<div class="media-kv"><span class="k">Swapper</span><span class="v" id="mediaHealthSwapper">checking...</span></div>
<div class="media-kv"><span class="k">Image Gen Service</span><span class="v" id="mediaHealthImageGen">checking...</span></div>
<div class="media-kv"><span class="k">Image models</span><span class="v" id="mediaImageModelsState">checking...</span></div>
<div class="media-row">
<button class="btn btn-ghost btn-sm" onclick="mediaRefreshHealth()">↻ Оновити статус</button>
<button class="btn btn-ghost btn-sm" id="mediaLoadImageModelBtn" onclick="mediaLoadDefaultImageModel()">⬇ Load image model</button>
</div>
</div>
<div class="media-card">
<div class="media-title">Generate</div>
<textarea id="mediaPrompt" class="media-input" placeholder="Опиши, що згенерувати (image/video)"></textarea>
<div class="media-row">
<label class="aurora-note" style="margin:0;">Width</label>
<input id="mediaWidth" type="number" value="1024" min="256" max="2048" style="width:90px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;">
<label class="aurora-note" style="margin:0;">Height</label>
<input id="mediaHeight" type="number" value="1024" min="256" max="2048" style="width:90px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;">
<label class="aurora-note" style="margin:0;">Steps</label>
<input id="mediaSteps" type="number" value="28" min="1" max="120" style="width:90px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 8px;font-size:0.78rem;">
</div>
<div class="media-row">
<button class="btn btn-gold" id="mediaGenerateImageBtn" onclick="mediaGenerateImage()">🖼 Generate Image</button>
<button class="btn btn-ghost" id="mediaGenerateVideoBtn" onclick="mediaGenerateVideo()">🎬 Generate Video</button>
</div>
<div id="mediaGenStatus" class="aurora-note" style="margin-top:8px;">Готово</div>
<div id="mediaGenHint" class="aurora-note" style="margin-top:4px;"></div>
</div>
<div class="media-card">
<div class="media-title">Recent Media Jobs</div>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span id="mediaRecentJobsCount" class="aurora-note" style="margin:0;">loading...</span>
<button class="btn btn-ghost btn-sm" onclick="mediaRefreshJobs()">↻ Оновити</button>
</div>
<div id="mediaRecentJobs" style="display:flex; flex-direction:column; gap:6px;">
<div class="aurora-note">Завантаження...</div>
</div>
</div>
</div>
</div>
<!-- ── Projects ── -->
<div class="section" id="section-projects" style="padding:16px; overflow-y:auto; gap:14px; display:none; flex-direction:column;">
<!-- Mode toggle: Workspace / Agents -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap;">
<div style="display:flex; border:1px solid var(--border); border-radius:6px; overflow:hidden;">
<button id="modeWorkspaceBtn" onclick="projectsSwitchMode('workspace')"
style="padding:5px 14px; font-size:0.82rem; font-weight:600; background:var(--gold); color:#0e0e12; border:none; cursor:pointer;">
📁 Workspace
</button>
<button id="modeAgentsBtn" onclick="projectsSwitchMode('agents')"
style="padding:5px 14px; font-size:0.82rem; font-weight:600; background:var(--bg2); color:var(--muted); border:none; cursor:pointer;">
🤖 Agents (NODA1)
</button>
</div>
<!-- Workspace actions -->
<div id="workspaceActions">
<button class="btn btn-gold" onclick="showNewProjectDialog()" style="font-size:0.8rem; padding:6px 12px;">+ Новий проєкт</button>
</div>
<!-- Agents actions -->
<div id="agentsActions" style="display:none; gap:6px; align-items:center; flex-wrap:wrap;">
<div style="display:flex; border:1px solid var(--border); border-radius:5px; overflow:hidden; font-size:0.78rem;">
<button id="nodeChipNODA1" onclick="agentsSetNode('NODA1')"
style="padding:3px 10px; background:var(--gold); color:#0e0e12; border:none; cursor:pointer; font-weight:600;">NODA1</button>
<button id="nodeChipNODA2" onclick="agentsSetNode('NODA2')"
style="padding:3px 10px; background:var(--bg2); color:var(--muted); border:none; cursor:pointer;">NODA2</button>
<button id="nodeChipALL" onclick="agentsSetNode('NODA1,NODA2')"
style="padding:3px 10px; background:var(--bg2); color:var(--muted); border:none; cursor:pointer;">All</button>
</div>
<span id="nodeLatencyBadges" style="font-size:0.7rem; color:var(--muted);"></span>
<!-- Capability filters -->
<label style="font-size:0.72rem;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:3px;" title="Show voice agents only">
<input type="checkbox" id="filterVoice" onchange="agentsApplyFilters()" style="accent-color:var(--accent);"> 🎙 Voice
</label>
<label style="font-size:0.72rem;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:3px;" title="Show Telegram-enabled agents only">
<input type="checkbox" id="filterTelegram" onchange="agentsApplyFilters()" style="accent-color:var(--accent);"> 💬 Telegram
</label>
<button class="btn btn-ghost btn-sm" onclick="agentsLoad()">↻ Sync</button>
<button class="btn btn-ghost btn-sm" style="font-size:0.72rem;opacity:0.6;" onclick="agentsToggleDebug()" title="Toggle debug panel">🔍 Debug</button>
<button class="btn btn-ghost btn-sm" onclick="agentsBulkDiff()" title="Show drift report for all agents">📊 Diff All</button>
<button class="btn btn-ghost btn-sm" onclick="agentsBulkApply(true,'all')" title="Dry-run all overrides">📋 Plan All</button>
<!-- Apply dropdown -->
<div style="position:relative; display:inline-block;">
<button class="btn btn-gold btn-sm" onclick="agentsToggleApplyMenu(event)" id="applyMenuBtn">⚡ Apply ▾</button>
<div id="applyMenu" style="display:none; position:absolute; top:100%; right:0; z-index:100;
background:var(--bg2); border:1px solid var(--border); border-radius:5px; min-width:150px; box-shadow:0 4px 12px rgba(0,0,0,0.4);">
<button onclick="agentsBulkApply(false,'canary',2)" style="display:block;width:100%;text-align:left;padding:7px 12px;background:none;border:none;color:var(--text);cursor:pointer;font-size:0.8rem;"
onmouseover="this.style.background='var(--bg3)'" onmouseout="this.style.background='none'">🐣 Canary (2 agents)</button>
<button onclick="agentsBulkApply(false,'canary',5)" style="display:block;width:100%;text-align:left;padding:7px 12px;background:none;border:none;color:var(--text);cursor:pointer;font-size:0.8rem;"
onmouseover="this.style.background='var(--bg3)'" onmouseout="this.style.background='none'">🐣 Canary (5 agents)</button>
<div style="border-top:1px solid var(--border);"></div>
<button onclick="agentsBulkApply(false,'all')" style="display:block;width:100%;text-align:left;padding:7px 12px;background:none;border:none;color:var(--text);cursor:pointer;font-size:0.8rem;"
onmouseover="this.style.background='var(--bg3)'" onmouseout="this.style.background='none'">⚡ Apply All</button>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="agentsExportPrompts()" title="Export all prompts as JSON">📤 Export</button>
</div>
</div>
<!-- Project list view (Workspace mode) -->
<div id="projectsListView">
<div class="projects-grid" id="projectsGrid">
<div style="color:var(--muted); font-size:0.85rem;">Завантаження...</div>
</div>
</div>
<!-- Agents view (Agents mode) -->
<div id="agentsView" style="display:none; flex-direction:column; gap:0; height:calc(100vh - 120px); overflow:hidden;">
<!-- Node errors banner -->
<div id="nodeErrorsBanner" style="display:none; background:rgba(220,80,60,0.12); border:1px solid rgba(220,80,60,0.4); border-radius:5px; padding:6px 12px; font-size:0.75rem; color:var(--warn); margin-bottom:6px; flex-shrink:0;"></div>
<!-- Monitor missing banner -->
<div id="monitorMissingBanner" style="display:none; background:rgba(240,140,30,0.12); border:1px solid rgba(240,140,30,0.5); border-radius:5px; padding:6px 12px; font-size:0.75rem; color:var(--warn); margin-bottom:6px; flex-shrink:0;">
⚠️ <b>Monitor</b> відсутній на ноді: <span id="monitorMissingNodes"></span>
<span style="color:var(--muted);"> — необхідний агент для кожної ноди</span>
</div>
<!-- Canary continue banner -->
<div id="canaryContinueBanner" style="display:none; background:rgba(255,165,0,0.12); border:1px solid var(--warn); border-radius:5px; padding:8px 12px; font-size:0.78rem; color:var(--warn); margin-bottom:6px; flex-shrink:0; display:flex; align-items:center; gap:10px;">
<span id="canaryContinueMsg"></span>
<button class="btn btn-gold btn-sm" onclick="agentsCanaryContinue()">✅ Continue Rollout (Apply All)</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('canaryContinueBanner').style.display='none'">✗ Cancel</button>
</div>
<!-- Debug panel (dev) -->
<div id="agentsDebugPanel" style="display:none; background:rgba(0,0,0,0.4); border:1px solid var(--border); border-radius:5px; padding:6px 10px; font-size:0.68rem; color:var(--muted); margin-bottom:6px; flex-shrink:0; font-family:monospace; line-height:1.6;">
<span style="color:var(--gold);font-weight:700;">DEBUG</span>
&nbsp;|&nbsp;fetch: <span id="dbgFetchTs"></span>
&nbsp;|&nbsp;nodes: <span id="dbgNodes"></span>
&nbsp;|&nbsp;items: <span id="dbgCount"></span>
&nbsp;|&nbsp;ok: <span id="dbgNodesOk"></span>/<span id="dbgNodesTotal"></span>
&nbsp;|&nbsp;errors: <span id="dbgErrors" style="color:var(--warn);"></span>
&nbsp;|&nbsp;<button class="btn btn-ghost btn-sm" style="font-size:0.65rem; padding:1px 5px;" onclick="agentsLoad()"></button>
&nbsp;<button class="btn btn-ghost btn-sm" style="font-size:0.65rem; padding:1px 5px;" onclick="document.getElementById('agentsDebugPanel').style.display='none'"></button>
</div>
<!-- Main content row -->
<div style="display:flex; flex-direction:row; gap:0; flex:1; overflow:hidden;">
<!-- Left: agents list -->
<div style="width:240px; min-width:200px; border-right:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden;">
<div id="agentsListStatus" style="padding:8px 10px; font-size:0.72rem; color:var(--muted); border-bottom:1px solid var(--border);"></div>
<div id="agentsList" style="flex:1; overflow-y:auto; padding:6px;">
<div style="color:var(--muted);font-size:0.8rem;">↻ натисніть Sync</div>
</div>
</div>
<!-- Right: agent editor -->
<div id="agentEditor" style="flex:1; padding:16px; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
<div style="color:var(--muted); font-size:0.85rem; text-align:center; margin-top:40px;">Оберіть агента зі списку</div>
</div>
</div><!-- end main content row -->
</div>
<!-- Single project view (tabs: Documents | Sessions | Dialog Map) -->
<div id="projectDetailView" style="display:none;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
<button onclick="showProjectsList()" style="background:none; border:none; color:var(--muted); cursor:pointer; font-size:0.9rem;">← Назад</button>
<h3 id="projectDetailName" style="font-size:0.95rem; font-weight:700; color:var(--gold);"></h3>
</div>
<div class="tab-row">
<button class="tab-btn active" data-ptab="docs" onclick="switchProjectTab(this,'docs')">📄 Документи</button>
<button class="tab-btn" data-ptab="board" onclick="switchProjectTab(this,'board')">📋 Kanban</button>
<button class="tab-btn" data-ptab="meetings" onclick="switchProjectTab(this,'meetings')">📅 Зустрічі</button>
<button class="tab-btn" data-ptab="sessions" onclick="switchProjectTab(this,'sessions')">💬 Сесії</button>
<button class="tab-btn" data-ptab="map" onclick="switchProjectTab(this,'map')">🗺 Карта діалогів</button>
</div>
<!-- Documents tab -->
<div id="ptab-docs">
<div style="display:flex; gap:8px; margin-bottom:10px;">
<input id="docSearchInput" type="text" placeholder="Пошук документів..." style="flex:1; padding:6px 10px; background:var(--bg2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.85rem;" oninput="searchProjectDocs()">
<button class="btn" onclick="triggerFileUpload()" style="font-size:0.8rem; padding:6px 10px;">📎 Завантажити</button>
</div>
<div id="docsList"></div>
</div>
<!-- Sessions tab -->
<div id="ptab-sessions" style="display:none;">
<div id="sessionsList"></div>
</div>
<!-- Board / Kanban tab -->
<div id="ptab-board" style="display:none;">
<div style="display:flex; gap:8px; margin-bottom:10px; align-items:center;">
<button class="btn btn-gold" onclick="showCreateTaskModal()" style="font-size:0.8rem; padding:6px 12px;">+ Задача</button>
<span style="color:var(--muted); font-size:0.8rem;" id="boardStatsLabel"></span>
</div>
<div id="kanbanBoard" style="display:grid; grid-template-columns: repeat(4,1fr); gap:10px; min-height:300px;">
<div class="kanban-col" id="col-backlog">
<div class="kanban-col-header" style="color:#888;">📋 Беклог</div>
<div class="kanban-tasks" id="tasks-backlog"></div>
</div>
<div class="kanban-col" id="col-in_progress">
<div class="kanban-col-header" style="color:#f4a261;">🔄 В роботі</div>
<div class="kanban-tasks" id="tasks-in_progress"></div>
</div>
<div class="kanban-col" id="col-review">
<div class="kanban-col-header" style="color:#a8d8ea;">👁 Review</div>
<div class="kanban-tasks" id="tasks-review"></div>
</div>
<div class="kanban-col" id="col-done">
<div class="kanban-col-header" style="color:#69b578;">✅ Готово</div>
<div class="kanban-tasks" id="tasks-done"></div>
</div>
</div>
</div>
<!-- Meetings tab -->
<div id="ptab-meetings" style="display:none;">
<div style="display:flex; gap:8px; margin-bottom:10px;">
<button class="btn btn-gold" onclick="showCreateMeetingModal()" style="font-size:0.8rem; padding:6px 12px;">+ Зустріч</button>
</div>
<div id="meetingsList"></div>
</div>
<!-- Dialog Map tab (project-level graph) -->
<div id="ptab-map" style="display:none;">
<!-- Toolbar row 1: type filter + controls -->
<div style="display:flex; gap:8px; margin-bottom:6px; align-items:center; flex-wrap:wrap;">
<span style="font-size:0.8rem; color:var(--muted);">Тип:</span>
<select id="mapNodeTypeFilter" multiple style="max-width:200px; padding:4px; background:var(--bg2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.78rem;" onchange="renderDialogGraph()">
<option value="message" selected>💬 message</option>
<option value="task" selected>✅ task</option>
<option value="doc" selected>📄 doc</option>
<option value="meeting" selected>📅 meeting</option>
<option value="agent_run" selected>🤖 agent_run</option>
<option value="ops_run" selected>⚙️ ops_run</option>
<option value="decision" selected>🎯 decision</option>
<option value="goal" selected>🏆 goal</option>
</select>
<button class="btn" onclick="loadProjectDialogMap()" style="font-size:0.78rem; padding:4px 8px;">↻ Оновити</button>
<button class="btn" onclick="runHygiene(false)" style="font-size:0.78rem; padding:4px 8px; background:rgba(255,200,0,0.1);" title="Запустити семантичну нормалізацію графу">🧹 Hygiene</button>
<button class="btn" onclick="runHygiene(true)" style="font-size:0.78rem; padding:4px 8px;" title="Dry-run: показати зміни без застосування">🔍 Dry-run</button>
<span id="mapStats" style="font-size:0.78rem; color:var(--muted); margin-left:auto;"></span>
</div>
<!-- Toolbar row 2: importance slider + lifecycle filter -->
<div style="display:flex; gap:12px; margin-bottom:8px; align-items:center; flex-wrap:wrap; padding:6px 8px; background:var(--bg2); border-radius:6px; border:1px solid var(--border);">
<label style="font-size:0.78rem; color:var(--muted); display:flex; align-items:center; gap:6px; white-space:nowrap;">
Важливість ≥ <span id="importanceThresholdVal">0.35</span>
<input type="range" id="importanceThreshold" min="0" max="1" step="0.05" value="0.35"
style="width:100px; accent-color:var(--gold);" oninput="updateImportanceLabel(); renderDialogGraph()">
</label>
<label style="font-size:0.78rem; color:var(--muted); display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="showArchivedNodes" onchange="renderDialogGraph()">
показати archived/superseded
</label>
<label style="font-size:0.78rem; color:var(--muted); display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="showLowImportance" onchange="renderDialogGraph()">
показати message/low
</label>
</div>
<!-- SVG-based force graph -->
<div style="position:relative; background:var(--bg2); border-radius:8px; border:1px solid var(--border); overflow:hidden;">
<svg id="dialogGraphSvg" width="100%" height="500" style="display:block;"></svg>
<div id="mapLegend" style="position:absolute; bottom:8px; right:8px; font-size:0.7rem; color:var(--muted); display:flex; flex-direction:column; gap:2px;">
<span><span style="color:#f0d070">decision</span> · <span style="color:#70c8f0">agent_run</span> · <span style="color:#70f09a">task</span> · <span style="color:#f07090">meeting</span></span>
<span style="opacity:0.6;">○ archived/superseded</span>
</div>
</div>
<!-- Hygiene result panel -->
<div id="hygieneResult" style="display:none; margin-top:8px; background:rgba(255,200,0,0.06); border:1px solid rgba(255,200,0,0.3); border-radius:8px; padding:10px; font-size:0.8rem;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<b>🧹 Hygiene результат</b>
<button onclick="document.getElementById('hygieneResult').style.display='none'" style="background:none;border:none;color:var(--muted);cursor:pointer;"></button>
</div>
<div id="hygieneResultBody"></div>
</div>
<!-- Node details panel -->
<div id="mapNodeDetail" style="display:none; margin-top:10px; background:var(--bg2); border:1px solid var(--border); border-radius:8px; padding:12px; font-size:0.85rem;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span id="mapNodeDetailTitle" style="font-weight:700; color:var(--gold);"></span>
<button onclick="document.getElementById('mapNodeDetail').style.display='none'" style="background:none;border:none;color:var(--muted);cursor:pointer;"></button>
</div>
<div id="mapNodeDetailBody"></div>
<!-- Evidence + Reflection tabs for agent_run nodes -->
<div id="agentRunPanel" style="display:none; margin-top:10px; border-top:1px solid var(--border); padding-top:8px;">
<div style="display:flex; gap:4px; margin-bottom:8px;">
<button id="tabEvidence" class="btn btn-sm tab-active" onclick="switchAgentRunTab('evidence')" style="font-size:0.76rem; padding:3px 8px;">📄 Evidence</button>
<button id="tabReflection" class="btn btn-sm" onclick="switchAgentRunTab('reflection')" style="font-size:0.76rem; padding:3px 8px;">🧠 Reflection</button>
</div>
<div id="evidencePanel" style="font-size:0.78rem; color:var(--muted); max-height:180px; overflow-y:auto;"></div>
<div id="reflectionPanel" style="display:none; font-size:0.78rem; color:var(--muted); max-height:180px; overflow-y:auto;"></div>
<div id="reflectionActions" style="margin-top:8px; display:none;">
<button class="btn btn-gold" onclick="reflectNow()" style="font-size:0.78rem; padding:4px 10px;">🧠 Reflect Now</button>
</div>
</div>
<div style="display:flex; gap:8px; margin-top:10px; flex-wrap:wrap;">
<button class="btn" onclick="createTaskFromNode()" style="font-size:0.78rem;"> Задача з цього вузла</button>
<button class="btn" onclick="openLinkModal()" style="font-size:0.78rem;">🔗 Зв'язати з...</button>
</div>
</div>
<!-- Legacy session tree (collapsible) -->
<details style="margin-top:12px;">
<summary style="cursor:pointer; font-size:0.82rem; color:var(--muted); user-select:none;">📜 Дерево сесій (legacy tree view)</summary>
<div style="display:flex; gap:8px; margin:8px 0;">
<select id="mapSessionSelect" style="flex:1; padding:6px; background:var(--bg2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.82rem;" onchange="loadDialogMap()">
<option value="">Оберіть сесію...</option>
</select>
</div>
<div class="dialog-map" id="dialogMapContainer" style="min-height:100px;"></div>
</details>
</div>
</div>
</div>
<!-- ── Ops ── -->
<div class="section" id="section-ops">
<div class="section-title">Governance операції</div>
<div class="ops-grid" id="opsGrid"></div>
<div id="opsRunning" style="display:none;" class="ops-running">
<div class="spinner"></div> <span id="opsRunningLabel">Виконую...</span>
</div>
<div class="section-title" id="opsResultTitle" style="display:none;">Результат</div>
<div class="ops-result" id="opsResult" style="display:none;"></div>
</div>
<!-- ── Hub / Integrations ── -->
<div class="section" id="section-hub">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div class="section-title" style="margin:0;">Інтеграції CTO</div>
<button class="btn btn-ghost refresh-btn" onclick="loadIntegrations()">↻ Оновити</button>
<button class="btn btn-ghost refresh-btn" onclick="configureOpenCodeUrl()">OpenCode URL</button>
<button class="btn btn-ghost refresh-btn" onclick="runNotionStatus()">Notion Status</button>
<button class="btn btn-ghost refresh-btn" onclick="runNotionCreateTask()">Notion Task+</button>
<button class="btn btn-ghost refresh-btn" onclick="runNotionCreatePage()">Notion Page+</button>
<button class="btn btn-ghost refresh-btn" onclick="runNotionCreateDatabase()">Notion DB+</button>
<button class="btn btn-ghost refresh-btn" onclick="runNotionUpdatePage()">Notion Update</button>
</div>
<div class="nodes-grid" id="integrationsGrid">
<div style="color:var(--muted);font-size:0.85rem;">Завантаження...</div>
</div>
</div>
<!-- ── Nodes ── -->
<div class="section" id="section-nodes">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;">
<div class="section-title" style="margin:0;">🖥 Network Control Panel</div>
<button class="btn btn-ghost refresh-btn" onclick="loadNodes()">↻ Оновити</button>
<button class="btn btn-ghost refresh-btn" onclick="addNodePrompt()"> Додати ноду</button>
<span style="margin-left:auto;font-size:0.7rem;color:var(--muted);" id="nodesSyncTs"></span>
</div>
<!-- Banner: required agents missing -->
<div id="nodesRequiredMissingBanner" style="display:none;background:rgba(240,140,30,0.12);border:1px solid rgba(240,140,30,0.5);border-radius:5px;padding:6px 12px;font-size:0.75rem;color:var(--warn);margin-bottom:8px;">
⚠️ <b>Обовʼязковий агент відсутній</b>: <span id="nodesRequiredMissingDetail"></span>
</div>
<!-- Banner: version mismatch -->
<div id="nodesVersionMismatchBanner" style="display:none;background:rgba(200,80,80,0.1);border:1px solid rgba(200,80,80,0.4);border-radius:5px;padding:6px 12px;font-size:0.75rem;color:var(--err);margin-bottom:8px;">
🔀 <b>Version drift</b>: gateway і control-plane мають різні build_sha — <span id="nodesVersionMismatchDetail"></span>
</div>
<div class="nodes-grid" id="nodesGrid"><div style="color:var(--muted);font-size:0.85rem;">Завантаження...</div></div>
</div>
<!-- ── Memory ── -->
<div class="section" id="section-memory">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div class="section-title" style="margin:0;">Memory Service</div>
<button class="btn btn-ghost refresh-btn" onclick="loadMemoryStatus()">↻ Оновити</button>
</div>
<div class="memory-card" id="memoryStatusCard">
<h3>🧠 Статус Memory Service</h3>
<div style="color:var(--muted);font-size:0.85rem;">Завантаження...</div>
</div>
<div class="memory-card" style="margin-top:12px;">
<h3>🎙️ Голосові налаштування</h3>
<div class="memory-row">
<span class="label">STT (розпізнавання)</span>
<span class="value">whisper-large-v3-turbo</span>
</div>
<div class="memory-row">
<span class="label">TTS (синтез мовлення)</span>
<span class="value">edge-tts + macOS say</span>
</div>
<div class="memory-row">
<span class="label">Голос TTS</span>
<span class="value">
<div class="voices-list" id="voicesList">
<div class="voice-chip active" data-voice="default" onclick="selectVoice(this)">Polina (uk-UA)</div>
<div class="voice-chip" data-voice="Ostap" onclick="selectVoice(this)">Ostap (uk-UA)</div>
<div class="voice-chip" data-voice="Milena" onclick="selectVoice(this)">Milena (macOS)</div>
<div class="voice-chip" data-voice="Yuri" onclick="selectVoice(this)">Yuri (macOS)</div>
</div>
</span>
</div>
</div>
<div class="memory-card" style="margin-top:12px;">
<h3>🧪 Тест TTS</h3>
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
<textarea id="ttsTestText" rows="2" style="flex:1;padding:8px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:inherit;font-size:0.85rem;min-width:180px;">Привіт! Я Sofiia, CTO DAARION. Система активна.</textarea>
<button class="btn btn-gold" onclick="testTTS()">▶ Озвучити</button>
</div>
</div>
</div>
<!-- ── CTO Strategic Dashboard ── -->
<div class="section" id="section-cto" style="overflow-y:auto;">
<div style="padding:16px 20px; border-bottom:1px solid var(--border); background:var(--bg); display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<div style="font-size:1rem; font-weight:700; color:var(--gold);">🎯 CTO Strategic Dashboard</div>
<select id="ctoProjectSel" onchange="ctoDashboardLoad()" style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:0.83rem;">
<option value="">— оберіть проєкт —</option>
</select>
<select id="ctoWindowSel" onchange="ctoDashboardLoad()" style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:0.83rem;">
<option value="7d">7 днів</option>
<option value="24h">24 год</option>
<option value="30d">30 днів</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="ctoRefreshSnapshot()" title="Перерахувати snapshot">↻ Snapshot</button>
<button class="btn btn-ghost btn-sm" onclick="ctoRunSignals(false)" title="Перерахувати сигнали">⚡ Сигнали</button>
<button class="btn btn-ghost btn-sm" onclick="ctoRunSignals(true)" title="Dry-run сигналів" style="color:var(--muted);">🔍 Dry-run</button>
<span id="ctoStatus" style="font-size:0.78rem;color:var(--muted);margin-left:auto;"></span>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px; padding:16px; flex:1; min-height:0;" id="ctoDashGrid">
<!-- Left: Now -->
<div style="display:flex; flex-direction:column; gap:12px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted);">📊 Стан зараз</div>
<div id="ctoMetricsTiles" style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.5rem;font-weight:700;color:var(--gold);" id="mTile-wip"></div>
<div style="font-size:0.72rem;color:var(--muted);">WIP</div>
</div>
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.5rem;font-weight:700;color:var(--ok);" id="mTile-done"></div>
<div style="font-size:0.72rem;color:var(--muted);">Виконано</div>
</div>
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.5rem;font-weight:700;color:var(--err);" id="mTile-risks"></div>
<div style="font-size:0.72rem;color:var(--muted);">Ризики</div>
</div>
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.5rem;font-weight:700;color:var(--accent);" id="mTile-runs"></div>
<div style="font-size:0.72rem;color:var(--muted);">Runs</div>
</div>
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.3rem;font-weight:700;color:var(--warn);" id="mTile-cycle"></div>
<div style="font-size:0.72rem;color:var(--muted);">Цикл (д)</div>
</div>
<div class="memory-card" style="text-align:center;padding:10px 8px;">
<div style="font-size:1.3rem;font-weight:700;color:var(--text);" id="mTile-quality"></div>
<div style="font-size:0.72rem;color:var(--muted);">Якість</div>
</div>
</div>
<!-- Workflow Launcher -->
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-top:4px;">🚀 Workflow Launcher</div>
<div id="ctoWorkflows" style="display:flex; flex-direction:column; gap:6px;">
<button class="ops-btn" onclick="ctoLaunchWorkflow('release_check')">
<span class="ops-icon">📋</span>
<span class="ops-name">Release Check</span>
<span class="ops-desc">Перевірити готовність релізу</span>
</button>
<button class="ops-btn" onclick="ctoLaunchWorkflow('incident_triage')">
<span class="ops-icon">🚨</span>
<span class="ops-name">Incident Triage</span>
<span class="ops-desc">Аналіз та тріаж інциденту</span>
</button>
<button class="ops-btn" onclick="ctoLaunchWorkflow('postmortem_draft')">
<span class="ops-icon">📝</span>
<span class="ops-name">Postmortem Draft</span>
<span class="ops-desc">Генерація постмортему</span>
</button>
<button class="ops-btn" onclick="ctoLaunchWorkflow('alert_triage')">
<span class="ops-icon">⚠️</span>
<span class="ops-name">Alert Triage</span>
<span class="ops-desc">Обробка поточних алертів</span>
</button>
</div>
<!-- Lessons panel -->
<div style="margin-top:12px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--gold); margin-bottom:6px;">🧾 Lessons</div>
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-bottom:8px;">
<button class="btn btn-ghost btn-sm" onclick="ctoGenerateLesson(false)">⚡ Generate</button>
<button class="btn btn-ghost btn-sm" onclick="ctoGenerateLesson(true)" title="Dry-run: preview without saving">🔍 Dry-run</button>
</div>
<div id="ctoLessonsList" style="display:flex;flex-direction:column;gap:5px;max-height:180px;overflow-y:auto;">
<div style="color:var(--muted);font-size:0.78rem;">Завантаження...</div>
</div>
</div>
<!-- Governance Gates panel -->
<div style="margin-top:14px; border-top:1px solid var(--border); padding-top:12px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--gold); margin-bottom:6px;">🛡 Governance Gates</div>
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-bottom:8px;">
<button class="btn btn-ghost btn-sm" onclick="ctoEvaluateGates(true)">🔍 Preview</button>
<button class="btn btn-ghost btn-sm" onclick="ctoEvaluateGates(false)">✅ Evaluate & Save</button>
</div>
<div id="ctoGatesPanel" style="display:flex;flex-direction:column;gap:5px;max-height:200px;overflow-y:auto;">
<div style="color:var(--muted);font-size:0.78rem;">Оберіть проєкт і натисніть Preview</div>
</div>
</div>
<!-- Audit Trail button -->
<div style="margin-top:10px; border-top:1px solid var(--border); padding-top:10px;">
<button class="btn btn-ghost btn-sm" style="width:100%;justify-content:center;" onclick="auditOpenDrawer()">📜 Audit Trail</button>
</div>
</div>
<!-- Center: Signals + Tasks -->
<div style="display:flex; flex-direction:column; gap:12px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted);">⚡ Відкриті сигнали</div>
<div id="ctoSignalsPanel" style="display:flex;flex-direction:column;gap:6px;max-height:320px;overflow-y:auto;">
<div style="color:var(--muted);font-size:0.82rem;">Немає даних — оберіть проєкт і натисніть «⚡ Сигнали»</div>
</div>
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-top:8px;">🔴 Топ ризик-задачі</div>
<div id="ctoRiskTasks" style="display:flex;flex-direction:column;gap:5px;max-height:220px;overflow-y:auto;">
<div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div>
</div>
</div>
<!-- Right: Mini Graph + Signal Detail -->
<div style="display:flex; flex-direction:column; gap:12px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted);">🕸 Mini Graph <span id="ctoGraphFilter" style="color:var(--accent);font-size:0.72rem;margin-left:8px;"></span></div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;position:relative;" id="ctoMiniGraphWrap">
<svg id="ctoMiniGraph" width="100%" height="360" style="display:block;">
<text x="50%" y="50%" text-anchor="middle" fill="#555" font-size="13">Оберіть проєкт</text>
</svg>
</div>
<!-- Signal Detail Drawer (inline) -->
<div id="ctoSignalDrawer" style="display:none;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;font-size:0.83rem;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span id="ctoDrawerSeverity" style="padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;"></span>
<strong id="ctoDrawerTitle" style="flex:1;font-size:0.88rem;"></strong>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('ctoSignalDrawer').style.display='none'"></button>
</div>
<p id="ctoDrawerSummary" style="color:var(--muted);margin-bottom:8px;"></p>
<div id="ctoDrawerEvidence" style="background:var(--bg2);border-radius:6px;padding:8px;font-size:0.78rem;white-space:pre-wrap;max-height:120px;overflow-y:auto;"></div>
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="ctoSignalAction('ack')">✅ Підтвердити</button>
<button class="btn btn-ghost btn-sm" onclick="ctoSignalAction('resolve')">✔ Вирішити</button>
<button class="btn btn-ghost btn-sm" onclick="ctoSignalAction('dismiss')" style="color:var(--muted);">🚫 Ігнорувати</button>
<button class="btn btn-gold btn-sm" id="ctoMitigateBtn" onclick="ctoCreateMitigationPlan()">🛡 Mitigation Plan</button>
</div>
<!-- Mitigation Result (shown after plan creation) -->
<div id="ctoMitigationResult" style="display:none;margin-top:10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:0.8rem;">
<div style="color:var(--ok);font-weight:600;margin-bottom:6px;">✓ Mitigation Plan створено</div>
<div id="ctoMitigationSummary" style="color:var(--muted);"></div>
<div id="ctoMitigationTasks" style="margin-top:6px;display:flex;flex-direction:column;gap:4px;"></div>
<button class="btn btn-ghost btn-sm" style="margin-top:8px;" onclick="ctoFocusMitigationPlan()">🔍 Показати в графі</button>
</div>
<!-- Playbooks section -->
<div id="ctoPlaybooksSection" style="margin-top:14px; border-top:1px solid var(--border); padding-top:10px;">
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--gold); margin-bottom:8px;">📚 Playbooks</div>
<div id="ctoPlaybooksList" style="display:flex;flex-direction:column;gap:6px;">
<div style="color:var(--muted);font-size:0.78rem;">Завантаження...</div>
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" id="ctoPromoteBtn" onclick="ctoPromoteToPlaybook()">📥 Promote to Playbook</button>
</div>
<div id="ctoPlaybookResult" style="display:none;margin-top:8px;font-size:0.78rem;color:var(--ok);"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Lesson Drawer/Modal ── -->
<div id="ctoLessonDrawer" style="display:none; position:fixed; right:0; top:0; width:520px; height:100vh;
background:var(--bg2); border-left:1px solid var(--border); z-index:200; overflow-y:auto; padding:16px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
<div style="font-weight:700; color:var(--gold); font-size:0.95rem;">🧾 Lessons Learned</div>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('ctoLessonDrawer').style.display='none'"></button>
</div>
<div id="ctoLessonDrawerMeta" style="font-size:0.78rem; color:var(--muted); margin-bottom:10px;"></div>
<div id="ctoLessonDrawerImprovements" style="margin-bottom:12px; display:none;">
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--gold); margin-bottom:6px;">📌 Improvement Tasks</div>
<div id="ctoLessonImprovementList" style="display:flex;flex-direction:column;gap:4px;"></div>
</div>
<div id="ctoLessonDrawerSignals" style="margin-bottom:12px; display:none;">
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:6px;">🔗 Evidence Signals</div>
<div id="ctoLessonSignalLinks" style="display:flex;flex-direction:column;gap:3px;font-size:0.75rem;"></div>
</div>
<!-- Trend vs Previous -->
<div style="margin-bottom:12px;">
<div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:1px;color:var(--gold);margin-bottom:6px;">📈 Trend vs Previous</div>
<div id="ctoLessonTrendSection" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px;display:none;"></div>
</div>
<!-- Impact -->
<div style="margin-bottom:12px;">
<div style="font-size:0.72rem;text-transform:uppercase;letter-spacing:1px;color:var(--gold);margin-bottom:6px;">🎯 Impact</div>
<div id="ctoLessonImpactSection" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px;display:none;"></div>
</div>
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:6px;">📄 Report</div>
<pre id="ctoLessonMarkdown" style="white-space:pre-wrap; font-size:0.73rem; color:var(--muted);
background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:10px; max-height:60vh; overflow-y:auto;"></pre>
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="ctoFocusLessonNode()">🔍 Focus in Graph</button>
<button class="btn btn-ghost btn-sm" onclick="ctoRecomputeImpact()" title="Перерахувати impact score">⚡ Impact</button>
</div>
</div>
<!-- ── Audit Trail Drawer ── -->
<div id="auditDrawer" style="display:none; position:fixed; right:0; top:0; width:560px; height:100vh;
background:var(--bg2); border-left:1px solid var(--border); z-index:300; overflow:hidden; display:none; flex-direction:column;">
<div style="padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; background:var(--bg);">
<div style="font-weight:700; color:var(--gold); font-size:0.95rem;">📜 Governance Audit Trail</div>
<button class="btn btn-ghost btn-sm" onclick="auditCloseDrawer()"></button>
</div>
<!-- Tabs -->
<div style="display:flex; border-bottom:1px solid var(--border); background:var(--bg);">
<button id="auditTabProject" class="btn btn-ghost btn-sm" style="flex:1;border-radius:0;padding:8px;font-size:0.8rem;border-bottom:2px solid var(--gold);" onclick="auditSwitchTab('project')">📁 Project</button>
<button id="auditTabPortfolio" class="btn btn-ghost btn-sm" style="flex:1;border-radius:0;padding:8px;font-size:0.8rem;border-bottom:2px solid transparent;" onclick="auditSwitchTab('portfolio')">🌐 Portfolio</button>
</div>
<!-- Filters -->
<div style="padding:8px 12px; border-bottom:1px solid var(--border); display:flex; gap:6px; flex-wrap:wrap; align-items:center; background:var(--bg);">
<select id="auditFilterType" onchange="auditLoad()" style="padding:3px 6px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:0.75rem;">
<option value="">All types</option>
<option value="gate_previewed">gate_previewed</option>
<option value="gate_evaluated">gate_evaluated</option>
<option value="drift_planned">drift_planned</option>
<option value="drift_run_queued">drift_run_queued</option>
<option value="drift_run_started">drift_run_started</option>
<option value="drift_run_failed">drift_run_failed</option>
<option value="drift_run_completed">drift_run_completed</option>
<option value="autopilot_mode_changed">autopilot_mode_changed</option>
</select>
<select id="auditFilterStatus" onchange="auditLoad()" style="padding:3px 6px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:0.75rem;">
<option value="">All statuses</option>
<option value="ok">ok</option>
<option value="error">error</option>
<option value="skipped">skipped</option>
</select>
<select id="auditFilterSince" onchange="auditLoad()" style="padding:3px 6px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:0.75rem;">
<option value="1h">Last 1h</option>
<option value="24h" selected>Last 24h</option>
<option value="7d">Last 7d</option>
<option value="">All time</option>
</select>
<button class="btn btn-ghost btn-sm" style="font-size:0.65rem;" onclick="auditLoad()"></button>
</div>
<!-- Events list -->
<div id="auditEventsList" style="flex:1; overflow-y:auto; padding:10px 12px; display:flex; flex-direction:column; gap:5px;">
<div style="color:var(--muted);font-size:0.8rem;">Завантаження...</div>
</div>
</div>
<!-- ── CTO Portfolio (Cross-Project) ── -->
<div class="section" id="section-portfolio" style="overflow-y:auto; padding:0;">
<div style="padding:14px 20px; border-bottom:1px solid var(--border); background:var(--bg); display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<div style="font-size:1rem; font-weight:700; color:var(--gold);">🌐 Portfolio View</div>
<select id="portWindowSel" onchange="portLoad()" style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:0.83rem;">
<option value="7d">7 днів</option>
<option value="24h">24 год</option>
<option value="30d">30 днів</option>
</select>
<select id="portSigFilter" onchange="portLoadSignals()" style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:0.83rem;">
<option value="open">Відкриті</option>
<option value="all">Всі</option>
<option value="ack">ACK</option>
<option value="resolved">Вирішені</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="portLoad()">↻ Оновити</button>
<button class="btn btn-ghost btn-sm" onclick="portRecomputeAll()" title="Compute snapshots + signals for all projects">⚡ Compute All</button>
<button class="btn btn-ghost btn-sm" onclick="portDriftRecompute()" title="Recompute portfolio drift signals based on lesson streaks">🔄 Drift</button>
<span id="portStatus" style="font-size:0.78rem;color:var(--muted);margin-left:auto;"></span>
</div>
<!-- Portfolio empty-state (shown when no data) -->
<div id="portEmptyState" style="display:none; flex-direction:column; align-items:center; justify-content:center; height:calc(100% - 52px); gap:16px; padding:40px; text-align:center;">
<div style="font-size:2rem; opacity:0.5;">🌐</div>
<div style="font-size:1rem; font-weight:600; color:var(--text);">Портфель порожній</div>
<div style="font-size:0.85rem; color:var(--muted); max-width:380px;">Немає метрик. Створи проєкт або згенеруй Snapshots для всіх проєктів.</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;">
<button class="btn btn-ghost" onclick="switchTab('projects')">📁 Перейти до Проєктів</button>
<button class="btn btn-gold" onclick="portRecomputeAll()">↻ Compute Snapshots для всіх</button>
</div>
</div>
<div id="portContent" style="display:grid; grid-template-columns:1fr 340px; gap:0; height:calc(100% - 52px);">
<!-- Left: Projects table -->
<div style="padding:16px; overflow-y:auto; border-right:1px solid var(--border);">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:10px;">📁 Проєкти</div>
<table style="width:100%; border-collapse:collapse; font-size:0.83rem;" id="portProjectsTable">
<thead>
<tr style="color:var(--muted); font-size:0.72rem; text-transform:uppercase; letter-spacing:0.5px;">
<th style="text-align:left; padding:4px 8px; border-bottom:1px solid var(--border);">Проєкт</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Work In Progress">WIP</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Виконано за вікно">Done</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Відкриті ризики">[RISK]</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Agent Runs у вікні">Runs</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Цикл (дні)">Цикл</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Якість runs">Якість</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Час останнього snapshot">Updated</th>
<th style="text-align:center; padding:4px 6px; border-bottom:1px solid var(--border);" title="Latest Lessons Learned bucket">Lesson</th>
</tr>
</thead>
<tbody id="portProjectsBody">
<tr><td colspan="8" style="padding:16px; color:var(--muted); text-align:center;">Завантаження...</td></tr>
</tbody>
</table>
</div>
<!-- Right: Top signals + Portfolio Drift -->
<div style="padding:16px; overflow-y:auto;">
<!-- Portfolio Drift Signals panel -->
<div style="margin-bottom:14px;">
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--gold); margin-bottom:8px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;">
📡 Portfolio Drift
<button class="btn btn-ghost btn-sm" style="font-size:0.65rem;padding:1px 5px;" onclick="portLoadDriftSignals()"></button>
</div>
<!-- Autopilot mode pill -->
<div style="display:flex; gap:5px; align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span style="font-size:0.72rem; color:var(--muted);">Autopilot:</span>
<span id="autopilotPill" style="padding:2px 10px; border-radius:20px; font-size:0.72rem; font-weight:600; cursor:pointer; background:var(--bg2); color:var(--muted); border:1px solid var(--border);" onclick="cycleAutopilot()">OFF</span>
<span id="autopilotArmed" style="display:none; font-size:0.7rem; color:var(--warn); font-weight:600;"></span>
</div>
<div id="portDriftSignalsList" style="display:flex; flex-direction:column; gap:5px; max-height:220px; overflow-y:auto;">
<div style="color:var(--muted);font-size:0.75rem;">Натисніть ↻ для завантаження</div>
</div>
</div>
<div style="border-top:1px solid var(--border);margin-bottom:12px;"></div>
<div style="font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:10px;">⚡ Топ Сигнали</div>
<div id="portSignalsList" style="display:flex; flex-direction:column; gap:7px;">
<div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div>
</div>
</div>
</div>
</div> <!-- end portContent -->
</div> <!-- end section-portfolio -->
<!-- ── Budget Dashboard ──────────────────────────────────────────────── -->
<div class="section" id="section-budget" style="overflow-y:auto;">
<div style="padding:14px 20px;border-bottom:1px solid var(--border);background:var(--bg);display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<div style="font-size:1rem;font-weight:700;color:var(--gold);">💰 Бюджет провайдерів AI</div>
<select id="budgetWindowSel" onchange="budgetLoad()" style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:0.83rem;">
<option value="24">24 год</option>
<option value="168">7 днів</option>
<option value="720" selected>30 днів</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="budgetLoad()">↻ Оновити</button>
<button class="btn btn-ghost btn-sm" onclick="budgetShowLimitsModal()">⚙️ Ліміти</button>
<span id="budgetStatus" style="font-size:0.78rem;color:var(--muted);margin-left:auto;"></span>
</div>
<!-- Summary tiles -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:16px;" id="budgetSummaryTiles">
<div class="card" style="text-align:center;">
<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:1px;">24 год</div>
<div id="budgetTotal24h" style="font-size:1.6rem;font-weight:700;color:var(--gold);">$0.00000</div>
<div style="font-size:0.75rem;color:var(--muted);">витрачено</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:1px;">7 днів</div>
<div id="budgetTotal7d" style="font-size:1.6rem;font-weight:700;color:var(--accent);">$0.00000</div>
<div style="font-size:0.75rem;color:var(--muted);">витрачено</div>
</div>
<div class="card" style="text-align:center;">
<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:1px;">30 днів</div>
<div id="budgetTotal30d" style="font-size:1.6rem;font-weight:700;color:#e67e22;">$0.00000</div>
<div style="font-size:0.75rem;color:var(--muted);">витрачено</div>
</div>
</div>
<!-- Provider cards -->
<div style="padding:0 16px 16px;display:flex;flex-direction:column;gap:12px;" id="budgetProviderList">
<div style="color:var(--muted);font-size:0.85rem;text-align:center;padding:20px;">Завантаження...</div>
</div>
<!-- Model catalog -->
<div class="card" style="margin:0 16px 16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<div style="font-size:0.85rem;font-weight:600;">🗂 Каталог моделей</div>
<button class="btn btn-ghost btn-sm" onclick="budgetLoadCatalog(true)">↻ Refresh Ollama</button>
<span id="catalogCount" style="font-size:0.75rem;color:var(--muted);margin-left:auto;"></span>
</div>
<div id="catalogList" style="display:flex;flex-direction:column;gap:4px;max-height:280px;overflow-y:auto;">
<div style="color:var(--muted);font-size:0.82rem;">Натисніть "Оновити" для завантаження</div>
</div>
</div>
<!-- Auto-router test panel -->
<div class="card" style="margin:0 16px 16px;">
<div style="font-size:0.85rem;font-weight:600;margin-bottom:10px;">🧠 Тест Auto-Router (Cursor-style)</div>
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
<textarea id="autoRouterPrompt" rows="2" placeholder="Введіть запит для класифікації..." style="flex:1;min-width:220px;padding:8px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:inherit;font-size:0.85rem;resize:vertical;"></textarea>
<div style="display:flex;flex-direction:column;gap:6px;">
<label style="display:flex;align-items:center;gap:5px;font-size:0.8rem;cursor:pointer;">
<input type="checkbox" id="arForceFast"> ⚡ Fast
</label>
<label style="display:flex;align-items:center;gap:5px;font-size:0.8rem;cursor:pointer;">
<input type="checkbox" id="arForceCapable"> 🧠 Capable
</label>
<label style="display:flex;align-items:center;gap:5px;font-size:0.8rem;cursor:pointer;">
<input type="checkbox" id="arPreferLocal"> 🖥️ Local
</label>
<label style="display:flex;align-items:center;gap:5px;font-size:0.8rem;cursor:pointer;">
<input type="checkbox" id="arPreferCheap"> 💸 Cheap
</label>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="autoRouterTest()">▶ Класифікувати</button>
<div id="autoRouterResult" style="margin-top:10px;font-size:0.83rem;display:none;"></div>
</div>
<!-- Limits modal -->
<div id="budgetLimitsModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:1000;display:none;align-items:center;justify-content:center;">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;min-width:320px;max-width:480px;width:90%;">
<div style="font-size:1rem;font-weight:700;margin-bottom:14px;">⚙️ Налаштування лімітів</div>
<div style="margin-bottom:10px;">
<label style="font-size:0.82rem;color:var(--muted);">Провайдер</label>
<select id="limitProvider" style="width:100%;margin-top:4px;padding:7px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;">
<option value="anthropic">Anthropic Claude</option>
<option value="grok">xAI Grok</option>
<option value="deepseek">DeepSeek</option>
<option value="mistral">Mistral AI</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div style="margin-bottom:10px;">
<label style="font-size:0.82rem;color:var(--muted);">Місячний ліміт (USD)</label>
<input type="number" id="limitMonthly" step="0.01" min="0" placeholder="напр. 10.00" style="width:100%;margin-top:4px;padding:7px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;box-sizing:border-box;">
</div>
<div style="margin-bottom:16px;">
<label style="font-size:0.82rem;color:var(--muted);">Поповнений баланс (USD) — для підрахунку залишку</label>
<input type="number" id="limitTopup" step="0.01" min="0" placeholder="напр. 5.00" style="width:100%;margin-top:4px;padding:7px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;box-sizing:border-box;">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('budgetLimitsModal').style.display='none'">Скасувати</button>
<button class="btn btn-primary btn-sm" onclick="budgetSaveLimits()">Зберегти</button>
</div>
</div>
</div>
</div> <!-- end section-budget -->
</main>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
const API = '';
let chatHistory = [];
let recording = false;
let voiceMode = false;
let mediaRecorder = null;
let audioChunks = [];
let selectedVoice = 'default';
let currentAudio = null;
let auroraMode = 'tactical';
let auroraSelectedFile = null;
let auroraJobId = null;
let auroraSmartRunId = null;
let auroraSmartStatusCache = null;
let auroraPollTimer = null;
let auroraResultCache = null;
let auroraAnalysisCache = null;
let auroraSuggestedPriority = 'balanced';
let auroraSuggestedExport = {};
let auroraPresetMode = 'balanced';
let auroraPollInFlight = false;
let auroraPollErrorCount = 0;
let auroraLastProgress = 0;
let auroraStatusCache = null;
let auroraRecentJobsCache = [];
let auroraTabBootstrapped = false;
let auroraChatHistory = [];
let auroraChatBusy = false;
let auroraFolderPath = null;
let auroraPreviewObjectUrl = null;
let auroraPreviewVideoEl = null;
let auroraVideoDurationSec = 0;
let auroraClipBindingsReady = false;
const AURORA_MIN_CLIP_SEC = 0.1;
const AURORA_MAX_TRANSIENT_ERRORS = 12;
const AURORA_ACTIVE_JOB_KEY = 'aurora_active_job_id';
const AURORA_SMART_RUN_KEY = 'aurora_smart_run_id';
const AURORA_TIMING_CACHE_PREFIX = 'aurora_timing_cache_v1:';
try { auroraJobId = localStorage.getItem(AURORA_ACTIVE_JOB_KEY) || null; } catch (_) {}
try { auroraSmartRunId = localStorage.getItem(AURORA_SMART_RUN_KEY) || null; } catch (_) {}
let mediaTabBootstrapped = false;
let aistalkTabBootstrapped = false;
let aistalkRunId = null;
let aistalkPollTimer = null;
let aistalkCatalogCache = null;
let aistalkRuntimeCache = null;
let aistalkChatHistory = [];
let aistalkChatSessionId = `aistalk_ui_${Math.random().toString(36).slice(2, 10)}`;
let aistalkMemoryTimelineCache = [];
// ── Session persistence ────────────────────────────────────────────────────
let _currentSessionId = localStorage.getItem('sofiia_session_id') || ('sess_' + Math.random().toString(36).slice(2, 14));
let _currentProjectId = localStorage.getItem('sofiia_project_id') || 'default';
function _saveSession() {
localStorage.setItem('sofiia_session_id', _currentSessionId);
localStorage.setItem('sofiia_project_id', _currentProjectId);
}
// ── Projects state ─────────────────────────────────────────────────────────
let _activeProjectId = _currentProjectId;
let _activeProjectName = 'Default';
let _projectsCache = [];
// ─── Auth (cookie-based) ──────────────────────────────────────────────────────
// Login: POST /api/auth/login { key: "..." } → server sets httpOnly cookie
// All subsequent requests carry cookie automatically — no X-API-Key headers needed.
function getApiKey() {
// Legacy: still read from localStorage for API clients that set it manually
return localStorage.getItem('sofiia_console_api_key') || '';
}
function getAuthHeaders() {
// Cookies are sent automatically by browser — no manual headers needed
// Keep Content-Type only; backward-compat X-API-Key for curl users
const h = { 'Content-Type': 'application/json' };
const k = getApiKey();
if (k) {
try { new Headers({'X-API-Key': k}); h['X-API-Key'] = k; } catch(_) {}
}
return h;
}
function ensureApiKey() {
showLoginOverlay();
}
function showLoginOverlay(errorMsg) {
const overlay = document.getElementById('loginOverlay');
if (!overlay) return;
const errEl = document.getElementById('loginError');
if (errorMsg) { errEl.textContent = errorMsg; errEl.style.display = 'block'; }
else { errEl.style.display = 'none'; }
overlay.style.display = 'flex';
setTimeout(() => document.getElementById('loginKeyInput')?.focus(), 60);
}
function hideLoginOverlay() {
const o = document.getElementById('loginOverlay');
if (o) o.style.display = 'none';
}
let _loginInProgress = false;
async function submitLogin() {
const input = document.getElementById('loginKeyInput');
const k = (input?.value || '').trim();
const errEl = document.getElementById('loginError');
if (!k) { errEl.textContent = 'Введіть ключ'; errEl.style.display = 'block'; return; }
const btn = document.getElementById('loginBtn');
btn.disabled = true;
btn.textContent = 'Перевірка…';
errEl.style.display = 'none';
_loginInProgress = true;
try {
// Key travels in JSON body — no header encoding issues
const r = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: k }),
credentials: 'include', // receive cookie
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errEl.textContent = '❌ ' + (d.detail || 'Невірний ключ');
errEl.style.display = 'block';
input.value = '';
input.focus();
btn.disabled = false; btn.textContent = 'Увійти';
return;
}
// Success — cookie is set by server
// Also store in localStorage for display only (never sent as header from login)
localStorage.setItem('sofiia_console_api_key', k);
hideLoginOverlay();
await _bootstrapApp();
} catch(e) {
errEl.textContent = '⚠ ' + e.message;
errEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Увійти';
} finally {
_loginInProgress = false;
}
}
async function _bootstrapApp() {
await checkHealth();
loadOpsActions();
loadSidebarProjects();
switchTab('nodes');
}
async function logoutConsole() {
await fetch(`${API}/api/auth/logout`, { method: 'POST', credentials: 'include' }).catch(() => {});
localStorage.removeItem('sofiia_console_api_key');
showLoginOverlay();
}
// Intercept fetch: add credentials:include automatically + handle 401
const _origFetch = window.fetch;
window.fetch = async function(url, opts = {}) {
// Always include cookies for same-origin requests
if (!opts.credentials) opts = { ...opts, credentials: 'include' };
const resp = await _origFetch(url, opts);
if (resp.status === 401 && !_loginInProgress) {
showLoginOverlay('⚠ Сесія закінчилась. Введіть ключ знову.');
}
return resp;
};
// ─── Tabs ────────────────────────────────────────────────────────────────────
function switchTab(tabName) {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
const btn = document.querySelector(`nav button[data-tab="${tabName}"]`);
if (btn) btn.classList.add('active');
const sec = document.getElementById('section-' + tabName);
if (sec) sec.classList.add('active');
if (tabName === 'nodes') loadNodes();
if (tabName === 'memory') loadMemoryStatus();
if (tabName === 'ops') loadOpsActions();
if (tabName === 'hub') loadIntegrations();
if (tabName === 'projects') loadProjects();
if (tabName === 'cto') initCtoDashboard();
if (tabName === 'portfolio') portLoad();
if (tabName === 'aurora') auroraInitTab();
if (tabName === 'budget') { budgetLoad(); budgetLoadCatalog(false); }
if (tabName === 'aistalk') aistalkInitTab();
if (tabName === 'media-gen') mediaInitTab();
}
document.querySelectorAll('nav button').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// ── Aurora UI ───────────────────────────────────────────────────────────────
function auroraEsc(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function auroraPersistActiveJob() {
try {
if (auroraJobId) localStorage.setItem(AURORA_ACTIVE_JOB_KEY, auroraJobId);
else localStorage.removeItem(AURORA_ACTIVE_JOB_KEY);
} catch (_) {}
}
function auroraPersistSmartRun() {
try {
if (auroraSmartRunId) localStorage.setItem(AURORA_SMART_RUN_KEY, auroraSmartRunId);
else localStorage.removeItem(AURORA_SMART_RUN_KEY);
} catch (_) {}
}
function auroraSetSmartRunId(runId) {
const normalized = String(runId || '').trim();
auroraSmartRunId = normalized || null;
const el = document.getElementById('auroraSmartRunId');
if (el) el.textContent = auroraSmartRunId || '—';
auroraPersistSmartRun();
}
function auroraSetSmartPolicyText(text) {
const el = document.getElementById('auroraSmartPolicy');
const hint = document.getElementById('auroraSmartHint');
const msg = String(text || '').trim() || '—';
if (el) el.textContent = msg;
if (hint) hint.textContent = `policy: ${msg}`;
}
function auroraSmartConfig() {
return {
enabled: Boolean(document.getElementById('auroraSmartEnabled')?.checked),
strategy: document.getElementById('auroraSmartStrategy')?.value || 'auto',
budget_tier: document.getElementById('auroraSmartBudget')?.value || 'normal',
prefer_quality: Boolean(document.getElementById('auroraSmartPreferQuality')?.checked),
};
}
function auroraTimingCacheKey(jobId) {
const id = String(jobId || '').trim();
return id ? `${AURORA_TIMING_CACHE_PREFIX}${id}` : '';
}
function auroraGetPersistedTiming(jobId) {
try {
const key = auroraTimingCacheKey(jobId);
if (!key) return null;
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (_) {
return null;
}
}
function auroraPersistTiming(jobId, data) {
try {
const key = auroraTimingCacheKey(jobId);
if (!key || !data || typeof data !== 'object') return;
localStorage.setItem(key, JSON.stringify({
eta_seconds: Number.isFinite(Number(data.eta_seconds)) ? Number(data.eta_seconds) : null,
estimated_total_seconds: Number.isFinite(Number(data.estimated_total_seconds)) ? Number(data.estimated_total_seconds) : null,
live_fps: Number.isFinite(Number(data.live_fps)) ? Number(data.live_fps) : null,
eta_confidence: data.eta_confidence || null,
ts: Date.now(),
}));
} catch (_) {}
}
function auroraSetActiveJobId(jobId) {
const normalized = String(jobId || '').trim();
auroraJobId = normalized || null;
const el = document.getElementById('auroraJobId');
if (el) el.textContent = auroraJobId || '—';
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = !auroraJobId;
auroraUpdateReprocessLabel();
if (auroraJobId) {
const cached = auroraGetPersistedTiming(auroraJobId);
if (cached) {
auroraUpdateTiming(null, cached.eta_seconds, cached.estimated_total_seconds);
auroraUpdateLivePerf(cached.live_fps, cached.eta_confidence);
}
}
auroraPersistActiveJob();
auroraUpdateCancelButton(null, null);
}
function auroraUpdateCancelButton(status, stage) {
const btn = document.getElementById('auroraCancelBtn');
if (!btn) return;
const s = String(status || '').toLowerCase();
const st = String(stage || '').toLowerCase();
const active = s === 'queued' || s === 'processing';
if (!active) {
btn.style.display = 'none';
btn.disabled = false;
btn.textContent = 'Зупинити';
return;
}
btn.style.display = 'inline-block';
const cancelling = st.includes('cancell') || st.includes('скасов');
btn.disabled = cancelling;
btn.textContent = cancelling ? 'Зупиняю...' : 'Зупинити';
}
function auroraSetMode(mode) {
auroraMode = mode === 'forensic' ? 'forensic' : 'tactical';
const t = document.getElementById('auroraModeTactical');
const f = document.getElementById('auroraModeForensic');
if (t) t.classList.toggle('active', auroraMode === 'tactical');
if (f) f.classList.toggle('active', auroraMode === 'forensic');
}
function auroraPickFile() {
const el = document.getElementById('auroraFileInput');
if (el) el.click();
}
function auroraIsAudioFile(file) {
if (!file || !file.name) return false;
const name = String(file.name).toLowerCase();
return ['.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg'].some(ext => name.endsWith(ext));
}
function auroraRevokePreviewObjectUrl() {
if (!auroraPreviewObjectUrl) return;
try { URL.revokeObjectURL(auroraPreviewObjectUrl); } catch (_) {}
auroraPreviewObjectUrl = null;
}
function auroraFormatClipSeconds(seconds) {
const value = Number(seconds);
if (!Number.isFinite(value)) return '—';
const rounded = Math.round(Math.max(0, value) * 10) / 10;
if (Math.abs(rounded - Math.round(rounded)) < 1e-9) return `${Math.round(rounded)}s`;
return `${rounded.toFixed(1)}s`;
}
function auroraClampClipWindow(startSec, endSec, durationSec) {
const total = Number(durationSec);
if (!Number.isFinite(total) || total <= 0) return { start: 0, end: 0 };
let start = Number(startSec);
let end = Number(endSec);
if (!Number.isFinite(start)) start = 0;
if (!Number.isFinite(end)) end = total;
start = Math.max(0, Math.min(start, total));
end = Math.max(0, Math.min(end, total));
if ((end - start) < AURORA_MIN_CLIP_SEC) {
if ((start + AURORA_MIN_CLIP_SEC) <= total) {
end = start + AURORA_MIN_CLIP_SEC;
} else {
end = total;
start = Math.max(0, end - AURORA_MIN_CLIP_SEC);
}
}
return { start, end };
}
function auroraUpdateClipSummary(startSec, endSec, durationSec) {
const summary = document.getElementById('auroraClipSummary');
const startLabel = document.getElementById('auroraClipStartLabel');
const endLabel = document.getElementById('auroraClipEndLabel');
if (startLabel) startLabel.textContent = auroraFormatClipSeconds(startSec);
if (endLabel) endLabel.textContent = auroraFormatClipSeconds(endSec);
if (summary) {
const clipDur = Math.max(0, Number(endSec) - Number(startSec));
summary.textContent = `${auroraFormatClipSeconds(startSec)}${auroraFormatClipSeconds(endSec)} (${auroraFormatClipSeconds(clipDur)}) · total ${auroraFormatClipSeconds(durationSec)}`;
}
}
function auroraApplyClipWindow(startSec, endSec, { syncFields = true, syncSliders = true, seekTo = null } = {}) {
const duration = Number(auroraVideoDurationSec || 0);
if (!Number.isFinite(duration) || duration <= 0) return;
const startRange = document.getElementById('auroraClipStartRange');
const endRange = document.getElementById('auroraClipEndRange');
const startInput = document.getElementById('auroraOptClipStart');
const durationInput = document.getElementById('auroraOptClipDuration');
const bounded = auroraClampClipWindow(startSec, endSec, duration);
if (syncSliders && startRange && endRange) {
startRange.value = bounded.start.toFixed(1);
endRange.value = bounded.end.toFixed(1);
}
if (syncFields && startInput && durationInput) {
const clipDuration = Math.max(AURORA_MIN_CLIP_SEC, bounded.end - bounded.start);
startInput.value = bounded.start > 0 ? bounded.start.toFixed(1).replace(/\.0$/, '') : '';
durationInput.value = clipDuration.toFixed(1).replace(/\.0$/, '');
}
auroraUpdateClipSummary(bounded.start, bounded.end, duration);
if (auroraPreviewVideoEl && Number.isFinite(Number(seekTo))) {
const target = Math.max(0, Math.min(Number(seekTo), duration));
try { auroraPreviewVideoEl.currentTime = target; } catch (_) {}
}
}
function auroraSyncClipFromExportInputs() {
const duration = Number(auroraVideoDurationSec || 0);
if (!Number.isFinite(duration) || duration <= 0) return;
const startInput = document.getElementById('auroraOptClipStart');
const durationInput = document.getElementById('auroraOptClipDuration');
const startValue = Number(startInput?.value || 0);
const durationValue = Number(durationInput?.value || 0);
const start = Number.isFinite(startValue) && startValue >= 0 ? startValue : 0;
const hasDuration = Number.isFinite(durationValue) && durationValue > 0;
const end = hasDuration ? start + durationValue : duration;
auroraApplyClipWindow(start, end, { syncFields: true, syncSliders: true });
}
function auroraHideClipPicker() {
const picker = document.getElementById('auroraClipPicker');
if (picker) picker.style.display = 'none';
const summary = document.getElementById('auroraClipSummary');
if (summary) summary.textContent = '—';
const startLabel = document.getElementById('auroraClipStartLabel');
if (startLabel) startLabel.textContent = '0s';
const endLabel = document.getElementById('auroraClipEndLabel');
if (endLabel) endLabel.textContent = '0s';
auroraPreviewVideoEl = null;
auroraVideoDurationSec = 0;
}
function auroraBindClipPicker() {
if (auroraClipBindingsReady) return;
auroraClipBindingsReady = true;
const startRange = document.getElementById('auroraClipStartRange');
const endRange = document.getElementById('auroraClipEndRange');
const startInput = document.getElementById('auroraOptClipStart');
const durationInput = document.getElementById('auroraOptClipDuration');
const setStartBtn = document.getElementById('auroraClipSetStartBtn');
const setEndBtn = document.getElementById('auroraClipSetEndBtn');
const fullBtn = document.getElementById('auroraClipFullBtn');
if (startRange && endRange) {
startRange.addEventListener('input', () => {
const start = Number(startRange.value || 0);
const end = Number(endRange.value || 0);
auroraApplyClipWindow(start, end, { syncFields: true, syncSliders: false, seekTo: start });
});
endRange.addEventListener('input', () => {
const start = Number(startRange.value || 0);
const end = Number(endRange.value || 0);
auroraApplyClipWindow(start, end, { syncFields: true, syncSliders: false, seekTo: end });
});
}
if (startInput) {
startInput.addEventListener('input', auroraSyncClipFromExportInputs);
startInput.addEventListener('change', auroraSyncClipFromExportInputs);
}
if (durationInput) {
durationInput.addEventListener('input', auroraSyncClipFromExportInputs);
durationInput.addEventListener('change', auroraSyncClipFromExportInputs);
}
if (setStartBtn) {
setStartBtn.addEventListener('click', () => {
if (!auroraPreviewVideoEl) return;
const current = Number(auroraPreviewVideoEl.currentTime || 0);
const end = Number(document.getElementById('auroraClipEndRange')?.value || auroraVideoDurationSec || 0);
auroraApplyClipWindow(current, end, { syncFields: true, syncSliders: true, seekTo: current });
});
}
if (setEndBtn) {
setEndBtn.addEventListener('click', () => {
if (!auroraPreviewVideoEl) return;
const current = Number(auroraPreviewVideoEl.currentTime || 0);
const start = Number(document.getElementById('auroraClipStartRange')?.value || 0);
auroraApplyClipWindow(start, current, { syncFields: true, syncSliders: true, seekTo: current });
});
}
if (fullBtn) {
fullBtn.addEventListener('click', () => {
const startField = document.getElementById('auroraOptClipStart');
const durField = document.getElementById('auroraOptClipDuration');
if (startField) startField.value = '';
if (durField) durField.value = '';
auroraApplyClipWindow(0, auroraVideoDurationSec, { syncFields: false, syncSliders: true, seekTo: 0 });
});
}
}
function auroraSetSelectedFile(file) {
auroraSelectedFile = file || null;
const label = document.getElementById('auroraSelectedFile');
const startBtn = document.getElementById('auroraStartBtn');
const analyzeBtn = document.getElementById('auroraAnalyzeBtn');
const audioBtn = document.getElementById('auroraAudioProcessBtn');
const isAudio = auroraIsAudioFile(file);
if (label) label.textContent = file ? `${file.name} (${(file.size / 1048576).toFixed(1)} MB)` : '—';
if (startBtn) startBtn.disabled = !file;
if (analyzeBtn) analyzeBtn.disabled = !file;
if (analyzeBtn) analyzeBtn.textContent = isAudio ? '🎧 Аудіо аналіз' : '🔍 Аналіз';
if (audioBtn) {
audioBtn.style.display = isAudio ? 'inline-block' : 'none';
audioBtn.disabled = !file;
}
const clipStartInput = document.getElementById('auroraOptClipStart');
const clipDurationInput = document.getElementById('auroraOptClipDuration');
if (clipStartInput) clipStartInput.value = '';
if (clipDurationInput) clipDurationInput.value = '';
auroraAnalysisCache = null;
auroraSuggestedPriority = 'balanced';
auroraSuggestedExport = {};
auroraPresetMode = 'balanced';
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('standby');
auroraResetAnalysisControls();
auroraUpdateQueuePosition(null);
auroraUpdateStorage(null);
const analysisCard = document.getElementById('auroraAnalysisCard');
if (analysisCard) analysisCard.style.display = 'none';
const audioCard = document.getElementById('auroraAudioAnalysisCard');
if (audioCard) audioCard.style.display = 'none';
const qualityWrap = document.getElementById('auroraQualityWrap');
if (qualityWrap) qualityWrap.style.display = 'none';
const detWrap = document.getElementById('auroraDetectionsWrap');
if (detWrap) detWrap.style.display = 'none';
const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn');
if (quickStartBtn) quickStartBtn.disabled = !file;
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = !auroraJobId;
auroraUpdateReprocessLabel();
const batchInfo = document.getElementById('auroraBatchInfo');
if (batchInfo && auroraBatchFiles.length <= 1) batchInfo.style.display = 'none';
auroraShowThumbPreview(file);
}
function auroraShowThumbPreview(file) {
const wrap = document.getElementById('auroraThumbPreview');
if (!wrap) return;
auroraBindClipPicker();
auroraRevokePreviewObjectUrl();
auroraHideClipPicker();
wrap.style.display = 'none';
wrap.innerHTML = '';
if (!file) return;
const type = (file.type || '').toLowerCase();
const url = URL.createObjectURL(file);
auroraPreviewObjectUrl = url;
if (type.startsWith('image/')) {
wrap.innerHTML = `<img src="${url}" alt="preview"><span class="aurora-thumb-label">Original</span>`;
wrap.style.display = 'block';
} else if (type.startsWith('video/')) {
const v = document.createElement('video');
v.src = url;
v.muted = true;
v.controls = true;
v.playsInline = true;
v.preload = 'metadata';
v.addEventListener('loadedmetadata', () => {
const picker = document.getElementById('auroraClipPicker');
const startRange = document.getElementById('auroraClipStartRange');
const endRange = document.getElementById('auroraClipEndRange');
const duration = Number(v.duration || 0);
auroraPreviewVideoEl = v;
auroraVideoDurationSec = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (!Number.isFinite(auroraVideoDurationSec) || auroraVideoDurationSec <= 0) {
if (picker) picker.style.display = 'none';
return;
}
if (startRange && endRange) {
const max = auroraVideoDurationSec.toFixed(1);
startRange.min = '0';
endRange.min = '0';
startRange.max = max;
endRange.max = max;
startRange.step = '0.1';
endRange.step = '0.1';
}
if (picker) picker.style.display = 'grid';
const startInput = document.getElementById('auroraOptClipStart');
const durInput = document.getElementById('auroraOptClipDuration');
const startVal = Number(startInput?.value || 0);
const durationVal = Number(durInput?.value || 0);
const start = Number.isFinite(startVal) && startVal >= 0 ? startVal : 0;
const hasDuration = Number.isFinite(durationVal) && durationVal > 0;
const end = hasDuration ? (start + durationVal) : auroraVideoDurationSec;
auroraApplyClipWindow(start, end, { syncFields: true, syncSliders: true });
});
v.addEventListener('loadeddata', () => { wrap.style.display = 'block'; });
wrap.appendChild(v);
const lbl = document.createElement('span');
lbl.className = 'aurora-thumb-label'; lbl.textContent = 'Original';
wrap.appendChild(lbl);
}
}
function auroraRenderRecentJobs(payload) {
const countEl = document.getElementById('auroraRecentJobsCount');
const wrap = document.getElementById('auroraRecentJobs');
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
if (countEl) countEl.textContent = `${jobs.length} / ${Number(payload?.total || jobs.length)} jobs`;
if (!wrap) return;
if (!jobs.length) {
wrap.innerHTML = '<div class="aurora-note">Історія job порожня.</div>';
return;
}
wrap.innerHTML = jobs.map((job) => {
const active = auroraJobId && auroraJobId === job.job_id ? ' · active' : '';
const progress = Number(job.progress || 0);
const status = auroraEsc(job.status || 'unknown');
const file = auroraEsc(job.file_name || job.job_id || 'job');
const jobId = auroraEsc(job.job_id || '');
const mode = auroraEsc(job.mode || 'tactical');
const media = auroraEsc(job.media_type || 'unknown');
const liveFps = job.live_fps;
const elapsed = job.elapsed_seconds;
const statusCls = status === 'completed' ? 'done' : status === 'failed' ? 'failed' : status === 'cancelled' ? 'cancelled' : '';
const meta = job.metadata || {};
const device = meta.device || '';
const deviceCls = device === 'mps' ? 'mps' : device === 'cuda' ? 'mps' : device === 'cpu' ? 'cpu' : '';
let chips = `<span class="chip ${statusCls}">${status}</span>`;
chips += `<span class="chip">${mode}</span>`;
chips += `<span class="chip">${media}</span>`;
if (device) chips += `<span class="chip ${deviceCls}">${device}</span>`;
if (Number.isFinite(liveFps) && liveFps > 0) chips += `<span class="chip">${liveFps.toFixed(1)} fps</span>`;
if (Number.isFinite(elapsed) && elapsed > 0) chips += `<span class="chip">${auroraFormatSeconds(elapsed)}</span>`;
if (progress > 0 && progress < 100) chips += `<span class="chip">${progress}%</span>`;
return `
<div class="aurora-kv" style="align-items:flex-start; gap:8px; border:1px solid rgba(255,255,255,0.05); border-radius:8px; padding:8px 10px;">
<span class="k" style="flex:1; min-width:0; text-align:left;">
<span style="display:block; color:var(--text); font-weight:600;">${file}</span>
<span class="aurora-note" style="display:block; margin-top:2px; word-break:break-all; font-size:0.62rem;">${jobId}</span>
<div class="aurora-job-meta">${chips}</div>
</span>
<span class="v" style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;">
<button class="btn btn-ghost btn-sm" data-aurora-open-job="${jobId}">Відкрити</button>
<button class="btn btn-ghost btn-sm" data-aurora-delete-job="${jobId}" style="color:#ff8b8b; border-color:rgba(255,80,80,0.35);">Видалити</button>
</span>
</div>
`;
}).join('');
wrap.querySelectorAll('[data-aurora-open-job]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = String(btn.getAttribute('data-aurora-open-job') || '').trim();
if (id) auroraSelectJob(id);
});
});
wrap.querySelectorAll('[data-aurora-delete-job]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = String(btn.getAttribute('data-aurora-delete-job') || '').trim();
if (id) await auroraDeleteJob(id);
});
});
}
async function auroraRefreshJobs() {
const countEl = document.getElementById('auroraRecentJobsCount');
if (countEl) countEl.textContent = 'loading...';
try {
const r = await fetch(`${API}/api/aurora/jobs?limit=20`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
auroraRecentJobsCache = Array.isArray(data.jobs) ? data.jobs : [];
auroraRenderRecentJobs(data);
} catch (e) {
if (countEl) countEl.textContent = 'error';
const wrap = document.getElementById('auroraRecentJobs');
if (wrap) wrap.innerHTML = `<div class="aurora-note">Не вдалося завантажити jobs: ${auroraEsc(e.message || e)}</div>`;
}
}
async function auroraDeleteJob(jobId, { silent = false } = {}) {
const id = String(jobId || '').trim();
if (!id) return false;
if (!silent) {
const ok = confirm(`Видалити job ${id} з історії та файлів?`);
if (!ok) return false;
}
try {
const r = await fetch(`${API}/api/aurora/delete/${encodeURIComponent(id)}?purge_files=true`, { method: 'POST' });
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
if (auroraJobId && auroraJobId === id) {
auroraClearActiveJob();
}
if (!silent) {
auroraChatAdd('assistant', `Job ${id} видалено з історії.`);
}
await auroraRefreshJobs();
return true;
} catch (e) {
if (!silent) {
alert(`Aurora delete error: ${e.message || e}`);
}
return false;
}
}
async function auroraPurgeTerminalJobs() {
const ok = confirm('Видалити всі завершені/failed/cancelled job-и з історії?');
if (!ok) return;
try {
const r = await fetch(`${API}/api/aurora/jobs?limit=200&status=completed,failed,cancelled`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const jobs = Array.isArray(data.jobs) ? data.jobs : [];
let deleted = 0;
for (const job of jobs) {
const id = String(job?.job_id || '').trim();
if (!id) continue;
const okDelete = await auroraDeleteJob(id, { silent: true });
if (okDelete) deleted += 1;
}
await auroraRefreshJobs();
auroraChatAdd('assistant', `Очищено ${deleted} terminal job(ів).`);
} catch (e) {
alert(`Aurora purge error: ${e.message || e}`);
}
}
function auroraSelectJob(jobId) {
const id = String(jobId || '').trim();
if (!id) return;
auroraSetActiveJobId(id);
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('manual open');
auroraStatusCache = null;
auroraResultCache = null;
auroraPollErrorCount = 0;
auroraLastProgress = 0;
auroraSetProgress(0, 'loading', 'loading job status...');
auroraUpdateQueuePosition(null);
auroraUpdateTiming(null, null, null);
auroraUpdateLivePerf(null, null);
auroraUpdateStorage(null);
auroraStopPolling();
auroraPollTimer = setInterval(auroraPollStatus, 2000);
auroraPollStatus();
}
function auroraClearActiveJob() {
auroraStopPolling();
auroraSetActiveJobId(null);
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('—');
auroraStatusCache = null;
auroraResultCache = null;
auroraLastProgress = 0;
auroraPollErrorCount = 0;
auroraSetProgress(0, 'idle', '—');
auroraUpdateQueuePosition(null);
auroraUpdateTiming(null, null, null);
auroraUpdateLivePerf(null, null);
auroraUpdateStorage(null);
}
let auroraBatchFiles = [];
function auroraOnFilePicked(input) {
const files = input?.files;
if (!files || !files.length) { auroraSetSelectedFile(null); return; }
if (files.length === 1) {
auroraBatchFiles = [];
auroraSetSelectedFile(files[0]);
} else {
auroraBatchFiles = Array.from(files);
auroraSetSelectedFile(files[0]);
const info = document.getElementById('auroraBatchInfo');
if (info) {
info.style.display = 'block';
info.textContent = `📦 Batch: ${files.length} файлів вибрано. Будуть оброблені послідовно.`;
}
}
}
function auroraBindDropzone() {
const dz = document.getElementById('auroraDropzone');
if (!dz || dz.dataset.bound === '1') return;
dz.dataset.bound = '1';
['dragenter', 'dragover'].forEach(evt => {
dz.addEventListener(evt, (e) => {
e.preventDefault();
dz.classList.add('drag');
});
});
['dragleave', 'drop'].forEach(evt => {
dz.addEventListener(evt, (e) => {
e.preventDefault();
dz.classList.remove('drag');
});
});
dz.addEventListener('drop', (e) => {
const files = e.dataTransfer?.files;
if (!files || !files.length) return;
if (files.length === 1) {
auroraBatchFiles = [];
auroraSetSelectedFile(files[0]);
} else {
auroraBatchFiles = Array.from(files);
auroraSetSelectedFile(files[0]);
const info = document.getElementById('auroraBatchInfo');
if (info) {
info.style.display = 'block';
info.textContent = `📦 Batch: ${files.length} файлів. Будуть оброблені послідовно.`;
}
}
});
}
function auroraCollectExportOptions() {
const opts = {};
const outscale = document.getElementById('auroraOptOutscale')?.value;
if (outscale && outscale !== 'auto') {
opts.upscale = Number(outscale);
opts.outscale = Number(outscale);
}
const clipPicker = document.getElementById('auroraClipPicker');
const pickerVisible = !!clipPicker && getComputedStyle(clipPicker).display !== 'none';
const startRange = document.getElementById('auroraClipStartRange');
const endRange = document.getElementById('auroraClipEndRange');
const durationTotal = Number(auroraVideoDurationSec || 0);
const canUseRanges =
pickerVisible &&
Number.isFinite(durationTotal) &&
durationTotal > 0 &&
startRange &&
endRange;
if (canUseRanges) {
const startRangeValue = Number(startRange.value || 0);
const endRangeValue = Number(endRange.value || durationTotal);
const bounded = auroraClampClipWindow(startRangeValue, endRangeValue, durationTotal);
const clipDuration = Math.max(AURORA_MIN_CLIP_SEC, bounded.end - bounded.start);
const isFullVideo = bounded.start <= 0.0001 && (durationTotal - bounded.end) <= 0.11;
if (!isFullVideo) {
if (bounded.start > 0.0001) opts.clip_start_sec = Number(bounded.start.toFixed(3));
opts.clip_duration_sec = Number(clipDuration.toFixed(3));
}
} else {
const clipStart = Number(document.getElementById('auroraOptClipStart')?.value || 0);
const clipDurationRaw = document.getElementById('auroraOptClipDuration')?.value;
const clipDuration = Number(clipDurationRaw || 0);
if (Number.isFinite(clipStart) && clipStart > 0) opts.clip_start_sec = clipStart;
if (clipDurationRaw !== '' && Number.isFinite(clipDuration) && clipDuration > 0) opts.clip_duration_sec = clipDuration;
}
const codec = document.getElementById('auroraOptCodec')?.value;
if (codec && codec !== 'auto') opts.encoder = codec;
const quality = document.getElementById('auroraOptQuality')?.value;
if (quality && quality !== 'balanced') opts.quality = quality;
const faceModel = document.getElementById('auroraOptFaceModel')?.value;
if (faceModel && faceModel !== 'auto') opts.face_model = faceModel;
return opts;
}
function auroraAbsoluteUrl(url) {
const value = String(url || '').trim();
if (!value) return '';
if (/^https?:\/\//i.test(value)) return value;
return `${API}${value}`;
}
function auroraSleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function auroraUpdateReprocessLabel() {
const btn = document.getElementById('auroraReprocessBtn');
const passes = Math.max(1, Math.min(4, Number(document.getElementById('auroraReprocessPasses')?.value || 1)));
if (btn) btn.textContent = `Повторна обробка ×${passes}`;
}
async function auroraWaitForTerminal(jobId, { timeoutSec = 10800, passLabel = '' } = {}) {
const id = String(jobId || '').trim();
if (!id) throw new Error('job_id missing');
const deadline = Date.now() + (timeoutSec * 1000);
while (Date.now() < deadline) {
const r = await fetch(`${API}/api/aurora/status/${encodeURIComponent(id)}`);
if (!r.ok) {
await auroraSleep(2000);
continue;
}
const st = await r.json();
const status = String(st.status || '').toLowerCase();
const stage = st.current_stage || 'processing';
if (passLabel) auroraSetProgress(st.progress || 1, status || 'processing', `${passLabel} · ${stage}`);
if (status === 'completed' || status === 'failed' || status === 'cancelled') return st;
await auroraSleep(2000);
}
throw new Error('reprocess timeout');
}
function auroraSetPreset(preset) {
const normalized = String(preset || 'balanced').trim();
auroraPresetMode = ['turbo', 'balanced', 'max_quality'].includes(normalized) ? normalized : 'balanced';
document.querySelectorAll('[data-aurora-preset]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-aurora-preset') === auroraPresetMode);
});
const quality = document.getElementById('auroraOptQuality');
const outscale = document.getElementById('auroraOptOutscale');
const codec = document.getElementById('auroraOptCodec');
const denoise = document.getElementById('auroraCtrlDenoise');
if (auroraPresetMode === 'turbo') {
if (quality) quality.value = 'fast';
if (outscale) outscale.value = '1';
if (codec) codec.value = 'h264_videotoolbox';
if (denoise) denoise.checked = false;
} else if (auroraPresetMode === 'max_quality') {
if (quality) quality.value = 'quality';
if (outscale) outscale.value = '2';
if (codec) codec.value = 'hevc_videotoolbox';
if (denoise) denoise.checked = true;
} else {
if (quality) quality.value = 'balanced';
if (outscale) outscale.value = 'auto';
if (codec) codec.value = 'auto';
}
auroraUpdateReprocessLabel();
}
function auroraUpdatePriorityLabel() {
const slider = document.getElementById('auroraPriorityBias');
const label = document.getElementById('auroraPriorityLabel');
const out = document.getElementById('auroraAnalysisPriority');
const bias = Number(slider?.value || 0);
let priority = 'balanced';
let text = 'Баланс: рівномірно';
if (bias <= -30) {
priority = 'faces';
text = `Фокус на обличчях (${Math.abs(bias)}%)`;
} else if (bias >= 30) {
priority = 'plates';
text = `Фокус на номерних знаках (${Math.abs(bias)}%)`;
}
if (label) label.textContent = text;
if (out) out.textContent = priority;
}
function auroraResetAnalysisControls() {
const denoise = document.getElementById('auroraCtrlDenoise');
const face = document.getElementById('auroraCtrlFaceRestore');
const plate = document.getElementById('auroraCtrlPlateRoi');
const maxFace = document.getElementById('auroraCtrlMaxFace');
const focusProfile = document.getElementById('auroraFocusProfile');
const taskHint = document.getElementById('auroraTaskHint');
const clipStart = document.getElementById('auroraOptClipStart');
const clipDuration = document.getElementById('auroraOptClipDuration');
const slider = document.getElementById('auroraPriorityBias');
if (denoise) denoise.checked = false;
if (face) face.checked = true;
if (plate) plate.checked = false;
if (maxFace) maxFace.checked = false;
if (focusProfile) focusProfile.value = 'auto';
if (taskHint) taskHint.value = '';
if (clipStart) clipStart.value = '';
if (clipDuration) clipDuration.value = '';
if (slider) slider.value = '0';
auroraSetPreset('balanced');
auroraUpdatePriorityLabel();
auroraUpdateReprocessLabel();
}
function auroraApplySuggestedExportOptions(suggested) {
if (!suggested || typeof suggested !== 'object') return;
const outscale = String(suggested.upscale ?? suggested.outscale ?? '').trim();
if (outscale && document.getElementById('auroraOptOutscale')) {
const el = document.getElementById('auroraOptOutscale');
const has = Array.from(el.options || []).some((o) => o.value === outscale);
if (has) el.value = outscale;
}
const encoder = String(suggested.encoder ?? '').trim();
if (encoder && document.getElementById('auroraOptCodec')) {
const el = document.getElementById('auroraOptCodec');
const has = Array.from(el.options || []).some((o) => o.value === encoder);
if (has) el.value = encoder;
}
const quality = String(suggested.quality ?? '').trim();
if (quality && document.getElementById('auroraOptQuality')) {
const el = document.getElementById('auroraOptQuality');
const has = Array.from(el.options || []).some((o) => o.value === quality);
if (has) el.value = quality;
}
const faceModel = String(suggested.face_model ?? '').trim();
if (faceModel && document.getElementById('auroraOptFaceModel')) {
const el = document.getElementById('auroraOptFaceModel');
const has = Array.from(el.options || []).some((o) => o.value === faceModel);
if (has) el.value = faceModel;
}
}
function auroraApplyAnalysisHints(data) {
const faces = Array.isArray(data?.faces) ? data.faces.length : 0;
const plates = Array.isArray(data?.license_plates) ? data.license_plates.length : 0;
const recs = Array.isArray(data?.recommendations) ? data.recommendations.map((x) => String(x || '').toLowerCase()) : [];
const quality = data?.quality_analysis || {};
const denoise = document.getElementById('auroraCtrlDenoise');
const face = document.getElementById('auroraCtrlFaceRestore');
const plate = document.getElementById('auroraCtrlPlateRoi');
const maxFace = document.getElementById('auroraCtrlMaxFace');
const focusProfile = document.getElementById('auroraFocusProfile');
const slider = document.getElementById('auroraPriorityBias');
const highNoise = ['high', 'very_high'].includes(String(quality.noise_level || '').toLowerCase());
const recDenoise = recs.some((x) => x.includes('denoise') || x.includes('noise'));
if (denoise) denoise.checked = highNoise || recDenoise;
if (face) face.checked = faces > 0;
if (plate) plate.checked = plates > 0;
const suggested = String(data?.suggested_priority || 'balanced').toLowerCase();
if (slider) {
if (suggested === 'faces') slider.value = '-55';
else if (suggested === 'plates') slider.value = '55';
else slider.value = '0';
}
if (focusProfile) {
if (suggested === 'details') focusProfile.value = 'text_readability';
else if (suggested === 'faces') focusProfile.value = 'max_faces';
else if (suggested === 'plates') focusProfile.value = 'plates';
else focusProfile.value = 'auto';
}
if (maxFace) maxFace.checked = suggested === 'faces';
if (suggested === 'faces' || suggested === 'plates') auroraSetPreset('max_quality');
else auroraSetPreset('balanced');
auroraUpdatePriorityLabel();
}
function auroraCollectAnalysisControls() {
const bias = Number(document.getElementById('auroraPriorityBias')?.value || 0);
const denoise = Boolean(document.getElementById('auroraCtrlDenoise')?.checked);
const faceRestore = Boolean(document.getElementById('auroraCtrlFaceRestore')?.checked);
const plateRoi = Boolean(document.getElementById('auroraCtrlPlateRoi')?.checked);
const maxFaceQuality = Boolean(document.getElementById('auroraCtrlMaxFace')?.checked);
const focusProfile = String(document.getElementById('auroraFocusProfile')?.value || 'auto').trim();
const taskHint = String(document.getElementById('auroraTaskHint')?.value || '').trim();
const preset = auroraPresetMode || 'balanced';
let priority = bias <= -30 ? 'faces' : bias >= 30 ? 'plates' : (auroraSuggestedPriority || 'balanced');
if (focusProfile === 'text_readability') priority = 'details';
if (focusProfile === 'plates') priority = 'plates';
if (focusProfile === 'max_faces' || maxFaceQuality) priority = 'faces';
return {
denoise,
face_restore: faceRestore,
plate_roi_enhance: plateRoi,
max_face_quality: maxFaceQuality,
focus_profile: focusProfile || 'auto',
task_hint: taskHint,
priority_bias: bias,
priority,
preset,
};
}
function auroraBuildAnalysisExportHints(controls) {
const c = controls || auroraCollectAnalysisControls();
const outscaleRaw = String(document.getElementById('auroraOptOutscale')?.value || 'auto').trim().toLowerCase();
const isAutoScale = !outscaleRaw || outscaleRaw === 'auto';
const hints = {
pre_denoise: Boolean(c.denoise),
temporal_denoise: Boolean(c.denoise && c.preset === 'max_quality'),
roi_only_faces: c.priority === 'faces',
face_restore: Boolean(c.face_restore),
plate_roi_enhance: Boolean(c.plate_roi_enhance),
max_face_quality: Boolean(c.max_face_quality),
focus_profile: c.focus_profile || 'auto',
task_hint: String(c.task_hint || '').trim(),
profile: c.preset || 'balanced',
priority_bias: Number(c.priority_bias || 0),
auto_forensic_outscale: true,
};
if (!hints.task_hint) delete hints.task_hint;
if (c.focus_profile === 'max_faces' || c.max_face_quality) {
hints.pre_denoise = true;
hints.temporal_denoise = true;
hints.roi_only_faces = true;
hints.face_model = 'codeformer';
hints.deblur_before_face = true;
hints.score_loop = true;
hints.allow_roi_upscale = true;
if (isAutoScale) hints.upscale = 2;
} else if (c.focus_profile === 'text_readability') {
hints.pre_denoise = true;
hints.temporal_denoise = true;
hints.roi_only_faces = false;
hints.deblur_before_face = true;
hints.score_loop = true;
hints.text_focus = true;
if (isAutoScale) hints.upscale = 2;
} else if (c.focus_profile === 'plates') {
hints.roi_only_faces = false;
hints.plate_roi_enhance = true;
}
return hints;
}
function auroraStartFromAnalysis() {
auroraStart();
}
async function auroraLoadQualityReport(jobId, refresh = false) {
const id = String(jobId || '').trim();
if (!id) return null;
try {
const r = await fetch(`${API}/api/aurora/quality/${encodeURIComponent(id)}?refresh=${refresh ? 'true' : 'false'}`);
if (!r.ok) return null;
return await r.json();
} catch (_) {
return null;
}
}
function auroraRenderQualityReport(report) {
const wrap = document.getElementById('auroraQualityWrap');
const content = document.getElementById('auroraQualityContent');
if (!wrap || !content) return;
if (!report || typeof report !== 'object') {
wrap.style.display = 'none';
content.innerHTML = '';
return;
}
const faces = report.faces || {};
const plates = report.plates || {};
const overall = report.overall || {};
const models = Array.isArray(overall.models) ? overall.models : [];
const warnings = Array.isArray(overall.warnings) ? overall.warnings : [];
const processingStatus = String(overall.processing_status || 'ok');
const degraded = processingStatus !== 'ok' || Boolean(overall.identical_to_input) || Boolean(overall.fallback_used);
const procSec = Number(overall.processing_time_sec);
const procText = Number.isFinite(procSec) ? auroraFormatSeconds(procSec) : '—';
const psnr = overall.psnr != null ? `${overall.psnr} dB` : '—';
const avgFace = faces.avg_confidence != null ? Number(faces.avg_confidence).toFixed(2) : '0.00';
const avgPlate = plates.avg_confidence != null ? Number(plates.avg_confidence).toFixed(2) : '0.00';
content.innerHTML = `
<div class="aurora-quality-group">
<div class="aurora-quality-head">Обличчя</div>
<div class="aurora-quality-line"><span>Виявлено</span><span>${Number(faces.detected || 0)} / ${Number(faces.source_detected || faces.detected || 0)}</span></div>
<div class="aurora-quality-line"><span>Avg confidence</span><span>${avgFace}</span></div>
<div class="aurora-quality-line"><span>Придатні для ідентифікації</span><span>${Number(faces.identifiable || 0)}</span></div>
</div>
<div class="aurora-quality-group">
<div class="aurora-quality-head">Номерні знаки</div>
<div class="aurora-quality-line"><span>Виявлено</span><span>${Number(plates.detected || 0)}</span></div>
<div class="aurora-quality-line"><span>Розпізнано текст</span><span>${Number(plates.recognized || 0)}</span></div>
<div class="aurora-quality-line"><span>Avg confidence</span><span>${avgPlate}</span></div>
<div class="aurora-quality-line"><span>Нерозпізнано</span><span>${Number(plates.unrecognized || 0)}${plates.unrecognized_reason ? ` (${auroraEsc(plates.unrecognized_reason)})` : ''}</span></div>
</div>
<div class="aurora-quality-group">
<div class="aurora-quality-head">Загальне</div>
<div class="aurora-quality-line"><span>Статус обробки</span><span style="${degraded ? 'color:var(--warn);' : 'color:var(--ok);'}">${auroraEsc(processingStatus)}</span></div>
<div class="aurora-quality-line"><span>PSNR</span><span>${psnr}</span></div>
<div class="aurora-quality-line"><span>Час обробки</span><span>${procText}</span></div>
<div class="aurora-quality-line"><span>Моделі</span><span>${models.length ? auroraEsc(models.join(', ')) : '—'}</span></div>
${warnings.length ? `<div class="aurora-note" style="margin-top:6px; color:var(--warn);">⚠ ${auroraEsc(warnings.join(' | '))}</div>` : ''}
</div>
`;
wrap.style.display = 'block';
}
function auroraNormalizeBoxPct(bbox, frameW, frameH) {
if (!Array.isArray(bbox) || bbox.length < 4) return null;
const fw = Number(frameW || 0);
const fh = Number(frameH || 0);
if (!Number.isFinite(fw) || !Number.isFinite(fh) || fw <= 0 || fh <= 0) return null;
let x1 = Number(bbox[0]); let y1 = Number(bbox[1]); let x2 = Number(bbox[2]); let y2 = Number(bbox[3]);
if (![x1, y1, x2, y2].every(Number.isFinite)) return null;
if (x2 < x1) [x1, x2] = [x2, x1];
if (y2 < y1) [y1, y2] = [y2, y1];
x1 = Math.max(0, Math.min(fw, x1));
x2 = Math.max(0, Math.min(fw, x2));
y1 = Math.max(0, Math.min(fh, y1));
y2 = Math.max(0, Math.min(fh, y2));
if ((x2 - x1) < 2 || (y2 - y1) < 2) return null;
return {
left: (x1 / fw) * 100,
top: (y1 / fh) * 100,
width: ((x2 - x1) / fw) * 100,
height: ((y2 - y1) / fh) * 100,
};
}
function auroraRenderDetectionsPanel(containerId, imageUrl, payload) {
const host = document.getElementById(containerId);
if (!host) return false;
const url = String(imageUrl || '').trim();
const data = (payload && typeof payload === 'object') ? payload : {};
const frame = (data.frame_size && typeof data.frame_size === 'object') ? data.frame_size : {};
const frameW = Number(frame.width || 0);
const frameH = Number(frame.height || 0);
const faces = Array.isArray(data.faces) ? data.faces : [];
const plates = Array.isArray(data.plates) ? data.plates : [];
if (!url) {
host.innerHTML = '<div class="aurora-note">preview unavailable</div>';
return false;
}
const boxHtml = [];
const pushBox = (kind, item) => {
const norm = auroraNormalizeBoxPct(item?.bbox, frameW, frameH);
if (!norm) return;
const conf = Number(item?.confidence);
const confText = Number.isFinite(conf) ? conf.toFixed(2) : '?';
let label = `${kind} (${confText})`;
if (kind === 'plate' && item?.text) {
label = `plate ${String(item.text)} (${confText})`;
}
boxHtml.push(
`<div class="aurora-bbox ${kind}" style="left:${norm.left}%;top:${norm.top}%;width:${norm.width}%;height:${norm.height}%;">` +
`<span class="aurora-bbox-label">${auroraEsc(label)}</span>` +
`</div>`
);
};
faces.forEach((item) => pushBox('face', item));
plates.forEach((item) => pushBox('plate', item));
host.innerHTML = `
<div class="aurora-detect-stage">
<img src="${auroraEsc(url)}" alt="detections preview">
<div class="aurora-detect-overlay">${boxHtml.join('')}</div>
</div>
<div class="aurora-note">${boxHtml.length} boxes</div>
`;
return boxHtml.length > 0;
}
function auroraRenderDetections(compare) {
const wrap = document.getElementById('auroraDetectionsWrap');
const beforeHost = document.getElementById('auroraDetectionsBefore');
const afterHost = document.getElementById('auroraDetectionsAfter');
if (!wrap || !beforeHost || !afterHost) return;
if (!compare || typeof compare !== 'object' || !compare.frame_preview || !compare.detections) {
wrap.style.display = 'none';
beforeHost.innerHTML = '';
afterHost.innerHTML = '';
return;
}
const fp = compare.frame_preview || {};
const beforeUrl = auroraAbsoluteUrl(fp.before_url || '');
const afterUrl = auroraAbsoluteUrl(fp.after_url || '');
const d = compare.detections || {};
const beforeOk = auroraRenderDetectionsPanel('auroraDetectionsBefore', beforeUrl, d.before || {});
const afterOk = auroraRenderDetectionsPanel('auroraDetectionsAfter', afterUrl, d.after || {});
wrap.style.display = (beforeOk || afterOk) ? 'block' : 'none';
}
function auroraShowCompare(beforeUrl, afterUrl) {
const wrap = document.getElementById('auroraCompareWrap');
if (!wrap || !beforeUrl || !afterUrl) return;
wrap.style.display = 'block';
wrap.innerHTML = `
<div class="aurora-compare-wrap" id="auroraCompareSlider">
<img src="${afterUrl}" alt="after" style="display:block; width:100%; max-height:320px; object-fit:contain; background:#000;">
<div class="aurora-compare-after" style="width:50%;">
<img src="${beforeUrl}" alt="before" style="display:block; max-height:320px; object-fit:contain; background:#000;">
</div>
<span class="aurora-compare-label" style="left:8px;">Before</span>
<span class="aurora-compare-label" style="right:8px;">After</span>
</div>
`;
const slider = document.getElementById('auroraCompareSlider');
if (!slider) return;
const afterEl = slider.querySelector('.aurora-compare-after');
const afterImg = afterEl?.querySelector('img');
if (afterImg) afterImg.style.width = slider.offsetWidth + 'px';
let dragging = false;
function update(e) {
const rect = slider.getBoundingClientRect();
const x = Math.max(0, Math.min(1, ((e.touches?.[0]?.clientX ?? e.clientX) - rect.left) / rect.width));
afterEl.style.width = (x * 100) + '%';
}
slider.addEventListener('pointerdown', (e) => { dragging = true; update(e); slider.setPointerCapture(e.pointerId); });
slider.addEventListener('pointermove', (e) => { if (dragging) update(e); });
slider.addEventListener('pointerup', () => { dragging = false; });
}
async function auroraRefreshHealth() {
const el = document.getElementById('auroraServiceState');
try {
const r = await fetch(`${API}/api/aurora/health`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
if (el) {
const rt = d.runtime || {};
const device = rt.device || (rt.force_cpu ? 'cpu' : 'auto');
const vtb = rt.ffmpeg_videotoolbox_hwaccel ? 'yes' : 'no';
el.textContent = `ok (device:${device}, torch:${rt.torch ? 'yes' : 'no'}, ffmpeg:${rt.ffmpeg ? 'yes' : 'no'}, vtb:${vtb})`;
el.style.color = 'var(--ok)';
}
} catch (e) {
if (el) {
el.textContent = 'offline';
el.style.color = 'var(--err)';
}
}
}
function auroraSetProgress(progress, status, stage) {
const bar = document.getElementById('auroraProgressFill');
const barWrap = document.getElementById('auroraProgressBar');
const txt = document.getElementById('auroraProgressText');
const st = document.getElementById('auroraJobStatus');
const sg = document.getElementById('auroraJobStage');
const logEl = document.getElementById('auroraLiveLog');
const val = Math.max(0, Math.min(100, Number(progress || 0)));
auroraLastProgress = val;
if (bar) bar.style.width = `${val}%`;
if (txt) txt.textContent = stage && stage !== '—' ? `${val}% · ${stage}` : `${val}%`;
if (st) st.textContent = status || 'processing';
if (sg) sg.textContent = stage || '—';
const isActive = status === 'processing' || status === 'queued';
if (barWrap) barWrap.classList.toggle('processing', isActive);
if (logEl) {
logEl.style.display = isActive ? 'block' : 'none';
if (isActive && stage && stage !== '—') {
auroraAppendLog(stage);
}
}
}
let _auroraLogLines = [];
function auroraAppendLog(line) {
const el = document.getElementById('auroraLiveLog');
if (!el) return;
const ts = new Date().toLocaleTimeString('uk-UA', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
if (_auroraLogLines.length && _auroraLogLines[_auroraLogLines.length - 1].text === line) return;
_auroraLogLines.push({ ts, text: line });
if (_auroraLogLines.length > 60) _auroraLogLines = _auroraLogLines.slice(-40);
el.innerHTML = _auroraLogLines.map((l, i) =>
`<div${i === _auroraLogLines.length - 1 ? ' class="log-new"' : ''}>${l.ts} ${auroraEsc(l.text)}</div>`
).join('');
el.scrollTop = el.scrollHeight;
}
function auroraFormatSeconds(totalSeconds) {
const sec = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`;
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
return `${s}s`;
}
function auroraUpdateTiming(elapsedSeconds, etaSeconds, estimatedTotalSeconds) {
const elapsedEl = document.getElementById('auroraElapsed');
const etaEl = document.getElementById('auroraEta');
if (elapsedEl) {
elapsedEl.textContent = Number.isFinite(Number(elapsedSeconds)) ? auroraFormatSeconds(elapsedSeconds) : '—';
}
if (etaEl) {
if (Number.isFinite(Number(etaSeconds))) {
etaEl.textContent = `~${auroraFormatSeconds(etaSeconds)}`;
} else if (Number.isFinite(Number(estimatedTotalSeconds))) {
etaEl.textContent = `total ~${auroraFormatSeconds(estimatedTotalSeconds)}`;
} else {
etaEl.textContent = 'calculating...';
}
}
}
function auroraUpdateLivePerf(liveFps, etaConfidence) {
const el = document.getElementById('auroraLivePerf');
if (!el) return;
const fpsVal = Number(liveFps);
const conf = String(etaConfidence || '').trim();
if (Number.isFinite(fpsVal) && fpsVal > 0) {
const fpsTxt = fpsVal >= 1 ? fpsVal.toFixed(2) : fpsVal.toFixed(3);
el.textContent = conf ? `${fpsTxt} fps (${conf})` : `${fpsTxt} fps`;
} else {
el.textContent = conf ? `— (${conf})` : '—';
}
}
function auroraUpdateQueuePosition(queuePosition) {
const el = document.getElementById('auroraQueuePos');
if (!el) return;
const pos = Number(queuePosition);
if (Number.isFinite(pos) && pos > 0) {
el.textContent = `#${pos}`;
} else {
el.textContent = '—';
}
}
function auroraUpdateStorage(storage) {
const el = document.getElementById('auroraStoragePath');
if (!el || !storage || typeof storage !== 'object') {
if (el) el.textContent = '—';
auroraSetFolderPath(null);
return;
}
const outputDir = storage.output_dir || storage.upload_dir || storage.input_path || '';
el.textContent = outputDir ? String(outputDir) : '—';
auroraSetFolderPath(outputDir || null);
}
function auroraSetFolderPath(pathValue) {
auroraFolderPath = pathValue ? String(pathValue) : null;
const revealBtn = document.getElementById('auroraRevealFolderBtn');
const link = document.getElementById('auroraFolderLink');
const hasPath = Boolean(auroraFolderPath);
if (revealBtn) revealBtn.disabled = !hasPath || !auroraJobId;
if (link) {
if (hasPath) {
link.href = `file://${encodeURI(auroraFolderPath)}`;
link.style.display = 'inline-flex';
link.textContent = '📁 Відкрити папку';
} else {
link.href = '#';
link.style.display = 'none';
}
}
}
async function auroraRevealFolder() {
if (!auroraJobId) {
alert('Немає активного job для відкриття папки');
return;
}
try {
const r = await fetch(`${API}/api/aurora/folder/${encodeURIComponent(auroraJobId)}/open`, { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
if (d.folder_path) {
auroraSetFolderPath(d.folder_path);
auroraChatAdd('assistant', `Папку відкрито: ${d.folder_path}`);
}
} catch (e) {
alert(`Aurora folder open error: ${e.message || e}`);
}
}
function auroraChatAdd(role, text) {
const log = document.getElementById('auroraChatLog');
if (!log) return;
const row = document.createElement('div');
row.className = `aurora-chat-row ${role === 'user' ? 'user' : 'assistant'}`;
row.textContent = String(text || '');
log.appendChild(row);
log.scrollTop = log.scrollHeight;
}
function auroraRenderChatActions(actions) {
const wrap = document.getElementById('auroraChatActions');
if (!wrap) return;
const list = Array.isArray(actions) ? actions : [];
if (!list.length) {
wrap.innerHTML = '';
return;
}
wrap.innerHTML = list.slice(0, 6).map((a, idx) => {
const label = auroraEsc((a && a.label) ? a.label : `Action ${idx + 1}`);
const payload = auroraEsc(JSON.stringify(a || {}));
return `<button class="btn btn-ghost btn-sm" data-aurora-action="${payload}" onclick="auroraHandleChatAction(this)">${label}</button>`;
}).join('');
}
async function auroraHandleChatAction(btn) {
try {
const raw = btn?.dataset?.auroraAction || '{}';
const action = JSON.parse(raw);
const type = String(action.type || '').toLowerCase();
if (type === 'refresh_status') {
await auroraPollStatus();
return;
}
if (type === 'refresh_health') {
await auroraRefreshHealth();
return;
}
if (type === 'cancel') {
await auroraCancel();
return;
}
if (type === 'open_result') {
if (auroraJobId) await auroraLoadResult(auroraJobId);
return;
}
if (type === 'reprocess') {
await auroraReprocess({
second_pass: Boolean(action.second_pass),
priority: action.priority || undefined,
});
return;
}
} catch (e) {
alert(`Aurora action error: ${e.message || e}`);
}
}
async function auroraSendChat(prefillText) {
if (auroraChatBusy) return;
const input = document.getElementById('auroraChatInput');
const sendBtn = document.getElementById('auroraChatSendBtn');
const text = String(prefillText || input?.value || '').trim();
if (!text) return;
auroraChatBusy = true;
if (sendBtn) sendBtn.disabled = true;
if (input && !prefillText) input.value = '';
auroraChatAdd('user', text);
try {
const r = await fetch(`${API}/api/aurora/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
job_id: auroraJobId || null,
analysis: auroraAnalysisCache || null,
}),
});
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
const data = await r.json();
auroraChatHistory.push({ role: 'user', text });
auroraChatHistory.push({ role: 'assistant', text: data.reply || '' });
auroraChatAdd('assistant', data.reply || 'No response');
auroraRenderChatActions(data.actions || []);
} catch (e) {
auroraChatAdd('assistant', `Помилка чату Aurora: ${e.message || e}`);
} finally {
auroraChatBusy = false;
if (sendBtn) sendBtn.disabled = false;
}
}
async function auroraReprocess(options) {
if (!auroraJobId) {
alert('Спочатку запустіть або оберіть job');
return;
}
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = true;
const incoming = (options && typeof options === 'object') ? options : {};
const passCountUi = Number(document.getElementById('auroraReprocessPasses')?.value || 1);
const passes = Math.max(1, Math.min(4, Number(incoming.passes) || passCountUi));
const secondPassUi = Boolean(document.getElementById('auroraReprocessSecondPass')?.checked);
const secondPass = Object.prototype.hasOwnProperty.call(incoming, 'second_pass')
? Boolean(incoming.second_pass)
: secondPassUi;
const analysisControls = auroraCollectAnalysisControls();
const uiExport = auroraCollectExportOptions();
const analysisExport = auroraBuildAnalysisExportHints(analysisControls);
const mergedExport = { ...auroraSuggestedExport, ...uiExport, ...analysisExport, ...(incoming.export_options || {}) };
let priority = incoming.priority || analysisControls.priority || auroraSuggestedPriority || 'balanced';
if (typeof priority !== 'string' || !priority.trim()) priority = 'balanced';
const basePayload = {
mode: auroraMode,
priority,
export_options: mergedExport,
};
let sourceJobId = auroraJobId;
let lastJobId = auroraJobId;
try {
auroraStopPolling();
for (let i = 1; i <= passes; i += 1) {
const payload = { ...basePayload, ...incoming, second_pass: secondPass };
const r = await fetch(`${API}/api/aurora/reprocess/${encodeURIComponent(sourceJobId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
const data = await r.json();
const newJobId = String(data.job_id || '').trim();
if (!newJobId) throw new Error('job_id missing in reprocess response');
lastJobId = newJobId;
auroraSetActiveJobId(newJobId);
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText(`reprocess ${i}/${passes}`);
auroraStatusCache = null;
auroraResultCache = null;
auroraPollErrorCount = 0;
auroraLastProgress = 1;
auroraPollInFlight = false;
const resultCard = document.getElementById('auroraResultCard');
if (resultCard) resultCard.style.display = 'none';
auroraSetProgress(1, 'processing', `dispatching reprocess ${i}/${passes}`);
auroraUpdateQueuePosition(null);
auroraUpdateTiming(0, null, null);
auroraUpdateLivePerf(null, null);
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'inline-block';
if (i < passes) {
const done = await auroraWaitForTerminal(newJobId, { passLabel: `reprocess ${i}/${passes}` });
const status = String(done?.status || '').toLowerCase();
if (status !== 'completed') {
throw new Error(`reprocess ${i}/${passes} завершився зі статусом ${status}`);
}
}
sourceJobId = newJobId;
}
auroraStopPolling();
auroraPollTimer = setInterval(auroraPollStatus, 2000);
await auroraPollStatus();
auroraChatAdd('assistant', `Запустила reprocess ×${passes}: ${lastJobId}`);
await auroraRefreshJobs();
} catch (e) {
alert(`Aurora reprocess error: ${e.message || e}`);
} finally {
if (reBtn) reBtn.disabled = false;
auroraUpdateReprocessLabel();
}
}
function auroraRenderAnalysis(data) {
const card = document.getElementById('auroraAnalysisCard');
if (!card) return;
card.style.display = 'block';
const quality = data.quality_analysis || {};
const faces = Array.isArray(data.faces) ? data.faces.length : 0;
const plates = Array.isArray(data.license_plates) ? data.license_plates.length : 0;
const qualityText = `noise=${quality.noise_level || 'unknown'}, brightness=${quality.brightness || 'unknown'}, blur=${quality.blur_level || 'unknown'}`;
const recs = Array.isArray(data.recommendations) ? data.recommendations : [];
document.getElementById('auroraAnalysisType').textContent = data.media_type || '—';
document.getElementById('auroraAnalysisFaces').textContent = String(faces);
document.getElementById('auroraAnalysisPlates').textContent = String(plates);
document.getElementById('auroraAnalysisQuality').textContent = qualityText;
document.getElementById('auroraAnalysisPriority').textContent = data.suggested_priority || 'balanced';
document.getElementById('auroraAnalysisRecs').innerHTML = recs.length
? recs.map(r => `<div class="aurora-note">• ${auroraEsc(r)}</div>`).join('')
: '<div class="aurora-note">Немає рекомендацій</div>';
auroraApplySuggestedExportOptions(auroraSuggestedExport || {});
auroraApplyAnalysisHints(data);
const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn');
if (quickStartBtn) quickStartBtn.disabled = !auroraSelectedFile;
}
function auroraRenderAudioAnalysis(data) {
const card = document.getElementById('auroraAudioAnalysisCard');
if (!card) return;
card.style.display = 'block';
const audio = (data && typeof data.audio === 'object') ? data.audio : {};
const recs = Array.isArray(data.recommendations) ? data.recommendations : [];
const duration = Number(audio.duration_seconds);
const bitrate = Number(audio.bit_rate);
document.getElementById('auroraAudioDuration').textContent = Number.isFinite(duration) && duration > 0 ? `${duration.toFixed(2)} s` : '—';
document.getElementById('auroraAudioSampleRate').textContent = audio.sample_rate_hz ? `${audio.sample_rate_hz} Hz` : '—';
document.getElementById('auroraAudioChannels').textContent = audio.channels ? String(audio.channels) : '—';
document.getElementById('auroraAudioCodec').textContent = audio.codec || '—';
document.getElementById('auroraAudioBitrate').textContent = Number.isFinite(bitrate) && bitrate > 0 ? `${Math.round(bitrate / 1000)} kbps` : '—';
document.getElementById('auroraAudioPriority').textContent = data.suggested_priority || 'speech';
document.getElementById('auroraAudioRecs').innerHTML = recs.length
? recs.map(r => `<div class="aurora-note">• ${auroraEsc(r)}</div>`).join('')
: '<div class="aurora-note">Немає рекомендацій</div>';
}
async function auroraAnalyze() {
if (!auroraSelectedFile) {
alert('Спочатку оберіть файл');
return;
}
const analyzeBtn = document.getElementById('auroraAnalyzeBtn');
if (analyzeBtn) {
analyzeBtn.disabled = true;
analyzeBtn.textContent = 'Analyzing...';
}
try {
const fd = new FormData();
fd.append('file', auroraSelectedFile);
const endpoint = auroraIsAudioFile(auroraSelectedFile) ? '/api/aurora/audio/analyze' : '/api/aurora/analyze';
const r = await fetch(`${API}${endpoint}`, { method: 'POST', body: fd });
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
const data = await r.json();
auroraAnalysisCache = data;
auroraSuggestedPriority = String(data.suggested_priority || 'balanced');
auroraSuggestedExport = (data.suggested_export && typeof data.suggested_export === 'object')
? data.suggested_export
: {};
const analysisCard = document.getElementById('auroraAnalysisCard');
const audioCard = document.getElementById('auroraAudioAnalysisCard');
if ((data.media_type || '') === 'audio') {
if (analysisCard) analysisCard.style.display = 'none';
auroraRenderAudioAnalysis(data);
} else {
if (audioCard) audioCard.style.display = 'none';
auroraRenderAnalysis(data);
}
} catch (e) {
alert(`Aurora analyze error: ${e.message || e}`);
} finally {
if (analyzeBtn) {
analyzeBtn.disabled = !auroraSelectedFile;
analyzeBtn.textContent = auroraIsAudioFile(auroraSelectedFile) ? '🎧 Аудіо аналіз' : '🔍 Аналіз';
}
}
}
async function auroraStartAudio() {
if (!auroraSelectedFile) {
alert('Спочатку оберіть аудіо файл');
return;
}
if (!auroraIsAudioFile(auroraSelectedFile)) {
alert('Audio process доступний тільки для аудіо файлів');
return;
}
const fd = new FormData();
fd.append('file', auroraSelectedFile);
fd.append('mode', auroraMode);
fd.append('priority', 'speech');
fd.append('export_options', JSON.stringify(auroraSuggestedExport || {}));
const btn = document.getElementById('auroraAudioProcessBtn');
try {
if (btn) btn.disabled = true;
const r = await fetch(`${API}/api/aurora/audio/process`, { method: 'POST', body: fd });
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
const data = await r.json();
auroraSetActiveJobId(data.job_id);
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('reprocess local');
auroraStatusCache = null;
auroraResultCache = null;
auroraPollErrorCount = 0;
auroraLastProgress = 1;
auroraPollInFlight = false;
auroraSetProgress(1, 'processing', 'dispatching audio');
auroraUpdateQueuePosition(null);
auroraUpdateTiming(0, null, null);
auroraUpdateLivePerf(null, null);
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'inline-block';
auroraStopPolling();
auroraPollTimer = setInterval(auroraPollStatus, 2000);
await auroraPollStatus();
await auroraRefreshJobs();
} catch (e) {
alert(`Aurora audio process error: ${e.message || e}`);
} finally {
if (btn) btn.disabled = !auroraSelectedFile;
}
}
function auroraStopPolling() {
if (auroraPollTimer) {
clearInterval(auroraPollTimer);
auroraPollTimer = null;
}
auroraPollInFlight = false;
}
async function auroraPollSmartStatus({ quiet = true } = {}) {
if (!auroraSmartRunId) return null;
try {
const r = await fetch(`${API}/api/aurora/process-smart/${encodeURIComponent(auroraSmartRunId)}`);
if (r.status === 404) {
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('—');
return null;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const smart = await r.json();
auroraSmartStatusCache = smart;
const strategy = smart?.policy?.strategy || 'auto';
const phase = smart?.phase || smart?.status || '—';
auroraSetSmartPolicyText(`${strategy} · ${phase}`);
return smart;
} catch (e) {
if (!quiet) console.warn('aurora smart status error:', e);
return auroraSmartStatusCache;
}
}
async function auroraPollStatus() {
if (!auroraJobId || auroraPollInFlight) return;
auroraPollInFlight = true;
try {
const r = await fetch(`${API}/api/aurora/status/${encodeURIComponent(auroraJobId)}`);
if (r.status === 404) {
const missingJobId = auroraJobId;
auroraStopPolling();
auroraClearActiveJob();
auroraChatAdd('assistant', `Job ${missingJobId} не знайдено. Можливо, його очистили або змінився data_dir.`);
await auroraRefreshJobs();
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const st = await r.json();
const smart = await auroraPollSmartStatus({ quiet: true });
auroraStatusCache = st;
auroraPollErrorCount = 0;
const cachedTiming = auroraGetPersistedTiming(auroraJobId) || {};
const effectiveEta = Number.isFinite(Number(st.eta_seconds)) ? st.eta_seconds : cachedTiming.eta_seconds;
const effectiveTotal = Number.isFinite(Number(st.estimated_total_seconds)) ? st.estimated_total_seconds : cachedTiming.estimated_total_seconds;
const effectiveFps = Number.isFinite(Number(st.live_fps)) ? st.live_fps : cachedTiming.live_fps;
const effectiveConfidence = (st.eta_confidence != null && String(st.eta_confidence).trim())
? st.eta_confidence
: cachedTiming.eta_confidence;
auroraSetProgress(st.progress, st.status, st.current_stage);
auroraUpdateTiming(st.elapsed_seconds, effectiveEta, effectiveTotal);
auroraUpdateLivePerf(effectiveFps, effectiveConfidence);
auroraPersistTiming(auroraJobId, {
eta_seconds: effectiveEta,
estimated_total_seconds: effectiveTotal,
live_fps: effectiveFps,
eta_confidence: effectiveConfidence,
});
auroraUpdateQueuePosition(st.queue_position);
auroraUpdateStorage(st.storage);
auroraUpdateCancelButton(st.status, st.current_stage);
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = !(st.status === 'completed' || st.status === 'failed' || st.status === 'cancelled');
if (st.status === 'completed') {
const smartActive = smart && !['completed', 'failed', 'cancelled'].includes(String(smart.status || '').toLowerCase());
if (smartActive) {
if (!auroraResultCache) {
await auroraLoadResult(auroraJobId);
}
const kStat = smart?.kling?.status ? ` · Kling ${smart.kling.status}` : '';
auroraSetProgress(99, 'processing', `smart orchestration (${smart.phase || 'running'}${kStat})`);
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'none';
return;
}
auroraStopPolling();
await auroraLoadResult(auroraJobId);
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'none';
if (smart && smart.selected_stack) {
auroraChatAdd('assistant', `Smart run завершено. Selected stack: ${smart.selected_stack}.`);
}
await auroraRefreshJobs();
} else if (st.status === 'failed' || st.status === 'cancelled') {
auroraStopPolling();
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'none';
if (st.error_message) alert(`Aurora: ${st.error_message}`);
await auroraRefreshJobs();
}
} catch (e) {
auroraPollErrorCount += 1;
const msg = String(e?.message || e || 'status_poll_error');
if (auroraPollErrorCount >= AURORA_MAX_TRANSIENT_ERRORS) {
auroraStopPolling();
auroraSetProgress(auroraLastProgress || 0, 'error', msg);
alert(`Aurora polling failed: ${msg}`);
return;
}
auroraSetProgress(
auroraLastProgress || 1,
'processing',
`transient status error (${auroraPollErrorCount}/${AURORA_MAX_TRANSIENT_ERRORS})`,
);
} finally {
auroraPollInFlight = false;
}
}
async function auroraStart() {
if (!auroraSelectedFile) {
alert('Оберіть файл для обробки');
return;
}
const analysisControls = auroraCollectAnalysisControls();
const smartCfg = auroraSmartConfig();
const fd = new FormData();
fd.append('file', auroraSelectedFile);
fd.append('mode', auroraMode);
fd.append('priority', analysisControls.priority || auroraSuggestedPriority || 'balanced');
const uiExport = auroraCollectExportOptions();
const analysisExport = auroraBuildAnalysisExportHints(analysisControls);
const mergedExport = { ...auroraSuggestedExport, ...uiExport, ...analysisExport };
fd.append('export_options', JSON.stringify(mergedExport));
if (smartCfg.enabled) {
fd.append('strategy', smartCfg.strategy || 'auto');
fd.append('prefer_quality', smartCfg.prefer_quality ? 'true' : 'false');
fd.append('budget_tier', smartCfg.budget_tier || 'normal');
fd.append('learning_enabled', 'true');
}
_auroraLogLines = [];
const startBtn = document.getElementById('auroraStartBtn');
const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn');
if (startBtn) startBtn.disabled = true;
if (quickStartBtn) quickStartBtn.disabled = true;
try {
const endpoint = smartCfg.enabled ? '/api/aurora/process-smart' : '/api/aurora/upload';
const r = await fetch(`${API}${endpoint}`, {
method: 'POST',
body: fd,
});
if (!r.ok) {
const body = await r.text();
throw new Error(body || `HTTP ${r.status}`);
}
const data = await r.json();
const localJobId = data.local_job_id || data.job_id;
if (!localJobId) {
throw new Error('job_id missing in response');
}
auroraSetActiveJobId(localJobId);
if (smartCfg.enabled) {
auroraSetSmartRunId(data.smart_run_id || null);
const policyStrategy = data?.policy?.strategy || smartCfg.strategy || 'auto';
const policyScore = Number(data?.policy?.score);
const scoreTxt = Number.isFinite(policyScore) ? ` (${policyScore.toFixed(2)})` : '';
auroraSetSmartPolicyText(`${policyStrategy}${scoreTxt}`);
auroraChatAdd('assistant', `Smart run ${data.smart_run_id || '—'}: strategy=${policyStrategy}`);
} else {
auroraSetSmartRunId(null);
auroraSmartStatusCache = null;
auroraSetSmartPolicyText('manual local');
}
auroraStatusCache = null;
auroraResultCache = null;
auroraPollErrorCount = 0;
auroraLastProgress = 1;
auroraPollInFlight = false;
auroraUpdateTiming(0, null, (auroraAnalysisCache || {}).estimated_processing_seconds || null);
auroraUpdateLivePerf(null, null);
auroraUpdateQueuePosition(null);
auroraUpdateStorage(null);
document.getElementById('auroraResultCard').style.display = 'none';
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = true;
auroraSetProgress(1, 'processing', smartCfg.enabled ? 'dispatching smart orchestration' : 'dispatching');
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) cancelBtn.style.display = 'inline-block';
auroraStopPolling();
auroraPollTimer = setInterval(auroraPollStatus, 2000);
await auroraPollStatus();
await auroraRefreshJobs();
} catch (e) {
alert(`Aurora start error: ${e.message || e}`);
auroraSetProgress(0, 'failed', 'upload_error');
} finally {
if (startBtn) startBtn.disabled = !auroraSelectedFile;
if (quickStartBtn) quickStartBtn.disabled = !auroraSelectedFile;
}
}
async function auroraCancel() {
if (!auroraJobId) return;
const cancelBtn = document.getElementById('auroraCancelBtn');
if (cancelBtn) {
cancelBtn.style.display = 'inline-block';
cancelBtn.disabled = true;
cancelBtn.textContent = 'Зупиняю...';
}
try {
await fetch(`${API}/api/aurora/cancel/${encodeURIComponent(auroraJobId)}`, { method: 'POST' });
await auroraPollStatus();
await auroraRefreshJobs();
} catch (_) {
auroraUpdateCancelButton('processing', null);
}
}
async function auroraLoadResult(jobId) {
try {
const [resR, cmpR] = await Promise.allSettled([
fetch(`${API}/api/aurora/result/${encodeURIComponent(jobId)}`),
fetch(`${API}/api/aurora/compare/${encodeURIComponent(jobId)}`),
]);
let data = {};
if (resR.status === 'fulfilled' && resR.value.ok) data = await resR.value.json();
let compare = null;
if (cmpR.status === 'fulfilled' && cmpR.value.ok) compare = await cmpR.value.json();
auroraResultCache = data;
auroraRenderResult(data, compare);
} catch (e) {
console.error('Aurora result error:', e);
}
}
async function auroraTryLoadForensicLog(fileUrl) {
try {
const r = await fetch(`${API}${fileUrl}`);
if (!r.ok) return null;
return await r.text();
} catch (_) {
return null;
}
}
function _auroraFmtSize(mb) {
if (mb == null) return '—';
if (mb >= 1024) return `${(mb/1024).toFixed(1)} GB`;
return `${mb.toFixed(1)} MB`;
}
function _auroraCompareRow(label, before, after, highlight) {
const bv = before != null ? String(before) : '—';
const av = after != null ? String(after) : '—';
const cls = highlight ? ' style="color:var(--gold);"' : '';
return `<tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
<td style="padding:3px 6px; color:var(--muted);">${label}</td>
<td style="padding:3px 6px; text-align:right;">${bv}</td>
<td style="padding:3px 6px; text-align:right;"${cls}>${av}</td>
</tr>`;
}
async function auroraRenderResult(data, compare) {
const card = document.getElementById('auroraResultCard');
if (!card) return;
card.style.display = 'block';
document.getElementById('auroraResultMode').textContent = data.mode || '—';
document.getElementById('auroraInputHash').textContent = (data.input_file || {}).hash || '—';
document.getElementById('auroraDigitalSignature').textContent = data.digital_signature || '—';
auroraUpdateStorage(data.storage || (auroraStatusCache || {}).storage || null);
const fallbackQuality = (!data.quality_report && auroraJobId)
? await auroraLoadQualityReport(auroraJobId, false)
: null;
auroraRenderQualityReport(data.quality_report || fallbackQuality);
const reportRow = document.getElementById('auroraForensicReportRow');
const reportLink = document.getElementById('auroraForensicReportLink');
if (data.mode === 'forensic' && data.forensic_report_url && reportRow && reportLink) {
reportRow.style.display = 'flex';
reportLink.href = `${API}${data.forensic_report_url}`;
} else if (reportRow) {
reportRow.style.display = 'none';
}
const files = Array.isArray(data.output_files) ? data.output_files : [];
const mainFile = files.find(f => f.type === 'video' || f.type === 'photo');
const links = files.map(f => {
const name = auroraEsc(f.name || f.type || 'artifact');
const href = f.url ? `${API}${auroraEsc(f.url)}` : '#';
const icon = f.type === 'video' ? '🎬' : f.type === 'photo' ? '🖼' : '📄';
return `<a href="${href}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:4px;">${icon} ${name}</a>`;
}).join('');
document.getElementById('auroraOutputLinks').innerHTML = links || '<div class="aurora-note">Немає output файлів</div>';
const dlBtn = document.getElementById('auroraDownloadResultBtn');
if (dlBtn && mainFile && mainFile.url) {
dlBtn.style.display = 'inline-flex';
dlBtn.setAttribute('data-url', `${API}${mainFile.url}`);
dlBtn.setAttribute('data-name', mainFile.name || 'aurora_result');
} else if (dlBtn) {
dlBtn.style.display = 'none';
}
const reBtn = document.getElementById('auroraReprocessBtn');
if (reBtn) reBtn.disabled = !auroraJobId;
const tbl = document.getElementById('auroraCompareTable');
const tbody = document.getElementById('auroraCompareRows');
if (tbl && tbody && compare && compare.before && compare.after) {
const b = compare.before;
const a = compare.after;
let rows = '';
rows += _auroraCompareRow('Файл', b.file_name, a.file_name, false);
rows += _auroraCompareRow('Резолюція', b.resolution, a.resolution, b.resolution !== a.resolution);
rows += _auroraCompareRow('Розмір', _auroraFmtSize(b.file_size_mb), _auroraFmtSize(a.file_size_mb), false);
rows += _auroraCompareRow('Кодек', b.codec, a.codec, b.codec !== a.codec);
rows += _auroraCompareRow('Тривалість', b.duration_s != null ? `${b.duration_s}s` : '—', a.duration_s != null ? `${a.duration_s}s` : '—', false);
rows += _auroraCompareRow('FPS', b.fps, a.fps, false);
rows += _auroraCompareRow('Кадрів', b.frame_count, a.frame_count, false);
if (compare.elapsed_seconds) {
const m = Math.floor(compare.elapsed_seconds / 60);
const s = compare.elapsed_seconds % 60;
rows += _auroraCompareRow('Час обробки', '', `${m}хв ${s}с`, false);
}
tbody.innerHTML = rows;
tbl.style.display = 'block';
} else if (tbl) {
tbl.style.display = 'none';
}
const facesRow = document.getElementById('auroraFacesRow');
const facesEl = document.getElementById('auroraFacesCount');
if (facesRow && compare) {
const fc = compare.faces_detected || 0;
facesEl.textContent = fc > 0 ? `${fc} (покращено)` : '0 (без облич у цьому відео)';
facesEl.style.color = fc > 0 ? 'var(--ok)' : 'var(--muted)';
facesRow.style.display = 'flex';
} else if (facesRow) {
facesRow.style.display = 'none';
}
const stepsWrap = document.getElementById('auroraStepsWrap');
const stepsList = document.getElementById('auroraStepsList');
if (stepsWrap && stepsList && compare && compare.enhance_steps && compare.enhance_steps.length) {
stepsList.innerHTML = compare.enhance_steps.map(s => {
const ms = s.time_ms != null ? `${(s.time_ms/1000).toFixed(1)}s` : '';
return `<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.03);">
<span>${auroraEsc(s.step)} <span style="color:var(--muted);">(${auroraEsc(s.agent)} / ${auroraEsc(s.model)})</span></span>
<span style="color:var(--gold);">${ms}</span>
</div>`;
}).join('');
stepsWrap.style.display = 'block';
} else if (stepsWrap) {
stepsWrap.style.display = 'none';
}
const compareWrap = document.getElementById('auroraCompareWrap');
if (compareWrap) compareWrap.style.display = 'none';
const inputFile = data.input_file || {};
const outputImage = files.find(f => /\.(jpg|jpeg|png|webp|tif)/i.test(f.name || '') && f.url);
const framePreview = (compare && typeof compare.frame_preview === 'object') ? compare.frame_preview : null;
if (framePreview?.before_url && framePreview?.after_url) {
auroraShowCompare(auroraAbsoluteUrl(framePreview.before_url), auroraAbsoluteUrl(framePreview.after_url));
} else if (inputFile.url && outputImage) {
const beforeUrl = auroraAbsoluteUrl(inputFile.url);
const afterUrl = auroraAbsoluteUrl(outputImage.url);
auroraShowCompare(beforeUrl, afterUrl);
}
auroraRenderDetections(compare);
const forensicWrap = document.getElementById('auroraForensicLogWrap');
const forensicPre = document.getElementById('auroraForensicLog');
if (data.mode === 'forensic') {
const logFile = files.find(f => (f.type || '').toLowerCase() === 'forensic_log');
const content = logFile ? await auroraTryLoadForensicLog(logFile.url) : null;
if (forensicWrap) forensicWrap.style.display = 'block';
if (forensicPre) forensicPre.textContent = content || 'forensic_log недоступний';
} else {
if (forensicWrap) forensicWrap.style.display = 'none';
if (forensicPre) forensicPre.textContent = '';
}
// ── Plates card ──
if (auroraJobId && (data.media_type === 'video' || data.media_type === 'photo')) {
auroraLoadPlates(auroraJobId);
}
// ── Kling card ── show for completed video jobs
const klingCard = document.getElementById('auroraKlingCard');
if (klingCard && data.media_type === 'video') {
klingCard.style.display = 'block';
// Check if Kling task was already submitted
auroraKlingCheckExisting();
} else if (klingCard) {
klingCard.style.display = 'none';
}
}
function auroraDownloadResult() {
const btn = document.getElementById('auroraDownloadResultBtn');
if (!btn) return;
const url = btn.getAttribute('data-url');
const name = btn.getAttribute('data-name') || 'aurora_result';
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = name;
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Plates (ALPR) ───────────────────────────────────────────────────────────
async function auroraLoadPlates(jobId) {
const card = document.getElementById('auroraPlatesCard');
const list = document.getElementById('auroraPlatesList');
const cnt = document.getElementById('auroraPlatesCount');
const note = document.getElementById('auroraPlatesNote');
if (!card || !list) return;
try {
const r = await fetch(`${API}/api/aurora/plates/${encodeURIComponent(jobId)}`);
if (!r.ok) return;
const data = await r.json();
const plates = data.unique || [];
cnt.textContent = plates.length;
if (plates.length === 0) {
list.innerHTML = '<div class="aurora-note">Номерних знаків не виявлено</div>';
note.style.display = 'none';
} else {
list.innerHTML = plates.map(p => {
const conf = p.confidence != null ? ` <span style="color:var(--muted);font-size:0.72rem;">${(p.confidence*100).toFixed(0)}%</span>` : '';
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 6px;background:rgba(245,166,35,0.07);border-radius:6px;margin-bottom:4px;">
<span style="font-family:monospace;font-size:0.85rem;font-weight:700;letter-spacing:2px;color:var(--gold);">${auroraEsc(p.text || '—')}</span>${conf}
<button onclick="navigator.clipboard.writeText('${auroraEsc(p.text || '')}');this.textContent='✓'" style="margin-left:auto;font-size:0.68rem;background:transparent;border:1px solid var(--border);border-radius:4px;padding:1px 6px;cursor:pointer;color:var(--muted);">Copy</button>
</div>`;
}).join('');
if (data.frames_sampled) {
note.textContent = `Просканировано ${data.frames_sampled} кадрів, всього ${data.plates_found} зчитувань`;
note.style.display = 'block';
}
}
card.style.display = 'block';
} catch (e) {
console.warn('Plates load error:', e);
}
}
// ── Kling AI ────────────────────────────────────────────────────────────────
let klingPollTimer = null;
async function auroraKlingCheckExisting() {
if (!auroraJobId) return;
try {
const r = await fetch(`${API}/api/aurora/kling/status/${encodeURIComponent(auroraJobId)}`);
if (!r.ok) return;
const d = await r.json();
_klingRenderStatus(d);
} catch (_) {}
}
async function auroraKlingSubmit() {
if (!auroraJobId) return;
const btn = document.getElementById('klingSubmitBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Надсилання...'; }
try {
const form = new FormData();
form.append('job_id', auroraJobId);
form.append('prompt', document.getElementById('klingPrompt')?.value || '');
form.append('negative_prompt', document.getElementById('klingNegative')?.value || '');
form.append('mode', document.getElementById('klingMode')?.value || 'pro');
form.append('duration', document.getElementById('klingDuration')?.value || '5');
form.append('cfg_scale', '0.5');
const r = await fetch(`${API}/api/aurora/kling/enhance`, { method:'POST', body:form });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || JSON.stringify(d));
_klingRenderStatus(d);
_klingStartPoll();
} catch (e) {
_klingSetStatus(`${e.message || e}`, 'var(--error)');
if (btn) { btn.disabled = false; btn.textContent = '🚀 Надіслати в Kling AI'; }
}
}
async function auroraKlingCheck() {
if (!auroraJobId) return;
try {
const r = await fetch(`${API}/api/aurora/kling/status/${encodeURIComponent(auroraJobId)}`);
const d = await r.json();
_klingRenderStatus(d);
if (!['succeed','completed','failed','error'].includes(d.status)) {
_klingStartPoll();
}
} catch (e) {
_klingSetStatus(`${e.message}`, 'var(--error)');
}
}
function _klingStartPoll() {
if (klingPollTimer) clearInterval(klingPollTimer);
klingPollTimer = setInterval(async () => {
if (!auroraJobId) { clearInterval(klingPollTimer); return; }
try {
const r = await fetch(`${API}/api/aurora/kling/status/${encodeURIComponent(auroraJobId)}`);
const d = await r.json();
_klingRenderStatus(d);
if (['succeed','completed','failed','error'].includes(d.status)) {
clearInterval(klingPollTimer);
klingPollTimer = null;
}
} catch (_) {}
}, 8000);
}
function _klingSetStatus(text, color) {
const el = document.getElementById('auroraKlingStatus');
if (el) { el.textContent = text; el.style.color = color || 'var(--muted)'; }
}
function _klingRenderStatus(d) {
const status = d.status || 'unknown';
const checkBtn = document.getElementById('klingCheckBtn');
const submitBtn = document.getElementById('klingSubmitBtn');
const prog = document.getElementById('auroraKlingProgress');
const bar = document.getElementById('klingProgressBar');
const progText = document.getElementById('klingProgressText');
const resultWrap = document.getElementById('klingResultWrap');
const resultInfo = document.getElementById('klingResultInfo');
const resultVideo = document.getElementById('klingResultVideo');
const colorMap = { succeed:'var(--ok)', completed:'var(--ok)', failed:'var(--error)', error:'var(--error)', processing:'var(--gold)', submitted:'var(--gold)' };
_klingSetStatus(status, colorMap[status] || 'var(--muted)');
if (['processing', 'submitted', 'waiting'].includes(status)) {
if (prog) { prog.style.display = 'block'; bar.style.width = '60%'; progText.textContent = 'Kling AI обробляє відео...'; }
if (checkBtn) checkBtn.style.display = 'inline-flex';
if (submitBtn) submitBtn.disabled = true;
} else if (['succeed', 'completed'].includes(status)) {
if (prog) prog.style.display = 'none';
if (checkBtn) checkBtn.style.display = 'none';
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '↻ Повторно надіслати'; }
const url = d.kling_result_url;
if (url && resultWrap) {
resultWrap.style.display = 'block';
resultInfo.innerHTML = `<a href="${auroraEsc(url)}" target="_blank" rel="noopener" style="color:var(--gold);">▶ Переглянути результат Kling AI</a>`;
if (resultVideo) { resultVideo.src = url; resultVideo.style.display = 'block'; }
}
} else if (['failed', 'error'].includes(status)) {
if (prog) prog.style.display = 'none';
if (checkBtn) checkBtn.style.display = 'none';
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '🔄 Повторити'; }
}
}
function auroraInitTab() {
auroraBindDropzone();
auroraRefreshHealth();
auroraUpdatePriorityLabel();
auroraUpdateReprocessLabel();
auroraSetSmartRunId(auroraSmartRunId);
if (!auroraSmartRunId) {
auroraSetSmartPolicyText('standby');
}
const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn');
if (quickStartBtn) quickStartBtn.disabled = !auroraSelectedFile;
if (!auroraTabBootstrapped) {
auroraSetActiveJobId(auroraJobId);
auroraResetAnalysisControls();
auroraTabBootstrapped = true;
}
if (auroraJobId) {
const cached = auroraGetPersistedTiming(auroraJobId);
if (cached) {
auroraUpdateTiming(null, cached.eta_seconds, cached.estimated_total_seconds);
auroraUpdateLivePerf(cached.live_fps, cached.eta_confidence);
} else {
auroraUpdateTiming(null, null, null);
auroraUpdateLivePerf(null, null);
}
} else {
auroraUpdateTiming(null, null, null);
auroraUpdateLivePerf(null, null);
}
auroraUpdateQueuePosition((auroraStatusCache || {}).queue_position || null);
auroraUpdateStorage((auroraStatusCache || {}).storage || null);
if (auroraSmartRunId) {
auroraPollSmartStatus({ quiet: true }).then((smart) => {
if (!smart || typeof smart !== 'object') return;
const localJob = smart?.local?.job_id || null;
if (!auroraJobId && localJob) {
auroraSetActiveJobId(localJob);
}
}).catch(() => {});
}
auroraRefreshJobs();
if (auroraJobId && !auroraPollTimer) {
auroraSetProgress(Math.max(1, auroraLastProgress || 0), 'processing', 'restoring previous job...');
auroraPollTimer = setInterval(auroraPollStatus, 2000);
auroraPollStatus();
}
if (!auroraChatHistory.length) {
auroraChatAdd('assistant', 'Aurora online. Можу пояснити ETA, місце збереження та запустити reprocess для поточного job.');
auroraRenderChatActions([
{ type: 'refresh_health', label: 'Перевірити сервіс' },
{ type: 'refresh_status', label: 'Оновити статус' },
]);
}
}
function aistalkSetOutput(obj) {
const el = document.getElementById('aistalkRunOutput');
if (!el) return;
try {
el.textContent = JSON.stringify(obj || {}, null, 2);
} catch (_) {
el.textContent = String(obj || '');
}
}
function aistalkSetRun(runId, status) {
aistalkRunId = runId || null;
const rid = document.getElementById('aistalkRunId');
if (rid) rid.textContent = aistalkRunId || '—';
const st = document.getElementById('aistalkRunStatus');
if (st) st.textContent = status || 'idle';
const cancelBtn = document.getElementById('aistalkCancelBtn');
if (cancelBtn) cancelBtn.disabled = !aistalkRunId || ['succeeded', 'failed', 'canceled'].includes(String(status || '').toLowerCase());
}
function aistalkUseAuroraContext() {
const input = document.getElementById('aistalkObjective');
const inputJson = document.getElementById('aistalkInputJson');
if (!input) return;
const parts = [];
if (auroraJobId) parts.push(`Aurora job_id=${auroraJobId}`);
if (auroraStatusCache && auroraStatusCache.status) parts.push(`status=${auroraStatusCache.status}`);
if (auroraStatusCache && auroraStatusCache.stage) parts.push(`stage=${auroraStatusCache.stage}`);
if (auroraStatusCache && Number.isFinite(Number(auroraStatusCache.progress))) parts.push(`progress=${Number(auroraStatusCache.progress)}%`);
if (!parts.length) {
input.value = 'Перевірити стан Aurora pipeline і дати рекомендації для next forensic steps.';
if (inputJson) inputJson.value = JSON.stringify({ service: 'aurora-service', env: 'prod', include_traces: false }, null, 2);
return;
}
input.value = `Перевірити Aurora pipeline: ${parts.join(', ')}. Дати короткий triage + наступні кроки.`;
if (inputJson) {
const payload = { service: 'aurora-service', env: 'prod', include_traces: false };
if (auroraJobId) payload.aurora_job_id = auroraJobId;
inputJson.value = JSON.stringify(payload, null, 2);
}
}
function aistalkApplyTemplate(name) {
const graphSel = document.getElementById('aistalkGraphSelect');
const objective = document.getElementById('aistalkObjective');
const inputJson = document.getElementById('aistalkInputJson');
if (!graphSel || !objective || !inputJson) return;
const templates = {
aurora_triage: {
graph: 'incident_triage',
objective: 'Перевірити Aurora pipeline, оцінити ризики та запропонувати next actions.',
input: { service: 'aurora-service', env: 'prod', include_traces: false },
},
aurora_release: {
graph: 'release_check',
objective: 'Release gate check для aurora-service перед продакшн змінами.',
input: { service_name: 'aurora-service', run_deps: true, run_drift: true, run_smoke: false },
},
alerts_sweep: {
graph: 'alert_triage',
objective: 'Запустити deterministic alert triage loop для поточних алертів.',
input: { dry_run: false, policy_profile: 'default' },
},
postmortem: {
graph: 'postmortem_draft',
objective: 'Побудувати postmortem для інциденту (вкажіть incident_id).',
input: { incident_id: 'inc_XXXXXXXX', service: 'aurora-service', env: 'prod', include_traces: false },
},
};
const t = templates[name];
if (!t) return;
graphSel.value = t.graph;
objective.value = t.objective;
inputJson.value = JSON.stringify(t.input, null, 2);
}
async function aistalkRefreshStatus() {
try {
const r = await fetch(`${API}/api/aistalk/status`, { cache: 'no-store' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
aistalkRuntimeCache = d.runtime || null;
const ad = d.adapter || {};
const relay = d.relay_health || {};
document.getElementById('aistalkAdapterState').textContent = ad.enabled ? `enabled (${ad.base_url || 'n/a'})` : 'disabled';
document.getElementById('aistalkRelayState').textContent = relay.ok ? `ok (${relay.url || 'relay'})` : `offline (${relay.error || 'unreachable'})`;
const sup = d.supervisor || {};
document.getElementById('aistalkSupervisorState').textContent = sup.healthy ? `ok (${sup.url || 'n/a'})` : `offline (${sup.error || 'unreachable'})`;
const graphs = Array.isArray(sup.graphs) ? sup.graphs : [];
document.getElementById('aistalkGraphsState').textContent = graphs.length ? graphs.join(', ') : '—';
if (graphs.length) {
const sel = document.getElementById('aistalkGraphSelect');
if (sel) {
const current = sel.value;
sel.innerHTML = graphs.map(g => `<option value="${auroraEsc(g)}">${auroraEsc(g)}</option>`).join('');
if (graphs.includes(current)) sel.value = current;
}
}
const aur = d.aurora || {};
document.getElementById('aistalkAuroraState').textContent = aur.ok ? 'ok' : `offline (${aur.error || 'n/a'})`;
const rt = d.runtime || {};
const rs = rt.resources || {};
const lim = rt.limits || {};
const rec = rt.recommended || {};
const cpu = Number(rs.cpu_count || 0);
const mem = rs.memory_gb;
document.getElementById('aistalkResourceState').textContent = cpu ? `${cpu} CPU · ${mem || 'n/a'} GB` : '—';
document.getElementById('aistalkRunLimitState').textContent = `${lim.max_parallel_team_runs || '—'} (active ${rt.active_team_runs ?? 0})`;
document.getElementById('aistalkChatLimitState').textContent = `${lim.max_parallel_chat || '—'} (active ${rt.active_chat ?? 0})`;
const teamInput = document.getElementById('aistalkLimitTeamInput');
const chatInput = document.getElementById('aistalkLimitChatInput');
if (teamInput && Number.isFinite(Number(lim.max_parallel_team_runs))) teamInput.value = String(lim.max_parallel_team_runs);
if (chatInput && Number.isFinite(Number(lim.max_parallel_chat))) chatInput.value = String(lim.max_parallel_chat);
document.getElementById('aistalkRuleText').textContent = `rule: ${(rec.rule || 'resource-based concurrency')} [profile=${lim.profile || rec.profile || 'balanced'}]`;
aistalkPopulateChatSelectors();
aistalkLoadCatalog(true);
} catch (e) {
document.getElementById('aistalkSupervisorState').textContent = `error: ${e.message || e}`;
}
}
function aistalkRenderCatalog(data) {
const dWrap = document.getElementById('aistalkCapabilityDomains');
const aWrap = document.getElementById('aistalkAgentsGrid');
if (!dWrap || !aWrap) return;
const domains = Array.isArray(data?.domains) ? data.domains : [];
const agents = Array.isArray(data?.agents) ? data.agents : [];
dWrap.innerHTML = domains.length
? domains.map(d => {
const names = Array.isArray(d.agents) ? d.agents.join(', ') : '';
return `<div class="media-job"><b>${auroraEsc(d.name || d.id || 'domain')}</b><div style="margin-top:4px;color:var(--muted);">${auroraEsc(names)}</div></div>`;
}).join('')
: '<div class="aurora-note">No capability domains</div>';
const runtime = aistalkRuntimeCache || {};
const availableModels = Array.isArray(runtime.available_models) ? runtime.available_models : [];
const agentModels = runtime.agent_models || {};
aWrap.innerHTML = agents.length
? agents.map(a => {
const aid = String(a.id || '').toLowerCase();
const role = auroraEsc((a.summary || '').trim() || '—');
const caps = Array.isArray(a.capabilities) ? a.capabilities.filter(Boolean) : [];
const outsList = Array.isArray(a.outputs) ? a.outputs.filter(Boolean) : [];
const outs = outsList.length ? outsList.slice(0, 4).map(x => auroraEsc(x)).join(' · ') : '—';
const capsText = caps.length ? caps.slice(0, 4).map(x => auroraEsc(x)).join(' · ') : '—';
const bnd = Array.isArray(a.boundaries) && a.boundaries.length ? auroraEsc(a.boundaries[0]) : '—';
const selected = String(agentModels[aid] || '');
const modelOptions = availableModels.length
? availableModels.map(m => `<option value="${auroraEsc(m)}" ${m === selected ? 'selected' : ''}>${auroraEsc(m)}</option>`).join('')
: `<option value="${auroraEsc(selected || 'qwen3:14b')}" selected>${auroraEsc(selected || 'qwen3:14b')}</option>`;
return `
<div class="media-job">
<div><b>${auroraEsc(a.name || a.id || 'agent')}</b></div>
<div style="margin-top:4px;color:var(--muted);">${role}</div>
<div style="margin-top:6px;font-size:0.72rem;color:var(--muted);">Can do: ${capsText}</div>
<div style="margin-top:4px;font-size:0.72rem;color:var(--muted);">Output: ${outs}</div>
<div style="margin-top:4px;font-size:0.72rem;color:var(--muted);">Boundary: ${bnd}</div>
<div style="margin-top:6px;">
<select id="aistalkModelSel_${auroraEsc(aid)}" onchange="aistalkSetAgentModel('${auroraEsc(aid)}', this.value)"
style="width:100%;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:5px 8px;font-size:0.74rem;">
${modelOptions}
</select>
</div>
</div>
`;
}).join('')
: '<div class="aurora-note">No agents catalog</div>';
}
async function aistalkLoadCatalog(force=false) {
if (aistalkCatalogCache && !force) {
aistalkRenderCatalog(aistalkCatalogCache);
return;
}
try {
const r = await fetch(`${API}/api/aistalk/catalog`, { cache: 'no-store' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
aistalkCatalogCache = d;
aistalkRenderCatalog(d);
} catch (e) {
const dWrap = document.getElementById('aistalkCapabilityDomains');
const aWrap = document.getElementById('aistalkAgentsGrid');
if (dWrap) dWrap.innerHTML = `<div class="aurora-note">Catalog error: ${auroraEsc(e.message || e)}</div>`;
if (aWrap) aWrap.innerHTML = '<div class="aurora-note">Catalog unavailable</div>';
}
}
function aistalkPopulateChatSelectors() {
const rt = aistalkRuntimeCache || {};
const models = Array.isArray(rt.available_models) ? rt.available_models : [];
const agentModels = rt.agent_models || {};
const agents = Array.isArray(aistalkCatalogCache?.agents) ? aistalkCatalogCache.agents : [];
const agentSel = document.getElementById('aistalkChatAgentSelect');
const modelSel = document.getElementById('aistalkChatModelSelect');
if (agentSel) {
const current = agentSel.value;
agentSel.innerHTML = agents.length
? agents.map(a => `<option value="${auroraEsc(a.id)}">${auroraEsc(a.name || a.id)}</option>`).join('')
: '<option value="orchestrator_synthesis">orchestrator_synthesis</option>';
if (current && [...agentSel.options].some(o => o.value === current)) agentSel.value = current;
}
if (modelSel) {
const current = modelSel.value;
modelSel.innerHTML = models.length
? models.map(m => `<option value="${auroraEsc(m)}">${auroraEsc(m)}</option>`).join('')
: '<option value="qwen3:14b">qwen3:14b</option>';
if (current && [...modelSel.options].some(o => o.value === current)) modelSel.value = current;
else if (agentSel && agentModels[agentSel.value]) modelSel.value = agentModels[agentSel.value];
}
}
async function aistalkSetAgentModel(agentId, model) {
try {
const r = await fetch(`${API}/api/aistalk/runtime/model`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ agent_id: agentId, model }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
if (!aistalkRuntimeCache) aistalkRuntimeCache = {};
if (!aistalkRuntimeCache.agent_models) aistalkRuntimeCache.agent_models = {};
aistalkRuntimeCache.agent_models[agentId] = model;
aistalkPopulateChatSelectors();
} catch (e) {
alert(`AISTALK model set error: ${e.message || e}`);
}
}
async function aistalkSaveLimits() {
const team = Number((document.getElementById('aistalkLimitTeamInput') || {}).value || 1);
const chat = Number((document.getElementById('aistalkLimitChatInput') || {}).value || 2);
try {
const r = await fetch(`${API}/api/aistalk/runtime/limits`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ max_parallel_team_runs: team, max_parallel_chat: chat }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
await aistalkRefreshStatus();
} catch (e) {
alert(`AISTALK limits error: ${e.message || e}`);
}
}
function aistalkChatAdd(role, text) {
const log = document.getElementById('aistalkChatLog');
if (!log) return;
const row = document.createElement('div');
row.className = `aurora-chat-row ${role === 'user' ? 'user' : 'assistant'}`;
row.textContent = text || '';
log.appendChild(row);
log.scrollTop = log.scrollHeight;
}
function aistalkClearChat() {
const log = document.getElementById('aistalkChatLog');
if (log) log.innerHTML = '';
aistalkChatHistory = [];
aistalkChatSessionId = `aistalk_ui_${Math.random().toString(36).slice(2, 10)}`;
aistalkRefreshMemoryTimeline();
}
function aistalkFmtTs(ts) {
if (!ts) return '—';
try {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return String(ts);
return d.toLocaleString();
} catch (_) {
return String(ts);
}
}
function aistalkRenderMemoryTimeline(events, meta = '') {
const wrap = document.getElementById('aistalkMemoryTimeline');
const metaEl = document.getElementById('aistalkMemoryTimelineMeta');
if (metaEl) metaEl.textContent = meta || `session: ${aistalkChatSessionId}`;
if (!wrap) return;
const list = Array.isArray(events) ? events : [];
if (!list.length) {
wrap.innerHTML = '<div class="aurora-note">Памʼять порожня для цієї сесії.</div>';
return;
}
wrap.innerHTML = list.slice(0, 20).map((ev) => {
const roleRaw = String(ev.role || 'unknown').toLowerCase();
const role = roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'user' ? 'user' : roleRaw);
const content = auroraEsc(String(ev.content || ev.text || '').trim() || '—');
const ts = auroraEsc(aistalkFmtTs(ev.ts || ev.timestamp || ev.created_at));
const source = auroraEsc(String(ev.source || 'memory-service'));
return `
<div class="media-job" style="padding:8px 10px;">
<div style="display:flex;justify-content:space-between;gap:8px;">
<b>${auroraEsc(role)}</b>
<span class="aurora-note" style="margin:0;">${ts}</span>
</div>
<div style="margin-top:4px; white-space:pre-wrap; word-break:break-word;">${content}</div>
<div class="aurora-note" style="margin-top:4px;">source: ${source}</div>
</div>
`;
}).join('');
}
async function aistalkRefreshMemoryTimeline() {
const wrap = document.getElementById('aistalkMemoryTimeline');
if (wrap) wrap.innerHTML = '<div class="aurora-note">Loading timeline...</div>';
try {
const r = await fetch(
`${API}/api/memory/context?agent_id=aistalk&user_id=aistalk_user&session_id=${encodeURIComponent(aistalkChatSessionId)}&limit=20`,
{ headers: getAuthHeaders(), cache: 'no-store' },
);
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
const events = Array.isArray(d.events) ? d.events : [];
aistalkMemoryTimelineCache = events;
const fallback = d.fallback ? ` · ${d.fallback}` : '';
aistalkRenderMemoryTimeline(events, `session: ${aistalkChatSessionId}${fallback}`);
} catch (e) {
aistalkRenderMemoryTimeline([], `session: ${aistalkChatSessionId} · error`);
const metaEl = document.getElementById('aistalkMemoryTimelineMeta');
if (metaEl) metaEl.textContent = `session: ${aistalkChatSessionId} · error: ${String(e.message || e)}`;
}
}
function aistalkChatUseAssignedModel() {
const agentSel = document.getElementById('aistalkChatAgentSelect');
const modelSel = document.getElementById('aistalkChatModelSelect');
const assigned = (aistalkRuntimeCache?.agent_models || {})[(agentSel || {}).value || ''];
if (assigned && modelSel) modelSel.value = assigned;
}
async function aistalkSendChat() {
const btn = document.getElementById('aistalkChatSendBtn');
const input = document.getElementById('aistalkChatInput');
const agentSel = document.getElementById('aistalkChatAgentSelect');
const modelSel = document.getElementById('aistalkChatModelSelect');
const msg = (input?.value || '').trim();
if (!msg) return;
if (btn) btn.disabled = true;
aistalkChatAdd('user', msg);
if (input) input.value = '';
try {
const r = await fetch(`${API}/api/aistalk/chat`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
message: msg,
agent_id: (agentSel && agentSel.value) || 'orchestrator_synthesis',
model: (modelSel && modelSel.value) || null,
session_id: aistalkChatSessionId,
project_id: 'aistalk',
history: aistalkChatHistory.slice(-8),
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
const reply = d.response || 'AISTALK: empty reply';
aistalkChatAdd('assistant', reply);
aistalkChatHistory.push({ role: 'user', content: msg });
aistalkChatHistory.push({ role: 'assistant', content: reply });
aistalkChatHistory = aistalkChatHistory.slice(-20);
await aistalkRefreshMemoryTimeline();
} catch (e) {
aistalkChatAdd('assistant', `Помилка: ${e.message || e}`);
} finally {
if (btn) btn.disabled = false;
}
}
async function aistalkRelayTest() {
try {
const r = await fetch(`${API}/api/aistalk/relay/test`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ type: 'aistalk.ping', message: 'manual relay test from UI' }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
aistalkSetOutput(d);
await aistalkRefreshStatus();
} catch (e) {
alert(`AISTALK relay test error: ${e.message || e}`);
}
}
async function aistalkStartRun() {
const btn = document.getElementById('aistalkStartBtn');
if (btn) btn.disabled = true;
try {
const graph = (document.getElementById('aistalkGraphSelect') || {}).value || 'incident_triage';
const objective = (document.getElementById('aistalkObjective') || {}).value || '';
let input = {};
const rawInput = ((document.getElementById('aistalkInputJson') || {}).value || '').trim();
if (rawInput) {
try {
const parsed = JSON.parse(rawInput);
if (parsed && typeof parsed === 'object') input = parsed;
else throw new Error('input JSON має бути object');
} catch (e) {
throw new Error(`Invalid input JSON: ${e.message || e}`);
}
}
const body = { graph, objective, input };
const r = await fetch(`${API}/api/aistalk/team/run`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
const runId = d.run_id || d.id || null;
aistalkSetRun(runId, d.status || 'queued');
aistalkSetOutput(d);
if (aistalkPollTimer) clearInterval(aistalkPollTimer);
if (runId) {
aistalkPollTimer = setInterval(aistalkPollRun, 2500);
aistalkPollRun();
}
} catch (e) {
aistalkSetOutput({ error: String(e.message || e) });
alert(`AISTALK run error: ${e.message || e}`);
} finally {
if (btn) btn.disabled = false;
}
}
async function aistalkPollRun() {
if (!aistalkRunId) return;
try {
const r = await fetch(`${API}/api/aistalk/team/run/${encodeURIComponent(aistalkRunId)}`, { cache: 'no-store' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
const status = String(d.status || 'unknown');
aistalkSetRun(aistalkRunId, status);
aistalkSetOutput(d);
if (['succeeded', 'failed', 'canceled'].includes(status.toLowerCase())) {
if (aistalkPollTimer) {
clearInterval(aistalkPollTimer);
aistalkPollTimer = null;
}
}
} catch (e) {
aistalkSetOutput({ run_id: aistalkRunId, error: String(e.message || e) });
}
}
async function aistalkCancelRun() {
if (!aistalkRunId) return;
try {
const r = await fetch(`${API}/api/supervisor/runs/${encodeURIComponent(aistalkRunId)}/cancel`, {
method: 'POST',
headers: getAuthHeaders(),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
aistalkSetOutput(d);
await aistalkPollRun();
} catch (e) {
alert(`AISTALK cancel error: ${e.message || e}`);
}
}
function aistalkInitTab() {
if (!aistalkTabBootstrapped) {
aistalkSetRun(null, 'idle');
const objective = document.getElementById('aistalkObjective');
const inputJson = document.getElementById('aistalkInputJson');
if (objective && !objective.value.trim() && inputJson && !inputJson.value.trim()) {
aistalkApplyTemplate('aurora_triage');
}
const chatLog = document.getElementById('aistalkChatLog');
if (chatLog && !chatLog.childElementCount) {
aistalkChatAdd('assistant', 'AISTALK online. Обери субагента і модель, потім надішли запит.');
}
const chatInput = document.getElementById('aistalkChatInput');
if (chatInput) {
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
aistalkSendChat();
}
});
}
aistalkTabBootstrapped = true;
}
aistalkRefreshStatus();
aistalkLoadCatalog();
aistalkRefreshMemoryTimeline();
if (aistalkRunId && !aistalkPollTimer) {
aistalkPollTimer = setInterval(aistalkPollRun, 2500);
aistalkPollRun();
}
}
function mediaSetStatus(text) {
const el = document.getElementById('mediaGenStatus');
if (el) el.textContent = text || '—';
}
function mediaSetHint(text) {
const el = document.getElementById('mediaGenHint');
if (el) el.textContent = text || '';
}
function mediaFmtProbe(probe) {
if (!probe || !probe.reachable) return 'offline';
if (probe.status === 200) return `ok (${probe.latency_ms || 'n/a'}ms)`;
return `http ${probe.status}`;
}
async function mediaRefreshHealth() {
try {
const r = await fetch(`${API}/api/media/health`, { cache: 'no-store' });
const d = await r.json();
const services = (d && d.services) || {};
document.getElementById('mediaHealthRouter').textContent = mediaFmtProbe(services.router);
document.getElementById('mediaHealthComfy').textContent = mediaFmtProbe(services.comfy_agent);
document.getElementById('mediaHealthComfyUi').textContent = mediaFmtProbe(services.comfy_ui);
document.getElementById('mediaHealthSwapper').textContent = mediaFmtProbe(services.swapper);
document.getElementById('mediaHealthImageGen').textContent = mediaFmtProbe(services.image_gen);
const models = Array.isArray(d.image_models) ? d.image_models : [];
const active = d.active_image_model || null;
document.getElementById('mediaImageModelsState').textContent = `${models.length} model(s)${active ? ` · active: ${active}` : ''}`;
} catch (e) {
document.getElementById('mediaHealthRouter').textContent = `error: ${e.message}`;
document.getElementById('mediaImageModelsState').textContent = 'error';
}
}
async function mediaLoadDefaultImageModel() {
const btn = document.getElementById('mediaLoadImageModelBtn');
if (btn) btn.disabled = true;
mediaSetStatus('Loading image model...');
mediaSetHint('');
try {
const r = await fetch(`${API}/api/media/models/image/load`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ model: 'flux-klein-4b' }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || `HTTP ${r.status}`);
mediaSetStatus('Image model loaded');
await mediaRefreshHealth();
} catch (e) {
mediaSetStatus(`Load model error: ${e.message}`);
mediaSetHint('Перевір swapper logs або використай fallback image-gen-service.');
} finally {
if (btn) btn.disabled = false;
}
}
function mediaJobHtml(job) {
const provider = auroraEsc(job.provider || 'unknown');
const prompt = auroraEsc(job.prompt || '');
const status = auroraEsc(job.status || 'unknown');
const dur = Number(job.duration_ms || 0);
const resultRaw = job.result;
const result = auroraEsc(typeof resultRaw === 'string' ? resultRaw : JSON.stringify(resultRaw || {}));
const err = auroraEsc(job.error || '');
return `
<div class="media-job">
<div><b>${auroraEsc(job.kind || 'job')}</b> · ${status} · ${provider}</div>
<div style="margin-top:3px;color:var(--muted);">${prompt}</div>
<div style="margin-top:3px;color:var(--muted);">duration: ${dur}ms</div>
${result ? `<div style="margin-top:4px;word-break:break-word;">${result}</div>` : ''}
${err ? `<div style="margin-top:4px;color:#ff8d8d;">${err}</div>` : ''}
</div>
`;
}
async function mediaRefreshJobs() {
const wrap = document.getElementById('mediaRecentJobs');
const count = document.getElementById('mediaRecentJobsCount');
if (wrap) wrap.innerHTML = '<div class="aurora-note">Оновлення...</div>';
try {
const r = await fetch(`${API}/api/media/jobs?limit=20`, { cache: 'no-store' });
const d = await r.json();
const jobs = Array.isArray(d.jobs) ? d.jobs : [];
if (count) count.textContent = `${jobs.length} jobs`;
if (wrap) {
wrap.innerHTML = jobs.length
? jobs.map(mediaJobHtml).join('')
: '<div class="aurora-note">Немає jobs</div>';
}
} catch (e) {
if (count) count.textContent = 'error';
if (wrap) wrap.innerHTML = `<div class="aurora-note">Помилка: ${auroraEsc(e.message)}</div>`;
}
}
async function mediaGenerateImage() {
const prompt = (document.getElementById('mediaPrompt')?.value || '').trim();
if (!prompt) return alert('Вкажи prompt');
const btn = document.getElementById('mediaGenerateImageBtn');
const width = Number(document.getElementById('mediaWidth')?.value || 1024);
const height = Number(document.getElementById('mediaHeight')?.value || 1024);
const steps = Number(document.getElementById('mediaSteps')?.value || 28);
if (btn) btn.disabled = true;
mediaSetHint('');
mediaSetStatus('Генерація image...');
try {
const r = await fetch(`${API}/api/media/generate/image`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ prompt, width, height, steps }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
mediaSetStatus('Image job completed');
await mediaRefreshJobs();
} catch (e) {
mediaSetStatus(`Image error: ${e.message}`);
const msg = String(e.message || '').toLowerCase();
if (msg.includes('image model not found')) {
mediaSetHint('У swapper не активна image модель. Натисни "Load image model".');
} else if (msg.includes('all image providers failed')) {
mediaSetHint('Comfy та image-gen-service недоступні, а swapper не має моделі або зламана.');
} else if (msg.includes('name or service not known') || msg.includes('connection')) {
mediaSetHint('Один із backend сервісів недоступний по мережі/DNS.');
}
} finally {
if (btn) btn.disabled = false;
}
}
async function mediaGenerateVideo() {
const prompt = (document.getElementById('mediaPrompt')?.value || '').trim();
if (!prompt) return alert('Вкажи prompt');
const btn = document.getElementById('mediaGenerateVideoBtn');
if (btn) btn.disabled = true;
mediaSetHint('');
mediaSetStatus('Генерація video...');
try {
const r = await fetch(`${API}/api/media/generate/video`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ prompt, seconds: 4, fps: 24, steps: 30 }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
mediaSetStatus('Video job completed');
await mediaRefreshJobs();
} catch (e) {
mediaSetStatus(`Video error: ${e.message}`);
mediaSetHint('Video fallback у swapper працює лише якщо налаштований xAI ключ.');
} finally {
if (btn) btn.disabled = false;
}
}
function mediaInitTab() {
mediaRefreshHealth();
mediaRefreshJobs();
if (!mediaTabBootstrapped) {
mediaSetStatus('Готово');
mediaTabBootstrapped = true;
}
}
// ── Sidebar ───────────────────────────────────────────────────────────────
function toggleSidebar() {
const s = document.getElementById('projectSidebar');
s.classList.toggle('collapsed');
}
async function loadSidebarProjects() {
try {
const r = await fetch(API + '/api/projects');
if (!r.ok) return;
_projectsCache = await r.json();
const el = document.getElementById('sidebarProjectList');
el.innerHTML = _projectsCache.map(p => `
<div class="project-item ${p.project_id === _currentProjectId ? 'active' : ''}"
onclick="selectProject('${p.project_id}', '${p.name.replace(/'/g,"\\'")}')">
<span>📁</span>
<span class="project-name">${p.name}</span>
</div>
`).join('');
} catch(e) {}
}
function selectProject(pid, name) {
_currentProjectId = pid;
_activeProjectId = pid;
_activeProjectName = name;
_saveSession();
loadSidebarProjects();
}
function showNewProjectDialog() {
const name = prompt('Назва проєкту:');
if (!name || !name.trim()) return;
const desc = prompt('Опис (необовʼязково):') || '';
fetch(API + '/api/projects', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: name.trim(), description: desc }),
}).then(r => r.json()).then(p => {
selectProject(p.project_id, p.name);
loadSidebarProjects();
loadProjects();
alert('Проєкт створено: ' + p.name);
}).catch(e => alert('Помилка: ' + e));
}
// ── Projects section ──────────────────────────────────────────────────────
async function loadProjects() {
try {
const r = await fetch(API + '/api/projects');
if (!r.ok) return;
_projectsCache = await r.json();
document.getElementById('projectDetailView').style.display = 'none';
document.getElementById('projectsListView').style.display = '';
const grid = document.getElementById('projectsGrid');
if (!_projectsCache.length) {
grid.innerHTML = '<div style="color:var(--muted)">Проєктів немає. Натисніть "+ Новий проєкт".</div>';
return;
}
grid.innerHTML = _projectsCache.map(p => `
<div class="project-card" onclick="openProject('${p.project_id}', '${p.name.replace(/'/g,"\\'")}')">
<h3>📁 ${p.name}</h3>
<p>${p.description || 'Без опису'}</p>
<div class="meta">Оновлено: ${p.updated_at}</div>
</div>
`).join('');
} catch(e) {
document.getElementById('projectsGrid').innerHTML = '<div style="color:var(--err)">Помилка завантаження</div>';
}
}
async function openProject(pid, name) {
_activeProjectId = pid;
_activeProjectName = name;
document.getElementById('projectsListView').style.display = 'none';
document.getElementById('projectDetailView').style.display = '';
document.getElementById('projectDetailName').textContent = '📁 ' + name;
switchProjectTab(null, 'docs');
loadProjectDocs(pid);
}
function showProjectsList() {
document.getElementById('projectDetailView').style.display = 'none';
document.getElementById('projectsListView').style.display = '';
}
// ── Projects / Agents mode toggle ─────────────────────────────────────────────
let _projectsMode = 'workspace'; // 'workspace' | 'agents'
let _selectedAgent = null; // {node_id, agent_id, ...}
function projectsSwitchMode(mode) {
_projectsMode = mode;
const wsBtn = document.getElementById('modeWorkspaceBtn');
const agBtn = document.getElementById('modeAgentsBtn');
const wsActions = document.getElementById('workspaceActions');
const agActions = document.getElementById('agentsActions');
const wsView = document.getElementById('projectsListView');
const agView = document.getElementById('agentsView');
const detail = document.getElementById('projectDetailView');
if (mode === 'workspace') {
wsBtn.style.background = 'var(--gold)'; wsBtn.style.color = '#0e0e12';
agBtn.style.background = 'var(--bg2)'; agBtn.style.color = 'var(--muted)';
wsActions.style.display = ''; agActions.style.display = 'none';
agView.style.display = 'none';
if (detail.style.display === 'none') wsView.style.display = '';
} else {
agBtn.style.background = 'var(--gold)'; agBtn.style.color = '#0e0e12';
wsBtn.style.background = 'var(--bg2)'; wsBtn.style.color = 'var(--muted)';
agActions.style.display = 'flex'; wsActions.style.display = 'none';
wsView.style.display = 'none'; detail.style.display = 'none';
agView.style.display = 'flex';
agentsLoad();
}
}
// ── Agents view ───────────────────────────────────────────────────────────────
let _agentsPlanId = null; // last computed plan_id for safe apply
let _agentsCurrentNodes = 'NODA1'; // currently selected nodes string
let _lastCanaryRunId = null; // bulk_run_id from last canary for continue rollout
let _agentsAllItems = []; // full unfiltered list from last fetch (for client-side filters)
function agentsSetNode(nodes) {
_agentsCurrentNodes = nodes;
// Update chip styles
['NODA1','NODA2','ALL'].forEach(chip => {
const el = document.getElementById(`nodeChip${chip}`);
if (!el) return;
const isActive = (chip === 'ALL' ? 'NODA1,NODA2' : chip) === nodes;
el.style.background = isActive ? 'var(--gold)' : 'var(--bg2)';
el.style.color = isActive ? '#0e0e12' : 'var(--muted)';
el.style.fontWeight = isActive ? '600' : '400';
});
agentsLoad();
}
function agentsToggleDebug() {
const panel = document.getElementById('agentsDebugPanel');
if (!panel) return;
const isVisible = panel.style.display !== 'none';
panel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) agentsLoad(); // refresh debug data immediately on open
}
function agentsApplyFilters() {
const filterVoice = document.getElementById('filterVoice')?.checked;
const filterTelegram = document.getElementById('filterTelegram')?.checked;
let items = _agentsAllItems;
if (filterVoice) items = items.filter(a => a.capabilities?.voice);
if (filterTelegram) items = items.filter(a => a.capabilities?.telegram);
_agentsRenderList(items);
}
function _agentsRenderList(items) {
const listEl = document.getElementById('agentsList');
const statusEl = document.getElementById('agentsListStatus');
if (!items.length) {
if (listEl) listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Агентів не знайдено за фільтрами</div>';
return;
}
const byNode = {};
items.forEach(a => { (byNode[a.node_id] = byNode[a.node_id] || []).push(a); });
if (listEl) {
listEl.innerHTML = Object.entries(byNode).map(([nid, agents]) => `
<div style="font-size:0.65rem;text-transform:uppercase;letter-spacing:1px;color:var(--gold);padding:4px 4px 2px;margin-top:4px;">${nid}</div>
${agents.map(a => _agentsRenderListItem(a)).join('')}
`).join('');
}
if (statusEl) {
const driftCount = items.filter(a => a.drift).length;
statusEl.innerHTML = `${items.length} агентів`
+ (driftCount ? ` · <span style="color:var(--warn);">⚠ ${driftCount} drift</span>` : '');
}
}
function agentsToggleApplyMenu(e) {
e.stopPropagation();
const m = document.getElementById('applyMenu');
if (m) m.style.display = m.style.display === 'none' ? 'block' : 'none';
}
document.addEventListener('click', () => {
const m = document.getElementById('applyMenu');
if (m) m.style.display = 'none';
});
async function agentsLoad() {
const nodes = _agentsCurrentNodes || 'NODA1';
const listEl = document.getElementById('agentsList');
const statusEl = document.getElementById('agentsListStatus');
const bannerEl = document.getElementById('nodeErrorsBanner');
const latencyEl = document.getElementById('nodeLatencyBadges');
if (listEl) listEl.innerHTML = '<div style="color:var(--muted);font-size:0.75rem;">⟳ Завантаження...</div>';
if (bannerEl) bannerEl.style.display = 'none';
try {
const r = await fetch(`${API}/api/agents?nodes=${encodeURIComponent(nodes)}`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
const items = data.items || [];
const nodeErrors = data.node_errors || [];
const stats = data.stats || {};
const requiredMissing = data.required_missing_nodes || [];
const driftCount = items.filter(a => a.drift).length;
_agentsAllItems = items; // store for client-side filtering
// Monitor missing banner
const monitorBanner = document.getElementById('monitorMissingBanner');
const monitorMissingSpan = document.getElementById('monitorMissingNodes');
if (monitorBanner && monitorMissingSpan) {
if (requiredMissing.length) {
monitorMissingSpan.textContent = requiredMissing.map(m => m.node_id).join(', ');
monitorBanner.style.display = 'block';
} else {
monitorBanner.style.display = 'none';
}
}
// Debug panel update
const _dbg = id => document.getElementById(id);
if (_dbg('agentsDebugPanel') && _dbg('agentsDebugPanel').style.display !== 'none') {
const ts = new Date().toLocaleTimeString('uk-UA', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
if (_dbg('dbgFetchTs')) _dbg('dbgFetchTs').textContent = ts;
if (_dbg('dbgNodes')) _dbg('dbgNodes').textContent = nodes;
if (_dbg('dbgCount')) _dbg('dbgCount').textContent = items.length;
if (_dbg('dbgNodesOk')) _dbg('dbgNodesOk').textContent = stats.nodes_ok ?? '?';
if (_dbg('dbgNodesTotal')) _dbg('dbgNodesTotal').textContent = stats.nodes_total ?? '?';
if (_dbg('dbgErrors')) {
_dbg('dbgErrors').textContent = nodeErrors.length
? nodeErrors.map(e => `${e.node_id}:${e.error}`).join(', ')
: 'none';
}
}
// Latency badges
if (latencyEl) {
const badges = items.reduce((acc, a) => {
if (a.latency_ms != null && !acc[a.node_id]) acc[a.node_id] = a.latency_ms;
return acc;
}, {});
latencyEl.innerHTML = Object.entries(badges).map(([nid, ms]) =>
`<span style="background:var(--bg2);border:1px solid var(--border);border-radius:3px;padding:1px 5px;margin-right:3px;">${nid} ${ms}ms</span>`
).join('') + nodeErrors.map(e =>
`<span style="background:rgba(200,50,50,0.15);border:1px solid var(--err);border-radius:3px;padding:1px 5px;margin-right:3px;color:var(--err);">${e.node_id} offline</span>`
).join('');
}
if (statusEl) {
statusEl.innerHTML = `${items.length} агентів`
+ (stats.nodes_ok < stats.nodes_total ? ` · <span style="color:var(--warn);">${stats.nodes_ok}/${stats.nodes_total} nodes</span>` : '')
+ (driftCount ? ` · <span style="color:var(--warn);">⚠ ${driftCount} drift</span>` : '');
}
// Node errors banner (non-blocking)
if (nodeErrors.length && bannerEl) {
bannerEl.style.display = 'block';
bannerEl.innerHTML = '⚠️ Node errors (results may be partial): '
+ nodeErrors.map(e => `<b>${e.node_id}</b>: ${e.error}`).join(' · ')
+ ' <button class="btn btn-ghost btn-sm" style="margin-left:8px;" onclick="agentsLoad()">↻ Retry</button>';
}
if (!items.length && !nodeErrors.length) {
if (listEl) listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Агентів не знайдено</div>';
return;
}
// Render with active filters applied
agentsApplyFilters();
// Append offline node indicators below list
if (nodeErrors.length && listEl) {
nodeErrors.forEach(e => {
listEl.innerHTML += `<div style="font-size:0.72rem;color:var(--err);padding:4px 8px;background:rgba(200,50,50,0.08);border-radius:3px;margin-top:4px;">⚠️ <b>${e.node_id}</b> unreachable</div>`;
});
}
} catch(e) {
if (listEl) listEl.innerHTML = `<div style="color:var(--err);">Помилка: ${e.message}</div>`;
}
}
function _agentsRenderListItem(a) {
const statusDot = a.status === 'healthy' ? '🟢' : a.status === 'degraded' ? '🟡' : '🔴';
const isSelected = _selectedAgent && _selectedAgent.agent_id === a.agent_id && _selectedAgent.node_id === a.node_id;
const overrideBadge = a.has_override ? ' <span style="color:var(--accent);font-size:0.58rem;">✎</span>' : '';
const driftBadge = a.drift ? ' <span style="color:var(--warn);font-size:0.58rem;font-weight:700;">DRIFT</span>' : '';
const isInternal = a.visibility === 'internal' || a.telegram_mode === 'off';
const isPlanned = a.lifecycle_status === 'planned';
const caps = a.capabilities || {};
// Special badges from gateway
const badges = (a.badges || []);
const perNodeBadge = badges.includes('per-node') ? ' <span style="background:rgba(80,120,200,0.2);color:#7ab;font-size:0.55rem;border-radius:2px;padding:0 3px;">per-node</span>' : '';
const voiceBadge = caps.voice ? ' <span style="background:rgba(200,80,200,0.2);color:#d9a;font-size:0.62rem;border-radius:2px;padding:0 3px;" title="Voice capable">🎙</span>' : '';
const telegramBadge = caps.telegram ? ' <span style="background:rgba(50,150,220,0.15);color:#7be;font-size:0.62rem;border-radius:2px;padding:0 3px;" title="Telegram active">💬</span>' : '';
const internalBadge = isInternal ? ' <span style="background:rgba(100,100,100,0.3);color:var(--muted);font-size:0.55rem;border-radius:2px;padding:0 3px;">internal</span>' : '';
const plannedBadge = isPlanned ? ' <span style="background:rgba(255,200,0,0.15);color:var(--warn);font-size:0.55rem;border-radius:2px;padding:0 3px;">planned</span>' : '';
const allBadges = perNodeBadge + voiceBadge + telegramBadge + internalBadge + plannedBadge + overrideBadge + driftBadge;
return `<div id="agent-item-${a.node_id}-${a.agent_id}"
onclick="agentsSelectAgent(${JSON.stringify(a).replace(/"/g,'&quot;')})"
style="padding:5px 8px; border-radius:5px; cursor:pointer; font-size:0.79rem; display:flex; align-items:center; gap:5px;
background:${isSelected ? 'var(--bg2)' : 'transparent'}; border:1px solid ${isSelected ? 'var(--gold)' : 'transparent'};
margin-bottom:2px; opacity:${isPlanned ? '0.7' : '1'};"
onmouseover="this.style.background='var(--bg2)'" onmouseout="this.style.background='${isSelected ? 'var(--bg2)' : 'transparent'}'">
<span>${statusDot}</span>
<span style="flex:1;font-weight:${isSelected ? '600' : '400'};color:${isSelected ? 'var(--gold)' : isPlanned ? 'var(--muted)' : 'var(--text)'};">
${a.display_name || a.agent_id}${allBadges}
</span>
</div>`;
}
function agentsSelectAgent(agent) {
_selectedAgent = agent;
_agentsPlanId = null;
const allItems = document.querySelectorAll('[id^="agent-item-"]');
allItems.forEach(el => {
const isThis = el.id === `agent-item-${agent.node_id}-${agent.agent_id}`;
el.style.background = isThis ? 'var(--bg2)' : 'transparent';
el.style.borderColor = isThis ? 'var(--gold)' : 'transparent';
});
agentsRenderEditor(agent);
}
function agentsRenderEditor(agent) {
const el = document.getElementById('agentEditor');
if (!el) return;
const statusDot = agent.status === 'healthy' ? '🟢' : agent.status === 'degraded' ? '🟡' : '🔴';
const domains = (agent.domains || []).join(', ') || agent.domain || '';
const prompt = agent.system_prompt_md || '';
const driftBanner = agent.drift
? `<div style="background:rgba(255,165,0,0.12);border:1px solid var(--warn);border-radius:5px;padding:6px 10px;font-size:0.75rem;color:var(--warn);">
⚠️ <b>DRIFT</b> — локальний override не застосований. Desired hash: <code>${agent.desired_hash||'?'}</code> / Last applied: <code>${agent.last_applied_hash||'none'}</code>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;" onclick="agentsApply('${agent.node_id}','${agent.agent_id}',true)">🔍 Перевірити diff</button>
</div>` : '';
const appliedInfo = agent.last_applied_at
? `<span style="color:var(--ok);">✓ applied ${agent.last_applied_at.substring(0,16)}</span>`
: `<span style="color:var(--muted);">not yet applied</span>`;
el.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div>
<div style="font-size:1rem; font-weight:700; color:var(--gold);">${statusDot} ${agent.display_name || agent.agent_id}
${(agent.badges||[]).map(b => `<span style="background:rgba(80,120,200,0.2);color:#9ac;font-size:0.6rem;border-radius:3px;padding:1px 5px;margin-left:3px;">${b}</span>`).join('')}
${agent.lifecycle_status === 'planned' ? '<span style="background:rgba(255,200,0,0.15);color:var(--warn);font-size:0.62rem;border-radius:3px;padding:1px 5px;margin-left:3px;">planned</span>' : ''}
</div>
<div style="font-size:0.75rem; color:var(--muted);">${agent.node_id} · <code>${agent.agent_id}</code>
${agent.visibility && agent.visibility !== 'public' ? ` · <span style="color:var(--muted);">${agent.visibility}</span>` : ''}
${agent.telegram_mode === 'off' ? ' · <span style="color:var(--muted);">no-telegram</span>' : ''}
${agent.has_override ? ' · <span style="color:var(--accent);">override</span>' : ''}
· ${appliedInfo}
</div>
</div>
<div style="display:flex; gap:5px; flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="agentsSaveOverride('${agent.node_id}','${agent.agent_id}')">💾 Save</button>
<button class="btn btn-ghost btn-sm" onclick="agentsApply('${agent.node_id}','${agent.agent_id}',true)" id="btnDryRun">🔍 Diff</button>
<button class="btn btn-gold btn-sm" onclick="agentsApplyConfirmed('${agent.node_id}','${agent.agent_id}')" id="btnApply">🚀 Apply</button>
<button class="btn btn-ghost btn-sm" style="color:var(--muted);" onclick="agentsShowVersions('${agent.node_id}','${agent.agent_id}')">📜 Versions</button>
<button class="btn btn-ghost btn-sm" style="color:var(--muted);" onclick="agentsResetOverride('${agent.node_id}','${agent.agent_id}')">↺ Reset</button>
<button class="btn btn-ghost btn-sm" style="color:var(--muted);" onclick="agentsHideAgent('${agent.node_id}','${agent.agent_id}')">👁 Hide</button>
</div>
</div>
${driftBanner}
<div style="display:flex; flex-direction:column; gap:8px;">
<label style="font-size:0.78rem; color:var(--muted);">Display Name</label>
<input id="ae-name" type="text" value="${(agent.display_name||'').replace(/"/g,'&quot;')}"
style="padding:6px 10px; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:5px; font-size:0.85rem;">
<label style="font-size:0.78rem; color:var(--muted);">Domain / Tags</label>
<input id="ae-domain" type="text" value="${domains.replace(/"/g,'&quot;')}"
style="padding:6px 10px; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:5px; font-size:0.85rem;"
placeholder="dao, strategy, governance">
<label style="font-size:0.78rem; color:var(--muted);">System Prompt (Markdown)</label>
<textarea id="ae-prompt" rows="14"
style="padding:8px 10px; background:var(--bg2); border:1px solid var(--border); color:var(--text); border-radius:5px; font-size:0.78rem; font-family:monospace; resize:vertical;"
placeholder="Enter system prompt in Markdown...">${prompt.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</textarea>
</div>
<!-- Diff panel (shown after dry-run) -->
<div id="agentDiffPanel" style="display:none; background:var(--bg2); border:1px solid var(--border); border-radius:5px; padding:8px;">
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:4px;">Diff preview</div>
<pre id="agentDiffText" style="font-size:0.72rem; color:var(--text); overflow-x:auto; white-space:pre-wrap; max-height:200px; overflow-y:auto;"></pre>
<div id="agentDiffMeta" style="font-size:0.7rem; color:var(--muted); margin-top:4px;"></div>
</div>
<!-- Versions panel -->
<div id="agentVersionsPanel" style="display:none; border-top:1px solid var(--border); padding-top:8px;">
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--muted); margin-bottom:4px;">Version History</div>
<div id="agentVersionsList" style="font-size:0.75rem;"></div>
</div>
<div id="agentEditorResult" style="display:none; padding:8px; border-radius:5px; font-size:0.78rem;"></div>
<div style="border-top:1px solid var(--border); padding-top:8px;">
<div style="font-size:0.72rem; color:var(--muted);">
Status: <span style="color:${agent.status==='healthy'?'var(--ok)':'var(--warn)'};">${agent.status||'unknown'}</span>
· prompt_loaded: ${agent.prompt_loaded ? '✓' : '✗'}
· telegram: ${agent.telegram_token_configured ? '✓' : '✗'}
</div>
</div>
`;
}
async function agentsSaveOverride(nodeId, agentId) {
const name = (document.getElementById('ae-name') || {}).value || '';
const domain = (document.getElementById('ae-domain') || {}).value || '';
const prompt = (document.getElementById('ae-prompt') || {}).value || '';
const resultEl = document.getElementById('agentEditorResult');
try {
const r = await fetch(`${API}/api/agents/${nodeId}/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-API-Key': getApiKey() },
body: JSON.stringify({ display_name: name || null, domain: domain || null, system_prompt_md: prompt || null }),
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
_agentsPlanId = null; // reset plan_id after save
if (resultEl) { resultEl.style.display='block'; resultEl.style.background='rgba(0,200,100,0.1)'; resultEl.style.color='var(--ok)'; resultEl.textContent='✓ Збережено · версія: ' + (d.override?.version_hash||'?'); }
agentsLoad();
} catch(e) {
if (resultEl) { resultEl.style.display='block'; resultEl.style.background='rgba(200,0,0,0.1)'; resultEl.style.color='var(--err)'; resultEl.textContent='⚠️ ' + e.message; }
}
}
async function agentsApply(nodeId, agentId, dryRun) {
const resultEl = document.getElementById('agentEditorResult');
const diffPanel = document.getElementById('agentDiffPanel');
const diffText = document.getElementById('agentDiffText');
const diffMeta = document.getElementById('agentDiffMeta');
if (resultEl) { resultEl.style.display='block'; resultEl.style.background='var(--bg2)'; resultEl.style.color='var(--muted)'; resultEl.textContent='⟳ ' + (dryRun ? 'Обчислення diff...' : 'Applying...'); }
try {
const url = `${API}/api/agents/${nodeId}/${agentId}/apply?dry_run=${dryRun}` + (!dryRun && _agentsPlanId ? `&plan_id=${_agentsPlanId}` : '');
const r = await fetch(url, { method: 'POST', headers: { 'X-API-Key': getApiKey() } });
if (r.status === 409) {
const d = await r.json();
if (resultEl) { resultEl.style.color='var(--warn)'; resultEl.textContent='⚠️ Plan mismatch — зробіть Diff знову'; }
_agentsPlanId = null;
return;
}
if (r.status === 423) {
const d = await r.json();
if (resultEl) { resultEl.style.color='var(--err)'; resultEl.textContent='🔒 ' + (d.error || 'PROMPT_FREEZE gate active'); }
return;
}
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
if (dryRun) {
_agentsPlanId = d.plan_id;
if (diffPanel && diffText && diffMeta) {
diffPanel.style.display = 'block';
diffText.textContent = d.diff_text || '(no diff — prompts identical)';
diffMeta.textContent = `plan_id: ${d.plan_id} · will_change: ${d.will_change}`;
_colorDiff(diffText);
}
if (resultEl) { resultEl.style.color='var(--accent)'; resultEl.textContent = d.will_change ? `📋 Є зміни (plan_id: ${d.plan_id})` : '✓ Без змін'; }
} else {
const errs = d.errors || [];
if (resultEl) {
resultEl.style.color = errs.length ? 'var(--warn)' : 'var(--ok)';
resultEl.textContent = errs.length ? `⚠️ ${errs.map(e=>e.error).join('; ')}` : `✓ Applied (plan: ${d.plan_id})`;
}
if (!errs.length) agentsLoad();
}
} catch(e) {
if (resultEl) { resultEl.style.display='block'; resultEl.style.color='var(--err)'; resultEl.textContent='⚠️ ' + e.message; }
}
}
async function agentsApplyConfirmed(nodeId, agentId) {
if (!_agentsPlanId) {
// Run dry-run first, then ask to confirm
await agentsApply(nodeId, agentId, true);
const diffText = (document.getElementById('agentDiffText')||{}).textContent||'';
if (!diffText || diffText.includes('no diff')) return;
if (!confirm(`Застосувати зміни до ${agentId}@${nodeId}?\n\nplan_id: ${_agentsPlanId}`)) return;
} else {
if (!confirm(`Застосувати до ${agentId}@${nodeId}?\n\nplan_id: ${_agentsPlanId}`)) return;
}
await agentsApply(nodeId, agentId, false);
}
function _colorDiff(el) {
if (!el) return;
const lines = el.textContent.split('\n');
el.innerHTML = lines.map(l => {
if (l.startsWith('+')) return `<span style="color:var(--ok);">${l.replace(/</g,'&lt;')}</span>`;
if (l.startsWith('-')) return `<span style="color:var(--err);">${l.replace(/</g,'&lt;')}</span>`;
if (l.startsWith('@')) return `<span style="color:var(--accent);">${l.replace(/</g,'&lt;')}</span>`;
return `<span style="color:var(--muted);">${l.replace(/</g,'&lt;')}</span>`;
}).join('\n');
}
async function agentsShowVersions(nodeId, agentId) {
const panel = document.getElementById('agentVersionsPanel');
const listEl = document.getElementById('agentVersionsList');
if (!panel || !listEl) return;
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if (panel.style.display === 'none') return;
listEl.innerHTML = '⟳ Завантаження...';
try {
const r = await fetch(`${API}/api/agents/${nodeId}/${agentId}/versions?limit=8`, { headers: { 'X-API-Key': getApiKey() } });
const d = await r.json();
const versions = d.versions || [];
if (!versions.length) { listEl.innerHTML = '<span style="color:var(--muted);">Версій немає</span>'; return; }
listEl.innerHTML = versions.map((v,i) => `
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);">
<code style="font-size:0.7rem;color:var(--accent);">${v.version_hash}</code>
<span style="font-size:0.7rem;color:var(--muted);flex:1;">${(v.created_at||'').substring(0,16)}</span>
${i===0?'<span style="font-size:0.65rem;color:var(--ok);">current</span>':''}
<button class="btn btn-ghost btn-sm" style="font-size:0.65rem;padding:2px 6px;"
onclick="agentsRollback('${nodeId}','${agentId}','${v.version_hash}')">↺ Rollback</button>
</div>
`).join('');
} catch(e) {
listEl.innerHTML = `<span style="color:var(--err);">${e.message}</span>`;
}
}
async function agentsRollback(nodeId, agentId, versionHash) {
if (!confirm(`Rollback ${agentId} до версії ${versionHash}?`)) return;
const resultEl = document.getElementById('agentEditorResult');
try {
const r = await fetch(`${API}/api/agents/${nodeId}/${agentId}/rollback?version_hash=${versionHash}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
if (resultEl) { resultEl.style.display='block'; resultEl.style.color='var(--ok)'; resultEl.textContent=`↺ Rollback до ${versionHash} виконано`; }
_agentsPlanId = null;
agentsLoad();
} catch(e) {
if (resultEl) { resultEl.style.display='block'; resultEl.style.color='var(--err)'; resultEl.textContent='⚠️ ' + e.message; }
}
}
async function agentsResetOverride(nodeId, agentId) {
if (!confirm(`Скинути local override для ${agentId}?`)) return;
const r = await fetch(`${API}/api/agents/${nodeId}/${agentId}/reset`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
if (r.ok) {
_selectedAgent = null;
_agentsPlanId = null;
document.getElementById('agentEditor').innerHTML = '<div style="color:var(--ok);margin-top:40px;text-align:center;">↺ Override скинуто</div>';
agentsLoad();
}
}
async function agentsHideAgent(nodeId, agentId) {
await fetch(`${API}/api/agents/${nodeId}/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-API-Key': getApiKey() },
body: JSON.stringify({ is_hidden: true }),
});
_selectedAgent = null;
_agentsPlanId = null;
document.getElementById('agentEditor').innerHTML = '<div style="color:var(--muted);margin-top:40px;text-align:center;">👁 Агент прихований</div>';
agentsLoad();
}
// ── Bulk agent actions ─────────────────────────────────────────────────────────
async function agentsBulkApply(dryRun, mode, limit) {
const nodes = _agentsCurrentNodes || 'NODA1';
mode = mode || 'all';
limit = limit || 2;
const document_getElementById = id => document.getElementById(id);
// Close dropdown
const menu = document_getElementById('applyMenu');
if (menu) menu.style.display = 'none';
const modeLabel = dryRun ? '📋 Plan All' : (mode === 'canary' ? `🐣 Canary (${limit})` : '⚡ Apply All');
if (!dryRun && !confirm(`${modeLabel} на ${nodes}?`)) return;
const editorEl = document_getElementById('agentEditor');
if (editorEl) editorEl.innerHTML = `<div style="color:var(--muted);padding:20px;">⟳ ${modeLabel}...</div>`;
try {
const params = new URLSearchParams({ nodes, dry_run: dryRun, mode, limit });
const r = await fetch(`${API}/api/agents/bulk/apply?${params}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
const d = await r.json();
const results = d.results || [];
const nodeErrors = d.node_errors || [];
const summary = d.summary || {};
if (mode === 'canary' && !dryRun) {
_lastCanaryRunId = d.bulk_run_id;
const canaryBanner = document_getElementById('canaryContinueBanner');
const canaryMsg = document_getElementById('canaryContinueMsg');
const stopped = results.some(r => r.status === 'skipped' && r.error?.includes('canary stopped'));
const applied = results.filter(r => r.status === 'applied').length;
const failed = results.filter(r => r.status === 'failed').length;
if (canaryBanner && canaryMsg) {
if (!failed && applied > 0) {
canaryMsg.textContent = `✅ Canary успішний: ${applied} агентів. Продовжити повний rollout?`;
canaryBanner.style.display = 'flex';
} else if (stopped || failed) {
canaryMsg.innerHTML = `⚠️ Canary зупинено після помилки. Applied: ${applied}, Failed: ${failed}. Перевірте деталі.`;
canaryBanner.style.display = 'flex';
}
}
}
if (editorEl) {
editorEl.innerHTML = `
<div style="font-size:0.95rem;font-weight:700;color:var(--gold);margin-bottom:8px;">${modeLabel}${nodes}</div>
${nodeErrors.length ? `<div style="color:var(--err);font-size:0.75rem;margin-bottom:8px;">⚠️ Node errors: ${nodeErrors.map(e=>`<b>${e.node_id}</b>: ${e.error}`).join(' · ')}</div>` : ''}
<div style="font-size:0.78rem;color:var(--muted);margin-bottom:8px;">
${Object.entries(summary).map(([s,n]) => `<span style="background:var(--bg2);border-radius:3px;padding:2px 7px;margin-right:4px;">${s}: <b>${n}</b></span>`).join('')}
</div>
${results.length === 0 ? '<div style="color:var(--muted);">Немає overrides</div>' :
results.map(item => `
<div style="padding:6px 8px;border:1px solid ${item.status==='failed'?'var(--err)':item.status==='blocked'?'var(--warn)':item.drift?'rgba(255,165,0,0.3)':'var(--border)'};border-radius:5px;margin-bottom:4px;display:flex;align-items:center;gap:8px;">
<code style="font-size:0.78rem;color:var(--text);min-width:100px;">${item.agent_id}</code>
<span style="font-size:0.7rem;color:var(--bg2);background:${item.status==='applied'?'var(--ok)':item.status==='failed'?'var(--err)':item.status==='blocked'?'var(--warn)':'var(--muted)'};padding:1px 6px;border-radius:3px;">${item.status}</span>
<span style="font-size:0.68rem;color:var(--muted);">${item.node_id}</span>
${item.drift ? '<span style="font-size:0.65rem;color:var(--warn);">DRIFT</span>' : ''}
${item.error ? `<span style="font-size:0.68rem;color:var(--err);">${item.error}</span>` : ''}
</div>`).join('')}
`;
}
if (!dryRun && mode === 'all') setTimeout(() => agentsLoad(), 600);
} catch(e) {
if (editorEl) editorEl.innerHTML = `<div style="color:var(--err);">⚠️ ${e.message}</div>`;
}
}
async function agentsCanaryContinue() {
const banner = document.getElementById('canaryContinueBanner');
if (banner) banner.style.display = 'none';
if (!confirm('Продовжити: Apply All на всіх нодах?')) return;
await agentsBulkApply(false, 'all');
}
async function agentsBulkDiff() {
const nodes = _agentsCurrentNodes || 'NODA1';
const editorEl = document.getElementById('agentEditor');
if (editorEl) editorEl.innerHTML = '<div style="color:var(--muted);padding:20px;">⟳ Збираємо drift report...</div>';
try {
const r = await fetch(`${API}/api/agents/bulk/diff?nodes=${encodeURIComponent(nodes)}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
const d = await r.json();
const report = d.report || [];
const nodeErrors = d.node_errors || [];
if (editorEl) {
editorEl.innerHTML = `
<div style="font-size:0.95rem;font-weight:700;color:var(--gold);margin-bottom:8px;">📊 Drift Report — ${nodes}</div>
${nodeErrors.length ? `<div style="color:var(--err);font-size:0.75rem;margin-bottom:8px;">⚠️ ${nodeErrors.map(e=>`${e.node_id}: ${e.error}`).join(' · ')}</div>` : ''}
${report.length === 0 ? '<div style="color:var(--muted);">Немає overrides</div>' : report.map(item => `
<div style="padding:8px;border:1px solid ${item.drift?'var(--warn)':'var(--border)'};border-radius:5px;margin-bottom:6px;">
<div style="display:flex;align-items:center;gap:8px;">
<code style="font-size:0.78rem;color:var(--text);">${item.agent_id}</code>
<span style="font-size:0.65rem;color:var(--muted);">${item.node_id}</span>
${item.drift ? '<span style="color:var(--warn);font-size:0.72rem;font-weight:700;">DRIFT</span>' : '<span style="color:var(--ok);font-size:0.72rem;">in sync</span>'}
<span style="font-size:0.68rem;color:var(--muted);">lines: ${item.diff_lines}</span>
</div>
${item.diff_text ? `<pre style="margin-top:4px;font-size:0.7rem;color:var(--muted);max-height:80px;overflow:auto;">${item.diff_text.replace(/</g,'&lt;')}</pre>` : ''}
</div>
`).join('')}
`;
}
} catch(e) {
if (editorEl) editorEl.innerHTML = `<div style="color:var(--err);">⚠️ ${e.message}</div>`;
}
}
async function agentsExportPrompts() {
const nodes = _agentsCurrentNodes || 'NODA1';
try {
const r = await fetch(`${API}/api/agents/export/prompts?nodes=${encodeURIComponent(nodes)}`, { headers: { 'X-API-Key': getApiKey() } });
const d = await r.json();
const blob = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `agent-prompts-${nodes.replace(',','-')}-${new Date().toISOString().substring(0,10)}.json`;
a.click(); URL.revokeObjectURL(url);
} catch(e) {
alert('Export failed: ' + e.message);
}
}
function switchProjectTab(btn, tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
else {
const b = document.querySelector(`.tab-btn[data-ptab="${tab}"]`);
if (b) b.classList.add('active');
}
const tabs = ['docs', 'board', 'meetings', 'sessions', 'map'];
tabs.forEach(t => {
const el = document.getElementById(`ptab-${t}`);
if (el) el.style.display = t === tab ? '' : 'none';
});
if (tab === 'sessions') loadProjectSessions(_activeProjectId);
if (tab === 'map') { loadMapSessionOptions(_activeProjectId); loadProjectDialogMap(); }
if (tab === 'board') loadKanbanBoard(_activeProjectId);
if (tab === 'meetings') loadMeetings(_activeProjectId);
}
// ── Documents ─────────────────────────────────────────────────────────────
async function loadProjectDocs(pid) {
const el = document.getElementById('docsList');
el.innerHTML = '<div style="color:var(--muted); font-size:0.82rem;">Завантаження...</div>';
try {
const r = await fetch(API + '/api/projects/' + pid + '/documents?limit=50');
if (!r.ok) throw new Error('HTTP ' + r.status);
const docs = await r.json();
if (!docs.length) {
el.innerHTML = '<div style="color:var(--muted); font-size:0.82rem;">Документів немає. Натисніть 📎 для завантаження.</div>';
return;
}
el.innerHTML = docs.map(d => {
const icon = d.mime?.startsWith('image') ? '🖼' : d.mime === 'application/pdf' ? '📄' : d.mime?.includes('spreadsheet') || d.mime?.includes('excel') ? '📊' : '📝';
const sizeKB = Math.round((d.size_bytes || 0) / 1024);
return `<div class="doc-row">
<span class="doc-icon">${icon}</span>
<span class="doc-name" title="${d.filename}">${d.title || d.filename}</span>
<span class="doc-meta">${sizeKB}KB · ${d.created_at?.slice(0,10)}</span>
<a href="${API}/api/files/${d.file_id}/download" target="_blank" style="font-size:0.75rem; color:var(--accent); text-decoration:none;">↓</a>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div style="color:var(--err); font-size:0.82rem;">Помилка: ${e.message}</div>`;
}
}
let _searchTimer = null;
function searchProjectDocs() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(async () => {
const q = document.getElementById('docSearchInput').value.trim();
if (!q) { loadProjectDocs(_activeProjectId); return; }
try {
const r = await fetch(API + '/api/projects/' + _activeProjectId + '/search', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ query: q }),
});
const data = await r.json();
const el = document.getElementById('docsList');
if (!data.documents?.length) { el.innerHTML = '<div style="color:var(--muted)">Нічого не знайдено</div>'; return; }
el.innerHTML = data.documents.map(d => `
<div class="doc-row">
<span class="doc-name">${d.filename}</span>
<span class="doc-meta">${d.mime} · ${d.created_at?.slice(0,10)}</span>
</div>
`).join('');
} catch(e) {}
}, 400);
}
// ── File Upload ───────────────────────────────────────────────────────────
function triggerFileUpload() {
document.getElementById('fileUploadInput').click();
}
async function handleFileUpload(input) {
const file = input.files[0];
if (!file) return;
const progress = document.getElementById('uploadProgress');
progress.style.display = 'inline';
progress.textContent = `${file.name}...`;
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch(
`${API}/api/files/upload?project_id=${encodeURIComponent(_currentProjectId)}&title=${encodeURIComponent(file.name)}`,
{ method: 'POST', body: fd }
);
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || 'Upload failed');
}
const data = await r.json();
progress.textContent = `${data.filename} (${Math.round(data.size_bytes/1024)}KB)`;
// Paste file reference into chat
const ta = document.getElementById('chatInput');
ta.value += (ta.value ? '\n' : '') + `[Файл: ${data.filename}] ${data.preview_text ? '— ' + data.preview_text.slice(0,80) : ''}`;
// Reload docs if we're in docs tab
if (_activeProjectId === _currentProjectId) loadProjectDocs(_activeProjectId);
setTimeout(() => { progress.style.display = 'none'; }, 4000);
} catch(e) {
progress.textContent = `${e.message}`;
setTimeout(() => { progress.style.display = 'none'; }, 5000);
}
input.value = '';
}
// ── Sessions ──────────────────────────────────────────────────────────────
async function loadProjectSessions(pid) {
const el = document.getElementById('sessionsList');
el.innerHTML = '<div style="color:var(--muted); font-size:0.82rem;">Завантаження...</div>';
try {
const r = await fetch(API + '/api/sessions?project_id=' + pid + '&limit=30');
const sessions = await r.json();
if (!sessions.length) { el.innerHTML = '<div style="color:var(--muted)">Сесій немає</div>'; return; }
el.innerHTML = sessions.map(s => `
<div class="session-row" onclick="resumeSession('${s.session_id}', '${(s.title||s.session_id).replace(/'/g,"\\'")}')">
<span>💬</span>
<span class="s-title">${s.title || s.session_id}</span>
<span class="s-meta">${s.turn_count || 0} повідомлень · ${s.last_active?.slice(0,10)}</span>
</div>
`).join('');
} catch(e) {
el.innerHTML = `<div style="color:var(--err)">Помилка: ${e.message}</div>`;
}
}
async function resumeSession(sid, title) {
// Switch to chat tab and load history
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelector('[data-tab="chat"]').classList.add('active');
document.getElementById('section-chat').classList.add('active');
_currentSessionId = sid;
_saveSession();
try {
const r = await fetch(API + '/api/chat/history?session_id=' + sid + '&limit=50');
const data = await r.json();
const log = document.getElementById('chatLog');
log.innerHTML = '';
chatHistory = [];
for (const msg of (data.messages || [])) {
addMsg(msg.content, msg.role === 'user' ? 'user' : 'ai');
chatHistory.push({ role: msg.role, content: msg.content });
}
log.scrollTop = log.scrollHeight;
addMsg(`— Відновлено сесію: ${title} (${data.count} повідомлень) —`, 'system');
} catch(e) {
addMsg('Помилка відновлення сесії: ' + e.message, 'system');
}
}
async function loadMapSessionOptions(pid) {
try {
const r = await fetch(API + '/api/sessions?project_id=' + pid + '&limit=30');
const sessions = await r.json();
const sel = document.getElementById('mapSessionSelect');
sel.innerHTML = '<option value="">Оберіть сесію...</option>' +
sessions.map(s => `<option value="${s.session_id}">${s.title || s.session_id} (${s.turn_count} turns)</option>`).join('');
if (sessions.length > 0) {
sel.value = sessions[0].session_id;
loadDialogMap();
}
} catch(e) {}
}
// ── Dialog Map ────────────────────────────────────────────────────────────
async function loadDialogMap() {
const sid = document.getElementById('mapSessionSelect').value;
if (!sid) return;
const container = document.getElementById('dialogMapContainer');
container.innerHTML = '<div style="color:var(--muted)">Завантаження карти...</div>';
try {
const r = await fetch(API + '/api/sessions/' + sid + '/map');
const data = await r.json();
container.innerHTML = renderDialogTree(data);
} catch(e) {
container.innerHTML = `<div style="color:var(--err)">Помилка: ${e.message}</div>`;
}
}
function renderDialogTree(mapData) {
const { nodes, edges } = mapData;
if (!nodes || !nodes.length) return '<div style="color:var(--muted)">Немає повідомлень</div>';
// Build child map
const childrenOf = {};
const nodeById = {};
for (const n of nodes) { nodeById[n.id] = n; childrenOf[n.id] = []; }
for (const e of edges) {
if (e.from && childrenOf[e.from]) childrenOf[e.from].push(e.to);
}
// Find roots (no parent)
const allChildren = new Set(edges.map(e => e.to));
const roots = nodes.filter(n => !allChildren.has(n.id));
function renderNode(id, depth) {
const n = nodeById[id];
if (!n) return '';
const cls = n.role === 'user' ? 'user' : 'assistant';
const icon = n.role === 'user' ? '👤' : '🤖';
const kids = childrenOf[id] || [];
const kidsHtml = kids.map(kid => renderNode(kid, depth+1)).join('');
return `<details class="map-node ${cls}" ${depth < 2 ? 'open' : ''}>
<summary>
${icon} <span class="map-node-preview">${n.preview}</span>
<span class="map-node-ts">${n.ts?.slice(11,16)}</span>
<button class="fork-btn" onclick="event.stopPropagation(); forkFromNode('${id}')" title="Розгалузити від цього повідомлення">⎇</button>
</summary>
${kidsHtml ? '<div class="map-children">' + kidsHtml + '</div>' : ''}
</details>`;
}
return roots.map(r => renderNode(r.id, 0)).join('');
}
async function forkFromNode(msgId) {
const sid = document.getElementById('mapSessionSelect').value;
if (!sid) return;
const newTitle = prompt('Назва нової сесії (Enter для продовження):') || '';
try {
const r = await fetch(API + '/api/sessions/' + sid + '/fork', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ from_msg_id: msgId, new_title: newTitle, project_id: _activeProjectId }),
});
const data = await r.json();
alert(`Нова сесія створена: ${data.new_session_id} (${data.copied_turns} повідомлень)`);
loadMapSessionOptions(_activeProjectId);
} catch(e) {
alert('Помилка форку: ' + e.message);
}
}
// ── Kanban Board ──────────────────────────────────────────────────────────────
let _kanbanTasks = [];
async function loadKanbanBoard(projectId) {
if (!projectId) return;
try {
const r = await fetch(`${API}/api/projects/${projectId}/tasks?limit=200`);
if (!r.ok) return;
_kanbanTasks = await r.json();
renderKanban();
const label = document.getElementById('boardStatsLabel');
if (label) label.textContent = `${_kanbanTasks.length} задач`;
} catch(e) { console.error('loadKanbanBoard:', e); }
}
function renderKanban() {
const statuses = ['backlog', 'in_progress', 'review', 'done'];
statuses.forEach(s => {
const el = document.getElementById(`tasks-${s}`);
if (!el) return;
const tasks = _kanbanTasks.filter(t => t.status === s);
el.innerHTML = tasks.map(t => taskCardHTML(t)).join('');
});
}
function taskCardHTML(t) {
const labelsHTML = (t.labels || []).map(l => `<span class="task-label">${l}</span>`).join('');
const dueStr = t.due_at ? `📅 ${t.due_at.slice(0,10)}` : '';
const nextStatus = {backlog:'in_progress', in_progress:'review', review:'done', done:'backlog'};
const nextLabel = {backlog:'→ В роботу', in_progress:'→ Review', review:'→ Готово', done:'→ Беклог'};
return `<div class="task-card task-priority-${t.priority}" onclick="showTaskDetail('${t.task_id}')">
<div class="task-title">${escHTML(t.title)}</div>
<div class="task-meta">${labelsHTML}${dueStr ? `<span class="task-label">${dueStr}</span>` : ''}</div>
<button class="task-status-btn" onclick="event.stopPropagation(); moveTask('${t.task_id}','${nextStatus[t.status]}')">${nextLabel[t.status]}</button>
</div>`;
}
async function moveTask(taskId, newStatus) {
try {
const pid = _activeProjectId;
await fetch(`${API}/api/projects/${pid}/tasks/${taskId}`, {
method: 'PATCH',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ status: newStatus }),
});
await loadKanbanBoard(pid);
} catch(e) { console.error('moveTask:', e); }
}
function showTaskDetail(taskId) {
const t = _kanbanTasks.find(x => x.task_id === taskId);
if (!t) return;
const msg = `Задача: ${t.title}\nОпис: ${t.description || '—'}\nСтатус: ${t.status}\nПріоритет: ${t.priority}\nДедлайн: ${t.due_at || '—'}`;
alert(msg);
}
function showCreateTaskModal() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'taskModal';
modal.innerHTML = `<div class="modal-box">
<h3> Нова задача</h3>
<label>Назва *</label>
<input type="text" id="taskTitle" placeholder="Що потрібно зробити?">
<label>Опис</label>
<textarea id="taskDesc" placeholder="Деталі..."></textarea>
<label>Статус</label>
<select id="taskStatus">
<option value="backlog">📋 Беклог</option>
<option value="in_progress">🔄 В роботі</option>
<option value="review">👁 Review</option>
<option value="done">✅ Готово</option>
</select>
<label>Пріоритет</label>
<select id="taskPriority">
<option value="low">🟢 Низький</option>
<option value="normal" selected>🔵 Нормальний</option>
<option value="high">🟠 Високий</option>
<option value="urgent">🔴 Терміновий</option>
</select>
<label>Дедлайн (опційно)</label>
<input type="date" id="taskDue">
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('taskModal').remove()">Скасувати</button>
<button class="btn btn-gold" onclick="submitCreateTask()">Створити</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
setTimeout(() => document.getElementById('taskTitle')?.focus(), 50);
}
async function submitCreateTask() {
const title = document.getElementById('taskTitle')?.value.trim();
if (!title) { alert('Назва обов\'язкова'); return; }
const body = {
title,
description: document.getElementById('taskDesc')?.value || '',
status: document.getElementById('taskStatus')?.value || 'backlog',
priority: document.getElementById('taskPriority')?.value || 'normal',
due_at: document.getElementById('taskDue')?.value || null,
};
try {
const r = await fetch(`${API}/api/projects/${_activeProjectId}/tasks`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (r.ok) {
document.getElementById('taskModal')?.remove();
await loadKanbanBoard(_activeProjectId);
} else {
const err = await r.json();
alert('Помилка: ' + (err.detail || 'Unknown'));
}
} catch(e) { alert('Помилка: ' + e.message); }
}
// ── Meetings ──────────────────────────────────────────────────────────────────
let _meetingsList = [];
async function loadMeetings(projectId) {
if (!projectId) return;
try {
const r = await fetch(`${API}/api/projects/${projectId}/meetings?limit=50`);
if (!r.ok) return;
_meetingsList = await r.json();
renderMeetings();
} catch(e) { console.error('loadMeetings:', e); }
}
function renderMeetings() {
const el = document.getElementById('meetingsList');
if (!el) return;
if (!_meetingsList.length) {
el.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;padding:12px;">Зустрічей ще немає</div>';
return;
}
el.innerHTML = _meetingsList.map(m => {
const dt = new Date(m.starts_at);
const dtStr = isNaN(dt) ? m.starts_at : dt.toLocaleString('uk-UA', {dateStyle:'medium', timeStyle:'short'});
return `<div class="meeting-card">
<div class="meeting-title">${escHTML(m.title)}</div>
<div class="meeting-meta">
<span>📅 ${dtStr}</span>
<span>⏱ ${m.duration_min} хв</span>
${m.location ? `<span>📍 ${escHTML(m.location)}</span>` : ''}
${m.attendees?.length ? `<span>👥 ${m.attendees.length}</span>` : ''}
</div>
${m.agenda ? `<div style="font-size:0.8rem;color:var(--text);margin-top:6px;">${escHTML(m.agenda.slice(0,200))}</div>` : ''}
</div>`;
}).join('');
}
function showCreateMeetingModal() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'meetingModal';
modal.innerHTML = `<div class="modal-box">
<h3>📅 Нова зустріч</h3>
<label>Назва *</label>
<input type="text" id="mtgTitle" placeholder="Назва зустрічі">
<label>Дата і час *</label>
<input type="datetime-local" id="mtgStartsAt">
<label>Тривалість (хв)</label>
<input type="number" id="mtgDuration" value="30" min="5" max="480">
<label>Місце / Посилання</label>
<input type="text" id="mtgLocation" placeholder="Zoom / офіс / ...">
<label>Порядок денний</label>
<textarea id="mtgAgenda" placeholder="Що обговорюємо?"></textarea>
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('meetingModal').remove()">Скасувати</button>
<button class="btn btn-gold" onclick="submitCreateMeeting()">Створити</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
setTimeout(() => document.getElementById('mtgTitle')?.focus(), 50);
}
async function submitCreateMeeting() {
const title = document.getElementById('mtgTitle')?.value.trim();
const starts_at = document.getElementById('mtgStartsAt')?.value;
if (!title || !starts_at) { alert('Назва та час обов\'язкові'); return; }
const body = {
title,
starts_at: new Date(starts_at).toISOString(),
duration_min: parseInt(document.getElementById('mtgDuration')?.value || '30'),
location: document.getElementById('mtgLocation')?.value || '',
agenda: document.getElementById('mtgAgenda')?.value || '',
};
try {
const r = await fetch(`${API}/api/projects/${_activeProjectId}/meetings`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (r.ok) {
document.getElementById('meetingModal')?.remove();
await loadMeetings(_activeProjectId);
} else {
const err = await r.json();
alert('Помилка: ' + (err.detail || 'Unknown'));
}
} catch(e) { alert('Помилка: ' + e.message); }
}
// ── Dialog Map (Project Graph — SVG force layout) ─────────────────────────────
let _graphData = { nodes: [], edges: [] };
let _selectedNodeId = null;
const _NODE_COLORS = {
message: '#4a9eff', task: '#69b578', doc: '#a8d8ea',
meeting: '#f4a261', agent_run: '#c084fc', ops_run: '#fb923c',
repo_changeset: '#fbbf24', pull_request: '#34d399',
decision: '#f9a8d4', goal: '#fde68a',
};
const _NODE_ICONS = {
message: '💬', task: '✅', doc: '📄', meeting: '📅',
agent_run: '🤖', ops_run: '⚙️', repo_changeset: '🔧',
pull_request: '🔀', decision: '🎯', goal: '🏆',
};
async function loadProjectDialogMap() {
const pid = _activeProjectId;
if (!pid) return;
try {
const r = await fetch(`${API}/api/projects/${pid}/dialog-map`);
if (!r.ok) return;
_graphData = await r.json();
renderDialogGraph();
const stats = document.getElementById('mapStats');
if (stats) stats.textContent = `${_graphData.node_count} вузлів · ${_graphData.edge_count} зв'язків`;
} catch(e) { console.error('loadProjectDialogMap:', e); }
}
function renderDialogGraph() {
const svg = document.getElementById('dialogGraphSvg');
if (!svg) return;
const W = svg.clientWidth || 700, H = 500;
// Filter by node type
const filterEl = document.getElementById('mapNodeTypeFilter');
const allowed = filterEl
? Array.from(filterEl.selectedOptions).map(o => o.value)
: Object.keys(_NODE_COLORS);
// Importance threshold
const importanceMin = parseFloat(document.getElementById('importanceThreshold')?.value || '0.35');
// Lifecycle filter
const showArchived = document.getElementById('showArchivedNodes')?.checked || false;
const showLow = document.getElementById('showLowImportance')?.checked || false;
let nodes = (_graphData.nodes || []).filter(n => {
if (!allowed.includes(n.node_type)) return false;
const lifecycle = n.lifecycle || 'active';
if (!showArchived && (lifecycle === 'archived' || lifecycle === 'superseded' || lifecycle === 'invalid')) return false;
const importance = typeof n.importance === 'number' ? n.importance : 0.3;
if (!showLow && importance < importanceMin) return false;
return true;
});
const nodeIds = new Set(nodes.map(n => n.node_id));
const edges = (_graphData.edges || []).filter(e => nodeIds.has(e.from_node_id) && nodeIds.has(e.to_node_id));
if (!nodes.length) {
svg.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#666" font-size="14">
Графу ще немає. Створіть задачу, документ або зустріч.
</text>`;
return;
}
// Simple force-directed layout (no external lib)
const pos = {};
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length;
const r = Math.min(W, H) * 0.35;
pos[n.node_id] = {
x: W / 2 + r * Math.cos(angle),
y: H / 2 + r * Math.sin(angle),
};
});
// Simple spring simulation (5 iterations)
for (let iter = 0; iter < 8; iter++) {
// Repulsion
nodes.forEach(a => {
nodes.forEach(b => {
if (a.node_id === b.node_id) return;
const dx = pos[a.node_id].x - pos[b.node_id].x;
const dy = pos[a.node_id].y - pos[b.node_id].y;
const d = Math.sqrt(dx*dx + dy*dy) + 0.01;
const f = 2500 / (d * d);
pos[a.node_id].x += (dx / d) * f;
pos[a.node_id].y += (dy / d) * f;
});
});
// Attraction (edges)
edges.forEach(e => {
const a = pos[e.from_node_id], b = pos[e.to_node_id];
if (!a || !b) return;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx*dx + dy*dy) + 0.01;
const f = (d - 120) * 0.03;
a.x += (dx / d) * f; a.y += (dy / d) * f;
b.x -= (dx / d) * f; b.y -= (dy / d) * f;
});
// Keep within bounds
nodes.forEach(n => {
pos[n.node_id].x = Math.max(20, Math.min(W - 20, pos[n.node_id].x));
pos[n.node_id].y = Math.max(20, Math.min(H - 20, pos[n.node_id].y));
});
}
// Render
const defs = `<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#666"/>
</marker>
</defs>`;
const edgeSVG = edges.map(e => {
const a = pos[e.from_node_id], b = pos[e.to_node_id];
if (!a || !b) return '';
return `<line class="graph-edge ${e.edge_type}" x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}"/>`;
}).join('');
const nodeSVG = nodes.map(n => {
const p = pos[n.node_id];
const color = _NODE_COLORS[n.node_type] || '#888';
const icon = _NODE_ICONS[n.node_type] || '●';
const label = (n.title || n.node_type).slice(0, 18);
const selected = n.node_id === _selectedNodeId;
// Size by importance (7..14px range)
const imp = typeof n.importance === 'number' ? n.importance : 0.3;
const r = Math.round(7 + imp * 7);
const selR = r + 3;
// Lifecycle visual
const lifecycle = n.lifecycle || 'active';
const opacity = lifecycle === 'active' ? '0.9' : lifecycle === 'superseded' ? '0.45' : '0.25';
const strokeDash = (lifecycle === 'archived' || lifecycle === 'superseded') ? 'stroke-dasharray="3,2"' : '';
const lifecycleBadge = lifecycle !== 'active' ? `<text dy="-${r+2}" text-anchor="middle" font-size="7" fill="#aaa">${lifecycle}</text>` : '';
return `<g class="graph-node" onclick="selectGraphNode('${n.node_id}')" transform="translate(${p.x.toFixed(1)},${p.y.toFixed(1)})">
<circle r="${selected ? selR : r}" fill="${color}" stroke="${selected ? '#fff' : color}" stroke-width="${selected ? 2 : 1}" ${strokeDash} opacity="${opacity}"/>
${lifecycleBadge}
<text dy="${r+12}" text-anchor="middle" font-size="10">${escHTML(label)}</text>
</g>`;
}).join('');
svg.innerHTML = defs + edgeSVG + nodeSVG;
}
function selectGraphNode(nodeId) {
_selectedNodeId = nodeId;
const node = (_graphData.nodes || []).find(n => n.node_id === nodeId);
if (!node) return;
renderDialogGraph();
const detail = document.getElementById('mapNodeDetail');
const title = document.getElementById('mapNodeDetailTitle');
const body = document.getElementById('mapNodeDetailBody');
const agentRunPanel = document.getElementById('agentRunPanel');
if (detail && title && body) {
const lifecycle = node.lifecycle || 'active';
const lifecycleBadge = lifecycle !== 'active'
? `<span style="font-size:0.7rem; padding:1px 5px; border-radius:4px; background:rgba(255,255,255,0.1); margin-left:6px;">${lifecycle}</span>`
: '';
const importancePct = typeof node.importance === 'number' ? `${Math.round(node.importance * 100)}%` : '';
title.innerHTML = `${_NODE_ICONS[node.node_type] || '●'} ${escHTML(node.title || node.node_type)} <span style="color:var(--muted); font-size:0.75rem;">[${node.node_type}]</span>${lifecycleBadge}`;
const inEdges = (_graphData.edges || []).filter(e => e.to_node_id === nodeId);
const outEdges = (_graphData.edges || []).filter(e => e.from_node_id === nodeId);
body.innerHTML = `
<div style="color:var(--muted);font-size:0.78rem;margin-bottom:6px;">
ref: ${escHTML(node.ref_id)} · ${(node.created_at||'').slice(0,16)}
${importancePct ? `· ⚖ <b>${importancePct}</b>` : ''}
</div>
${node.summary ? `<div style="font-size:0.82rem;margin-bottom:8px;">${escHTML(node.summary.slice(0,300))}</div>` : ''}
${inEdges.length ? `<div style="font-size:0.78rem;color:var(--muted);">← вхідні: ${inEdges.map(e=>escHTML(e.edge_type)).join(', ')}</div>` : ''}
${outEdges.length ? `<div style="font-size:0.78rem;color:var(--muted);">→ вихідні: ${outEdges.map(e=>escHTML(e.edge_type)).join(', ')}</div>` : ''}
`;
// Show agent_run panel for agent_run nodes
if (agentRunPanel) {
if (node.node_type === 'agent_run') {
showAgentRunPanel(node);
} else {
agentRunPanel.style.display = 'none';
_selectedAgentRunId = null;
}
}
detail.style.display = '';
}
}
function createTaskFromNode() {
const node = (_graphData.nodes || []).find(n => n.node_id === _selectedNodeId);
if (!node) return;
document.getElementById('mapNodeDetail').style.display = 'none';
showCreateTaskModal();
setTimeout(() => {
const titleEl = document.getElementById('taskTitle');
if (titleEl) titleEl.value = `[${node.node_type}] ${node.title}`;
}, 100);
}
function openLinkModal() {
const fromNode = (_graphData.nodes || []).find(n => n.node_id === _selectedNodeId);
if (!fromNode) return;
const nodeOptions = (_graphData.nodes || [])
.filter(n => n.node_id !== _selectedNodeId)
.map(n => `<option value="${n.node_id}">${_NODE_ICONS[n.node_type]||'●'} ${n.title||n.node_type}</option>`)
.join('');
if (!nodeOptions) { alert('Немає інших вузлів для зв\'язку'); return; }
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'linkModal';
modal.innerHTML = `<div class="modal-box">
<h3>🔗 Зв'язати вузол</h3>
<div style="font-size:0.82rem; color:var(--muted); margin-bottom:10px;">Від: ${fromNode.title||fromNode.node_type}</div>
<label>До вузла</label>
<select id="linkToNode">${nodeOptions}</select>
<label>Тип зв'язку</label>
<select id="linkEdgeType">
<option value="references">references</option>
<option value="derives_task">derives_task</option>
<option value="updates_doc">updates_doc</option>
<option value="schedules_meeting">schedules_meeting</option>
<option value="resolves">resolves</option>
<option value="relates_to">relates_to</option>
<option value="supersedes">supersedes</option>
<option value="reflects_on">reflects_on</option>
<option value="produced_by">produced_by</option>
</select>
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('linkModal').remove()">Скасувати</button>
<button class="btn btn-gold" onclick="submitCreateLink('${fromNode.node_type}','${fromNode.ref_id}')">Зв'язати</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
}
async function submitCreateLink(fromType, fromId) {
const toNodeId = document.getElementById('linkToNode')?.value;
const edgeType = document.getElementById('linkEdgeType')?.value || 'references';
const toNode = (_graphData.nodes || []).find(n => n.node_id === toNodeId);
if (!toNode) return;
try {
await fetch(`${API}/api/projects/${_activeProjectId}/dialog/link`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
from_type: fromType, from_id: fromId,
to_type: toNode.node_type, to_id: toNode.ref_id,
edge_type: edgeType,
}),
});
document.getElementById('linkModal')?.remove();
await loadProjectDialogMap();
} catch(e) { alert('Помилка: ' + e.message); }
}
function escHTML(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Graph Intelligence ────────────────────────────────────────────────────
function updateImportanceLabel() {
const v = document.getElementById('importanceThreshold')?.value || '0.35';
const el = document.getElementById('importanceThresholdVal');
if (el) el.textContent = parseFloat(v).toFixed(2);
}
async function runHygiene(dryRun = true) {
if (!_activeProjectId) return;
const btn = event?.target;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
const r = await fetch(`${API}/api/projects/${_activeProjectId}/graph/hygiene/run`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ dry_run: dryRun, scope: 'all' }),
});
const data = await r.json();
const panel = document.getElementById('hygieneResult');
const body = document.getElementById('hygieneResultBody');
if (!panel || !body) return;
const s = data.stats || {};
const changes = data.changes || [];
const archived = changes.filter(c => c.action === 'archive_duplicate').length;
const importance_updates = changes.filter(c => c.action === 'update_importance').length;
body.innerHTML = `
<div style="display:flex; gap:16px; margin-bottom:6px; flex-wrap:wrap;">
<span>📊 Вузлів: <b>${s.nodes_scanned||0}</b></span>
<span>🔑 Fingerprints: <b>${s.fingerprints_computed||0}</b></span>
<span>🔍 Дублікатів: <b>${s.duplicates_found||0}</b></span>
<span>🗄 Archived: <b>${archived}</b></span>
<span>⚖ Importance: <b>${importance_updates}</b></span>
${dryRun ? '<span style="color:var(--gold);">🔍 DRY-RUN (нічого не збережено)</span>' : '<span style="color:#70f09a;">✅ Застосовано</span>'}
</div>
${archived > 0 ? `<details><summary style="cursor:pointer; font-size:0.76rem;">Archived дублікати (${archived})</summary>
<ul style="margin:4px 0; padding-left:16px;">
${changes.filter(c=>c.action==='archive_duplicate').slice(0,10).map(c=>`<li>${escHTML(c.title||c.node_id)} → <i>${c.new_lifecycle}</i></li>`).join('')}
</ul></details>` : ''}
`;
panel.style.display = 'block';
if (!dryRun) {
setTimeout(() => loadProjectDialogMap(), 500);
}
} catch(e) {
alert('Hygiene помилка: ' + e.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = dryRun ? '🔍 Dry-run' : '🧹 Hygiene'; }
}
}
// ─── Agent Run: Evidence + Reflection tabs ─────────────────────────────────
let _selectedAgentRunId = null;
function switchAgentRunTab(tab) {
document.getElementById('tabEvidence').classList.toggle('tab-active', tab === 'evidence');
document.getElementById('tabReflection').classList.toggle('tab-active', tab === 'reflection');
document.getElementById('evidencePanel').style.display = tab === 'evidence' ? 'block' : 'none';
document.getElementById('reflectionPanel').style.display = tab === 'reflection' ? 'block' : 'none';
document.getElementById('reflectionActions').style.display = tab === 'reflection' ? 'block' : 'none';
if (tab === 'reflection' && _selectedAgentRunId) {
loadReflection(_selectedAgentRunId);
}
}
function showAgentRunPanel(node) {
const panel = document.getElementById('agentRunPanel');
const evPanel = document.getElementById('evidencePanel');
const reflPanel = document.getElementById('reflectionPanel');
if (!panel) return;
_selectedAgentRunId = node.ref_id;
panel.style.display = 'block';
evPanel.style.display = 'block';
reflPanel.style.display = 'none';
document.getElementById('reflectionActions').style.display = 'none';
// Build evidence from node props
let props = {};
try { props = JSON.parse(node.props || '{}'); } catch(e) {}
evPanel.innerHTML = `
<div style="color:var(--muted); margin-bottom:4px;">Run ID: <code style="font-size:0.72rem;">${escHTML(node.ref_id)}</code></div>
<div>Graph: <b>${escHTML(props.graph || '-')}</b></div>
<div>Status: <b>${escHTML(props.status || '-')}</b></div>
<div style="margin-top:6px; color:var(--muted);">${escHTML(node.summary||'Немає summary')}</div>
<div style="margin-top:4px; font-size:0.72rem; color:var(--muted);">Для повного Evidence Pack — перегляньте Documents проєкту.</div>
`;
}
async function loadReflection(runId) {
const reflPanel = document.getElementById('reflectionPanel');
const actions = document.getElementById('reflectionActions');
if (!reflPanel || !_activeProjectId) return;
reflPanel.innerHTML = '<span style="color:var(--muted);">Завантаження...</span>';
// Try to find existing reflection node in graph
const existing = (_graphData.nodes || []).find(n =>
n.node_type === 'decision' && n.ref_id === `reflection:${runId}`
);
if (existing) {
let refl = {};
try { refl = JSON.parse(existing.props || '{}'); } catch(e) {}
reflPanel.innerHTML = `
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:8px;">
<span>📊 Повнота: <b>${Math.round((refl.plan_completeness_score||0)*100)}%</b></span>
<span>🔬 Якість Evidence: <b>${Math.round((refl.evidence_quality_score||0)*100)}%</b></span>
<span>🤞 Довіра: <b>${escHTML(refl.confidence||'-')}</b></span>
</div>
${refl.open_risks?.length ? `<div><b>⚠️ Ризики:</b><ul style="margin:2px 0; padding-left:14px;">${refl.open_risks.map(r=>`<li>${escHTML(r)}</li>`).join('')}</ul></div>` : ''}
${refl.missing_steps?.length ? `<div style="margin-top:4px;"><b>❓ Незавершені кроки:</b><ul style="margin:2px 0; padding-left:14px;">${refl.missing_steps.map(s=>`<li>${escHTML(s)}</li>`).join('')}</ul></div>` : ''}
${refl.recommended_next_actions?.length ? `<div style="margin-top:4px;"><b>💡 Рекомендації:</b><ul style="margin:2px 0; padding-left:14px;">${refl.recommended_next_actions.map(a=>`<li>${escHTML(a)}</li>`).join('')}</ul></div>` : ''}
<div style="margin-top:6px; font-size:0.72rem; color:var(--muted);">Reflected: ${escHTML(refl.reflected_at||existing.created_at||'')}</div>
`;
if (actions) actions.style.display = 'none';
} else {
reflPanel.innerHTML = `<div style="color:var(--muted);">Рефлексія ще не виконувалась для цього run.</div>`;
if (actions) actions.style.display = 'block';
}
}
async function reflectNow() {
if (!_selectedAgentRunId || !_activeProjectId) return;
const btn = document.querySelector('#reflectionActions .btn-gold');
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
const r = await fetch(`${API}/api/projects/${_activeProjectId}/supervisor/reflect`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ run_id: _selectedAgentRunId }),
});
const data = await r.json();
if (data.ok) {
await loadProjectDialogMap();
await loadReflection(_selectedAgentRunId);
} else {
alert('Помилка reflection: ' + (data.detail || 'unknown'));
}
} catch(e) { alert('Помилка: ' + e.message); }
finally { if (btn) { btn.disabled = false; btn.textContent = '🧠 Reflect Now'; } }
}
function _setModelIfPresent(value) {
const sel = document.getElementById('modelSelect');
if (!sel || !value) return false;
const exists = Array.from(sel.options).some(opt => opt.value === value);
if (!exists) return false;
sel.value = value;
return true;
}
async function initModelSelection() {
const sel = document.getElementById('modelSelect');
if (!sel) return;
const saved = localStorage.getItem('sofiia_chat_model');
if (saved) _setModelIfPresent(saved);
try {
const r = await fetch(`${API}/api/chat/config`, { cache: 'no-store' });
if (r.ok) {
const d = await r.json();
const preferred = (d.preferred_model || '').trim();
if (!saved && preferred) _setModelIfPresent(preferred);
}
} catch (e) {
// non-fatal: keep static default from markup
}
localStorage.setItem('sofiia_chat_model', sel.value);
sel.addEventListener('change', () => {
localStorage.setItem('sofiia_chat_model', sel.value);
});
}
// ─── Init ─────────────────────────────────────────────────────────────────
(async function init() {
// Check session via cookie (server validates) — no localStorage key dependency
// Overlay is visible by default (CSS display:flex); hide once session confirmed
try {
const r = await fetch(`${API}/api/auth/check`, { credentials: 'include' });
if (r.ok) {
hideLoginOverlay();
} else {
// Not authenticated — keep overlay, focus input
document.getElementById('loginKeyInput')?.focus();
return;
}
} catch(_) {
// Network error on check — keep overlay
document.getElementById('loginKeyInput')?.focus();
return;
}
await initModelSelection();
await checkHealth();
loadOpsActions();
loadSidebarProjects();
setInterval(checkHealth, 30000);
document.getElementById('chatInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// Auto-resize textarea
document.getElementById('chatInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Restore session from localStorage (non-blocking)
if (_currentSessionId) {
try {
const r = await fetch(API + '/api/chat/history?session_id=' + _currentSessionId + '&limit=20');
if (r.ok) {
const data = await r.json();
if (data.messages && data.messages.length > 0) {
for (const msg of data.messages) {
addMsg(msg.content, msg.role === 'user' ? 'user' : 'ai');
chatHistory.push({ role: msg.role, content: msg.content });
}
const log = document.getElementById('chatLog');
if (log) log.scrollTop = log.scrollHeight;
}
}
} catch(e) { /* Session restore failed — not critical */ }
}
})();
async function checkHealth() {
const dot = document.getElementById('headerDot');
const status = document.getElementById('globalStatus');
try {
const r = await fetch(API + '/api/memory/status');
const d = await r.json();
if (d.ok) {
dot.className = 'status-dot ok';
const pts = (d.vector_store?.memories?.points_count) || 0;
status.textContent = `Memory: ${pts} records · Voice: OK`;
} else {
dot.className = 'status-dot err';
status.textContent = 'Memory: ' + (d.error || 'error');
}
} catch(e) {
dot.className = 'status-dot err';
status.textContent = 'Офлайн: ' + e.message;
}
}
// ─── Chat ─────────────────────────────────────────────────────────────────
function addMsg(text, type) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg ' + type;
if (type !== 'system') {
const sender = document.createElement('div');
sender.className = 'sender';
sender.textContent = type === 'user' ? 'Ви' : 'Sofiia';
div.appendChild(sender);
}
const content = document.createElement('div');
content.textContent = text;
div.appendChild(content);
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 = 'typingIndicator';
const sender = document.createElement('div');
sender.className = 'sender';
sender.textContent = 'Sofiia';
div.appendChild(sender);
const ind = document.createElement('div');
ind.className = 'typing-indicator';
ind.innerHTML = '<span></span><span></span><span></span>';
div.appendChild(ind);
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function removeTyping() {
const el = document.getElementById('typingIndicator');
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 modelVal = document.getElementById('modelSelect').value;
const [provider, ...modelParts] = modelVal.split(':');
const modelName = modelParts.join(':');
let reply = '';
if (provider === 'ollama' || provider === 'router' || provider === 'grok' || provider === 'glm') {
const streamMode = document.getElementById('streamMode')?.checked;
const autoSpeak = document.getElementById('autoSpeak')?.checked;
// Phase 2: stream mode — early TTS on first sentence (ollama only)
if (streamMode && autoSpeak && provider === 'ollama') {
await voiceChatStream(text);
document.getElementById('sendBtn').disabled = false;
if (voiceMode && !recording) setTimeout(() => startListening(), 600);
return; // voiceChatStream handles display + audio
}
_vmark('t3'); // LLM request sent
const voiceQuality = document.getElementById('voiceQuality')?.checked;
const voiceProfile = voiceQuality ? 'voice_quality_uk' : 'voice_fast_uk';
const r = await fetch(API + '/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
model: modelVal,
node_id: 'NODA2',
history: chatHistory.slice(-12),
voice_profile: voiceProfile,
session_id: _currentSessionId,
project_id: _currentProjectId,
})
});
_vmark('t5'); // LLM done (full response)
const d = await r.json();
if (!d.ok) throw new Error(d.detail || JSON.stringify(d));
reply = d.response;
} else {
reply = `${provider.toUpperCase()} API потребує налаштування ключа. Оберіть Ollama модель або Router.`;
}
removeTyping();
addMsg(reply, 'ai');
chatHistory.push({ role: 'assistant', content: reply });
if (document.getElementById('autoSpeak').checked) {
await speakText(reply);
}
if (voiceMode && !recording) {
setTimeout(() => startListening(), 600);
}
} catch(e) {
removeTyping();
addMsg('⚠ Помилка: ' + e.message, 'system');
}
document.getElementById('sendBtn').disabled = false;
}
// ─── Audio Queue (Phase 2) ─────────────────────────────────────────────────
// Plays audio clips in sequence. Each entry: {url, mime, revokeOnEnd}
const _audioQueue = [];
let _audioPlaying = false;
let _fetchAbortController = null; // AbortController for _fetchRestChunks
let _isFetchingRest = false; // true while _fetchRestChunks is running
let _queueUnderflowCount = 0; // starvation counter: playback caught up before TTS done
function _audioQueuePush(url, mime) {
_audioQueue.push({ url, mime });
if (!_audioPlaying) _audioQueueAdvance();
}
function _audioQueueAdvance() {
if (_audioQueue.length === 0) {
// Queue empty — check if background fetch is still running (starvation)
if (_isFetchingRest) {
_queueUnderflowCount++;
console.warn('[voice-queue] underflow #' + _queueUnderflowCount +
' — playback caught up with TTS synthesis. Consider shorter chunks or faster model.');
}
_audioPlaying = false;
_updateStopButton(false);
return;
}
_audioPlaying = true;
_updateStopButton(true);
const { url, mime } = _audioQueue.shift();
const audio = new Audio(url);
audio.oncanplay = () => { _vmark('t8'); };
audio.onplay = () => { _vmark('t9'); _vreport(); };
audio.onended = () => { URL.revokeObjectURL(url); _audioQueueAdvance(); };
audio.onerror = () => { URL.revokeObjectURL(url); _audioQueueAdvance(); };
audio.play().catch(e => { console.warn('Audio play error:', e); _audioQueueAdvance(); });
currentAudio = audio;
}
function _audioQueueClear() {
// Abort any ongoing background TTS fetches
if (_fetchAbortController) { _fetchAbortController.abort(); _fetchAbortController = null; }
_audioQueue.length = 0;
_audioPlaying = false;
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
_updateStopButton(false);
}
function _updateStopButton(playing) {
const btn = document.getElementById('stopVoiceBtn');
if (btn) {
btn.style.display = playing ? 'inline-flex' : 'none';
btn.style.opacity = playing ? '1' : '0';
}
}
function stopVoice() {
_audioQueueClear();
document.getElementById('voiceStatus').textContent = '✋ Зупинено';
setTimeout(() => {
const el = document.getElementById('voiceStatus');
if (el && el.textContent === '✋ Зупинено') el.textContent = 'Готовий';
}, 2000);
}
async function _b64ToBlob(b64, mime) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type: mime || 'audio/mpeg' });
}
// ─── TTS ──────────────────────────────────────────────────────────────────
function _stripMarkdown(text) {
return text
.replace(/<think>[\s\S]*?<\/think>/gi, '') // <think> blocks
.replace(/\*\*(.+?)\*\*/g, '$1') // **bold**
.replace(/\*(.+?)\*/g, '$1') // *italic*
.replace(/^#{1,6}\s+/gm, '') // ## headers
.replace(/^[\-\*]\s+/gm, '') // - list items
.replace(/^\d+\.\s+/gm, '') // 1. numbered lists
.replace(/`{1,3}[^`]*`{1,3}/g, '') // `code` / ```code```
.replace(/https?:\/\/\S+/g, '') // URLs
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [link text](url)
.replace(/\n{3,}/g, '\n\n') // collapse excessive newlines
.trim();
}
async function speakText(text) {
_audioQueueClear();
const cleanText = _stripMarkdown(text);
if (!cleanText) return;
try {
_vmark('t6'); // TTS request sent
const r = await fetch(API + '/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 600 chars per call — Phase 2 chunker handles longer texts via /stream
body: JSON.stringify({ text: cleanText.substring(0, 600), voice: selectedVoice })
});
if (!r.ok) { console.warn('TTS HTTP', r.status); return; }
const ct = r.headers.get('content-type') || 'audio/mpeg';
_vmark('t7'); // TTS first byte (response received)
const arrayBuf = await r.arrayBuffer();
const blob = new Blob([arrayBuf], { type: ct });
const url = URL.createObjectURL(blob);
currentAudio = new Audio(url);
currentAudio.oncanplay = () => { _vmark('t8'); }; // audio canplay
currentAudio.onplay = () => { _vmark('t9'); _vreport(); }; // audio playing → report
currentAudio.onended = () => URL.revokeObjectURL(url);
await currentAudio.play();
} catch(e) {
console.warn('TTS error:', e);
}
}
// ─── Phase 2: Voice Stream (early TTS on first sentence) ──────────────────
// Called instead of sendMessage+speakText when streamMode=true.
async function voiceChatStream(text) {
_audioQueueClear();
_vmark('t3'); // LLM request sent
const modelVal = document.getElementById('modelSelect').value;
const voiceQuality = document.getElementById('voiceQuality')?.checked;
const voiceProfile = voiceQuality ? 'voice_quality_uk' : 'voice_fast_uk';
let d, voiceMode, voiceNode;
try {
const r = await fetch(API + '/api/voice/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
model: modelVal,
voice: selectedVoice,
voice_profile: voiceProfile,
history: chatHistory.slice(-8),
session_id: _currentSessionId,
project_id: _currentProjectId,
})
});
_vmark('t5'); // LLM + first TTS done
if (!r.ok) throw new Error(`stream HTTP ${r.status}`);
// Read Voice HA headers from the BFF response (passed through from Router)
voiceMode = r.headers.get('X-Voice-Mode');
voiceNode = r.headers.get('X-Voice-Node');
d = await r.json();
} catch(e) {
throw e; // bubble to sendMessage error handler
}
if (!d.ok) throw new Error(d.detail || 'voice stream error');
// Show full text in chat
removeTyping();
addMsg(d.full_text, 'ai');
chatHistory.push({ role: 'assistant', content: d.full_text });
// Play first sentence immediately (already synthesized by BFF)
if (d.first_audio_b64) {
_vmark('t7'); // first audio received
const blob = await _b64ToBlob(d.first_audio_b64, d.first_audio_mime || 'audio/mpeg');
const url = URL.createObjectURL(blob);
_audioQueuePush(url, d.first_audio_mime || 'audio/mpeg');
}
// Fetch TTS for remaining chunks in background (non-blocking)
if (d.rest_chunks && d.rest_chunks.length > 0) {
_fetchRestChunks(d.rest_chunks, selectedVoice);
}
// Show latency hint
const m = d.meta || {};
const el = document.getElementById('voiceStatus');
if (el) el.title = `Stream: LLM=${m.llm_ms}ms TTS=${m.tts_ms}ms chunks=${m.chunks_total}`;
// Show "🌐 Remote" badge ONLY when X-Voice-Mode header == "remote"
// (not based on meta fields to avoid false positives with proxy/local routing)
const remoteBadge = document.getElementById('voiceRemoteBadge');
if (remoteBadge) {
if (voiceMode === 'remote' && voiceNode) {
remoteBadge.textContent = `🌐 ${voiceNode}`;
remoteBadge.title = `Voice HA: TTS routed to ${voiceNode} (X-Voice-Mode=remote)`;
remoteBadge.style.display = 'inline-block';
setTimeout(() => { remoteBadge.style.display = 'none'; }, 8000);
} else {
remoteBadge.style.display = 'none';
}
}
}
async function _fetchRestChunks(chunks, voice) {
_isFetchingRest = true;
_queueUnderflowCount = 0; // reset per turn
_fetchAbortController = new AbortController();
const signal = _fetchAbortController.signal;
for (const chunk of chunks) {
if (signal.aborted) break;
const cleanChunk = _stripMarkdown(chunk);
if (!cleanChunk) continue;
try {
const r = await fetch(API + '/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: cleanChunk, voice }),
signal,
});
if (!r.ok || signal.aborted) continue;
const ct = r.headers.get('content-type') || 'audio/mpeg';
const buf = await r.arrayBuffer();
if (signal.aborted) break;
const blob = new Blob([buf], { type: ct });
const url = URL.createObjectURL(blob);
_audioQueuePush(url, ct);
} catch(e) {
if (e.name === 'AbortError') break;
console.warn('rest chunk TTS error:', e);
}
}
_isFetchingRest = false;
_fetchAbortController = null;
// After all chunks fetched: if nothing in queue and not playing → all done
if (_audioQueue.length === 0 && !_audioPlaying) {
_updateStopButton(false);
}
}
async function testTTS() {
const text = document.getElementById('ttsTestText').value.trim();
if (text) await speakText(text);
}
function selectVoice(el) {
document.querySelectorAll('.voice-chip').forEach(c => c.classList.remove('active'));
el.classList.add('active');
selectedVoice = el.dataset.voice;
}
// ─── Voice latency telemetry ──────────────────────────────────────────────
const _vt = {}; // voice turn timestamps
function _vmark(name) {
_vt[name] = performance.now();
try { performance.mark('voice_' + name); } catch(_) {}
}
// Rolling telemetry store (last 20 turns)
const _voiceTelemetry = [];
const _TELEM_MAX = 20;
let _turnCounter = 0; // monotonic per-session turn ID for idempotency
function _vreport() {
if (!_vt.t0) return;
const ttfa_ms = (_vt.t9 && _vt.t3) ? Math.round(_vt.t9 - _vt.t3) : null; // request→first audio
_turnCounter++;
const _sessionId = window._voiceSessionId || (window._voiceSessionId = 'ui_' + Date.now().toString(36));
const r = {
ts: Date.now(),
session_id: _sessionId,
turn_id: String(_turnCounter), // idempotency key for dedup
ttfa_ms,
stt_ms: (_vt.t2 && _vt.t1) ? Math.round(_vt.t2 - _vt.t1) : null,
llm_ms: (_vt.t5 && _vt.t3) ? Math.round(_vt.t5 - _vt.t3) : null,
tts_first_ms: (_vt.t7 && _vt.t6) ? Math.round(_vt.t7 - _vt.t6) : null,
audio_decode_ms: (_vt.t9 && _vt.t7) ? Math.round(_vt.t9 - _vt.t7) : null,
e2e_ms: (_vt.t9 && _vt.t0) ? Math.round(_vt.t9 - _vt.t0) : null,
underflows: _queueUnderflowCount,
model: document.getElementById('modelSelect')?.value || null,
voice_profile: document.getElementById('voiceQuality')?.checked ? 'voice_quality_uk' : 'voice_fast_uk',
};
console.info('[voice-latency]', JSON.stringify(r));
// Store rolling telemetry
_voiceTelemetry.push(r);
if (_voiceTelemetry.length > _TELEM_MAX) _voiceTelemetry.shift();
// Show latency hint in voiceStatus tooltip
if (r.ttfa_ms || r.e2e_ms) {
const el = document.getElementById('voiceStatus');
if (el) {
const hint = [
r.ttfa_ms != null ? `TTFA:${r.ttfa_ms}ms` : null,
r.llm_ms != null ? `LLM:${r.llm_ms}ms` : null,
r.tts_first_ms != null ? `TTS:${r.tts_first_ms}ms` : null,
r.e2e_ms != null ? `E2E:${r.e2e_ms}ms` : null,
r.underflows ? `⚠underflow:${r.underflows}` : null,
].filter(Boolean).join(' | ');
el.title = hint;
}
}
// Beacon to BFF for server-side Prometheus (fire-and-forget)
try {
const payload = JSON.stringify({ event: 'voice_turn', ...r });
if (navigator.sendBeacon) {
navigator.sendBeacon(API + '/api/telemetry/voice', new Blob([payload], { type: 'application/json' }));
} else {
// Fallback: non-blocking fetch (ignore errors)
fetch(API + '/api/telemetry/voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload,
keepalive: true,
}).catch(() => {});
}
} catch (_e) { /* telemetry must not break voice UX */ }
}
// ─── Voice Degradation Badge ──────────────────────────────────────────────
// Polls /api/voice/degradation_status every 30s.
// Shows badge only when state != 'ok'. Badge colour:
// orange: degraded_tts / degraded_llm
// red: emergency
// gold: fast_lock (info, not critical)
let _degradPollTimer = null;
const _DEGRAD_POLL_MS = 30_000;
const _DEGRAD_COLORS = {
'degraded_tts': { bg: 'rgba(251,146,60,0.15)', border: 'rgba(251,146,60,0.6)', color: '#fb923c' },
'degraded_llm': { bg: 'rgba(251,146,60,0.15)', border: 'rgba(251,146,60,0.6)', color: '#fb923c' },
'fast_lock': { bg: 'rgba(234,179,8,0.15)', border: 'rgba(234,179,8,0.6)', color: '#eab308' },
'emergency': { bg: 'rgba(239,68,68,0.2)', border: 'rgba(239,68,68,0.7)', color: '#ef4444' },
};
async function _pollDegradation() {
try {
const r = await fetch(API + '/api/voice/degradation_status', { signal: AbortSignal.timeout(5000) });
if (!r.ok) return;
const d = await r.json();
const badge = document.getElementById('voiceDegradBadge');
if (!badge) return;
if (d.state === 'ok' || !d.ui_badge) {
badge.style.display = 'none';
badge.textContent = '';
return;
}
// Show badge
const c = _DEGRAD_COLORS[d.state] || _DEGRAD_COLORS['degraded_tts'];
badge.style.background = c.bg;
badge.style.borderColor = c.border;
badge.style.color = c.color;
badge.textContent = d.ui_badge;
badge.title = `${d.reason} | p95 TTFA: ${d.p95?.ttfa_ms ? Math.round(d.p95.ttfa_ms)+'ms' : 'N/A'} | TTS: ${d.p95?.tts_first_ms ? Math.round(d.p95.tts_first_ms)+'ms' : 'N/A'}`;
badge.style.display = 'inline-block';
// Auto-demote profile on FAST_LOCK (override checkbox silently)
if (d.state === 'fast_lock' || d.state === 'emergency') {
const qBox = document.getElementById('voiceQuality');
if (qBox && qBox.checked) {
qBox.checked = false;
qBox.title = `Auto-demoted to fast profile: ${d.reason}`;
}
}
} catch (_e) { /* degradation badge must not break UI */ }
}
function _startDegradPolling() {
_pollDegradation(); // immediate
_degradPollTimer = setInterval(_pollDegradation, _DEGRAD_POLL_MS);
}
// Start polling when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _startDegradPolling);
} else {
_startDegradPolling();
}
// Expose telemetry summary for debugging/audit
window._voiceStats = function() {
if (!_voiceTelemetry.length) { console.info('No voice turns yet'); return; }
const vals = k => _voiceTelemetry.map(r => r[k]).filter(v => v != null).sort((a,b) => a-b);
const p50 = arr => arr.length ? arr[Math.floor(arr.length * 0.5)] : null;
const p95 = arr => arr.length ? arr[Math.floor(arr.length * 0.95)] : null;
const summary = {};
for (const key of ['ttfa_ms', 'llm_ms', 'tts_first_ms', 'e2e_ms', 'stt_ms']) {
const v = vals(key);
summary[key] = { p50: p50(v), p95: p95(v), n: v.length };
}
summary.underflows_total = _voiceTelemetry.reduce((s, r) => s + (r.underflows||0), 0);
console.table(summary);
return summary;
};
// ─── STT / Voice ──────────────────────────────────────────────────────────
async function startListening() {
if (recording) return;
document.getElementById('voiceStatus').textContent = '🎙️ Слухаю...';
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = async () => {
_vmark('t0'); // user stopped speaking
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('audio', blob, 'audio.webm');
document.getElementById('voiceStatus').textContent = '🔄 Розпізнаю...';
try {
_vmark('t1'); // STT request sent
const r = await fetch(API + '/api/voice/stt', { method: 'POST', body: fd });
const d = await r.json();
_vmark('t2'); // STT text ready
if (d.text) {
document.getElementById('chatInput').value = d.text;
document.getElementById('voiceStatus').textContent = '✓ Розпізнано';
sendMessage();
} else {
document.getElementById('voiceStatus').textContent = '✓ Готовий';
if (voiceMode) startListening();
}
} catch(e) {
document.getElementById('voiceStatus').textContent = '✓ Готовий';
if (voiceMode) startListening();
}
};
mediaRecorder.start();
recording = true;
document.getElementById('voiceBtn').classList.add('recording');
// Auto-stop after 10s
setTimeout(() => stopListening(), 10000);
} catch(e) {
document.getElementById('voiceStatus').textContent = '⚠ Немає доступу до мікрофону';
}
}
function stopListening() {
if (recording && mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
recording = false;
document.getElementById('voiceBtn').classList.remove('recording');
}
}
function toggleVoice() {
if (!voiceMode && !recording) {
// Push-to-talk: start recording once
startListening();
} else if (recording) {
stopListening();
document.getElementById('voiceStatus').textContent = '✓ Готовий';
} else {
voiceMode = false;
document.getElementById('voiceStatus').textContent = '✓ Готовий';
}
}
document.getElementById('contVoice').addEventListener('change', function() {
voiceMode = this.checked;
if (voiceMode && !recording) startListening();
else if (!voiceMode) stopListening();
});
// ─── Ops ──────────────────────────────────────────────────────────────────
const OPS_META = {
risk_dashboard: { icon: '📊', desc: 'Risk dashboard (всі сервіси)' },
pressure_dashboard: { icon: '🏗️', desc: 'Architecture pressure' },
backlog_dashboard: { icon: '📋', desc: 'Backlog items dashboard' },
backlog_generate_weekly: { icon: '🔄', desc: 'Генерація weekly backlog' },
pieces_status: { icon: '🧩', desc: 'Pieces OS status (NODA2 host)' },
notion_status: { icon: '🗂️', desc: 'Notion API status' },
notion_create_task: { icon: '✅', desc: 'Notion: create task' },
notion_create_page: { icon: '📄', desc: 'Notion: create page' },
notion_update_page: { icon: '✏️', desc: 'Notion: update page' },
notion_create_database: { icon: '🧱', desc: 'Notion: create database' },
release_check: { icon: '🚀', desc: 'Release check (staging)' },
};
async function loadOpsActions() {
try {
const r = await fetch(API + '/api/ops/actions');
const d = await r.json();
const grid = document.getElementById('opsGrid');
grid.innerHTML = '';
(d.actions || []).forEach(id => {
const meta = OPS_META[id] || { icon: '⚡', desc: '' };
const btn = document.createElement('button');
btn.className = 'ops-btn';
btn.innerHTML = `<span class="ops-icon">${meta.icon}</span><span class="ops-name">${id.replace(/_/g,' ')}</span><span class="ops-desc">${meta.desc}</span>`;
btn.onclick = () => runOps(id);
grid.appendChild(btn);
});
} catch(e) {
document.getElementById('opsGrid').innerHTML = `<div style="color:var(--muted);font-size:0.85rem;">Помилка завантаження: ${e.message}</div>`;
}
}
async function runOps(actionId) {
const runEl = document.getElementById('opsRunning');
const labelEl = document.getElementById('opsRunningLabel');
const resultEl = document.getElementById('opsResult');
const titleEl = document.getElementById('opsResultTitle');
runEl.style.display = 'flex';
labelEl.textContent = `Виконую: ${actionId.replace(/_/g,' ')}...`;
resultEl.style.display = 'none';
titleEl.style.display = 'none';
try {
const r = await fetch(API + '/api/ops/run', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ action_id: actionId, node_id: 'NODA2', params: {} })
});
if (r.status === 401) {
localStorage.removeItem('sofiia_console_api_key');
ensureApiKey();
throw new Error('Потрібен коректний X-API-Key');
}
const d = await r.json();
runEl.style.display = 'none';
titleEl.style.display = 'block';
resultEl.style.display = 'block';
resultEl.textContent = JSON.stringify(d, null, 2);
} catch(e) {
runEl.style.display = 'none';
titleEl.style.display = 'block';
resultEl.style.display = 'block';
resultEl.textContent = 'Помилка: ' + e.message;
}
}
// ─── Hub / Integrations ───────────────────────────────────────────────────
async function loadIntegrations() {
const grid = document.getElementById('integrationsGrid');
grid.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Завантаження...</div>';
try {
const opencodeUrl = localStorage.getItem('opencode_probe_url') || '';
const qs = opencodeUrl ? ('?opencode_url=' + encodeURIComponent(opencodeUrl)) : '';
const r = await fetch(API + '/api/integrations/status' + qs);
const d = await r.json();
const items = d.integrations || {};
const rows = Object.entries(items);
grid.innerHTML = '';
rows.forEach(([id, info]) => {
const ok = !!info.reachable;
const status = ok ? '✓ OK' : '✗ Offline';
const url = info.url || '';
const latency = info.latency_ms != null ? `${info.latency_ms} ms` : '—';
const extra = info.status != null ? `HTTP ${info.status}` : (info.error || '');
const link = url && url.startsWith('http')
? `<a href="${url}" target="_blank" style="color:var(--gold);text-decoration:none;">Відкрити</a>`
: `<span style="color:var(--muted);">—</span>`;
grid.innerHTML += `
<div class="node-card">
<div class="node-header">
<div class="node-dot ${ok ? 'ok' : 'err'}"></div>
<div>
<div class="node-name">${id.replace(/_/g,' ')}</div>
<div class="node-url">${url || ''}</div>
</div>
</div>
<div class="node-detail">Статус: <span>${status}</span></div>
<div class="node-detail">Latency: <span>${latency}</span></div>
${extra ? `<div class="node-detail">Деталі: <span>${extra}</span></div>` : ''}
<div class="node-detail">${link}</div>
</div>
`;
});
} catch (e) {
grid.innerHTML = `<div style="color:var(--err);font-size:0.85rem;">Помилка: ${e.message}</div>`;
}
}
function configureOpenCodeUrl() {
const curr = localStorage.getItem('opencode_probe_url') || '';
const next = prompt('OpenCode HTTP URL (наприклад http://127.0.0.1:4141). Пусто = desktop/cli mode.', curr) || '';
if (next.trim()) localStorage.setItem('opencode_probe_url', next.trim());
else localStorage.removeItem('opencode_probe_url');
loadIntegrations();
}
async function runNotionStatus() {
ensureApiKey();
await runOps('notion_status');
}
async function runNotionCreateTask() {
ensureApiKey();
const databaseId = prompt('Notion database_id для тасків:');
if (!databaseId) return;
const title = prompt('Назва таски:');
if (!title) return;
const r = await fetch(API + '/api/ops/run', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
action_id: 'notion_create_task',
node_id: 'NODA2',
params: { database_id: databaseId, title: title }
})
});
const d = await r.json();
alert(JSON.stringify(d, null, 2));
}
async function runNotionCreatePage() {
ensureApiKey();
const databaseId = prompt('Notion database_id:');
if (!databaseId) return;
const title = prompt('Назва сторінки:');
if (!title) return;
const content = prompt('Текст сторінки (optional):') || '';
const r = await fetch(API + '/api/ops/run', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
action_id: 'notion_create_page',
node_id: 'NODA2',
params: { database_id: databaseId, title: title, content: content }
})
});
const d = await r.json();
alert(JSON.stringify(d, null, 2));
}
async function runNotionCreateDatabase() {
ensureApiKey();
const parentPageId = prompt('Notion parent_page_id:');
if (!parentPageId) return;
const title = prompt('Назва бази:', 'Sofiia Tasks') || 'Sofiia Tasks';
const r = await fetch(API + '/api/ops/run', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
action_id: 'notion_create_database',
node_id: 'NODA2',
params: { parent_page_id: parentPageId, title: title }
})
});
const d = await r.json();
alert(JSON.stringify(d, null, 2));
}
async function runNotionUpdatePage() {
ensureApiKey();
const pageId = prompt('Notion page_id:');
if (!pageId) return;
const archived = confirm('Архівувати сторінку? OK=так, Cancel=ні (тільки статус/властивості).');
const r = await fetch(API + '/api/ops/run', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
action_id: 'notion_update_page',
node_id: 'NODA2',
params: { page_id: pageId, archived: archived }
})
});
const d = await r.json();
alert(JSON.stringify(d, null, 2));
}
// ─── Nodes ────────────────────────────────────────────────────────────────
async function loadNodes() {
const grid = document.getElementById('nodesGrid');
grid.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Завантаження...</div>';
try {
const r = await fetch(API + '/api/nodes/dashboard');
const d = await r.json();
grid.innerHTML = '';
// Update last sync timestamp
const tsEl = document.getElementById('nodesSyncTs');
if (tsEl) tsEl.textContent = 'sync: ' + new Date().toLocaleTimeString();
// Fetch console build_sha for version mismatch check
let consoleBuildSha = null;
try {
const vr = await fetch(API + '/api/meta/version');
const vd = await vr.json();
consoleBuildSha = vd.build_sha;
} catch(_) {}
// Collect required missing and version mismatches across all nodes
const requiredMissingList = [];
const versionMismatches = [];
(d.nodes || []).forEach(n => {
const ok = n.router_ok;
const gwOk = n.gateway_ok;
const disabled = n.disabled === true;
const nodeRole = n.node_role || 'prod';
const isOffline = !n.online && !disabled;
// Required missing — only flag if gateway is reachable
if (gwOk && n.gateway_required_missing && n.gateway_required_missing.length > 0) {
n.gateway_required_missing.forEach(aid => {
requiredMissingList.push(`${n.node_id}: ${aid}`);
});
}
// Version mismatch
const gwSha = n.gateway_build_sha;
const shaShort = gwSha ? gwSha.slice(0, 8) : null;
const consoleShaShort = consoleBuildSha ? consoleBuildSha.slice(0, 8) : null;
const hasMismatch = gwOk && gwSha && consoleBuildSha && gwSha !== consoleBuildSha && gwSha !== 'dev' && consoleBuildSha !== 'dev';
if (hasMismatch) {
versionMismatches.push(`${n.node_id} gateway=${shaShort} console=${consoleShaShort}`);
}
// Latency badge
const gwLatency = n.gateway_latency_ms != null ? `${n.gateway_latency_ms}ms` : null;
const rtrLatency = n.router_latency_ms != null ? `${n.router_latency_ms}ms` : null;
// Build sha display
const buildShaDisplay = gwSha && gwSha !== 'dev'
? `<span title="gateway build_sha" style="font-family:monospace;font-size:0.65rem;color:var(--muted);">${gwSha.slice(0,8)}</span>`
: '';
const mismatchBadge = hasMismatch
? `<span style="background:rgba(200,80,80,0.2);color:var(--err);font-size:0.6rem;border-radius:3px;padding:1px 4px;margin-left:4px;">🔀 DRIFT</span>`
: '';
// Agents count
const agentsCount = n.gateway_agents_count != null
? `<div class="node-detail">Агентів: <span>${n.gateway_agents_count}</span></div>`
: '';
// Required missing per node
const reqMissing = (gwOk && n.gateway_required_missing && n.gateway_required_missing.length > 0)
? `<div class="node-detail" style="color:var(--warn);">⚠ Missing: <span>${n.gateway_required_missing.join(', ')}</span></div>`
: '';
// Gateway build time
const gwBuildTime = n.gateway_build_time && n.gateway_build_time !== 'local'
? `<div class="node-detail">GW build: <span style="font-size:0.68rem;color:var(--muted);">${n.gateway_build_time.replace('T', ' ').slice(0,16)}</span></div>`
: '';
// Node role badge
const roleBadge = nodeRole === 'dev'
? `<span style="background:rgba(100,150,250,0.15);color:#7ab;font-size:0.58rem;border-radius:2px;padding:0 3px;margin-left:4px;">dev</span>`
: `<span style="background:rgba(50,200,100,0.15);color:#7c9;font-size:0.58rem;border-radius:2px;padding:0 3px;margin-left:4px;">prod</span>`;
grid.innerHTML += `
<div class="node-card" style="${isOffline ? 'opacity:0.65;' : ''}">
<div class="node-header">
<div class="node-dot ${disabled ? '' : (ok ? 'ok' : 'err')}"></div>
<div style="flex:1;min-width:0;">
<div class="node-name" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;">
${n.label || n.node_id}${roleBadge}${mismatchBadge}
</div>
<div class="node-url">${n.router_url || ''}</div>
</div>
</div>
<div class="node-detail">Router: <span>${disabled ? '⏸ Disabled' : (ok ? `✓ OK${rtrLatency ? ' · ' + rtrLatency : ''}` : '✗ Недоступний')}</span></div>
${gwOk !== undefined && gwOk !== null ? `<div class="node-detail">Gateway: <span>${gwOk ? `✓ OK${gwLatency ? ' · ' + gwLatency : ''}` : '✗ Недоступний'}</span>&nbsp;${buildShaDisplay}</div>` : ''}
${agentsCount}
${reqMissing}
${gwBuildTime}
<div class="node-detail">SSH: <span>${n.ssh_configured ? '✓ Configured' : '—'}</span></div>
${n.supervisor_ok !== undefined && n.supervisor_ok !== null ? `<div class="node-detail">Supervisor: <span>${n.supervisor_ok ? '✓ OK' : '✗ Недоступний'}</span></div>` : ''}
${isOffline ? '<div class="node-detail" style="color:var(--err);font-size:0.72rem;">⚫ offline</div>' : ''}
</div>`;
});
// Show banners
const reqBanner = document.getElementById('nodesRequiredMissingBanner');
const reqDetail = document.getElementById('nodesRequiredMissingDetail');
if (reqBanner && reqDetail) {
if (requiredMissingList.length > 0) {
reqDetail.textContent = requiredMissingList.join(', ');
reqBanner.style.display = 'block';
} else {
reqBanner.style.display = 'none';
}
}
const vmBanner = document.getElementById('nodesVersionMismatchBanner');
const vmDetail = document.getElementById('nodesVersionMismatchDetail');
if (vmBanner && vmDetail) {
if (versionMismatches.length > 0) {
vmDetail.textContent = versionMismatches.join(' | ');
vmBanner.style.display = 'block';
} else {
vmBanner.style.display = 'none';
}
}
if (!d.nodes || d.nodes.length === 0) {
grid.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Ноди не налаштовані. Перевірте config/nodes_registry.yml</div>';
}
} catch(e) {
grid.innerHTML = `<div style="color:var(--err);font-size:0.85rem;">Помилка: ${e.message}</div>`;
}
}
async function addNodePrompt() {
ensureApiKey();
const nodeId = (prompt('Node ID (наприклад NODA3):') || '').trim().toUpperCase();
if (!nodeId) return;
const label = (prompt('Label:', nodeId) || nodeId).trim();
const routerUrl = (prompt('Router URL (http://host:port):') || '').trim();
if (!routerUrl) return alert('Router URL обовʼязковий');
const gatewayUrl = (prompt('Gateway URL (optional):') || '').trim();
const monitorUrl = (prompt('Monitor URL (optional, default=router):') || '').trim();
const supervisorUrl = (prompt('Supervisor URL (optional):') || '').trim();
const r = await fetch(API + '/api/nodes/add', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
node_id: nodeId,
label: label,
router_url: routerUrl,
gateway_url: gatewayUrl,
monitor_url: monitorUrl || routerUrl,
supervisor_url: supervisorUrl,
enabled: true
})
});
const d = await r.json();
if (!r.ok) return alert('Помилка: ' + (d.detail || JSON.stringify(d)));
await loadNodes();
}
// ─── Memory ───────────────────────────────────────────────────────────────
async function loadMemoryStatus() {
const card = document.getElementById('memoryStatusCard');
try {
const r = await fetch(API + '/api/memory/status');
const d = await r.json();
const pts = (d.vector_store?.memories?.points_count) || 0;
const status = d.ok ? 'ok' : 'err';
card.innerHTML = `
<h3>🧠 Статус Memory Service</h3>
<div class="memory-row"><span class="label">Статус</span><span class="badge ${status}">${d.ok ? 'Активний' : 'Недоступний'}</span></div>
<div class="memory-row"><span class="label">URL</span><span class="value" style="font-size:0.78rem;">${d.memory_url || '—'}</span></div>
<div class="memory-row"><span class="label">Векторів (memories)</span><span class="value">${pts}</span></div>
<div class="memory-row"><span class="label">STT</span><span class="value">${d.stt || '—'}</span></div>
<div class="memory-row"><span class="label">TTS</span><span class="value">${d.tts || '—'}</span></div>
${d.error ? `<div class="memory-row"><span class="label">Помилка</span><span class="value" style="color:var(--err);font-size:0.78rem;">${d.error}</span></div>` : ''}
`;
} catch(e) {
card.innerHTML = `<h3>🧠 Memory Service</h3><div style="color:var(--err);font-size:0.85rem;">Помилка: ${e.message}</div>`;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// CTO Strategic Dashboard
// ──────────────────────────────────────────────────────────────────────────────
let _ctoProjectId = null;
let _ctoSignalId = null;
let _ctoSnapshot = null;
async function initCtoDashboard() {
// Populate project selector
try {
const r = await fetch(API + '/api/projects');
if (!r.ok) return;
const projects = await r.json();
const sel = document.getElementById('ctoProjectSel');
const current = sel.value;
// Prefer: explicit current → last CTO project → last opened project
const preferred = current || localStorage.getItem('sofiia_cto_project_id') || _currentProjectId;
sel.innerHTML = '<option value="">— оберіть проєкт —</option>' +
projects.map(p => `<option value="${p.project_id}" ${p.project_id===preferred?'selected':''}>${p.name}</option>`).join('');
if (!current && preferred) {
sel.value = preferred;
}
} catch(e) {}
ctoDashboardLoad();
}
async function ctoDashboardLoad() {
const sel = document.getElementById('ctoProjectSel');
_ctoProjectId = sel.value;
if (!_ctoProjectId) return;
localStorage.setItem('sofiia_cto_project_id', _ctoProjectId);
const window_ = document.getElementById('ctoWindowSel').value;
await Promise.all([
ctoLoadSnapshot(window_),
ctoLoadSignals(),
ctoLoadRiskTasks(),
ctoLoadMiniGraph(),
ctoLoadLessons(),
]);
}
async function ctoLoadSnapshot(window_) {
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/graph/snapshot?window=${window_}`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) {
setStatus('Snapshot не знайдено — натисніть ↻ Snapshot');
return;
}
const data = await r.json();
_ctoSnapshot = data.metrics || {};
ctoRenderMetrics(_ctoSnapshot);
setStatus(`Snapshot: ${(_ctoSnapshot.computed_at || '').slice(0, 16)}`);
} catch(e) {
setStatus('Snapshot: ' + e.message);
}
}
function ctoRenderMetrics(m) {
document.getElementById('mTile-wip').textContent = m.wip ?? '—';
document.getElementById('mTile-done').textContent = m.tasks_done ?? '—';
document.getElementById('mTile-risks').textContent = m.risk_tasks_open ?? '—';
document.getElementById('mTile-runs').textContent = m.agent_runs_in_window ?? '—';
document.getElementById('mTile-cycle').textContent =
m.cycle_time_proxy_days != null ? m.cycle_time_proxy_days.toFixed(1) : '—';
const q = m.run_quality_avg;
const qEl = document.getElementById('mTile-quality');
qEl.textContent = q != null ? Math.round(q * 100) + '%' : '—';
qEl.style.color = q == null ? 'var(--text)' : q >= 0.7 ? 'var(--ok)' : q >= 0.5 ? 'var(--warn)' : 'var(--err)';
}
async function ctoRefreshSnapshot() {
if (!_ctoProjectId) { alert('Оберіть проєкт'); return; }
const window_ = document.getElementById('ctoWindowSel').value;
setStatus('Перераховую snapshot...');
try {
await fetch(`${API}/api/projects/${_ctoProjectId}/graph/snapshot?window=${window_}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
await ctoLoadSnapshot(window_);
} catch(e) { setStatus('Помилка: ' + e.message); }
}
async function ctoRunSignals(dryRun) {
if (!_ctoProjectId) { alert('Оберіть проєкт'); return; }
const window_ = document.getElementById('ctoWindowSel').value;
setStatus(dryRun ? 'Dry-run сигналів...' : 'Перераховую сигнали...');
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/graph/signals/recompute?window=${window_}&dry_run=${dryRun}`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
const data = await r.json();
const newCount = (data.diff || []).filter(d => d.action === 'new').length;
setStatus(`${dryRun ? '[dry-run] ' : ''}Знайдено ${data.signals_generated} сигналів, нових: ${newCount}`);
if (!dryRun) await ctoLoadSignals();
if (dryRun && data.diff?.length) {
const panel = document.getElementById('ctoSignalsPanel');
panel.innerHTML = `<div style="color:var(--accent);font-size:0.8rem;padding:6px 0;">Dry-run результат (не збережено):</div>` +
data.diff.map(d => `<div style="font-size:0.78rem;color:${d.action==='new'?'var(--warn)':'var(--muted)'};">
[${d.action.toUpperCase()}] ${d.signal_type}${d.title || d.status || ''}</div>`).join('');
}
} catch(e) { setStatus('Помилка: ' + e.message); }
}
async function ctoLoadSignals() {
if (!_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/graph/signals?status=open`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const data = await r.json();
const panel = document.getElementById('ctoSignalsPanel');
if (!data.signals.length) {
panel.innerHTML = '<div style="color:var(--ok);font-size:0.82rem;">✓ Відкритих сигналів немає</div>';
return;
}
const sevColor = { critical: 'var(--err)', high: 'var(--warn)', medium: 'var(--gold)', low: 'var(--muted)' };
panel.innerHTML = data.signals.map(s => `
<div onclick="ctoOpenSignal(${JSON.stringify(JSON.stringify(s))})"
style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;cursor:pointer;transition:border-color 0.15s;"
onmouseover="this.style.borderColor='var(--gold)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;">
<span style="padding:1px 6px;border-radius:3px;font-size:0.7rem;font-weight:700;background:${sevColor[s.severity]}22;color:${sevColor[s.severity]};">${s.severity.toUpperCase()}</span>
<span style="font-size:0.82rem;font-weight:600;">${s.title}</span>
</div>
<div style="font-size:0.75rem;color:var(--muted);">${s.signal_type} · ${s.created_at?.slice(0,16)}</div>
</div>`).join('');
} catch(e) {}
}
let _ctoCurrentSignalType = '';
function ctoOpenSignal(signalJson) {
const s = JSON.parse(signalJson);
_ctoSignalId = s.id;
_ctoCurrentSignalType = s.signal_type || '';
_ctoMitigationPlanNodeId = null;
_ctoMitigationTaskIds = [];
const res = document.getElementById('ctoMitigationResult');
if (res) res.style.display = 'none';
const pbRes = document.getElementById('ctoPlaybookResult');
if (pbRes) { pbRes.style.display = 'none'; pbRes.textContent = ''; }
const btn = document.getElementById('ctoMitigateBtn');
if (btn) { btn.disabled = false; btn.textContent = '🛡 Mitigation Plan'; }
const sevColor = { critical: 'var(--err)', high: 'var(--warn)', medium: 'var(--gold)', low: 'var(--muted)' };
const sev = document.getElementById('ctoDrawerSeverity');
sev.textContent = s.severity.toUpperCase();
sev.style.background = (sevColor[s.severity] || 'var(--muted)') + '22';
sev.style.color = sevColor[s.severity] || 'var(--muted)';
document.getElementById('ctoDrawerTitle').textContent = s.title;
document.getElementById('ctoDrawerSummary').textContent = s.summary || '';
document.getElementById('ctoDrawerEvidence').textContent = JSON.stringify(s.evidence, null, 2);
document.getElementById('ctoSignalDrawer').style.display = 'block';
// Highlight nodes in mini graph
const nodeIds = s.evidence?.node_ids || s.evidence?.release_node_ids || s.evidence?.blocker_task_ids || [];
if (nodeIds.length) ctoHighlightNodes(nodeIds);
// Load playbooks for this signal type
ctoLoadPlaybooks(s.signal_type);
}
async function ctoLoadPlaybooks(signalType) {
const listEl = document.getElementById('ctoPlaybooksList');
if (!listEl || !_ctoProjectId) return;
listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Завантаження...</div>';
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/playbooks?signal_type=${signalType}&limit=3`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) { listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">—</div>'; return; }
const data = await r.json();
const pbs = data.playbooks || [];
if (!pbs.length) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Ще немає playbooks для цього типу.</div>';
return;
}
listEl.innerHTML = pbs.map(pb => {
const sr = pb.success_rate != null ? Math.round(pb.success_rate * 100) : 0;
const ema = pb.ema_time_to_resolve_h ? pb.ema_time_to_resolve_h.toFixed(1) + 'г' : '—';
const srColor = sr >= 70 ? 'var(--ok)' : sr >= 40 ? 'var(--warn)' : 'var(--err)';
return `<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:0.78rem;font-weight:600;flex:1;">${pb.context_key || pb.signal_type}</span>
<span style="color:${srColor};font-size:0.73rem;font-weight:700;">${sr}%</span>
</div>
<div style="font-size:0.7rem;color:var(--muted);margin-bottom:5px;">uses: ${pb.uses} · avg: ${ema}</div>
<button class="btn btn-ghost btn-sm" style="font-size:0.72rem;" onclick="ctoApplyPlaybook('${pb.playbook_id}')">▶ Apply</button>
</div>`;
}).join('');
} catch(e) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">—</div>';
}
}
async function ctoApplyPlaybook(playbookId) {
if (!_ctoProjectId || !_ctoSignalId) return;
const res = document.getElementById('ctoPlaybookResult');
if (res) { res.style.display = 'block'; res.textContent = '⟳ Застосування playbook...'; }
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/graph/signals/${_ctoSignalId}/mitigate?playbook_id=${playbookId}`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
const data = r.ok ? await r.json() : {};
if (r.ok) {
if (res) res.textContent = `✓ Playbook застосовано: ${data.task_count ?? 0} задач створено`;
_ctoMitigationPlanNodeId = data.plan_node_id;
_ctoMitigationTaskIds = data.task_ids || [];
await ctoLoadSignals();
} else {
if (res) { res.textContent = 'Помилка: ' + (data.detail || 'unknown'); res.style.color = 'var(--err)'; }
}
} catch(e) {
if (res) { res.textContent = 'Помилка: ' + e.message; res.style.color = 'var(--err)'; }
}
}
async function ctoPromoteToPlaybook() {
if (!_ctoProjectId || !_ctoSignalId) return;
const res = document.getElementById('ctoPlaybookResult');
if (res) { res.style.display = 'block'; res.textContent = '⟳ Зберігаємо playbook...'; res.style.color = 'var(--muted)'; }
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/playbooks/from-signal/${_ctoSignalId}`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
const data = r.ok ? await r.json() : {};
if (r.ok) {
const created = data.created ? 'новий playbook' : 'оновлено існуючий';
if (res) { res.textContent = `✓ Playbook ${created}: ${data.context_key}`; res.style.color = 'var(--ok)'; }
// Reload playbooks list
ctoLoadPlaybooks(_ctoCurrentSignalType);
} else {
if (res) { res.textContent = 'Помилка: ' + (data.detail || 'unknown'); res.style.color = 'var(--err)'; }
}
} catch(e) {
if (res) { res.textContent = 'Помилка: ' + e.message; res.style.color = 'var(--err)'; }
}
}
async function ctoSignalAction(action) {
if (!_ctoSignalId) return;
try {
await fetch(`${API}/api/projects/${_ctoProjectId}/graph/signals/${_ctoSignalId}/${action}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
document.getElementById('ctoSignalDrawer').style.display = 'none';
_ctoSignalId = null;
await ctoLoadSignals();
} catch(e) { alert('Помилка: ' + e.message); }
}
let _ctoMitigationPlanNodeId = null;
let _ctoMitigationTaskIds = [];
async function ctoCreateMitigationPlan() {
if (!_ctoProjectId || !_ctoSignalId) return;
const btn = document.getElementById('ctoMitigateBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Створюю...'; }
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/graph/signals/${_ctoSignalId}/mitigate`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || r.statusText);
}
const data = await r.json();
_ctoMitigationPlanNodeId = data.plan_node_id;
_ctoMitigationTaskIds = data.task_ids || [];
// Show result panel
const res = document.getElementById('ctoMitigationResult');
res.style.display = 'block';
document.getElementById('ctoMitigationSummary').textContent =
`${data.signal_type}${data.task_count} tasks створено`;
// Show first 3 task titles (we don't have titles here, so show IDs truncated)
document.getElementById('ctoMitigationTasks').innerHTML =
_ctoMitigationTaskIds.slice(0, 4).map((tid, i) =>
`<div style="color:var(--text);padding:3px 0;">📋 Mitigation task ${i + 1}</div>`
).join('');
if (btn) { btn.textContent = '✓ Plan створено'; }
setStatus(`Mitigation plan: ${data.task_count} tasks`);
// Reload signals (signal is now ack'd)
await ctoLoadSignals();
// Focus graph on plan node
if (_ctoMitigationPlanNodeId) {
ctoHighlightNodes([_ctoMitigationPlanNodeId, ..._ctoMitigationTaskIds.slice(0, 3)]);
await ctoLoadMiniGraph();
}
} catch(e) {
setStatus('Помилка: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = '🛡 Mitigation Plan'; }
}
}
function ctoFocusMitigationPlan() {
if (_ctoMitigationPlanNodeId) {
ctoHighlightNodes([_ctoMitigationPlanNodeId, ..._ctoMitigationTaskIds]);
ctoLoadMiniGraph();
document.getElementById('ctoGraphFilter').textContent = `(mitigation plan)`;
}
}
async function ctoLoadRiskTasks() {
if (!_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/tasks?status=todo&limit=20`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const data = await r.json();
const tasks = (data.tasks || data || []).filter(t =>
t.title?.startsWith('[RISK]') || t.priority === 'urgent' || t.priority === 'high'
).slice(0, 8);
const el = document.getElementById('ctoRiskTasks');
if (!tasks.length) { el.innerHTML = '<div style="color:var(--muted);font-size:0.82rem;">Критичних ризиків немає</div>'; return; }
const priColor = { urgent: 'var(--err)', high: 'var(--warn)', medium: 'var(--gold)', low: 'var(--muted)' };
el.innerHTML = tasks.map(t => `
<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:7px 10px;font-size:0.82rem;">
<span style="color:${priColor[t.priority]||'var(--muted)'};">●</span>
<span style="margin-left:5px;">${t.title}</span>
<span style="float:right;color:var(--muted);font-size:0.72rem;">${t.status}</span>
</div>`).join('');
} catch(e) {}
}
// Mini Graph for CTO (reuses SVG rendering, filtered for importance>=0.5)
let _ctoGraphData = null;
let _ctoHighlightIds = new Set();
async function ctoLoadMiniGraph() {
if (!_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/dialog/graph`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
_ctoGraphData = await r.json();
ctoRenderMiniGraph();
} catch(e) {}
}
function ctoHighlightNodes(ids) {
_ctoHighlightIds = new Set(ids);
ctoRenderMiniGraph();
document.getElementById('ctoGraphFilter').textContent = `(виділено: ${ids.length})`;
}
function ctoRenderMiniGraph() {
const svg = document.getElementById('ctoMiniGraph');
if (!_ctoGraphData) return;
const { nodes = [], edges = [] } = _ctoGraphData;
const W = svg.clientWidth || 320, H = 360;
// Filter: importance >= 0.5, lifecycle=active, exclude low-value message nodes
const filtered = nodes.filter(n =>
(n.lifecycle || 'active') === 'active' &&
(n.importance || 0.3) >= 0.5 &&
n.node_type !== 'message'
);
if (!filtered.length) {
svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="#555" font-size="12">Немає важливих вузлів</text>';
return;
}
// Simple force-like layout: circle
const cx = W / 2, cy = H / 2, r = Math.min(W, H) * 0.38;
const angle = (2 * Math.PI) / filtered.length;
const pos = {};
filtered.forEach((n, i) => {
pos[n.node_id] = {
x: cx + r * Math.cos(i * angle - Math.PI / 2),
y: cy + r * Math.sin(i * angle - Math.PI / 2),
};
});
const nodeColors = {
decision: '#c9a87c', agent_run: '#a78bfa', task: '#22c55e',
doc: '#60a5fa', meeting: '#f472b6', goal: '#fbbf24',
ops_run: '#fb923c', message: '#6b7280',
};
const nodeIds = new Set(filtered.map(n => n.node_id));
const visEdges = edges.filter(e => nodeIds.has(e.from_node_id) && nodeIds.has(e.to_node_id));
let html = '';
// Edges
visEdges.forEach(e => {
const f = pos[e.from_node_id], t = pos[e.to_node_id];
if (!f || !t) return;
html += `<line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#3f3f46" stroke-width="1" opacity="0.5"/>`;
});
// Nodes
filtered.forEach(n => {
const p = pos[n.node_id];
const imp = n.importance || 0.3;
const nr = 6 + imp * 8;
const col = nodeColors[n.node_type] || '#6b7280';
const hl = _ctoHighlightIds.has(n.node_id) || _ctoHighlightIds.has(n.ref_id);
const title = (n.title || n.node_type || '').slice(0, 22);
html += `<circle cx="${p.x}" cy="${p.y}" r="${nr}" fill="${col}" opacity="${hl ? 1 : 0.75}"
stroke="${hl ? '#fff' : 'none'}" stroke-width="${hl ? 2 : 0}">
<title>${n.node_type}: ${n.title || n.ref_id}</title></circle>`;
html += `<text x="${p.x}" y="${p.y + nr + 11}" text-anchor="middle" fill="#a1a1aa" font-size="9">${title}</text>`;
});
svg.innerHTML = html;
}
async function ctoLaunchWorkflow(graphName) {
const pid = _ctoProjectId;
if (!pid) { alert('Оберіть проєкт'); return; }
const confirmMsg = `Запустити workflow "${graphName}" для проєкту?`;
if (!confirm(confirmMsg)) return;
setStatus(`Запуск ${graphName}...`);
try {
const r = await fetch(`${API}/api/supervisor/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': getApiKey() },
body: JSON.stringify({
graph_name: graphName,
project_id: pid,
input: { source: 'cto_dashboard', graph_name: graphName }
})
});
const data = await r.json();
setStatus(`${graphName} запущено: ${data.run_id || data.id || 'ok'}`);
} catch(e) { setStatus('Помилка: ' + e.message); }
}
function setStatus(msg) {
const el = document.getElementById('ctoStatus');
if (el) el.textContent = msg;
}
// ──────────────────────────────────────────────────────────────────────────────
// Portfolio (Cross-Project)
// ──────────────────────────────────────────────────────────────────────────────
async function portLoad() {
await Promise.all([portLoadProjects(), portLoadSignals()]);
}
function _portSnapshotAge(isoStr) {
if (!isoStr) return '—';
try {
const d = new Date(isoStr);
const diffMs = Date.now() - d.getTime();
const mins = Math.round(diffMs / 60000);
if (mins < 60) return `${mins}хв тому`;
const hrs = Math.round(diffMs / 3600000);
if (hrs < 24) return `${hrs}г тому`;
return `${Math.round(hrs/24)}д тому`;
} catch(e) { return isoStr.slice(0, 10); }
}
async function portLoadProjects() {
const window_ = document.getElementById('portWindowSel').value;
const statusEl = document.getElementById('portStatus');
if (statusEl) statusEl.textContent = 'Завантаження...';
try {
const r = await fetch(`${API}/api/cto/portfolio/snapshots?window=${window_}`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) { if (statusEl) statusEl.textContent = 'Помилка portfolio'; return; }
const data = await r.json();
portRenderProjectsTable(data.projects || []);
if (statusEl) statusEl.textContent = `${data.count} проєктів · вікно: ${window_}`;
} catch(e) {
if (statusEl) statusEl.textContent = 'Помилка: ' + e.message;
}
}
function portRenderProjectsTable(projects) {
const tbody = document.getElementById('portProjectsBody');
const empty = document.getElementById('portEmptyState');
const content = document.getElementById('portContent');
if (!projects.length) {
// Show empty-state, hide content
if (empty) empty.style.display = 'flex';
if (content) content.style.display = 'none';
tbody.innerHTML = '';
return;
}
if (empty) empty.style.display = 'none';
if (content) content.style.display = 'grid';
const qColor = q => q == null ? 'var(--muted)' : q >= 0.7 ? 'var(--ok)' : q >= 0.5 ? 'var(--warn)' : 'var(--err)';
const riskColor = n => n > 0 ? 'var(--err)' : 'var(--ok)';
tbody.innerHTML = projects.map(p => {
const m = p.metrics || {};
const age = _portSnapshotAge(p.snapshot_at);
const q = m.run_quality_avg;
const lessonBucket = p.latest_lesson_bucket || '';
const ltf = p.latest_lesson_trend_flags || {};
const streaks = p.latest_lesson_streaks || {};
function _ltfArrow(improving, regressing) {
if (improving) return '<span style="color:var(--ok)" title="Improving">↑</span>';
if (regressing) return '<span style="color:var(--err)" title="Regressing">↓</span>';
return '';
}
function _streakBadge(metric, label) {
const s = streaks[metric] || {};
if (!s.len || s.len < 2) return '';
const color = s.dir === 'regressing' ? 'var(--err)' : s.dir === 'improving' ? 'var(--ok)' : 'var(--muted)';
const arrow = s.dir === 'regressing' ? '↓' : s.dir === 'improving' ? '↑' : '→';
return `<span style="font-size:0.62rem;color:${color};margin-left:2px;" title="${label} ${s.dir} ×${s.len} since ${s.since_bucket||'?'}">${arrow}×${s.len}</span>`;
}
const rArrow = ltf ? _ltfArrow(ltf.risk_improving, ltf.risk_regressing) : '';
const qArrow = ltf ? _ltfArrow(ltf.quality_improving, ltf.quality_regressing) : '';
const oArrow = ltf ? _ltfArrow(ltf.ops_improving, ltf.ops_regressing) : '';
const rStreak = _streakBadge('risk', 'Risk');
const qStreak = _streakBadge('quality', 'Quality');
const oStreak = _streakBadge('ops', 'Ops');
const trendPill = (rArrow || qArrow || oArrow || rStreak || qStreak || oStreak)
? `<span style="font-size:0.7rem;margin-left:4px;">${rArrow}${rStreak}${qArrow}${qStreak}${oArrow}${oStreak}</span>` : '';
const lessonCell = lessonBucket
? `<span style="font-size:0.68rem;color:var(--gold);cursor:pointer;text-decoration:underline;"
onclick="event.stopPropagation();portOpenProjectLesson('${p.project_id}')">${lessonBucket}</span>${trendPill}`
: `<span style="color:var(--muted);font-size:0.68rem;">—</span>`;
return `<tr style="border-bottom:1px solid var(--border); cursor:pointer;"
onmouseover="this.style.background='rgba(201,168,124,0.05)'"
onmouseout="this.style.background=''"
onclick="portOpenProject('${p.project_id}', '${(p.name||'').replace(/'/g,"\\'")}')">
<td style="padding:8px 8px; font-weight:500;">${p.name || p.project_id}</td>
<td style="text-align:center; padding:8px 6px; color:${m.wip > 0 ? 'var(--accent)' : 'var(--muted)'};">${m.wip ?? '—'}</td>
<td style="text-align:center; padding:8px 6px; color:var(--ok);">${m.tasks_done ?? '—'}</td>
<td style="text-align:center; padding:8px 6px; color:${riskColor(m.risk_tasks_open || 0)};">${m.risk_tasks_open ?? '—'}</td>
<td style="text-align:center; padding:8px 6px; color:var(--muted);">${m.agent_runs_in_window ?? '—'}</td>
<td style="text-align:center; padding:8px 6px; color:var(--warn);">${m.cycle_time_proxy_days != null ? m.cycle_time_proxy_days.toFixed(1) : '—'}</td>
<td style="text-align:center; padding:8px 6px; color:${qColor(q)}; font-weight:600;">${q != null ? Math.round(q*100)+'%' : '—'}</td>
<td style="text-align:center; padding:8px 6px; font-size:0.72rem; color:var(--muted);" title="${p.snapshot_at || ''}">${age}</td>
<td style="text-align:center; padding:8px 6px;">${lessonCell}</td>
</tr>`;
}).join('');
}
async function portRecomputeAll() {
const statusEl = document.getElementById('portStatus');
if (statusEl) statusEl.textContent = '⟳ Обчислення...';
try {
const window_ = document.getElementById('portWindowSel').value;
const [sr, sgr] = await Promise.all([
fetch(`${API}/api/cto/portfolio/snapshots/recompute?window=${window_}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
}),
fetch(`${API}/api/cto/portfolio/signals/recompute?window=${window_}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
}),
]);
const sd = sr.ok ? await sr.json() : {};
if (statusEl) statusEl.textContent = `✓ Snapshots: ${sd.computed ?? 0} обчислено, ${sd.skipped ?? 0} пропущено`;
await portLoad();
} catch(e) {
if (statusEl) statusEl.textContent = 'Помилка: ' + e.message;
}
}
async function portDriftRecompute() {
const statusEl = document.getElementById('portStatus');
if (statusEl) statusEl.textContent = '⟳ Drift recompute...';
try {
const r = await fetch(`${API}/api/cto/portfolio/drift/recompute?dry_run=false`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
const d = r.ok ? await r.json() : {};
const cnt = (d.changes || []).length;
if (statusEl) statusEl.textContent = `✓ Drift: ${cnt} signal(s) updated`;
await portLoadDriftSignals();
} catch(e) {
if (statusEl) statusEl.textContent = 'Помилка drift: ' + e.message;
}
}
async function portLoadDriftSignals() {
const el = document.getElementById('portDriftSignalsList');
if (!el) return;
try {
const r = await fetch(`${API}/api/cto/portfolio/drift/signals?status=open`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) { el.innerHTML = '<div style="color:var(--muted);font-size:0.75rem;">—</div>'; return; }
const data = await r.json();
const signals = data.signals || [];
if (!signals.length) {
el.innerHTML = '<div style="color:var(--ok);font-size:0.75rem;">✓ Немає drift-сигналів</div>';
return;
}
const sevColor = s => s === 'critical' ? 'var(--err)' : s === 'high' ? 'var(--warn)' : 'var(--muted)';
el.innerHTML = signals.map(s => {
const ev = s.evidence_parsed || {};
const projects = ev.projects || [];
const pList = projects.slice(0, 3).map(p => `${p.project_id || ''} (×${p.streak?.len || '?'})`).join(', ');
const autoRuns = ((ev.auto_actions || {}).runs || []);
const runSummary = autoRuns.length
? `<div style="font-size:0.65rem;color:var(--muted);margin-top:2px;">⚡ ${autoRuns.length} run(s): ${[...new Set(autoRuns.map(r=>r.status))].join(', ')}</div>`
: '';
return `<div id="drift-sig-${s.id}" style="background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:6px 8px;font-size:0.72rem;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap;">
<span style="color:${sevColor(s.severity)};font-weight:600;cursor:pointer;" onclick="portJumpToDriftSignal(${JSON.stringify(s).replace(/"/g,'&quot;')})">${s.signal_type?.replace('portfolio_','').replace('_drift','') || '?'} <span style="color:var(--muted);font-size:0.65rem;">${s.severity}</span></span>
<div style="display:flex;gap:4px;">
<button class="btn btn-ghost btn-sm" style="font-size:0.62rem;padding:1px 5px;" onclick="portAutoRun('${s.id}',true)" title="Plan (dry-run)">📋 Plan</button>
<button class="btn btn-ghost btn-sm" style="font-size:0.62rem;padding:1px 5px;" onclick="portAutoRun('${s.id}',false)" title="Execute runs">⚡ Run</button>
</div>
</div>
<div style="color:var(--muted);margin-top:2px;">${pList || 'No projects'}</div>
${runSummary}
<div id="drift-sig-status-${s.id}" style="font-size:0.65rem;color:var(--ok);margin-top:2px;"></div>
</div>`;
}).join('');
} catch(e) {
if (el) el.innerHTML = '<div style="color:var(--muted);font-size:0.75rem;">—</div>';
}
}
function portJumpToDriftSignal(sig) {
try {
const ev = typeof sig.evidence_parsed === 'object' ? sig.evidence_parsed : JSON.parse(sig.evidence || '{}');
const projects = ev.projects || [];
if (projects.length && projects[0].project_id) {
portJumpToProject(projects[0].project_id);
}
} catch(e) {}
}
async function portAutoRun(signalId, dryRun) {
const statusEl = document.getElementById(`drift-sig-status-${signalId}`);
if (statusEl) statusEl.textContent = dryRun ? '⟳ Planning...' : '⟳ Running...';
try {
// Auto-plan first
await fetch(`${API}/api/cto/portfolio/drift/${signalId}/auto-plan`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
// Then run (or dry-run)
const r = await fetch(`${API}/api/cto/portfolio/drift/${signalId}/auto-run?dry_run=${dryRun}`, {
method: 'POST', headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
const fired = d.fired || [];
const skipped = d.skipped || [];
if (statusEl) {
if (dryRun) {
statusEl.textContent = `📋 Planned: ${fired.length} run(s)`;
} else {
const done = fired.filter(f => f.status === 'running' || f.status === 'done').length;
const failed = fired.filter(f => f.status === 'failed').length;
statusEl.textContent = `✓ Fired: ${done} ${failed ? '⚠️ failed:'+failed : ''} skip:${skipped.length}`;
}
}
// Reload to show updated status
setTimeout(portLoadDriftSignals, 800);
} catch(e) {
if (statusEl) statusEl.textContent = '⚠️ ' + e.message;
}
}
// ── Autopilot mode (localStorage, no DB) ────────────────────────────────────
const _AUTOPILOT_MODES = ['OFF', 'SUGGEST', 'ARMED'];
const _AUTOPILOT_COLORS = {OFF:'var(--muted)', SUGGEST:'var(--accent)', ARMED:'var(--warn)'};
let _autopilotArmedTimer = null;
function _getAutopilotMode() { return localStorage.getItem('autopilot_mode') || 'OFF'; }
function _setAutopilotMode(m) {
localStorage.setItem('autopilot_mode', m);
const pill = document.getElementById('autopilotPill');
if (pill) { pill.textContent = m; pill.style.color = _AUTOPILOT_COLORS[m] || 'var(--muted)'; }
}
function cycleAutopilot() {
const cur = _getAutopilotMode();
const idx = _AUTOPILOT_MODES.indexOf(cur);
const next = _AUTOPILOT_MODES[(idx + 1) % _AUTOPILOT_MODES.length];
_setAutopilotMode(next);
_logAutopilotChange(cur, next);
const armedEl = document.getElementById('autopilotArmed');
if (next === 'ARMED') {
if (_autopilotArmedTimer) clearTimeout(_autopilotArmedTimer);
let remaining = 10 * 60;
const tick = () => {
if (remaining <= 0) { _setAutopilotMode('SUGGEST'); if (armedEl) armedEl.style.display='none'; return; }
if (armedEl) { armedEl.style.display='inline'; armedEl.textContent = `Armed: ${Math.floor(remaining/60)}:${String(remaining%60).padStart(2,'0')}`; }
remaining--;
_autopilotArmedTimer = setTimeout(tick, 1000);
};
tick();
} else {
if (armedEl) armedEl.style.display = 'none';
if (_autopilotArmedTimer) { clearTimeout(_autopilotArmedTimer); _autopilotArmedTimer = null; }
}
}
async function portLoadSignals() {
const status = document.getElementById('portSigFilter').value;
try {
const r = await fetch(`${API}/api/cto/portfolio/signals?status=${status}&limit=30`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const data = await r.json();
portRenderSignals(data.signals || []);
} catch(e) {}
}
function portRenderSignals(signals) {
const el = document.getElementById('portSignalsList');
if (!signals.length) {
el.innerHTML = '<div style="color:var(--ok);font-size:0.82rem;">✓ Немає сигналів</div>';
return;
}
const sevColor = { critical: 'var(--err)', high: 'var(--warn)', medium: 'var(--gold)', low: 'var(--muted)' };
const statusBadge = { open: '🔴', ack: '🟡', resolved: '✅', dismissed: '⬜' };
el.innerHTML = signals.slice(0, 20).map(s => `
<div onclick="portJumpToProject('${s.project_id}')"
style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;cursor:pointer;transition:border-color 0.15s;"
onmouseover="this.style.borderColor='var(--gold)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;flex-wrap:wrap;">
<span>${statusBadge[s.status] || '○'}</span>
<span style="padding:1px 5px;border-radius:3px;font-size:0.68rem;font-weight:700;background:${sevColor[s.severity]}22;color:${sevColor[s.severity]};">${s.severity.toUpperCase()}</span>
<span style="font-size:0.8rem;font-weight:600;flex:1;">${s.title}</span>
</div>
<div style="font-size:0.72rem;color:var(--muted);">📁 ${s.project_name} · ${s.signal_type} · ${s.created_at?.slice(0,10)}</div>
</div>`).join('');
}
function portOpenProject(pid, name) {
// Switch to CTO tab and select the project
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
const ctoBtn = document.querySelector('[data-tab="cto"]');
if (ctoBtn) ctoBtn.classList.add('active');
const ctoSec = document.getElementById('section-cto');
if (ctoSec) ctoSec.classList.add('active');
const sel = document.getElementById('ctoProjectSel');
if (sel) { sel.value = pid; _ctoProjectId = pid; }
// Persist last used project
localStorage.setItem('sofiia_cto_project_id', pid);
initCtoDashboard();
}
function portJumpToProject(pid) {
portOpenProject(pid, '');
}
function portOpenProjectLesson(pid) {
portOpenProject(pid, '');
// After CTO loads, open latest lesson
setTimeout(() => ctoOpenLatestLesson(), 800);
}
// ── Lessons ───────────────────────────────────────────────────────────────────
let _ctoLessonNodeId = null;
async function ctoLoadLessons() {
const el = document.getElementById('ctoLessonsList');
if (!el || !_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/lessons?limit=5`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) { el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">—</div>'; return; }
const data = await r.json();
const lessons = data.lessons || [];
if (!lessons.length) {
el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Немає lessons. Натисніть «⚡ Generate».</div>';
return;
}
const qC = q => q == null ? 'var(--muted)' : q >= 0.7 ? 'var(--ok)' : q >= 0.5 ? 'var(--warn)' : 'var(--err)';
function _deltaArrow(delta, metric) {
if (!delta) return '';
const d = delta[metric]; if (!d) return '';
const t = d.trend;
const goodDown = ['risk_open','ops_failure_rate','cycle_time_h'];
const goodUp = ['quality_avg','done'];
if (t === 'flat' || t === 'new') return '<span style="color:var(--muted)">→</span>';
const isGood = (goodDown.includes(metric) && t==='down') || (goodUp.includes(metric) && t==='up');
return isGood
? `<span style="color:var(--ok)">${t==='up'?'↑':'↓'}</span>`
: `<span style="color:var(--err)">${t==='up'?'↑':'↓'}</span>`;
}
el.innerHTML = lessons.map(l => {
const m = l.metrics || {};
const delta = m.delta || null;
const tf = m.trend_flags || {};
const age = _portSnapshotAge(l.updated_at);
const q = m.run_quality_avg;
const impScore = (l.impact_score != null && l.impact_score !== 0)
? `<span style="margin-left:6px;font-size:0.68rem;color:${l.impact_score>0?'var(--ok)':l.impact_score<0?'var(--err)':'var(--muted)'}">⚡${l.impact_score>0?'+':''}${l.impact_score?.toFixed(2)}</span>`
: '';
const riskBadge = delta ? _deltaArrow(delta,'risk_open') : '';
const qualBadge = delta ? _deltaArrow(delta,'quality_avg') : '';
const opsBadge = delta ? _deltaArrow(delta,'ops_failure_rate') : '';
return `<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:7px 10px;cursor:pointer;"
onmouseover="this.style.borderColor='var(--gold)'" onmouseout="this.style.borderColor='var(--border)'"
onclick="ctoOpenLesson('${l.lesson_id}')">
<div style="display:flex;align-items:center;gap:6px;justify-content:space-between;">
<span style="font-size:0.8rem;font-weight:600;color:var(--gold);">${l.date_bucket}</span>
<span style="font-size:0.68rem;display:flex;gap:3px;align-items:center;">
${riskBadge ? `<span title="Risk">${riskBadge}</span>` : ''}
${qualBadge ? `<span title="Quality">${qualBadge}</span>` : ''}
${opsBadge ? `<span title="Ops">${opsBadge}</span>` : ''}
${impScore}
<span style="color:var(--muted)">${age}</span>
</span>
</div>
<div style="font-size:0.7rem;color:var(--muted);margin-top:2px;">
signals: ${m.signals_in_window ?? 0} · quality: ${q != null ? Math.round(q*100)+'%' : '—'}
· tasks: ${m.improvement_tasks_count ?? 0}
</div>
</div>`;
}).join('');
} catch(e) {
if (el) el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">—</div>';
}
}
async function ctoOpenLatestLesson() {
if (!_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/lessons?limit=1`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const data = await r.json();
if (data.lessons && data.lessons.length > 0) {
ctoOpenLesson(data.lessons[0].lesson_id);
}
} catch(e) {}
}
async function ctoOpenLesson(lessonId) {
if (!_ctoProjectId || !lessonId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/lessons/${lessonId}`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const l = await r.json();
_ctoLessonNodeId = l.lesson_node_id || null;
// Populate drawer
const metaEl = document.getElementById('ctoLessonDrawerMeta');
if (metaEl) {
metaEl.textContent = `${l.date_bucket} · ${l.window} · ${l.status} · updated ${_portSnapshotAge(l.updated_at)}`;
}
// Improvement tasks
const itEl = document.getElementById('ctoLessonDrawerImprovements');
const itList = document.getElementById('ctoLessonImprovementList');
const taskIds = l.improvement_task_ids || [];
if (taskIds.length && itEl && itList) {
itEl.style.display = 'block';
itList.innerHTML = taskIds.map(tid =>
`<div style="font-size:0.75rem;padding:4px 6px;background:var(--bg);border-radius:4px;border:1px solid var(--border);">📌 ${tid}</div>`
).join('');
} else if (itEl) {
itEl.style.display = 'none';
}
// Evidence signals
const sigEl = document.getElementById('ctoLessonDrawerSignals');
const sigList = document.getElementById('ctoLessonSignalLinks');
const sigIds = l.linked_signal_ids || [];
if (sigIds.length && sigEl && sigList) {
sigEl.style.display = 'block';
sigList.innerHTML = sigIds.slice(0, 8).map(sid =>
`<div style="cursor:pointer;color:var(--accent);" onclick="ctoOpenLessonSignal('${sid}')">🔗 ${sid.slice(0,16)}…</div>`
).join('');
} else if (sigEl) {
sigEl.style.display = 'none';
}
// Markdown
const mdEl = document.getElementById('ctoLessonMarkdown');
if (mdEl) mdEl.textContent = l.markdown || '(no content)';
// Trend section
const trendEl = document.getElementById('ctoLessonTrendSection');
if (trendEl) {
const delta = l.delta;
const tf = l.trend_flags;
const prev = l.previous;
if (delta && prev) {
function _dRow(label, metric, fmtFn) {
const d = delta[metric] || {};
const t = d.trend;
const curr = l.current?.[metric];
const pv = prev[metric];
const goodDown = ['risk_open','ops_failure_rate','cycle_time_h'];
const goodUp = ['quality_avg','done'];
let arrowHtml = '<span style="color:var(--muted)">→</span>';
if (t === 'up' || t === 'down') {
const isGood = (goodDown.includes(metric) && t==='down') || (goodUp.includes(metric) && t==='up');
const color = isGood ? 'var(--ok)' : 'var(--err)';
arrowHtml = `<span style="color:${color}">${t==='up'?'↑':'↓'}</span>`;
}
const absV = d.abs != null ? (d.abs > 0 ? '+' : '') + d.abs.toFixed(2) : '—';
return `<tr>
<td style="padding:2px 8px 2px 0;color:var(--muted);font-size:0.72rem;">${label}</td>
<td style="padding:2px 4px;font-size:0.72rem;">${pv != null ? fmtFn(pv) : '—'}</td>
<td style="padding:2px 4px;font-size:0.72rem;">${curr != null ? fmtFn(curr) : '—'}</td>
<td style="padding:2px 4px;font-size:0.72rem;color:var(--muted);">${absV}</td>
<td style="padding:2px 4px;">${arrowHtml}</td>
</tr>`;
}
const pct = v => v != null ? Math.round(v*100)+'%' : '—';
const num = v => v != null ? String(Math.round(v*10)/10) : '—';
let improving = [], regressing = [];
if (tf) {
improving = Object.keys(tf).filter(k => k.endsWith('_improving') && tf[k]).map(k => k.replace('_improving',''));
regressing = Object.keys(tf).filter(k => k.endsWith('_regressing') && tf[k]).map(k => k.replace('_regressing',''));
}
trendEl.innerHTML = `
<div style="font-size:0.72rem;color:var(--muted);margin-bottom:6px;">vs <strong>${prev.date_bucket || '?'}</strong></div>
<table style="border-collapse:collapse;width:100%;">
<thead><tr>
<th style="text-align:left;font-size:0.68rem;color:var(--muted);padding-bottom:3px;">Metric</th>
<th style="font-size:0.68rem;color:var(--muted);">Prev</th>
<th style="font-size:0.68rem;color:var(--muted);">Now</th>
<th style="font-size:0.68rem;color:var(--muted);">Δ</th>
<th style="font-size:0.68rem;color:var(--muted);"></th>
</tr></thead>
<tbody>
${_dRow('[RISK] open','risk_open',num)}
${_dRow('Quality','quality_avg',pct)}
${_dRow('Ops failure','ops_failure_rate',pct)}
${_dRow('Done','done',num)}
${_dRow('WIP','wip',num)}
</tbody>
</table>
${improving.length ? `<div style="margin-top:6px;font-size:0.72rem;">✅ Improving: <strong>${improving.join(', ')}</strong></div>` : ''}
${regressing.length ? `<div style="font-size:0.72rem;">⚠️ Regressing: <strong style="color:var(--err)">${regressing.join(', ')}</strong></div>` : ''}
`;
trendEl.style.display = 'block';
} else {
trendEl.innerHTML = '<div style="font-size:0.72rem;color:var(--muted);">Немає попереднього bucket для порівняння.</div>';
trendEl.style.display = 'block';
}
}
// Impact section
const impEl = document.getElementById('ctoLessonImpactSection');
if (impEl) {
const imp = l.impact;
const score = l.impact_score;
if (imp && imp.evaluated_bucket) {
const sColor = score > 0.5 ? 'var(--ok)' : score > 0 ? 'var(--warn)' : score < 0 ? 'var(--err)' : 'var(--muted)';
// Attribution
const attr = imp.attribution || {};
const improv = imp.improvements || {};
const attrLevel = attr.level || 'unknown';
const attrColor = attrLevel === 'strong' ? 'var(--ok)' : attrLevel === 'weak' ? 'var(--warn)' : 'var(--muted)';
const attrLabel = { strong: '💪 Strong', weak: '⚡ Weak', unknown: '? Unknown' }[attrLevel] || attrLevel;
const compRatio = improv.completion_ratio;
const compPct = compRatio != null ? Math.round(compRatio * 100) + '%' : '—';
const taskCount = (improv.task_ids || []).length;
const doneCount = (improv.done_task_ids || []).length;
const compBar = compRatio != null
? `<div style="height:4px;background:var(--border);border-radius:2px;margin:4px 0;"><div style="height:100%;width:${Math.round(compRatio*100)}%;background:${attrColor};border-radius:2px;"></div></div>`
: '';
impEl.innerHTML = `
<div style="font-size:0.72rem;color:var(--muted);margin-bottom:4px;">Оцінено по bucket <strong>${imp.evaluated_bucket}</strong></div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="font-size:0.9rem;font-weight:600;color:${sColor}">Score: ${score > 0 ? '+' : ''}${score?.toFixed(3) ?? '—'}</div>
<span style="font-size:0.72rem;color:${attrColor};background:rgba(0,0,0,0.15);padding:1px 6px;border-radius:10px;">${attrLabel}</span>
</div>
${compBar}
<div style="font-size:0.7rem;color:var(--muted);">
Tasks: ${doneCount}/${taskCount} done (${compPct}) · attr: ${attr.rule || '—'}
</div>
<div style="font-size:0.7rem;color:var(--muted);margin-top:3px;">
Risk Δ: ${imp.risk_open_delta ?? '—'} · Ops Δ: ${typeof imp.ops_failure_delta === 'number' ? imp.ops_failure_delta.toFixed(3) : '—'} · Quality Δ: ${typeof imp.quality_delta === 'number' ? imp.quality_delta.toFixed(3) : '—'}
</div>
`;
impEl.style.display = 'block';
} else {
impEl.innerHTML = '<div style="font-size:0.72rem;color:var(--muted);">Impact буде розраховано після наступного bucket.</div>';
impEl.style.display = 'block';
}
}
// Show drawer
document.getElementById('ctoLessonDrawer').style.display = 'block';
} catch(e) {
alert('Помилка: ' + e.message);
}
}
async function ctoOpenLessonSignal(signalId) {
if (!_ctoProjectId) return;
try {
const r = await fetch(`${API}/api/projects/${_ctoProjectId}/graph/signals?status=all`, {
headers: { 'X-API-Key': getApiKey() }
});
if (!r.ok) return;
const data = await r.json();
const sig = (data.signals || []).find(s => s.id === signalId);
if (sig) {
document.getElementById('ctoLessonDrawer').style.display = 'none';
ctoOpenSignal(JSON.stringify(sig));
}
} catch(e) {}
}
function ctoFocusLessonNode() {
if (_ctoLessonNodeId) {
ctoHighlightNodes([_ctoLessonNodeId]);
}
document.getElementById('ctoLessonDrawer').style.display = 'none';
}
async function ctoRecomputeImpact() {
if (!_ctoProjectId) return;
const impEl = document.getElementById('ctoLessonImpactSection');
if (impEl) impEl.innerHTML = '<div style="font-size:0.72rem;color:var(--muted);">⟳ Розрахунок...</div>';
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/lessons/impact/recompute?force=true`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
const data = r.ok ? await r.json() : {};
const res = data.result;
if (impEl) {
if (res) {
const score = res.impact_score ?? 0;
const sColor = score > 0.5 ? 'var(--ok)' : score > 0 ? 'var(--warn)' : score < 0 ? 'var(--err)' : 'var(--muted)';
const ij = res.impact_json || {};
const attr = ij.attribution || {};
const improv = ij.improvements || {};
const attrLevel = attr.level || 'unknown';
const attrColor = attrLevel === 'strong' ? 'var(--ok)' : attrLevel === 'weak' ? 'var(--warn)' : 'var(--muted)';
const attrLabel = { strong: '💪 Strong', weak: '⚡ Weak', unknown: '? Unknown' }[attrLevel] || attrLevel;
const compRatio = improv.completion_ratio;
const compPct = compRatio != null ? Math.round(compRatio * 100) + '%' : '—';
impEl.innerHTML = `
<div style="font-size:0.72rem;color:var(--muted);">✅ Оновлено · bucket <strong>${ij.evaluated_bucket || '—'}</strong></div>
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
<span style="font-size:0.9rem;font-weight:600;color:${sColor}">Score: ${score>0?'+':''}${score.toFixed(3)}</span>
<span style="font-size:0.72rem;color:${attrColor};background:rgba(0,0,0,0.15);padding:1px 6px;border-radius:10px;">${attrLabel}</span>
</div>
<div style="font-size:0.7rem;color:var(--muted);margin-top:3px;">
Tasks: ${(improv.done_task_ids||[]).length}/${(improv.task_ids||[]).length} done (${compPct})
</div>
<div style="font-size:0.7rem;color:var(--muted);">
Risk Δ: ${ij.risk_open_delta ?? '—'} · Ops Δ: ${typeof ij.ops_failure_delta === 'number' ? ij.ops_failure_delta.toFixed(3) : '—'} · Quality Δ: ${typeof ij.quality_delta === 'number' ? ij.quality_delta.toFixed(3) : '—'}
</div>`;
} else {
impEl.innerHTML = '<div style="font-size:0.72rem;color:var(--muted);">Недостатньо даних (потрібно 2+ lessons).</div>';
}
impEl.style.display = 'block';
}
} catch(e) {
if (impEl) impEl.innerHTML = `<div style="font-size:0.72rem;color:var(--err);">${e.message}</div>`;
}
}
async function ctoGenerateLesson(dryRun) {
if (!_ctoProjectId) { alert('Оберіть проєкт'); return; }
const el = document.getElementById('ctoLessonsList');
if (el) el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">⟳ Генерація...</div>';
try {
const r = await fetch(
`${API}/api/projects/${_ctoProjectId}/lessons/generate?dry_run=${dryRun}`,
{ method: 'POST', headers: { 'X-API-Key': getApiKey() } }
);
const data = r.ok ? await r.json() : {};
if (dryRun) {
// Show dry-run result in lesson drawer
const mdEl = document.getElementById('ctoLessonMarkdown');
if (mdEl) mdEl.textContent = data.markdown || '(empty)';
const metaEl = document.getElementById('ctoLessonDrawerMeta');
if (metaEl) {
const tasks = (data.planned_improvement_tasks || []).length;
metaEl.textContent = `[DRY-RUN] ${data.date_bucket} · ${tasks} improvement tasks planned · ${(data.evidence?.signal_ids || []).length} signals`;
}
const itEl = document.getElementById('ctoLessonDrawerImprovements');
const itList = document.getElementById('ctoLessonImprovementList');
if (itEl && itList) {
const tasks = data.planned_improvement_tasks || [];
if (tasks.length) {
itEl.style.display = 'block';
itList.innerHTML = tasks.map(t =>
`<div style="font-size:0.75rem;padding:4px 6px;background:var(--bg);border-radius:4px;border:1px solid var(--border);">📌 ${t.title}</div>`
).join('');
} else {
itEl.style.display = 'none';
}
}
document.getElementById('ctoLessonDrawer').style.display = 'block';
if (el) el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Dry-run complete. Check drawer.</div>';
} else {
if (r.ok) {
await ctoLoadLessons();
if (data.lesson_id) ctoOpenLesson(data.lesson_id);
} else {
if (el) el.innerHTML = `<div style="color:var(--err);font-size:0.78rem;">Помилка: ${data.detail || 'unknown'}</div>`;
}
}
} catch(e) {
if (el) el.innerHTML = `<div style="color:var(--err);font-size:0.78rem;">Помилка: ${e.message}</div>`;
}
}
// ── Governance Gates ─────────────────────────────────────────────────────────
async function ctoEvaluateGates(dryRun) {
if (!_ctoProjectId) { alert('Оберіть проєкт'); return; }
const panel = document.getElementById('ctoGatesPanel');
if (panel) panel.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">⟳ Оцінка...</div>';
try {
const endpoint = dryRun
? `${API}/api/projects/${_ctoProjectId}/governance/gates`
: `${API}/api/projects/${_ctoProjectId}/governance/gates/evaluate?dry_run=false`;
const method = dryRun ? 'GET' : 'POST';
const r = await fetch(endpoint, { method, headers: { 'X-API-Key': getApiKey() } });
if (!r.ok) { if (panel) panel.innerHTML = '<div style="color:var(--err);">Помилка</div>'; return; }
const data = await r.json();
const gates = data.gates || [];
const statusColor = s => s === 'PASS' ? 'var(--ok)' : s === 'BLOCKED' || s === 'FROZEN' ? 'var(--err)' : 'var(--warn)';
const statusIcon = s => s === 'PASS' ? '✓' : '⛔';
if (panel) {
panel.innerHTML = gates.map(g => `
<div style="background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:6px 8px;font-size:0.75rem;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-weight:600;font-size:0.72rem;">${g.name}</span>
<span style="color:${statusColor(g.status)};font-weight:700;font-size:0.72rem;">${statusIcon(g.status)} ${g.status}</span>
</div>
<div style="color:var(--muted);margin-top:2px;font-size:0.7rem;">${g.reason || ''}</div>
</div>
`).join('') + (data.dry_run ? '<div style="color:var(--muted);font-size:0.65rem;margin-top:4px;">Preview only — not saved</div>' : '');
}
// Show summary notification
const blocked = (data.summary || {}).blocked || [];
if (panel) {
const note = document.createElement('div');
note.style.cssText = `font-size:0.7rem;font-weight:600;margin-top:4px;color:${blocked.length ? 'var(--warn)' : 'var(--ok)'};`;
note.textContent = blocked.length ? `⚠️ Blocked: ${blocked.join(', ')}` : '🛡 All gates pass';
panel.appendChild(note);
}
} catch(e) {
if (panel) panel.innerHTML = `<div style="color:var(--err);">Помилка: ${e.message}</div>`;
}
}
// ── Audit Drawer ─────────────────────────────────────────────────────────────
let _auditTab = 'project';
function auditOpenDrawer() {
const d = document.getElementById('auditDrawer');
if (d) { d.style.display = 'flex'; }
auditLoad();
}
function auditCloseDrawer() {
const d = document.getElementById('auditDrawer');
if (d) d.style.display = 'none';
}
function auditSwitchTab(tab) {
_auditTab = tab;
const proj = document.getElementById('auditTabProject');
const port = document.getElementById('auditTabPortfolio');
if (proj) proj.style.borderBottomColor = tab === 'project' ? 'var(--gold)' : 'transparent';
if (port) port.style.borderBottomColor = tab === 'portfolio' ? 'var(--gold)' : 'transparent';
auditLoad();
}
function _auditSinceISO(range) {
if (!range) return '';
const now = new Date();
const mul = range.endsWith('h') ? 3600000 : range.endsWith('d') ? 86400000 : 0;
const n = parseInt(range) || 0;
if (!mul || !n) return '';
return new Date(now - n * mul).toISOString().replace('T', ' ').slice(0, 19) + 'Z';
}
async function auditLoad() {
const el = document.getElementById('auditEventsList');
if (!el) return;
el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">⟳ Завантаження...</div>';
const eventType = (document.getElementById('auditFilterType') || {}).value || '';
const status = (document.getElementById('auditFilterStatus') || {}).value || '';
const sinceRange = (document.getElementById('auditFilterSince') || {}).value || '24h';
const sinceISO = _auditSinceISO(sinceRange);
let url;
if (_auditTab === 'portfolio') {
url = `${API}/api/cto/audit/events?scope=portfolio&limit=100`;
} else {
if (!_ctoProjectId) { el.innerHTML = '<div style="color:var(--muted);">Оберіть проєкт</div>'; return; }
url = `${API}/api/projects/${_ctoProjectId}/audit/events?limit=100`;
}
if (eventType) url += `&event_type=${encodeURIComponent(eventType)}`;
if (status) url += `&status=${encodeURIComponent(status)}`;
if (sinceISO) url += `&since=${encodeURIComponent(sinceISO)}`;
try {
const r = await fetch(url, { headers: { 'X-API-Key': getApiKey() } });
if (!r.ok) { el.innerHTML = '<div style="color:var(--err);">Помилка завантаження</div>'; return; }
const data = await r.json();
const items = data.items || [];
if (!items.length) { el.innerHTML = '<div style="color:var(--muted);font-size:0.78rem;">Немає подій</div>'; return; }
el.innerHTML = items.map(ev => _auditRenderEvent(ev)).join('');
} catch(e) {
el.innerHTML = `<div style="color:var(--err);">Помилка: ${e.message}</div>`;
}
}
function _auditTimeAgo(isoStr) {
try {
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000;
if (diff < 60) return `${Math.round(diff)}s ago`;
if (diff < 3600) return `${Math.round(diff/60)}m ago`;
if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
return `${Math.round(diff/86400)}d ago`;
} catch { return isoStr || ''; }
}
function _auditSevColor(sev) {
return sev === 'critical' ? 'var(--err)' : sev === 'high' ? 'var(--warn)' : sev === 'warn' ? 'var(--warn)' : 'var(--muted)';
}
function _auditRenderEvent(ev) {
const evId = (ev.event_id || '').slice(0, 8);
const evidence = ev.evidence || {};
const msg = evidence.message || '';
const runId = (evidence.links || {}).run_id;
const jsonStr = JSON.stringify(evidence, null, 2);
const escapedJson = jsonStr.replace(/</g,'&lt;').replace(/>/g,'&gt;');
const statusColor = ev.status === 'error' ? 'var(--err)' : ev.status === 'skipped' ? 'var(--muted)' : 'var(--ok)';
return `<div style="background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:6px 8px;font-size:0.72rem;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:6px;flex-wrap:wrap;">
<div>
<span style="color:${_auditSevColor(ev.severity)};font-weight:600;">${ev.event_type || '?'}</span>
<span style="color:${statusColor};margin-left:4px;font-size:0.65rem;">[${ev.status || 'ok'}]</span>
</div>
<span style="color:var(--muted);font-size:0.65rem;white-space:nowrap;">${_auditTimeAgo(ev.created_at)}</span>
</div>
${msg ? `<div style="color:var(--muted);margin-top:2px;">${msg}</div>` : ''}
${runId ? `<div style="margin-top:3px;"><span style="color:var(--accent);font-size:0.65rem;cursor:pointer;" onclick="alert('Run: ${runId}')">▶ run: ${runId.slice(0,12)}...</span></div>` : ''}
<div style="margin-top:4px;">
<button class="btn btn-ghost btn-sm" style="font-size:0.6rem;padding:1px 4px;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">JSON ▾</button>
<pre style="display:none;margin-top:4px;font-size:0.62rem;white-space:pre-wrap;background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:6px;max-height:160px;overflow-y:auto;">${escapedJson}</pre>
</div>
</div>`;
}
// Hook: log autopilot mode changes
const _origCycleAutopilot = typeof cycleAutopilot === 'function' ? cycleAutopilot : null;
async function _logAutopilotChange(from, to) {
try {
// POST a fire-and-forget style — we call the gates endpoint with a dummy request to trigger DB write
// Since we have no direct JS→DB path, we use a lightweight local mechanism via fetch
// (optional: wire to a /api/cto/audit/autopilot endpoint if needed)
// For now just store in localStorage for display
const stored = JSON.parse(localStorage.getItem('audit_local') || '[]');
stored.unshift({event_type:'autopilot_mode_changed', severity:'info', status:'ok',
created_at: new Date().toISOString(), evidence:{message:`Autopilot: ${from}${to}`, links:{},
inputs:{from,to}, outputs:{}}});
localStorage.setItem('audit_local', JSON.stringify(stored.slice(0,50)));
} catch(e) {}
}
// Init autopilot pill on page load
document.addEventListener('DOMContentLoaded', () => {
const m = _getAutopilotMode();
const pill = document.getElementById('autopilotPill');
if (pill) { pill.textContent = m; pill.style.color = _AUTOPILOT_COLORS[m] || 'var(--muted)'; }
// Load build version info
_loadBuildVersion();
// Auto-refresh agents if Agents tab is visible
setInterval(() => {
if (_projectsMode === 'agents' &&
document.getElementById('section-projects')?.style.display !== 'none') {
agentsLoad();
}
}, 30000); // 30s auto-refresh when Agents tab open
});
// ── Build version footer ───────────────────────────────────────────────────────
async function _loadBuildVersion() {
try {
const r = await fetch(`${API}/api/meta/version`, {
headers: {'X-API-Key': getApiKey()}, cache: 'no-store'
});
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('buildVersionBadge');
if (el) {
const sha = (d.build_sha || 'dev').substring(0, 7);
const t = d.build_time || 'local';
el.textContent = `v${d.version} · ${sha} · ${t}`;
el.title = `build_sha: ${d.build_sha}\nbuild_time: ${d.build_time}`;
}
} catch(e) {}
}
// ── Budget Dashboard ──────────────────────────────────────────────────────────
const PROVIDER_COLORS = {
anthropic: '#7c3aed',
grok: '#f59e0b',
deepseek: '#3b82f6',
mistral: '#06b6d4',
openai: '#10b981',
ollama: '#6b7280',
};
async function budgetLoadCatalog(refreshOllama = false) {
const listEl = document.getElementById('catalogList');
const countEl = document.getElementById('catalogCount');
if (listEl) listEl.innerHTML = '<div style="color:var(--muted);font-size:0.82rem;">Завантаження...</div>';
try {
const r = await apiFetch(`/api/sofiia/catalog?refresh_ollama=${refreshOllama}`);
if (!r || r.error) {
if (listEl) listEl.innerHTML = `<div style="color:#f87171;">${r?.error || 'Недоступно'}</div>`;
return;
}
const models = r.models || [];
if (countEl) countEl.textContent = `${r.available_count}/${r.total} доступно`;
const PROVIDER_LABEL = {
anthropic: '🟣 Anthropic', grok: '⚡ Grok', deepseek: '🔵 DeepSeek',
glm: '🐉 GLM/Z.AI', mistral: '🌊 Mistral', openai: '🟢 OpenAI', ollama: '🖥️ Local',
};
const TIER_ICON = ['🆓', '💚', '💛', '🔴'];
if (listEl) listEl.innerHTML = models.map(m => {
const avail = m.available
? `<span style="color:#4ade80;font-size:0.7rem;">✓</span>`
: `<span style="color:#f87171;font-size:0.7rem;">✗</span>`;
const local = m.local ? '<span style="font-size:0.68rem;background:#1e3a5f;color:#60a5fa;border-radius:3px;padding:0 4px;">local</span>' : '';
const provider = PROVIDER_LABEL[m.provider] || m.provider;
const cost = TIER_ICON[m.cost_tier] || '';
const strengths = (m.strengths || []).slice(0, 3).map(s =>
`<span style="font-size:0.68rem;background:var(--bg2);border-radius:3px;padding:0 4px;">${s}</span>`
).join('');
return `<div style="display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;background:var(--bg2);font-size:0.8rem;">
${avail} ${cost}
<span style="font-weight:600;min-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${m.model_id}</span>
<span style="color:var(--muted);font-size:0.72rem;min-width:90px;">${provider}</span>
${local}
<span style="color:var(--muted);font-size:0.68rem;">${m.context_k}K ctx</span>
<span style="display:flex;gap:3px;flex-wrap:wrap;">${strengths}</span>
</div>`;
}).join('');
} catch(e) {
if (listEl) listEl.innerHTML = `<div style="color:#f87171;">Помилка: ${e.message}</div>`;
}
}
async function budgetLoad() {
const statusEl = document.getElementById('budgetStatus');
if (statusEl) statusEl.textContent = 'Завантаження...';
try {
const resp = await apiFetch('/api/sofiia/budget');
if (!resp || resp.error) {
document.getElementById('budgetProviderList').innerHTML =
`<div style="color:var(--muted);text-align:center;padding:20px;">⚠️ ${resp?.error || 'Недоступно'}</div>`;
if (statusEl) statusEl.textContent = 'Помилка';
return;
}
_budgetRenderSummary(resp.summary || {});
_budgetRenderProviders(resp.providers || []);
if (statusEl) statusEl.textContent = `Оновлено ${new Date().toLocaleTimeString('uk-UA')}`;
} catch(e) {
if (statusEl) statusEl.textContent = 'Помилка: ' + e.message;
}
}
function _budgetRenderSummary(s) {
const fmt = v => `$${(v || 0).toFixed(5)}`;
const el24 = document.getElementById('budgetTotal24h');
const el7d = document.getElementById('budgetTotal7d');
const el30d = document.getElementById('budgetTotal30d');
if (el24) el24.textContent = fmt(s.total_cost_24h);
if (el7d) el7d.textContent = fmt(s.total_cost_7d);
if (el30d) el30d.textContent = fmt(s.total_cost_30d);
}
function _budgetRenderProviders(providers) {
const list = document.getElementById('budgetProviderList');
if (!list) return;
if (!providers.length) {
list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px;">Нема даних (ще не було запитів)</div>';
return;
}
list.innerHTML = providers.map(p => {
const color = PROVIDER_COLORS[p.provider] || '#888';
const avail = p.available
? '<span style="background:#16a34a22;color:#4ade80;padding:2px 8px;border-radius:10px;font-size:0.72rem;">✓ Активний</span>'
: '<span style="background:#7f1d1d22;color:#f87171;padding:2px 8px;border-radius:10px;font-size:0.72rem;">✗ Немає ключа</span>';
// Balance bar
let balanceBar = '';
if (p.topup_balance_usd != null && p.topup_balance_usd > 0) {
const spent = p.cost_30d || 0;
const total = p.topup_balance_usd;
const pct = Math.min(100, (spent / total) * 100);
const remaining = Math.max(0, total - spent);
const barColor = pct > 80 ? '#ef4444' : pct > 60 ? '#f59e0b' : '#4ade80';
balanceBar = `
<div style="margin-top:8px;">
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:var(--muted);margin-bottom:3px;">
<span>Залишок балансу</span>
<span style="font-weight:600;color:${barColor};">$${remaining.toFixed(4)} / $${total.toFixed(2)}</span>
</div>
<div style="height:6px;background:var(--bg2);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.5s;"></div>
</div>
</div>`;
}
// Monthly limit bar
let limitBar = '';
if (p.monthly_limit_usd != null && p.monthly_limit_usd > 0) {
const spent = p.cost_30d || 0;
const total = p.monthly_limit_usd;
const pct = Math.min(100, (spent / total) * 100);
const barColor = pct > 80 ? '#ef4444' : pct > 60 ? '#f59e0b' : '#4ade80';
limitBar = `
<div style="margin-top:6px;">
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:var(--muted);margin-bottom:3px;">
<span>Місячний ліміт</span>
<span style="font-weight:600;color:${barColor};">${pct.toFixed(1)}% ($${spent.toFixed(4)} / $${total.toFixed(2)})</span>
</div>
<div style="height:6px;background:var(--bg2);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.5s;"></div>
</div>
</div>`;
}
// Top models
const topModels = (p.top_models || []).slice(0, 3).map(m =>
`<span style="font-size:0.72rem;background:var(--bg2);border-radius:4px;padding:1px 6px;">${m.model}</span>`
).join(' ');
return `
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:14px;border-left:3px solid ${color};">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;">
<span style="font-size:1.1rem;">${p.icon}</span>
<span style="font-weight:600;font-size:0.92rem;">${p.display_name}</span>
${avail}
<span style="margin-left:auto;font-size:0.75rem;color:var(--muted);">${p.calls_30d || 0} запитів / 30д</span>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:6px;">
<div style="text-align:center;background:var(--bg2);border-radius:6px;padding:6px;">
<div style="font-size:0.68rem;color:var(--muted);">24 год</div>
<div style="font-weight:700;color:${color};">$${(p.cost_24h||0).toFixed(5)}</div>
</div>
<div style="text-align:center;background:var(--bg2);border-radius:6px;padding:6px;">
<div style="font-size:0.68rem;color:var(--muted);">7 днів</div>
<div style="font-weight:700;color:${color};">$${(p.cost_7d||0).toFixed(5)}</div>
</div>
<div style="text-align:center;background:var(--bg2);border-radius:6px;padding:6px;">
<div style="font-size:0.68rem;color:var(--muted);">30 днів</div>
<div style="font-weight:700;color:${color};">$${(p.cost_30d||0).toFixed(5)}</div>
</div>
</div>
${topModels ? `<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap;">${topModels}</div>` : ''}
${balanceBar}
${limitBar}
</div>`;
}).join('');
}
function budgetShowLimitsModal() {
const m = document.getElementById('budgetLimitsModal');
if (m) { m.style.display = 'flex'; }
}
async function budgetSaveLimits() {
const provider = document.getElementById('limitProvider')?.value;
const monthly = parseFloat(document.getElementById('limitMonthly')?.value) || null;
const topup = parseFloat(document.getElementById('limitTopup')?.value) || null;
if (!provider) return;
try {
await apiFetch('/api/sofiia/budget/limits', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ provider, monthly_limit_usd: monthly, topup_balance_usd: topup }),
});
document.getElementById('budgetLimitsModal').style.display = 'none';
budgetLoad();
} catch(e) {
alert('Помилка: ' + e.message);
}
}
// ── Auto-Router test ──────────────────────────────────────────────────────────
async function autoRouterTest() {
const prompt = document.getElementById('autoRouterPrompt')?.value?.trim();
if (!prompt) return;
const resultEl = document.getElementById('autoRouterResult');
if (resultEl) { resultEl.style.display = 'block'; resultEl.innerHTML = '<span style="color:var(--muted);">Класифікація...</span>'; }
const body = {
prompt,
force_fast: document.getElementById('arForceFast')?.checked || false,
force_capable: document.getElementById('arForceCapable')?.checked || false,
prefer_local: document.getElementById('arPreferLocal')?.checked || false,
prefer_cheap: document.getElementById('arPreferCheap')?.checked || false,
};
try {
const r = await apiFetch('/api/sofiia/auto-route', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (!r || r.error) {
if (resultEl) resultEl.innerHTML = `<span style="color:#f87171;">⚠️ ${r?.error || 'Помилка'}</span>`;
return;
}
const taskBadge = `<span style="background:var(--bg2);border-radius:4px;padding:2px 8px;font-size:0.78rem;">${r.task_type}</span>`;
const complexBadge = `<span style="background:var(--bg2);border-radius:4px;padding:2px 8px;font-size:0.78rem;">${r.complexity}</span>`;
const provColor = PROVIDER_COLORS[r.provider] || '#888';
const candidates = (r.all_candidates || []).map((c, i) =>
`<span style="font-size:0.75rem;background:var(--bg2);border-radius:4px;padding:1px 6px;${i===0?'border:1px solid '+provColor:''}">${c}</span>`
).join(' ');
const fallbackNote = r.fallback_used ? '<span style="color:#f59e0b;font-size:0.75rem;">⚠️ fallback</span>' : '';
if (resultEl) resultEl.innerHTML = `
<div style="background:var(--bg2);border-radius:8px;padding:12px;border-left:3px solid ${provColor};">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;">
<span style="font-weight:700;color:${provColor};font-size:0.92rem;">→ ${r.model_id}</span>
<span style="font-size:0.78rem;color:var(--muted);">(${r.provider})</span>
${fallbackNote}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px;">
<span style="font-size:0.75rem;color:var(--muted);">Задача:</span>${taskBadge}
<span style="font-size:0.75rem;color:var(--muted);">Складність:</span>${complexBadge}
<span style="font-size:0.75rem;color:var(--muted);">Впевненість:</span><span style="font-size:0.78rem;">${(r.confidence*100).toFixed(0)}%</span>
</div>
<div style="font-size:0.75rem;color:var(--muted);margin-bottom:6px;">Причина: ${r.reason}</div>
<div style="font-size:0.75rem;color:var(--muted);">Кандидати: ${candidates}</div>
</div>`;
} catch(e) {
if (resultEl) resultEl.innerHTML = `<span style="color:#f87171;">Помилка: ${e.message}</span>`;
}
}
</script>
</body>
</html>