ducklm/app/api/static/index.html

511 lines
17 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;
}
.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;
}
@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>
</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;
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;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function addSystemMessage(title, text) {
addBubble(title, `<div>${escapeHtml(text)}</div>`);
}
function addJsonBubble(title, data) {
addBubble(title, `<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre>`);
}
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) {
addSystemMessage("Runtime", 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) {
addBubble("💻", escapeHtml(toolResult.output));
} else if (toolResult && toolResult.error) {
addSystemMessage("❌", toolResult.error);
}
}
return;
}
if (typeof result.output === "string") {
addBubble("Runtime", `<pre>${escapeHtml(result.output)}</pre>`);
return;
}
addJsonBubble("Runtime", 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) {
addBubble("🤖", escapeHtml(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) {
addBubble("💻", escapeHtml(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 body = {
input: promptEl.value || "browser task",
task_id: "web-" + Date.now(),
session_id: "web-session",
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;
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);
};
}
sendBtn.addEventListener("click", sendTask);
</script>
</body>
</html>