1006 lines
35 KiB
JavaScript
1006 lines
35 KiB
JavaScript
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 = `<strong>${role === "user" ? "You" : "DuckLM"}</strong><span>${escapeText(meta)}</span>`;
|
|
|
|
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 = "<i></i><i></i><i></i>";
|
|
|
|
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);
|