Add approval continuation and multi-step tools

This commit is contained in:
2026-05-20 03:34:13 +08:00
parent 2d3a047548
commit a4b7ef034a
13 changed files with 982 additions and 85 deletions
+90 -54
View File
@@ -165,6 +165,7 @@ function appendApprovalTerminal(article, eventPayload) {
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 = [
@@ -207,6 +208,7 @@ function inlineApprovalButton(label, action, tone = "") {
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;
@@ -226,6 +228,9 @@ async function resolveInlineApproval(button) {
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) {
@@ -320,8 +325,8 @@ function parseSseBlock(block) {
return {name: event.name, data: JSON.parse(event.data)};
}
async function streamChat(payload, onEvent) {
const response = await fetch("/v1/chat/stream", {
async function streamSse(url, payload, onEvent) {
const response = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
@@ -350,6 +355,85 @@ async function streamChat(payload, onEvent) {
}
}
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;
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 (!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);
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 sendMessage() {
if (state.running) return;
const input = document.querySelector("#message");
@@ -362,8 +446,7 @@ async function sendMessage() {
addMessage("user", message, "submitted");
input.value = "";
const pending = addMessage("assistant", "", "thinking", {reasoning: true});
let taskId = "";
let contentStarted = false;
const context = {taskId: "", contentStarted: false};
try {
await streamChat({
@@ -371,61 +454,14 @@ async function sendMessage() {
workspace: document.querySelector("#workspace").value,
debug: document.querySelector("#debug").checked,
}, async ({name, data}) => {
if (data.task_id) taskId = data.task_id;
if (name === "task_created") {
taskId = data.task_id;
setStatus("#task-status", taskId, "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 (!contentStarted) {
contentStarted = true;
setMessagePending(pending, "");
}
pending.querySelector(".message-meta span").textContent = "answering";
appendMessageText(pending, data.delta || "");
return;
}
if (name === "done") {
if (!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);
return;
}
if (name === "error") {
throw new Error(data.error || "Stream failed.");
}
await handleAssistantStreamEvent(pending, name, data, context);
});
} catch (error) {
if (!taskId) input.value = message;
if (!context.taskId) input.value = message;
setMessagePending(pending, error.message);
pending.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
if (taskId) await refreshEvents(taskId);
if (context.taskId) await refreshEvents(context.taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;