ducklm/app/api/static/index.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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
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>