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
9287 lines
447 KiB
HTML
9287 lines
447 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="uk">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SOFIIA — Control Console</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
:root {
|
||
--bg: #1a1a1f;
|
||
--bg2: #27272a;
|
||
--bg3: #0f0f12;
|
||
--border: #3f3f46;
|
||
--text: #e0e0e0;
|
||
--muted: #71717a;
|
||
--gold: #c9a87c;
|
||
--gold2: #8b7355;
|
||
--ok: #22c55e;
|
||
--warn: #f59e0b;
|
||
--err: #ef4444;
|
||
--accent: #a78bfa;
|
||
}
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg3); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||
|
||
/* ── Header ── */
|
||
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>
|
||
| fetch: <span id="dbgFetchTs">—</span>
|
||
| nodes: <span id="dbgNodes">—</span>
|
||
| items: <span id="dbgCount">—</span>
|
||
| ok: <span id="dbgNodesOk">—</span>/<span id="dbgNodesTotal">—</span>
|
||
| errors: <span id="dbgErrors" style="color:var(--warn);">—</span>
|
||
| <button class="btn btn-ghost btn-sm" style="font-size:0.65rem; padding:1px 5px;" onclick="agentsLoad()">↻</button>
|
||
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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,'"')})"
|
||
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,'"')}"
|
||
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,'"')}"
|
||
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,'<').replace(/>/g,'>')}</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,'<')}</span>`;
|
||
if (l.startsWith('-')) return `<span style="color:var(--err);">${l.replace(/</g,'<')}</span>`;
|
||
if (l.startsWith('@')) return `<span style="color:var(--accent);">${l.replace(/</g,'<')}</span>`;
|
||
return `<span style="color:var(--muted);">${l.replace(/</g,'<')}</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,'<')}</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ─── 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> ${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,'"')})">${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,'<').replace(/>/g,'>');
|
||
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>
|