const state = { running: false, messages: [], currentConversationId: "", conversations: [], }; 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 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(); if (tool === "os_update_check") return "$ os_update_check"; return `$ ${tool || "tool"}`; } function formatToolStart(tool, args) { const lines = [formatToolCommand(tool, args)]; const serializedArgs = JSON.stringify(args || {}, null, 2); if (serializedArgs !== "{}") lines.push(serializedArgs); return lines.join("\n"); } function appendToolTerminal(article, eventPayload) { const paragraph = article?.querySelector("p"); const terminal = createToolTerminal(eventPayload); paragraph?.before(terminal); document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight; } function updateToolTerminal(article, eventPayload) { const payload = eventPayload.payload || eventPayload; const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${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); status.textContent = result.ok ? "ok" : "error"; const parts = [body.textContent.trim()]; if (result.output) parts.push("\nstdout\n" + result.output.trimEnd()); if (result.error) parts.push("\nstderr\n" + result.error.trimEnd()); if (result.metadata && Object.keys(result.metadata).length) { parts.push("\nmetadata\n" + JSON.stringify(result.metadata, null, 2)); } 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 || {}; appendToolTerminal(article, { payload: { index: payload.index, tool: payload.tool, args: action.args || {}, }, }); const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`); 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 = [ body.textContent, "", "approval required", command, payload.reason || "", ].filter(Boolean).join("\n"); } terminal?.append(createInlineApprovalActions(payload.approval_id, command)); } 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.remove("is-waiting"); if (status) status.textContent = decision; if (body) body.textContent = `${command}\n\n${decision}: ${humanApprovalDecision(action)}`; actions?.remove(); if (taskId) { await continueAfterInlineApproval(terminal.closest(".message"), taskId, approvalId); } } 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; } 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 === "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 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 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(); } 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("#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("#debug")?.addEventListener("change", (event) => { document.querySelector("#debug-panel").hidden = !event.target.checked; }); } 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; document.querySelector("#memory-results").textContent = JSON.stringify(await jsonFetch(`/v1/memory/search?q=${encodeURIComponent(q)}`), null, 2); }); bindChat(); checkRuntime(); loadSimplePages().catch(console.error); refreshConversations().then(() => { if (state.conversations[0]) { return selectConversation(state.conversations[0].conversation_id); } }).catch(console.error);