ducklm/app/api/static/index.html

722 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ducklm runtime test chat</title>
<style>
:root {
--bg: #f5f1e8;
--panel: #fffdf7;
--border: #c9bba4;
--text: #1d1a16;
--accent: #195c4b;
--muted: #6f6659;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Iowan Old Style", serif;
background: radial-gradient(circle at top, #fff7df 0%, var(--bg) 60%);
color: var(--text);
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px 48px;
}
h1 {
margin: 0 0 8px;
font-size: 2.2rem;
}
p {
color: var(--muted);
margin: 0 0 24px;
}
.layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 20px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 18px;
box-shadow: 0 14px 30px rgba(35, 28, 17, 0.08);
max-width: 100%;
}
textarea, input {
width: 100%;
border: 1px solid var(--border);
background: #fff;
border-radius: 12px;
padding: 12px;
font: inherit;
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
margin-top: 12px;
background: var(--accent);
color: white;
border: 0;
border-radius: 999px;
padding: 10px 18px;
font: inherit;
cursor: pointer;
}
button.secondary {
background: #ffffff;
color: var(--accent);
border: 1px solid var(--border);
}
button.danger {
background: #b42318;
}
.messages, .events {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
width: 100%;
}
.messages {
width: 100%;
}
.events {
width: 100%;
}
.bubble, .event {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
background: white;
}
.event code {
color: var(--accent);
}
.row {
display: grid;
gap: 10px;
margin-top: 12px;
}
.feedback-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
dialog {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0;
width: min(560px, calc(100vw - 32px));
color: var(--text);
}
dialog::backdrop {
background: rgba(20, 18, 15, 0.4);
}
.modal-body {
display: grid;
gap: 12px;
padding: 18px;
background: var(--panel);
}
select {
width: 100%;
border: 1px solid var(--border);
background: #fff;
border-radius: 12px;
padding: 10px;
font: inherit;
}
label {
display: grid;
gap: 6px;
}
label.inline {
display: flex;
align-items: center;
gap: 8px;
}
label.inline input {
width: auto;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<h1>ducklm runtime test chat</h1>
<p>Thin browser client for checking task submission, tool execution and event replay.</p>
<div class="layout">
<section class="panel">
<div class="messages" id="messages"></div>
<div class="row">
<textarea id="prompt" placeholder="Опиши задачу..." style="min-height: 60px;"></textarea>
<button id="sendBtn">Отправить</button>
</div>
</section>
<aside class="panel">
<strong>Events</strong>
<div class="events" id="events"></div>
</aside>
</div>
<dialog id="feedbackDialog">
<form method="dialog" class="modal-body" id="feedbackForm">
<strong>Что было неверно?</strong>
<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">
<input type="checkbox" id="feedbackRemember" checked />
Запомнить для похожих задач
</label>
<label class="inline">
<input type="checkbox" id="feedbackRetry" />
Исправить ответ сейчас
</label>
<div>
<button id="submitFeedbackBtn" value="submit">Отправить</button>
<button class="secondary" value="cancel">Отмена</button>
</div>
</form>
</dialog>
</main>
<script>
const messages = document.getElementById("messages");
const events = document.getElementById("events");
const promptEl = document.getElementById("prompt");
const sendBtn = document.getElementById("sendBtn");
let lastPermissionRequest = null;
let lastSecretRequest = null;
let lastPasswordRequest = null;
const seenEvents = new Set();
let activePermissionBubble = null;
let activeSecretBubble = null;
let activePasswordBubble = null;
let currentTaskId = null;
let currentSessionId = "web-session";
let pendingFeedback = null;
const feedbackDialog = document.getElementById("feedbackDialog");
const feedbackForm = document.getElementById("feedbackForm");
const feedbackType = document.getElementById("feedbackType");
const feedbackSeverity = document.getElementById("feedbackSeverity");
const feedbackText = document.getElementById("feedbackText");
const feedbackCorrection = document.getElementById("feedbackCorrection");
const feedbackRemember = document.getElementById("feedbackRemember");
const feedbackRetry = document.getElementById("feedbackRetry");
function addBubble(title, body) {
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `<strong>${title}</strong><div>${body}</div>`;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
return el;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function addSystemMessage(title, text) {
return addBubble(title, `<div>${escapeHtml(text)}</div>`);
}
function addJsonBubble(title, data) {
return addBubble(title, `<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre>`);
}
function addFeedbackControls(bubble, answerText) {
if (!currentTaskId || !bubble) return;
const actions = document.createElement("div");
actions.className = "feedback-actions";
actions.innerHTML = `
<button class="secondary" data-kind="correct">Верно</button>
<button class="danger" data-kind="wrong">Неверно</button>
`;
actions.querySelector('[data-kind="correct"]').addEventListener("click", async () => {
const data = await submitFeedback({
feedback_type: "correct",
severity: "minor",
feedback: "User marked response as correct.",
correction: "",
remember: true,
retry: false,
assistant_answer: answerText
});
if (data?.status === "ok") {
actions.remove();
addSystemMessage("Feedback", "Оценка сохранена.");
}
});
actions.querySelector('[data-kind="wrong"]').addEventListener("click", () => {
pendingFeedback = { answerText, actions };
feedbackText.value = "";
feedbackCorrection.value = "";
feedbackType.value = "misunderstood_task";
feedbackSeverity.value = "major";
feedbackRemember.checked = true;
feedbackRetry.checked = false;
feedbackDialog.showModal();
});
bubble.appendChild(actions);
}
function renderRuntimeResult(result, status) {
if (!result) {
addSystemMessage("Runtime", "No result returned.");
return;
}
if (status === "awaiting_permission" && result.permission_request) {
lastPermissionRequest = result.permission_request;
renderPermissionControls(result.permission_request);
return;
}
if (status === "awaiting_input" && result.secret_request) {
addSystemMessage("System", result.secret_request.prompt || "Secret input required.");
lastSecretRequest = result.secret_request;
renderSecretControls(result.secret_request);
return;
}
if (status === "awaiting_password" && result.needs_sudo) {
lastPasswordRequest = result;
renderPasswordControls(result);
return;
}
if (status === "awaiting_permission" && result.error) {
addSystemMessage("System", result.error);
return;
}
if (result.message && !result.step_results) {
const bubble = addSystemMessage("Runtime", result.message);
addFeedbackControls(bubble, result.message);
}
if (result.step_results && Array.isArray(result.step_results)) {
for (const step of result.step_results) {
const toolResult = step.result?.result || step.result;
if (toolResult && toolResult.output) {
const bubble = addBubble("💻", escapeHtml(toolResult.output));
addFeedbackControls(bubble, String(toolResult.output));
} else if (toolResult && toolResult.error) {
addSystemMessage("❌", toolResult.error);
}
}
return;
}
if (typeof result.output === "string") {
const bubble = addBubble("Runtime", `<pre>${escapeHtml(result.output)}</pre>`);
addFeedbackControls(bubble, result.output);
return;
}
const bubble = addJsonBubble("Runtime", result);
addFeedbackControls(bubble, JSON.stringify(result));
}
function addEvent(event) {
const eventKey = `${event.task_id || "na"}:${event.sequence || JSON.stringify(event)}`;
if (seenEvents.has(eventKey)) {
return;
}
seenEvents.add(eventKey);
const el = document.createElement("div");
el.className = "event";
el.innerHTML = `<div><code>${event.type}</code></div><pre>${JSON.stringify(event.payload ?? event, null, 2)}</pre>`;
events.appendChild(el);
events.scrollTop = events.scrollHeight;
if (event.type === "permission_requested") {
lastPermissionRequest = event.payload;
renderPermissionControls(event.payload);
}
if (event.type === "permission_resolved" || event.type === "task_failed" || event.type === "task_completed") {
clearPermissionControls();
}
if (event.type === "orchestrator_result") {
const p = event.payload || {};
const directive = p.directive || {};
if (directive.type === "respond") {
const text = directive.payload?.text || directive.payload?.message || "";
if (text) {
const bubble = addBubble("🤖", escapeHtml(text));
addFeedbackControls(bubble, text);
}
}
}
if (event.type === "task_completed") {
const p = event.payload || {};
const execResult = p.execution_result || {};
if (execResult.step_results && Array.isArray(execResult.step_results)) {
for (const step of execResult.step_results) {
const toolResult = step.result?.result || step.result;
if (toolResult && toolResult.output) {
const bubble = addBubble("💻", escapeHtml(toolResult.output));
addFeedbackControls(bubble, String(toolResult.output));
}
}
}
}
if (event.type === "secret_requested") {
lastSecretRequest = event.payload;
renderSecretControls(event.payload);
}
if (event.type === "task_completed" || event.type === "task_failed") {
clearSecretControls();
clearPasswordControls();
}
}
function renderPermissionControls(request) {
clearPermissionControls();
const command = request.command || JSON.stringify(request);
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>⚠️ Требуется разрешение</strong>
<div style="margin: 8px 0; padding: 8px; background: #fff3cd; border-radius: 8px; font-family: monospace; font-size: 12px;">${escapeHtml(command)}</div>
<div>
<button data-decision="allow_once">Разрешить</button>
${request.allow_always !== false ? '<button data-decision="allow_always">Разрешить навсегда</button>' : ''}
<button data-decision="deny" style="background: #dc3545;">Запретить</button>
</div>
`;
el.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", async () => {
if (button.disabled) return;
await resolvePermission(button.dataset.decision);
});
});
activePermissionBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
}
function clearPermissionControls() {
if (activePermissionBubble) {
activePermissionBubble.remove();
activePermissionBubble = null;
}
}
function renderSecretControls(request) {
clearSecretControls();
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>Secret required</strong>
<div>${escapeHtml(request.prompt)}</div>
<div style="margin-top:8px;color:var(--muted)">Command: <code>${escapeHtml(request.command || "")}</code></div>
<input type="password" placeholder="Enter secret" />
<button>Submit secret</button>
`;
const input = el.querySelector("input");
const submitBtn = el.querySelector("button");
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter") {
event.preventDefault();
await resolveSecret(input.value);
}
});
submitBtn.addEventListener("click", async () => {
await resolveSecret(input.value);
});
activeSecretBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
input.focus();
}
function clearSecretControls() {
if (activeSecretBubble) {
activeSecretBubble.remove();
activeSecretBubble = null;
}
}
function renderPasswordControls(request) {
clearPasswordControls();
const command = request.command || "unknown command";
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>🔐 Требуется пароль sudo</strong>
<div style="margin: 8px 0; padding: 8px; background: #fff3cd; border-radius: 8px; font-family: monospace; font-size: 12px;">sudo ${escapeHtml(command)}</div>
<div style="margin-top:8px;">
<input type="password" placeholder="Введите пароль sudo" />
<button>Выполнить с sudo</button>
</div>
`;
const input = el.querySelector("input");
const button = el.querySelector("button");
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter") {
event.preventDefault();
await resolvePassword(input.value);
}
});
button.addEventListener("click", async () => {
await resolvePassword(input.value);
});
activePasswordBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
input.focus();
}
function clearPasswordControls() {
if (activePasswordBubble) {
activePasswordBubble.remove();
activePasswordBubble = null;
}
}
async function resolvePermission(decision) {
if (!lastPermissionRequest) {
addBubble("Client error", "No pending permission request.");
return;
}
if (activePermissionBubble) {
activePermissionBubble.querySelectorAll("button").forEach((button) => {
button.disabled = true;
});
}
const response = await fetch("/permissions/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastPermissionRequest.task_id,
decision
})
});
const data = await response.json();
clearPermissionControls();
if (!data || !data.status) {
addSystemMessage("Error", "Failed to resolve permission");
return;
}
if (decision === "deny") {
addSystemMessage("Permission", "Permission denied.");
} else if (decision === "allow_always") {
addSystemMessage("Permission", "Permission granted permanently for this pattern.");
} else {
addSystemMessage("Permission", "Permission granted once.");
}
events.innerHTML = "";
seenEvents.clear();
if (data.events && Array.isArray(data.events)) {
data.events.forEach(addEvent);
}
renderRuntimeResult(data.result, data.status);
}
async function resolveSecret(secret) {
if (!lastSecretRequest) {
addBubble("Client error", "No pending secret request.");
return;
}
if (!secret) {
addSystemMessage("System", "Secret cannot be empty.");
return;
}
if (activeSecretBubble) {
const input = activeSecretBubble.querySelector("input");
const button = activeSecretBubble.querySelector("button");
if (input) input.disabled = true;
if (button) button.disabled = true;
}
clearSecretControls();
addSystemMessage("System", "Secret submitted. Waiting for command result.");
const response = await fetch("/secrets/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastSecretRequest.task_id,
secret
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
async function resolvePassword(password) {
if (!lastPasswordRequest) {
addBubble("Client error", "No pending password request.");
return;
}
if (!password) {
addSystemMessage("System", "Пароль не может быть пустым.");
return;
}
if (activePasswordBubble) {
const input = activePasswordBubble.querySelector("input");
const button = activePasswordBubble.querySelector("button");
if (input) input.disabled = true;
if (button) button.disabled = true;
}
clearPasswordControls();
addSystemMessage("System", "Выполняю команду с sudo...");
const response = await fetch("/password/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastPasswordRequest.task_id,
password
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
async function sendTask() {
const taskId = "web-" + Date.now();
const body = {
input: promptEl.value || "browser task",
task_id: taskId,
session_id: currentSessionId,
context: {}
};
if (!promptEl.value.trim()) return;
addBubble("User", promptEl.value);
promptEl.value = "";
const response = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await response.json();
if (!data || !data.events) {
addSystemMessage("Error", "Invalid response from server");
return;
}
lastPermissionRequest = null;
lastSecretRequest = null;
lastPasswordRequest = null;
currentTaskId = data.task_id || taskId;
currentSessionId = body.session_id;
clearPermissionControls();
clearSecretControls();
clearPasswordControls();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
if (data.task_id) {
attachStream(data.task_id);
}
}
function attachStream(taskId) {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/stream/${taskId}`);
ws.onmessage = (message) => {
const data = JSON.parse(message.data);
addEvent(data);
};
}
async function submitFeedback(payload) {
if (!currentTaskId) {
addSystemMessage("Feedback", "Нет активной задачи для оценки.");
return;
}
const response = await fetch("/critic/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: currentTaskId,
session_id: currentSessionId,
...payload
})
});
const data = await response.json();
if (data.retry_result) {
events.innerHTML = "";
seenEvents.clear();
if (Array.isArray(data.retry_result.events)) {
data.retry_result.events.forEach(addEvent);
}
currentTaskId = data.retry_result.task_id || currentTaskId;
renderRuntimeResult(data.retry_result.result, data.retry_result.status);
}
if (data.status !== "ok") {
addSystemMessage("Feedback", data.message || "Не удалось сохранить feedback.");
}
return data;
}
feedbackForm.addEventListener("submit", async (event) => {
event.preventDefault();
const submitter = event.submitter;
if (submitter && submitter.value === "cancel") {
feedbackDialog.close();
return;
}
const feedback = feedbackText.value.trim() || "User marked response as incorrect.";
const data = await submitFeedback({
feedback_type: feedbackType.value,
severity: feedbackSeverity.value,
feedback,
correction: feedbackCorrection.value.trim(),
remember: feedbackRemember.checked,
retry: feedbackRetry.checked,
assistant_answer: pendingFeedback?.answerText || ""
});
if (pendingFeedback?.actions) {
pendingFeedback.actions.remove();
}
pendingFeedback = null;
feedbackDialog.close();
if (data?.status === "ok") {
addSystemMessage("Feedback", "Feedback отправлен.");
}
});
sendBtn.addEventListener("click", sendTask);
</script>
</body>
</html>