const state = {
running: false,
messages: [],
currentConversationId: "",
conversations: [],
auditFilter: "all",
};
async function jsonFetch(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
function escapeText(value) {
return String(value ?? "");
}
function setStatus(id, text, tone = "neutral") {
const node = document.querySelector(id);
if (!node) return;
node.textContent = text;
node.dataset.tone = tone;
}
function openActivity(tab = "") {
const drawer = document.querySelector("#activity-drawer");
const backdrop = document.querySelector("#activity-backdrop");
if (!drawer || !backdrop) return;
drawer.hidden = false;
backdrop.hidden = false;
if (tab) selectActivityTab(tab);
}
function closeActivity() {
const drawer = document.querySelector("#activity-drawer");
const backdrop = document.querySelector("#activity-backdrop");
if (drawer) drawer.hidden = true;
if (backdrop) backdrop.hidden = true;
}
function selectActivityTab(tab) {
document.querySelectorAll("[data-activity-tab]").forEach((button) => {
button.classList.toggle("active", button.dataset.activityTab === tab);
});
document.querySelectorAll("[data-activity-panel]").forEach((panel) => {
panel.classList.toggle("active", panel.dataset.activityPanel === tab);
});
}
function addMessage(role, content, meta = "", options = {}) {
const list = document.querySelector("#messages");
if (!list) return;
const article = document.createElement("article");
article.className = `message ${role}`;
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = role === "user" ? "U" : "D";
const bubble = document.createElement("div");
bubble.className = "bubble";
const messageMeta = document.createElement("div");
messageMeta.className = "message-meta";
messageMeta.innerHTML = `${role === "user" ? "You" : "DuckLM"}${escapeText(meta)}`;
const text = document.createElement("p");
text.textContent = content;
bubble.append(messageMeta);
if (role === "assistant" && options.reasoning) {
bubble.append(createInlineReasoning());
}
bubble.append(text);
article.append(avatar, bubble);
list.append(article);
list.scrollTop = list.scrollHeight;
return article;
}
function clearMessages() {
const messages = document.querySelector("#messages");
if (messages) messages.innerHTML = "";
const events = document.querySelector("#events");
if (events) events.innerHTML = "";
}
function setConversationHeader(conversation) {
const title = document.querySelector("#chat-title");
const subtitle = document.querySelector("#chat-subtitle");
if (title) title.textContent = conversation?.title || "Chat";
if (subtitle) {
subtitle.textContent = conversation?.workspace
? `Workspace: ${conversation.workspace}`
: "Messages are processed by the local Qwen role mapping through Duck Core.";
}
}
function addStoredMessage(message) {
const article = addMessage(
message.role,
message.content,
message.status || "saved",
{reasoning: message.role === "assistant" && Boolean(message.reasoning_content)},
);
if (message.reasoning_content) finishInlineReasoning(article, message.reasoning_content);
return article;
}
function createInlineReasoning() {
const section = document.createElement("section");
section.className = "message-reasoning is-collapsed";
const button = document.createElement("button");
button.className = "message-reasoning-toggle";
button.type = "button";
button.setAttribute("aria-expanded", "false");
const title = document.createElement("span");
title.textContent = "Размышление";
const status = document.createElement("span");
status.className = "message-reasoning-status";
status.textContent = "streaming";
button.append(title, status);
const body = document.createElement("pre");
body.hidden = true;
body.textContent = "";
section.append(button, body);
return section;
}
function createToolTerminal(eventPayload) {
const payload = eventPayload.payload || eventPayload;
const args = payload.args || {};
const terminal = document.createElement("section");
terminal.className = "tool-terminal";
terminal.dataset.toolIndex = String(payload.index || "");
const header = document.createElement("div");
header.className = "tool-terminal-header";
const dots = document.createElement("span");
dots.className = "terminal-dots";
dots.innerHTML = "";
const title = document.createElement("span");
title.className = "tool-terminal-title";
title.textContent = formatToolCommand(payload.tool, args);
const status = document.createElement("span");
status.className = "tool-terminal-status";
status.textContent = "running";
header.append(dots, title, status);
const body = document.createElement("pre");
body.className = "tool-terminal-body";
body.textContent = formatToolStart(payload.tool, args);
terminal.append(header, body);
return terminal;
}
function formatToolCommand(tool, args) {
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
if (tool === "list_dir") return `$ list_dir ${args.path || "."}`.trim();
if (tool === "search_files") return `$ search_files ${args.query || ""}`.trim();
return `$ ${tool || "tool"}`;
}
function formatToolStart(tool, args) {
return formatToolCommand(tool, args);
}
function appendToolTerminal(article, eventPayload) {
const paragraph = article?.querySelector("p");
const payload = eventPayload.payload || eventPayload;
const existing = findToolTerminal(article, payload.index);
if (existing) return existing;
const terminal = createToolTerminal(eventPayload);
paragraph?.before(terminal);
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
return terminal;
}
function findToolTerminal(article, index) {
return article?.querySelector(`.tool-terminal[data-tool-index="${index || ""}"]`);
}
function updateToolTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
const terminal = findToolTerminal(article, payload.index);
const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status");
const result = payload.result || {};
if (!body || !status) return;
terminal.classList.toggle("is-error", !result.ok);
terminal.classList.remove("is-waiting");
status.textContent = result.ok ? "ok" : "error";
const title = terminal.querySelector(".tool-terminal-title")?.textContent || body.textContent.trim();
const parts = [title];
if (result.output) parts.push("\nstdout\n" + result.output.trimEnd());
if (result.error) parts.push("\nstderr\n" + result.error.trimEnd());
body.textContent = parts.join("\n");
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function appendApprovalTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
const action = payload.action || {};
const terminal = appendToolTerminal(article, {
payload: {
index: payload.index,
tool: payload.tool,
args: action.args || {},
},
});
const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status");
const command = formatToolCommand(action.tool || payload.tool, action.args || {});
terminal?.classList.add("is-waiting");
if (terminal && payload.approval_id) terminal.dataset.approvalId = payload.approval_id;
if (terminal && eventPayload.task_id) terminal.dataset.taskId = eventPayload.task_id;
if (status) status.textContent = "approval";
if (body) {
body.textContent = [
command,
"",
"Требуется разрешение на выполнение.",
payload.reason || "",
].filter(Boolean).join("\n");
}
if (!terminal?.querySelector(".tool-approval-actions")) {
terminal?.append(createInlineApprovalActions(payload.approval_id, command));
}
}
function appendPasswordPrompt(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
const terminal = article?.querySelector(".tool-terminal.is-error") || article?.querySelector(".tool-terminal");
if (!terminal) return;
const body = terminal.querySelector(".tool-terminal-body");
const status = terminal.querySelector(".tool-terminal-status");
terminal.classList.add("is-waiting");
terminal.dataset.approvalId = payload.approval_id || terminal.dataset.approvalId || "";
terminal.dataset.taskId = eventPayload.task_id || terminal.dataset.taskId || "";
if (status) status.textContent = "password";
if (body) {
body.textContent = [
body.textContent.trim(),
"",
payload.reason || "Sudo password is required to continue.",
].filter(Boolean).join("\n");
}
if (!terminal.querySelector(".tool-password-form")) terminal.append(createPasswordForm());
}
function createPasswordForm() {
const form = document.createElement("form");
form.className = "tool-password-form";
const input = document.createElement("input");
input.type = "password";
input.autocomplete = "current-password";
input.placeholder = "Sudo password";
input.required = true;
const button = document.createElement("button");
button.type = "submit";
button.textContent = "Continue";
form.append(input, button);
return form;
}
function createInlineApprovalActions(approvalId, command) {
const actions = document.createElement("div");
actions.className = "tool-approval-actions";
actions.dataset.command = command || "tool action";
actions.append(
inlineApprovalButton("Allow once", "allow_once"),
inlineApprovalButton("Allow forever", "allow_forever"),
inlineApprovalButton("Deny", "deny", "danger"),
);
if (!approvalId) {
actions.querySelectorAll("button").forEach((button) => {
button.disabled = true;
});
}
return actions;
}
function inlineApprovalButton(label, action, tone = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.dataset.inlineApprovalAction = action;
if (tone) button.dataset.tone = tone;
return button;
}
async function resolveInlineApproval(button) {
const terminal = button.closest(".tool-terminal");
const approvalId = terminal?.dataset.approvalId;
const taskId = terminal?.dataset.taskId;
const action = button.dataset.inlineApprovalAction;
if (!terminal || !approvalId || !action) return;
terminal.querySelectorAll("[data-inline-approval-action]").forEach((item) => {
item.disabled = true;
});
await jsonFetch(`/v1/approvals/${approvalId}/${action}`, {method: "POST"});
const status = terminal.querySelector(".tool-terminal-status");
const body = terminal.querySelector(".tool-terminal-body");
const actions = terminal.querySelector(".tool-approval-actions");
const command = actions?.dataset.command || terminal.querySelector(".tool-terminal-title")?.textContent || "tool action";
const decision = action === "deny" ? "denied" : "allowed";
terminal.classList.toggle("is-error", action === "deny");
terminal.classList.toggle("is-waiting", action !== "deny");
if (status) status.textContent = action === "deny" ? decision : "running";
if (body) {
body.textContent = action === "deny"
? `${command}\n\n${decision}: ${humanApprovalDecision(action)}`
: `${command}\n\nРазрешено. Выполняю команду...`;
}
actions?.remove();
if (taskId) {
await continueAfterInlineApproval(terminal.closest(".message"), taskId, approvalId);
}
}
async function submitToolPassword(form) {
const terminal = form.closest(".tool-terminal");
const article = terminal?.closest(".message");
const taskId = terminal?.dataset.taskId;
const approvalId = terminal?.dataset.approvalId;
const input = form.querySelector("input");
const password = input?.value || "";
if (!terminal || !article || !taskId || !approvalId || !password) return;
form.querySelectorAll("input, button").forEach((item) => {
item.disabled = true;
});
await continueAfterPassword(article, taskId, approvalId, password);
input.value = "";
form.remove();
}
function humanApprovalDecision(action) {
if (action === "allow_once") return "разрешено один раз";
if (action === "allow_forever") return "разрешено навсегда";
return "запрещено";
}
function setMessagePending(article, text) {
const paragraph = article?.querySelector("p");
if (paragraph) paragraph.textContent = text;
}
function appendMessageText(article, delta) {
const paragraph = article?.querySelector("p");
if (!paragraph) return;
paragraph.textContent += delta;
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function appendInlineReasoning(article, delta) {
const block = article?.querySelector(".message-reasoning");
const body = block?.querySelector("pre");
const status = block?.querySelector(".message-reasoning-status");
if (!body) return;
body.textContent += delta;
if (status) status.textContent = "streaming";
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function finishInlineReasoning(article, reasoning) {
const block = article?.querySelector(".message-reasoning");
const body = block?.querySelector("pre");
const status = block?.querySelector(".message-reasoning-status");
if (!body) return;
body.textContent = reasoning?.trim() || body.textContent.trim() || "Размышления не были получены.";
if (status) status.textContent = "done";
}
async function refreshEvents(taskId) {
const events = await jsonFetch(`/v1/tasks/${taskId}/events`);
const list = document.querySelector("#events");
if (!list) return events;
list.innerHTML = "";
for (const event of events) {
const item = document.createElement("li");
const title = document.createElement("strong");
const detail = document.createElement("span");
title.textContent = `${event.sequence}. ${event.event_type}`;
detail.textContent = summarizeEvent(event.payload);
item.append(title, detail);
list.appendChild(item);
}
return events;
}
async function refreshCommandAudit() {
const container = document.querySelector("#command-audit");
if (!container) return;
const events = await jsonFetch("/v1/audit/commands?limit=20");
container.innerHTML = "";
if (!events.length) {
const empty = document.createElement("p");
empty.className = "compact-empty";
empty.textContent = "No command events yet.";
container.append(empty);
return;
}
for (const event of events) {
const payload = event.payload || {};
const item = document.createElement("article");
item.className = "audit-item";
const command = document.createElement("code");
command.textContent = payload.command || "shell command";
const meta = document.createElement("div");
meta.className = "audit-meta";
meta.append(
auditBadge(payload.action_type || "shell_command"),
auditBadge(payload.risk_level || "unknown", payload.risk_level),
auditBadge(payload.ok ? "ok" : "failed", payload.ok ? "ok" : "bad"),
);
const detail = document.createElement("span");
detail.textContent = [
payload.approved ? "approved" : "direct",
payload.blocked ? "blocked" : "",
payload.returncode !== null && payload.returncode !== undefined ? `exit ${payload.returncode}` : "",
].filter(Boolean).join(" · ");
item.append(command, meta, detail);
container.append(item);
}
}
function auditBadge(text, tone = "") {
const badge = document.createElement("span");
badge.className = "audit-badge";
badge.textContent = text;
if (tone) badge.dataset.tone = tone;
return badge;
}
async function refreshMemory(query = "") {
const container = document.querySelector("#memory-list");
if (!container) return;
const workspace = document.querySelector("#workspace")?.value || "./workspace";
const url = query.trim()
? `/v1/memory/search?q=${encodeURIComponent(query.trim())}&workspace=${encodeURIComponent(workspace)}`
: `/v1/memory?workspace=${encodeURIComponent(workspace)}&limit=20`;
const payload = await jsonFetch(url);
const results = payload.results || [];
container.innerHTML = "";
if (!results.length) {
const empty = document.createElement("p");
empty.className = "compact-empty";
empty.textContent = query.trim() ? "No matching memories." : "No workspace memories.";
container.append(empty);
return;
}
for (const memory of results) {
const item = document.createElement("article");
item.className = "memory-item";
const text = document.createElement("p");
text.textContent = memory.text;
const meta = document.createElement("span");
meta.textContent = `${memory.memory_type || "note"} · ${Number(memory.importance || 0).toFixed(1)} · ${memory.created_at || ""}`;
item.append(text, meta);
container.append(item);
}
}
function summarizeEvent(payload) {
if (!payload || typeof payload !== "object") return "";
if (payload.role && payload.latency_ms) {
return `${payload.role} · ${Math.round(payload.latency_ms)} ms`;
}
if (payload.content) {
return payload.content.slice(0, 140);
}
if (payload.final_response) {
return payload.final_response.slice(0, 140);
}
if (payload.error) {
return payload.error;
}
return JSON.stringify(payload);
}
function toggleInlineReasoning(button) {
const block = button.closest(".message-reasoning");
const body = block?.querySelector("pre");
if (!block || !body) return;
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
body.hidden = expanded;
block.classList.toggle("is-collapsed", expanded);
}
function parseSseBlock(block) {
const event = {name: "message", data: ""};
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event.name = line.slice(6).trim();
if (line.startsWith("data:")) event.data += line.slice(5).trimStart();
}
if (!event.data) return null;
return {name: event.name, data: JSON.parse(event.data)};
}
async function streamSse(url, payload, onEvent) {
const response = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await response.text());
if (!response.body) throw new Error("Streaming response is not available in this browser.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const blocks = buffer.split("\n\n");
buffer = blocks.pop() || "";
for (const block of blocks) {
const event = parseSseBlock(block);
if (event) await onEvent(event);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const event = parseSseBlock(buffer);
if (event) await onEvent(event);
}
}
async function streamChat(payload, onEvent) {
await streamSse("/v1/chat/stream", payload, onEvent);
}
async function handleAssistantStreamEvent(pending, name, data, context) {
if (data.task_id) context.taskId = data.task_id;
if (name === "task_created") {
context.taskId = data.task_id;
if (data.payload?.conversation_id) {
state.currentConversationId = data.payload.conversation_id;
}
setStatus("#task-status", data.task_id, "warn");
return;
}
if (name === "reasoning_delta") {
pending.querySelector(".message-meta span").textContent = "reasoning";
appendInlineReasoning(pending, data.delta || "");
return;
}
if (name === "tool_call_started") {
pending.querySelector(".message-meta span").textContent = "tool";
appendToolTerminal(pending, data);
return;
}
if (name === "tool_call_finished") {
pending.querySelector(".message-meta span").textContent = "tool";
updateToolTerminal(pending, data);
return;
}
if (name === "tool_approval_requested") {
pending.querySelector(".message-meta span").textContent = "approval";
appendApprovalTerminal(pending, data);
return;
}
if (name === "tool_password_requested") {
pending.querySelector(".message-meta span").textContent = "password";
appendPasswordPrompt(pending, data);
return;
}
if (name === "content_delta") {
if (!context.contentStarted) {
context.contentStarted = true;
setMessagePending(pending, "");
}
pending.querySelector(".message-meta span").textContent = "answering";
appendMessageText(pending, data.delta || "");
return;
}
if (name === "done") {
if (data.conversation_id) state.currentConversationId = data.conversation_id;
if (!context.contentStarted) {
setMessagePending(pending, data.final_response || "No final content returned.");
}
pending.querySelector(".message-meta span").textContent = data.status;
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
finishInlineReasoning(pending, data.reasoning_content);
await refreshEvents(data.task_id);
await refreshCommandAudit();
await refreshMemory();
await refreshConversations();
return;
}
if (name === "error") {
throw new Error(data.error || "Stream failed.");
}
}
async function continueAfterInlineApproval(article, taskId, approvalId) {
if (!article || state.running) return;
state.running = true;
document.querySelector("#run").disabled = true;
setStatus("#task-status", taskId, "warn");
const context = {taskId, contentStarted: false};
try {
await streamSse(
`/v1/tasks/${encodeURIComponent(taskId)}/continue/stream`,
{approval_id: approvalId},
async ({name, data}) => handleAssistantStreamEvent(article, name, data, context),
);
} catch (error) {
setMessagePending(article, error.message);
article.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
await refreshEvents(taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;
document.querySelector("#message")?.focus();
}
}
async function continueAfterPassword(article, taskId, approvalId, password) {
if (!article || state.running) return;
state.running = true;
document.querySelector("#run").disabled = true;
setStatus("#task-status", taskId, "warn");
const context = {taskId, contentStarted: false};
try {
await streamSse(
`/v1/tasks/${encodeURIComponent(taskId)}/password/stream`,
{approval_id: approvalId, password},
async ({name, data}) => handleAssistantStreamEvent(article, name, data, context),
);
} catch (error) {
setMessagePending(article, error.message);
article.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
await refreshEvents(taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;
document.querySelector("#message")?.focus();
}
}
async function refreshConversations() {
const list = document.querySelector("#conversation-list");
if (!list) return;
state.conversations = await jsonFetch("/v1/conversations");
list.innerHTML = "";
if (!state.conversations.length) {
const empty = document.createElement("p");
empty.className = "conversation-empty";
empty.textContent = "No saved chats.";
list.append(empty);
return;
}
for (const conversation of state.conversations) {
const button = document.createElement("button");
button.type = "button";
button.className = "conversation-item";
button.dataset.conversationId = conversation.conversation_id;
button.classList.toggle("active", conversation.conversation_id === state.currentConversationId);
const title = document.createElement("strong");
title.textContent = conversation.title;
const workspace = document.createElement("span");
workspace.textContent = conversation.workspace;
button.append(title, workspace);
list.append(button);
}
}
async function selectConversation(conversationId) {
const conversation = await jsonFetch(`/v1/conversations/${conversationId}`);
state.currentConversationId = conversation.conversation_id;
document.querySelector("#workspace").value = conversation.workspace;
setConversationHeader(conversation);
clearMessages();
if (!conversation.messages.length) {
addMessage("assistant", "Новый чат готов.", "ready");
} else {
for (const message of conversation.messages) addStoredMessage(message);
}
await refreshConversations();
await refreshMemory();
}
async function createNewConversation() {
const workspace = document.querySelector("#workspace").value || "./workspace";
const conversation = await jsonFetch("/v1/conversations", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({title: "New chat", workspace}),
});
await selectConversation(conversation.conversation_id);
setStatus("#task-status", "none");
}
async function sendMessage() {
if (state.running) return;
const input = document.querySelector("#message");
const message = input.value.trim();
if (!message) return;
state.running = true;
document.querySelector("#run").disabled = true;
setStatus("#task-status", "running", "warn");
addMessage("user", message, "submitted");
input.value = "";
const pending = addMessage("assistant", "", "thinking", {reasoning: true});
const context = {taskId: "", contentStarted: false};
try {
await streamChat({
message,
conversation_id: state.currentConversationId || null,
workspace: document.querySelector("#workspace").value,
debug: document.querySelector("#debug").checked,
}, async ({name, data}) => {
await handleAssistantStreamEvent(pending, name, data, context);
});
} catch (error) {
if (!context.taskId) input.value = message;
setMessagePending(pending, error.message);
pending.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
if (context.taskId) await refreshEvents(context.taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;
input.focus();
}
}
async function checkRuntime() {
try {
await jsonFetch("/health");
setStatus("#api-status", "online", "ok");
} catch {
setStatus("#api-status", "offline", "bad");
}
try {
const roles = await jsonFetch("/v1/models/ping");
const ok = Object.values(roles).every((item) => item.ok);
setStatus("#model-status", ok ? "online" : "degraded", ok ? "ok" : "warn");
} catch {
setStatus("#model-status", "offline", "bad");
}
}
function bindChat() {
const composer = document.querySelector("#composer");
const input = document.querySelector("#message");
composer?.addEventListener("submit", (event) => {
event.preventDefault();
sendMessage();
});
input?.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
document.querySelector("#new-chat")?.addEventListener("click", () => {
createNewConversation().catch(console.error);
});
document.querySelector("#reload-chat")?.addEventListener("click", () => {
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
});
document.querySelector("#activity-open")?.addEventListener("click", () => {
openActivity("events");
});
document.querySelector("#activity-close")?.addEventListener("click", closeActivity);
document.querySelector("#activity-backdrop")?.addEventListener("click", closeActivity);
document.querySelector("#activity-drawer")?.addEventListener("click", (event) => {
const tab = event.target.closest("[data-activity-tab]");
if (tab) selectActivityTab(tab.dataset.activityTab);
});
document.querySelector("#refresh-audit")?.addEventListener("click", () => {
refreshCommandAudit().catch(console.error);
});
document.querySelector("#refresh-memory")?.addEventListener("click", () => {
refreshMemory().catch(console.error);
});
document.querySelector("#memory-search-button")?.addEventListener("click", () => {
refreshMemory(document.querySelector("#memory-search-inline")?.value || "").catch(console.error);
});
document.querySelector("#memory-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.querySelector("#memory-text");
const text = input?.value.trim() || "";
if (!text) return;
await jsonFetch("/v1/memory", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
text,
workspace: document.querySelector("#workspace")?.value || "./workspace",
conversation_id: state.currentConversationId || null,
memory_type: "note",
importance: 0.6,
}),
});
input.value = "";
await refreshMemory();
});
document.querySelector("#conversation-list")?.addEventListener("click", (event) => {
const item = event.target.closest("[data-conversation-id]");
if (item) selectConversation(item.dataset.conversationId).catch(console.error);
});
document.querySelector("#messages")?.addEventListener("click", (event) => {
const approvalButton = event.target.closest("[data-inline-approval-action]");
if (approvalButton) {
resolveInlineApproval(approvalButton).catch(console.error);
return;
}
const button = event.target.closest(".message-reasoning-toggle");
if (button) toggleInlineReasoning(button);
});
document.querySelector("#messages")?.addEventListener("submit", (event) => {
const form = event.target.closest(".tool-password-form");
if (!form) return;
event.preventDefault();
submitToolPassword(form).catch(console.error);
});
}
async function loadSimplePages() {
const skills = document.querySelector("#skills");
if (skills) skills.textContent = JSON.stringify(await jsonFetch("/v1/skills"), null, 2);
const experience = document.querySelector("#experience");
if (experience) experience.textContent = JSON.stringify(await jsonFetch("/v1/experience"), null, 2);
const approvals = document.querySelector("#approvals");
if (approvals) await renderApprovals(approvals);
}
async function renderApprovals(container) {
const approvals = await jsonFetch("/v1/approvals/pending");
container.innerHTML = "";
if (!approvals.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No pending approvals.";
container.append(empty);
return;
}
for (const approval of approvals) {
const card = document.createElement("article");
card.className = "approval-card";
card.dataset.approvalId = approval.approval_id;
const header = document.createElement("div");
header.className = "approval-card-header";
const title = document.createElement("h2");
title.textContent = approval.normalized_action?.tool || "Tool action";
const status = document.createElement("span");
status.textContent = approval.status;
header.append(title, status);
const meta = document.createElement("dl");
meta.className = "approval-meta";
meta.append(metaRow("Task", approval.task_id));
meta.append(metaRow("Approval", approval.approval_id));
meta.append(metaRow("Created", approval.created_at));
const action = document.createElement("pre");
action.className = "approval-action";
action.textContent = JSON.stringify(approval.normalized_action, null, 2);
const actions = document.createElement("div");
actions.className = "approval-actions";
actions.append(
approvalButton("Allow once", "allow_once"),
approvalButton("Allow forever", "allow_forever"),
approvalButton("Deny", "deny", "danger"),
);
card.append(header, meta, action, actions);
container.append(card);
}
}
function metaRow(label, value) {
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = value || "";
row.append(dt, dd);
return row;
}
function approvalButton(label, action, tone = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.dataset.approvalAction = action;
if (tone) button.dataset.tone = tone;
return button;
}
document.querySelector("#approvals")?.addEventListener("click", async (event) => {
const button = event.target.closest("[data-approval-action]");
if (!button) return;
const card = button.closest(".approval-card");
const approvalId = card?.dataset.approvalId;
if (!approvalId) return;
button.disabled = true;
const action = button.dataset.approvalAction;
await jsonFetch(`/v1/approvals/${approvalId}/${action}`, {method: "POST"});
await renderApprovals(document.querySelector("#approvals"));
});
document.querySelector("#memory-search")?.addEventListener("click", async () => {
const q = document.querySelector("#memory-query").value;
await renderMemoryPageResults(q);
});
document.querySelector("#memory-list-all")?.addEventListener("click", async () => {
await renderMemoryPageResults("");
});
document.querySelector("#memory-page-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const textInput = document.querySelector("#memory-page-text");
const workspaceInput = document.querySelector("#memory-page-workspace");
const text = textInput?.value.trim() || "";
if (!text) return;
await jsonFetch("/v1/memory", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
text,
workspace: workspaceInput?.value.trim() || null,
memory_type: "note",
importance: 0.6,
}),
});
textInput.value = "";
await renderMemoryPageResults("");
});
async function renderMemoryPageResults(query) {
const container = document.querySelector("#memory-results");
if (!container) return;
const payload = query.trim()
? await jsonFetch(`/v1/memory/search?q=${encodeURIComponent(query.trim())}`)
: await jsonFetch("/v1/memory?limit=100");
const results = payload.results || [];
container.innerHTML = "";
if (!results.length) {
const empty = document.createElement("p");
empty.className = "compact-empty";
empty.textContent = "No memories found.";
container.append(empty);
return;
}
for (const memory of results) {
const item = document.createElement("article");
item.className = "memory-item";
const text = document.createElement("p");
text.textContent = memory.text;
const meta = document.createElement("span");
meta.textContent = `${memory.scope || "memory"} · ${memory.workspace || "global"} · ${memory.memory_type || "note"}`;
item.append(text, meta);
container.append(item);
}
}
renderMemoryPageResults("").catch(console.error);
bindChat();
checkRuntime();
loadSimplePages().catch(console.error);
refreshCommandAudit().catch(console.error);
refreshMemory().catch(console.error);
refreshConversations().then(() => {
if (state.conversations[0]) {
return selectConversation(state.conversations[0].conversation_id);
}
}).catch(console.error);