1090 lines
38 KiB
HTML
1090 lines
38 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>DuckLM Runtime</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f0f0f;
|
|
--panel: #1a1a1a;
|
|
--panel-light: #242424;
|
|
--border: #333;
|
|
--border-light: #444;
|
|
--text: #e8e8e8;
|
|
--text-muted: #888;
|
|
--accent: #4a9eff;
|
|
--accent-hover: #6aafff;
|
|
--success: #4caf50;
|
|
--warning: #ff9800;
|
|
--danger: #f44336;
|
|
--user-bubble: #2d4a3e;
|
|
--system-bubble: #1e3a5f;
|
|
--event-bg: #1a1a2e;
|
|
--event-border: #2a2a4e;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 1fr 380px;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* === Chat Panel === */
|
|
.chat-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 1px solid var(--border);
|
|
min-width: 0;
|
|
height: 100vh;
|
|
}
|
|
.chat-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
.chat-header h1 { font-size: 1.1rem; font-weight: 600; }
|
|
.chat-header .status {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--success);
|
|
}
|
|
.status-dot.offline { background: var(--danger); }
|
|
|
|
.messages {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.message {
|
|
max-width: 85%;
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
line-height: 1.5;
|
|
font-size: 0.9rem;
|
|
word-wrap: break-word;
|
|
}
|
|
.message.user {
|
|
align-self: flex-end;
|
|
background: var(--user-bubble);
|
|
color: #c8e6c9;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.message.assistant {
|
|
align-self: flex-start;
|
|
background: var(--system-bubble);
|
|
color: #bbdefb;
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
.message.system {
|
|
align-self: center;
|
|
background: var(--panel-light);
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
}
|
|
.message.error {
|
|
align-self: center;
|
|
background: #3a1a1a;
|
|
color: #ef9a9a;
|
|
border: 1px solid #5a2a2a;
|
|
}
|
|
.message .meta {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
margin-top: 6px;
|
|
opacity: 0.7;
|
|
}
|
|
.message .critic-score {
|
|
margin-top: 8px;
|
|
padding: 6px 10px;
|
|
background: rgba(0,0,0,0.3);
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
}
|
|
.message .critic-score .score-bar {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
.message .critic-score .score-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.message .critic-score .score-value {
|
|
font-weight: 600;
|
|
}
|
|
.message .critic-score .score-value.good { color: #4caf50; }
|
|
.message .critic-score .score-value.medium { color: #ff9800; }
|
|
.message .critic-score .score-value.bad { color: #f44336; }
|
|
|
|
.typing-indicator {
|
|
align-self: flex-start;
|
|
padding: 12px 16px;
|
|
background: var(--system-bubble);
|
|
border-radius: 12px;
|
|
border-bottom-left-radius: 4px;
|
|
display: none;
|
|
margin: 0 20px;
|
|
}
|
|
.typing-indicator.visible { display: flex; gap: 4px; }
|
|
.typing-indicator span {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--text-muted);
|
|
animation: typing 1.4s infinite;
|
|
}
|
|
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
|
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
|
@keyframes typing {
|
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
30% { transform: translateY(-6px); opacity: 1; }
|
|
}
|
|
|
|
.input-area {
|
|
padding: 16px 20px;
|
|
border-top: 1px solid var(--border);
|
|
background: var(--panel);
|
|
flex-shrink: 0;
|
|
}
|
|
.input-wrapper {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: flex-end;
|
|
}
|
|
.input-wrapper textarea {
|
|
flex: 1;
|
|
background: var(--panel-light);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 12px 16px;
|
|
color: var(--text);
|
|
font: inherit;
|
|
font-size: 0.9rem;
|
|
resize: none;
|
|
min-height: 48px;
|
|
max-height: 150px;
|
|
line-height: 1.4;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.input-wrapper textarea:focus { border-color: var(--accent); }
|
|
.input-wrapper textarea::placeholder { color: var(--text-muted); }
|
|
.send-btn {
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 12px;
|
|
padding: 12px 20px;
|
|
font: inherit;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
white-space: nowrap;
|
|
height: 48px;
|
|
}
|
|
.send-btn:hover { background: var(--accent-hover); }
|
|
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.input-hint {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
margin-top: 8px;
|
|
}
|
|
.input-hint kbd {
|
|
background: var(--panel-light);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
/* === Sidebar === */
|
|
.sidebar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--panel);
|
|
min-width: 0;
|
|
height: 100vh;
|
|
}
|
|
.sidebar-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
.sidebar-header h2 { font-size: 0.95rem; font-weight: 600; }
|
|
.sidebar-tabs { display: flex; gap: 4px; }
|
|
.sidebar-tab {
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
padding: 6px 12px;
|
|
border-radius: 8px;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.sidebar-tab.active {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: white;
|
|
}
|
|
.sidebar-tab:hover:not(.active) {
|
|
border-color: var(--border-light);
|
|
color: var(--text);
|
|
}
|
|
|
|
.sidebar-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
|
|
.events-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.event-item {
|
|
background: var(--event-bg);
|
|
border: 1px solid var(--event-border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
font-size: 0.75rem;
|
|
}
|
|
.event-item .event-type {
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
margin-bottom: 4px;
|
|
}
|
|
.event-item .event-type.task_received { color: #81c784; }
|
|
.event-item .event-type.task_completed { color: #4caf50; }
|
|
.event-item .event-type.task_failed { color: #f44336; }
|
|
.event-item .event-type.permission_requested { color: #ff9800; }
|
|
.event-item .event-type.memory_recall_used { color: #ce93d8; }
|
|
.event-item .event-type.memory_write_decided { color: #80deea; }
|
|
.event-item .event-payload {
|
|
color: var(--text-muted);
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
font-size: 0.7rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 80px;
|
|
overflow: hidden;
|
|
}
|
|
.event-item .event-time {
|
|
color: #555;
|
|
font-size: 0.65rem;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.runtime-log {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.runtime-entry {
|
|
background: var(--panel-light);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
font-size: 0.8rem;
|
|
}
|
|
.runtime-entry .title {
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
color: var(--text);
|
|
}
|
|
.runtime-entry .body {
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* Permission controls */
|
|
.permission-controls {
|
|
background: #2a1a00;
|
|
border: 1px solid #5a3a00;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
.permission-controls .command {
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
font-size: 0.75rem;
|
|
color: #ffcc80;
|
|
margin-bottom: 8px;
|
|
padding: 6px 8px;
|
|
background: #1a1000;
|
|
border-radius: 4px;
|
|
}
|
|
.permission-controls .buttons {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.permission-controls button {
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.permission-controls button.allow {
|
|
background: #1b5e20;
|
|
border-color: #2e7d32;
|
|
color: #a5d6a7;
|
|
}
|
|
.permission-controls button.deny {
|
|
background: #b71c1c;
|
|
border-color: #c62828;
|
|
color: #ef9a9a;
|
|
}
|
|
.permission-controls button:hover { filter: brightness(1.2); }
|
|
|
|
/* Feedback dialog */
|
|
dialog {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 0;
|
|
color: var(--text);
|
|
max-width: 500px;
|
|
width: 90vw;
|
|
}
|
|
dialog::backdrop { background: rgba(0, 0, 0, 0.7); }
|
|
.modal-body {
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
.modal-body h3 { font-size: 1rem; margin-bottom: 4px; }
|
|
.modal-body label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
}
|
|
.modal-body select, .modal-body textarea {
|
|
background: var(--panel-light);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
color: var(--text);
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
outline: none;
|
|
}
|
|
.modal-body select:focus, .modal-body textarea:focus { border-color: var(--accent); }
|
|
.modal-body textarea { min-height: 80px; resize: vertical; }
|
|
.modal-body .inline-label {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.modal-body .inline-label input[type="checkbox"] { width: 16px; height: 16px; }
|
|
.modal-body .buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
}
|
|
.modal-body button {
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.modal-body button.primary {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: white;
|
|
}
|
|
.modal-body button.secondary {
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
}
|
|
.modal-body button:hover { filter: brightness(1.1); }
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
.sidebar { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="chat-panel">
|
|
<div class="chat-header">
|
|
<h1>🦆 DuckLM</h1>
|
|
<div class="status">
|
|
<span class="status-dot" id="statusDot"></span>
|
|
<span id="statusText">Connecting...</span>
|
|
</div>
|
|
</div>
|
|
<div class="messages" id="messages"></div>
|
|
<div class="typing-indicator" id="typing">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
<div class="input-area">
|
|
<div class="input-wrapper">
|
|
<textarea id="prompt" placeholder="Опишите задачу..." rows="1"></textarea>
|
|
<button class="send-btn" id="sendBtn">Отправить</button>
|
|
</div>
|
|
<div class="input-hint">
|
|
<kbd>Enter</kbd> — отправить, <kbd>Shift+Enter</kbd> — перенос строки
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<h2>Runtime</h2>
|
|
<div class="sidebar-tabs">
|
|
<button class="sidebar-tab active" data-tab="events">События</button>
|
|
<button class="sidebar-tab" data-tab="log">Лог</button>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-content" id="sidebarContent">
|
|
<div class="events-list" id="eventsList"></div>
|
|
<div class="runtime-log" id="runtimeLog" style="display:none"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog id="feedbackDialog">
|
|
<form method="dialog" class="modal-body" id="feedbackForm">
|
|
<h3>Что было неверно?</h3>
|
|
<label>Тип ошибки
|
|
<select id="feedbackType">
|
|
<option value="misunderstood_task">Неправильно понял задачу</option>
|
|
<option value="wrong_tool">Выбрал не тот инструмент</option>
|
|
<option value="wrong_command">Выполнил не ту команду</option>
|
|
<option value="should_have_checked">Ответил без проверки</option>
|
|
<option value="hallucination">Выдумал факт</option>
|
|
<option value="incomplete">Неполный ответ</option>
|
|
<option value="unsafe">Опасное действие</option>
|
|
<option value="bad_format">Плохой формат ответа</option>
|
|
<option value="other">Другое</option>
|
|
</select>
|
|
</label>
|
|
<label>Критичность
|
|
<select id="feedbackSeverity">
|
|
<option value="minor">Мелкая ошибка</option>
|
|
<option value="major" selected>Существенная ошибка</option>
|
|
<option value="critical">Критическая ошибка</option>
|
|
</select>
|
|
</label>
|
|
<label>Комментарий
|
|
<textarea id="feedbackText" placeholder="Что именно было неверно?"></textarea>
|
|
</label>
|
|
<label>Как должно было быть
|
|
<textarea id="feedbackCorrection" placeholder="Корректировка"></textarea>
|
|
</label>
|
|
<label class="inline-label">
|
|
<input type="checkbox" id="feedbackRemember" checked />
|
|
Запомнить
|
|
</label>
|
|
<label class="inline-label">
|
|
<input type="checkbox" id="feedbackRetry" />
|
|
Исправить сейчас
|
|
</label>
|
|
<div class="buttons">
|
|
<button type="submit" class="primary" value="submit">Отправить</button>
|
|
<button type="button" class="secondary" value="cancel" onclick="feedbackDialog.close()">Отмена</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<script>
|
|
// === DOM ===
|
|
const messagesEl = document.getElementById("messages");
|
|
const eventsListEl = document.getElementById("eventsList");
|
|
const runtimeLogEl = document.getElementById("runtimeLog");
|
|
const promptEl = document.getElementById("prompt");
|
|
const sendBtn = document.getElementById("sendBtn");
|
|
const typingEl = document.getElementById("typing");
|
|
const statusDot = document.getElementById("statusDot");
|
|
const statusText = document.getElementById("statusText");
|
|
const feedbackDialog = document.getElementById("feedbackDialog");
|
|
const feedbackForm = document.getElementById("feedbackForm");
|
|
|
|
// === State ===
|
|
let currentSessionId = localStorage.getItem("ducklm_session") || "web-" + Date.now();
|
|
localStorage.setItem("ducklm_session", currentSessionId);
|
|
let currentTaskId = null;
|
|
let isProcessing = false;
|
|
let activeStream = null;
|
|
let activeStreamMessage = null;
|
|
let activeStreamText = "";
|
|
const streamedToolOutputTasks = new Set();
|
|
const seenEvents = new Set();
|
|
|
|
// === Load history from localStorage ===
|
|
function loadHistory() {
|
|
const savedMessages = localStorage.getItem("ducklm_messages");
|
|
const savedEvents = localStorage.getItem("ducklm_events");
|
|
if (savedMessages) {
|
|
try {
|
|
const msgs = JSON.parse(savedMessages);
|
|
msgs.forEach(m => addMessage(m.type, m.text, m.meta, false));
|
|
} catch(e) {}
|
|
}
|
|
if (savedEvents) {
|
|
try {
|
|
const events = JSON.parse(savedEvents);
|
|
events.forEach(e => addEventToSidebar(e, false));
|
|
} catch(e) {}
|
|
}
|
|
scrollToBottom();
|
|
}
|
|
|
|
function saveMessages() {
|
|
const msgs = [];
|
|
messagesEl.querySelectorAll(".message").forEach(el => {
|
|
msgs.push({
|
|
type: el.className.replace("message ", ""),
|
|
text: el.dataset.text || el.textContent,
|
|
meta: el.dataset.meta || ""
|
|
});
|
|
});
|
|
localStorage.setItem("ducklm_messages", JSON.stringify(msgs));
|
|
}
|
|
|
|
function saveEvents() {
|
|
const events = [];
|
|
eventsListEl.querySelectorAll(".event-item").forEach(el => {
|
|
events.push({
|
|
type: el.dataset.type || "",
|
|
payload: el.dataset.payload || "",
|
|
time: el.dataset.time || ""
|
|
});
|
|
});
|
|
localStorage.setItem("ducklm_events", JSON.stringify(events));
|
|
}
|
|
|
|
// === Auto-resize textarea ===
|
|
promptEl.addEventListener("input", () => {
|
|
promptEl.style.height = "auto";
|
|
promptEl.style.height = Math.min(promptEl.scrollHeight, 150) + "px";
|
|
});
|
|
|
|
// === Enter to send, Shift+Enter for newline ===
|
|
promptEl.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendTask();
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener("click", sendTask);
|
|
|
|
// === Sidebar tabs ===
|
|
document.querySelectorAll(".sidebar-tab").forEach(tab => {
|
|
tab.addEventListener("click", () => {
|
|
document.querySelectorAll(".sidebar-tab").forEach(t => t.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
const tabName = tab.dataset.tab;
|
|
eventsListEl.style.display = tabName === "events" ? "flex" : "none";
|
|
runtimeLogEl.style.display = tabName === "log" ? "flex" : "none";
|
|
});
|
|
});
|
|
|
|
// === Health check ===
|
|
async function checkHealth() {
|
|
try {
|
|
const resp = await fetch("/health");
|
|
if (resp.ok) {
|
|
statusDot.classList.remove("offline");
|
|
statusText.textContent = "Connected";
|
|
} else { throw new Error("not ok"); }
|
|
} catch {
|
|
statusDot.classList.add("offline");
|
|
statusText.textContent = "Disconnected";
|
|
}
|
|
}
|
|
checkHealth();
|
|
setInterval(checkHealth, 10000);
|
|
|
|
// === Scroll to bottom ===
|
|
function scrollToBottom() {
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
|
|
// === Send task ===
|
|
async function sendTask() {
|
|
const text = promptEl.value.trim();
|
|
if (!text || isProcessing) return;
|
|
|
|
isProcessing = true;
|
|
sendBtn.disabled = true;
|
|
promptEl.value = "";
|
|
promptEl.style.height = "auto";
|
|
|
|
addMessage("user", text);
|
|
typingEl.classList.add("visible");
|
|
scrollToBottom();
|
|
|
|
const taskId = "web-" + Date.now();
|
|
currentTaskId = taskId;
|
|
seenEvents.clear(); // Clear seen events for new request
|
|
|
|
try {
|
|
openTaskStream(taskId);
|
|
const response = await fetch("/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ input: text, task_id: taskId, session_id: currentSessionId, context: {} })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (!data || !["accepted", "completed"].includes(data.status)) {
|
|
addMessage("error", "Invalid response from server");
|
|
typingEl.classList.remove("visible");
|
|
isProcessing = false;
|
|
sendBtn.disabled = false;
|
|
return;
|
|
}
|
|
if (data.events) {
|
|
processEvents(data.events, data);
|
|
renderResult(data);
|
|
}
|
|
|
|
} catch (err) {
|
|
typingEl.classList.remove("visible");
|
|
addMessage("error", "Network error: " + err.message);
|
|
isProcessing = false;
|
|
sendBtn.disabled = false;
|
|
promptEl.focus();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
function openTaskStream(taskId) {
|
|
if (activeStream) {
|
|
activeStream.close();
|
|
}
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
activeStream = new WebSocket(`${protocol}://${window.location.host}/stream/${taskId}`);
|
|
activeStream.onmessage = (message) => {
|
|
const event = JSON.parse(message.data);
|
|
processEvents([event]);
|
|
};
|
|
activeStream.onerror = () => {
|
|
typingEl.classList.remove("visible");
|
|
isProcessing = false;
|
|
sendBtn.disabled = false;
|
|
};
|
|
activeStream.onclose = () => {
|
|
activeStream = null;
|
|
};
|
|
}
|
|
|
|
// === Process events ===
|
|
function processEvents(events, data) {
|
|
for (const event of events) {
|
|
if (event.type === "heartbeat") continue;
|
|
const eventKey = `${event.task_id}:${event.sequence}`;
|
|
if (seenEvents.has(eventKey)) continue;
|
|
seenEvents.add(eventKey);
|
|
addEventToSidebar(event);
|
|
|
|
if (event.type === "memory_recall_used") {
|
|
const recall = event.payload;
|
|
addRuntimeLog("🧠 Memory Recall", `Query: ${recall.query}\nResults: ${recall.results_count}\nReason: ${recall.reason}`);
|
|
}
|
|
if (event.type === "memory_write_decided") {
|
|
const write = event.payload;
|
|
addRuntimeLog("💾 Memory Write", `Kind: ${write.kind}\nDecision: ${write.decision}\nPreview: ${write.text_preview}`);
|
|
}
|
|
if (event.type === "tool_output_chunk") {
|
|
appendToolOutput(event.payload.chunk || "");
|
|
}
|
|
if (event.type === "task_completed" || event.type === "task_failed" || event.type === "task_awaiting_permission" || event.type === "task_awaiting_input" || event.type === "task_awaiting_review") {
|
|
typingEl.classList.remove("visible");
|
|
isProcessing = false;
|
|
sendBtn.disabled = false;
|
|
promptEl.focus();
|
|
const result = event.payload.execution_result || event.payload;
|
|
renderResult({
|
|
task_id: event.task_id,
|
|
status: event.type.replace("task_", ""),
|
|
result
|
|
});
|
|
saveMessages();
|
|
saveEvents();
|
|
}
|
|
}
|
|
}
|
|
|
|
function appendToolOutput(chunk) {
|
|
if (!chunk) return;
|
|
if (!activeStreamMessage) {
|
|
activeStreamText = "";
|
|
activeStreamMessage = document.createElement("div");
|
|
activeStreamMessage.className = "message assistant";
|
|
messagesEl.appendChild(activeStreamMessage);
|
|
}
|
|
activeStreamText += chunk;
|
|
if (currentTaskId) streamedToolOutputTasks.add(currentTaskId);
|
|
activeStreamMessage.dataset.text = activeStreamText;
|
|
activeStreamMessage.innerHTML = escapeHtml(activeStreamText);
|
|
saveMessages();
|
|
scrollToBottom();
|
|
}
|
|
|
|
// === Add event to sidebar ===
|
|
function addEventToSidebar(event, save = true) {
|
|
const el = document.createElement("div");
|
|
el.className = "event-item";
|
|
el.dataset.type = event.type;
|
|
el.dataset.payload = JSON.stringify(event.payload || {});
|
|
el.dataset.time = event.timestamp || "";
|
|
|
|
const typeClass = event.type.replace(/_/g, "_");
|
|
const time = event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : "";
|
|
|
|
el.innerHTML = `
|
|
<div class="event-type ${typeClass}">${formatEventType(event.type)}</div>
|
|
<div class="event-payload">${escapeHtml(JSON.stringify(event.payload || {}, null, 2).slice(0, 200))}</div>
|
|
<div class="event-time">${time}</div>
|
|
`;
|
|
eventsListEl.insertBefore(el, eventsListEl.firstChild);
|
|
if (save) saveEvents();
|
|
}
|
|
|
|
// === Add runtime log ===
|
|
function addRuntimeLog(title, body) {
|
|
const el = document.createElement("div");
|
|
el.className = "runtime-entry";
|
|
el.innerHTML = `<div class="title">${escapeHtml(title)}</div><div class="body">${escapeHtml(body)}</div>`;
|
|
runtimeLogEl.insertBefore(el, runtimeLogEl.firstChild);
|
|
}
|
|
|
|
// === Render result ===
|
|
function renderResult(data) {
|
|
activeStreamMessage = null;
|
|
activeStreamText = "";
|
|
const stepResults = data.result?.step_results || [];
|
|
for (const step of stepResults) {
|
|
const toolResult = step.result?.result || step.result;
|
|
if (toolResult?.output && !streamedToolOutputTasks.has(data.task_id)) {
|
|
let html = escapeHtml(String(toolResult.output));
|
|
if (step.result?.critic_score) {
|
|
const score = step.result.critic_score;
|
|
html += renderCriticScore(score);
|
|
}
|
|
addMessage("assistant", html, step.step_id);
|
|
} else if (toolResult?.error) {
|
|
addMessage("error", `Step ${step.step_id}: ${toolResult.error}`);
|
|
}
|
|
}
|
|
|
|
// Priority: response_directive > stepResults > message
|
|
if (data.result?.response_directive?.payload?.text) {
|
|
addMessage("assistant", data.result.response_directive.payload.text);
|
|
} else if (data.result?.message && !stepResults.length) {
|
|
addMessage("assistant", data.result.message);
|
|
}
|
|
|
|
if (data.status === "awaiting_permission") {
|
|
renderPermissionRequest(data);
|
|
} else if (data.status === "awaiting_input") {
|
|
renderSecretRequest(data);
|
|
} else if (data.status === "awaiting_review") {
|
|
renderReviewRequest(data);
|
|
} else if (data.status === "failed") {
|
|
addMessage("error", data.result?.error || "Task failed");
|
|
}
|
|
|
|
scrollToBottom();
|
|
}
|
|
|
|
function renderCriticScore(score) {
|
|
const c = score.correctness;
|
|
const u = score.usefulness;
|
|
const s = score.safety;
|
|
const cClass = c >= 0.7 ? "good" : c >= 0.4 ? "medium" : "bad";
|
|
const uClass = u >= 0.7 ? "good" : u >= 0.4 ? "medium" : "bad";
|
|
const sClass = s >= 0.7 ? "good" : s >= 0.4 ? "medium" : "bad";
|
|
return `<div class="critic-score">
|
|
<div>Critic: ${score.explanation || ""}</div>
|
|
<div class="score-bar">
|
|
<div class="score-item">✓ <span class="score-value ${cClass}">${c.toFixed(2)}</span></div>
|
|
<div class="score-item">⚡ <span class="score-value ${uClass}">${u.toFixed(2)}</span></div>
|
|
<div class="score-item">🛡 <span class="score-value ${sClass}">${s.toFixed(2)}</span></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderPermissionRequest(data) {
|
|
const permReq = data.result?.permission_request;
|
|
if (!permReq) return;
|
|
const el = document.createElement("div");
|
|
el.className = "message system";
|
|
el.innerHTML = `<div class="permission-controls">
|
|
<div>⚠️ Требуется разрешение:</div>
|
|
<div class="command">${escapeHtml(permReq.command || JSON.stringify(permReq))}</div>
|
|
<div class="buttons">
|
|
<button class="allow" onclick="resolvePermission('${data.task_id}', 'allow_once')">Разрешить</button>
|
|
${permReq.allow_always !== false ? `<button class="allow" onclick="resolvePermission('${data.task_id}', 'allow_always')">Навсегда</button>` : ""}
|
|
<button class="deny" onclick="resolvePermission('${data.task_id}', 'deny')">Запретить</button>
|
|
</div>
|
|
</div>`;
|
|
messagesEl.appendChild(el);
|
|
}
|
|
|
|
function renderSecretRequest(data) {
|
|
const secretReq = data.result?.secret_request;
|
|
if (!secretReq) return;
|
|
const el = document.createElement("div");
|
|
el.className = "message system";
|
|
el.innerHTML = `<div class="permission-controls">
|
|
<div>🔑 ${escapeHtml(secretReq.prompt || "Требуется ввод")}</div>
|
|
<input type="password" id="secretInput" placeholder="Введите..." style="width:100%;margin:8px 0;padding:8px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#e8e8e8" />
|
|
<div class="buttons"><button class="allow" onclick="resolveSecret('${data.task_id}')">Отправить</button></div>
|
|
</div>`;
|
|
messagesEl.appendChild(el);
|
|
}
|
|
|
|
function renderReviewRequest(data) {
|
|
const review = data.result?.review;
|
|
if (!review) return;
|
|
const assessment = review.critic_assessment || {};
|
|
const diagnosis = review.diagnosis || {};
|
|
const el = document.createElement("div");
|
|
el.className = "message system";
|
|
el.innerHTML = `<div class="permission-controls">
|
|
<div>Critic оценил действие как: ${escapeHtml(assessment.classification || "requires_review")}</div>
|
|
<div class="command">${escapeHtml(review.command || "")}</div>
|
|
<div>${escapeHtml(assessment.explanation || diagnosis.type || "")}</div>
|
|
<textarea id="reviewCorrection" placeholder="Комментарий или исправление..." style="width:100%;margin:8px 0;padding:8px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#e8e8e8"></textarea>
|
|
<div class="buttons">
|
|
<button class="deny" onclick="resolveReview('${data.task_id}', 'wrong_action')">Ошибочное действие</button>
|
|
<button class="allow" onclick="resolveReview('${data.task_id}', 'correct_action')">Всё верно</button>
|
|
</div>
|
|
</div>`;
|
|
messagesEl.appendChild(el);
|
|
}
|
|
|
|
async function resolvePermission(taskId, decision) {
|
|
// Disable all permission buttons to prevent double-click
|
|
document.querySelectorAll(".permission-controls button").forEach(btn => {
|
|
btn.disabled = true;
|
|
btn.style.opacity = "0.5";
|
|
});
|
|
|
|
try {
|
|
openTaskStream(taskId);
|
|
const resp = await fetch("/permissions/resolve", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ task_id: taskId, decision })
|
|
});
|
|
const data = await resp.json();
|
|
|
|
// Remove the permission request UI
|
|
const permEl = document.querySelector(".permission-controls")?.closest(".message.system");
|
|
if (permEl) {
|
|
permEl.innerHTML = `<div style="color:#81c784">✓ Permission ${decision}: ${data.status}</div>`;
|
|
}
|
|
|
|
// Synchronous fallback for tests/older runtimes.
|
|
if (data.status === "completed" || data.status === "failed") {
|
|
if (data.result?.response_directive?.payload?.text) {
|
|
addMessage("assistant", data.result.response_directive.payload.text);
|
|
} else if (data.result?.step_results?.length) {
|
|
renderResult(data);
|
|
} else if (data.result?.message) {
|
|
addMessage("assistant", data.result.message);
|
|
}
|
|
if (data.status === "failed") {
|
|
addMessage("error", data.result?.error || "Task failed");
|
|
}
|
|
} else if (data.status === "awaiting_input") {
|
|
// Need password/secret input
|
|
renderSecretRequest(data);
|
|
if (data.events) {
|
|
seenEvents.clear();
|
|
processEvents(data.events, data);
|
|
}
|
|
} else if (data.status === "awaiting_permission") {
|
|
// Still needs more permissions — render new request
|
|
renderPermissionRequest(data);
|
|
}
|
|
// Process any new events (clear seen to allow re-processing)
|
|
if (data.events) {
|
|
seenEvents.clear();
|
|
processEvents(data.events, data);
|
|
}
|
|
saveMessages();
|
|
saveEvents();
|
|
scrollToBottom();
|
|
|
|
} catch (err) {
|
|
addMessage("error", "Failed to resolve: " + err.message);
|
|
// Re-enable buttons on error
|
|
document.querySelectorAll(".permission-controls button").forEach(btn => {
|
|
btn.disabled = false;
|
|
btn.style.opacity = "1";
|
|
});
|
|
}
|
|
}
|
|
|
|
async function resolveSecret(taskId) {
|
|
const input = document.getElementById("secretInput");
|
|
if (!input?.value) return;
|
|
try {
|
|
openTaskStream(taskId);
|
|
const resp = await fetch("/secrets/resolve", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ task_id: taskId, secret: input.value })
|
|
});
|
|
const data = await resp.json();
|
|
addMessage("system", `Secret submitted: ${data.status}`);
|
|
// Synchronous fallback for tests/older runtimes.
|
|
if (data.status === "completed" || data.status === "failed") {
|
|
renderResult(data);
|
|
}
|
|
if (data.events) {
|
|
seenEvents.clear();
|
|
processEvents(data.events, data);
|
|
}
|
|
saveMessages();
|
|
saveEvents();
|
|
scrollToBottom();
|
|
} catch (err) { addMessage("error", "Failed to submit: " + err.message); }
|
|
}
|
|
|
|
async function resolveReview(taskId, decision) {
|
|
const correction = document.getElementById("reviewCorrection")?.value || "";
|
|
document.querySelectorAll(".permission-controls button").forEach(btn => {
|
|
btn.disabled = true;
|
|
btn.style.opacity = "0.5";
|
|
});
|
|
try {
|
|
openTaskStream(taskId);
|
|
const resp = await fetch("/review/resolve", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ task_id: taskId, decision, correction })
|
|
});
|
|
const data = await resp.json();
|
|
addMessage("system", `Review submitted: ${data.status}`);
|
|
if (data.events) {
|
|
seenEvents.clear();
|
|
processEvents(data.events, data);
|
|
}
|
|
} catch (err) {
|
|
addMessage("error", "Failed to submit review: " + err.message);
|
|
}
|
|
}
|
|
|
|
// === Add message ===
|
|
function addMessage(type, text, meta, save = true) {
|
|
const el = document.createElement("div");
|
|
el.className = `message ${type}`;
|
|
el.dataset.text = text;
|
|
el.dataset.meta = meta || "";
|
|
el.innerHTML = text + (meta ? `<div class="meta">${escapeHtml(meta)}</div>` : "");
|
|
messagesEl.appendChild(el);
|
|
if (save) saveMessages();
|
|
}
|
|
|
|
// === Feedback ===
|
|
feedbackForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const btn = feedbackForm.querySelector('button[value="submit"]');
|
|
if (btn.value !== "submit") { feedbackDialog.close(); return; }
|
|
const data = await submitFeedback({
|
|
feedback_type: document.getElementById("feedbackType").value,
|
|
severity: document.getElementById("feedbackSeverity").value,
|
|
feedback: document.getElementById("feedbackText").value,
|
|
correction: document.getElementById("feedbackCorrection").value,
|
|
remember: document.getElementById("feedbackRemember").checked,
|
|
retry: document.getElementById("feedbackRetry").checked,
|
|
task_id: currentTaskId
|
|
});
|
|
feedbackDialog.close();
|
|
if (data?.status === "ok") addMessage("system", "Обратная связь сохранена");
|
|
});
|
|
|
|
async function submitFeedback(params) {
|
|
try {
|
|
const resp = await fetch("/critic/feedback", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(params)
|
|
});
|
|
return await resp.json();
|
|
} catch { return null; }
|
|
}
|
|
|
|
// === Helpers ===
|
|
function escapeHtml(value) {
|
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
}
|
|
|
|
function formatEventType(type) {
|
|
const labels = {
|
|
task_received: "📥 Received", context_built: "🔧 Context",
|
|
thinker_called: "🤔 Thinker", json_compiler_called: "📋 Compiler",
|
|
orchestrator_result: "✅ Orchestrator", step_started: "▶️ Step",
|
|
tool_called: "🔨 Tool", tool_completed: "✔️ Tool Done",
|
|
permission_requested: "⚠️ Permission", task_completed: "✅ Completed",
|
|
task_awaiting_review: "🧭 Review", review_resolved: "🗳 Review",
|
|
task_failed: "❌ Failed", memory_recall_used: "🧠 Recall",
|
|
memory_write_decided: "💾 Write", checkpoint_saved: "💾 Checkpoint",
|
|
critic_called: "🔍 Critic", critic_result: "📊 Critic Result"
|
|
};
|
|
return labels[type] || type;
|
|
}
|
|
|
|
// === Init ===
|
|
loadHistory();
|
|
promptEl.focus();
|
|
</script>
|
|
</body>
|
|
</html>
|