From 2d3a04754825139b0a4f491dc734eab66d20d439 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 20 May 2026 01:07:06 +0800 Subject: [PATCH] Add inline approval controls to chat --- duck_core/web/static/app.js | 77 +++++++++++++++++++++++++++++++++- duck_core/web/static/style.css | 28 +++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js index 602a5d3..0ab1f13 100644 --- a/duck_core/web/static/app.js +++ b/duck_core/web/static/app.js @@ -151,19 +151,87 @@ function updateToolTerminal(article, eventPayload) { function appendApprovalTerminal(article, eventPayload) { const payload = eventPayload.payload || eventPayload; + const action = payload.action || {}; appendToolTerminal(article, { payload: { index: payload.index, tool: payload.tool, - args: payload.action?.args || {}, + 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 (status) status.textContent = "approval"; - if (body) body.textContent += `\n\napproval required\n${payload.reason || ""}`; + 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 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(); +} + +function humanApprovalDecision(action) { + if (action === "allow_once") return "разрешено один раз"; + if (action === "allow_forever") return "разрешено навсегда"; + return "запрещено"; } function setMessagePending(article, text) { @@ -403,6 +471,11 @@ function bindChat() { setStatus("#task-status", "none"); }); 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); }); diff --git a/duck_core/web/static/style.css b/duck_core/web/static/style.css index 7429f98..def1cc7 100644 --- a/duck_core/web/static/style.css +++ b/duck_core/web/static/style.css @@ -538,6 +538,34 @@ dd { overflow-wrap: anywhere; } +.tool-approval-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 12px 12px; + border-top: 1px solid #1e293b; + background: #111827; +} + +.tool-approval-actions button { + border: 0; + border-radius: 7px; + padding: 8px 10px; + background: #2563eb; + color: #ffffff; + font-size: 12px; + font-weight: 800; +} + +.tool-approval-actions button[data-tone="danger"] { + background: #b42318; +} + +.tool-approval-actions button:disabled { + cursor: wait; + opacity: 0.65; +} + .message-meta { display: flex; align-items: center;