feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
Cheng Zi Feng
2026-04-17 16:21:27 +08:00
committed by GitHub
parent b5c299f5d2
commit 72a2093cd6
64 changed files with 4138 additions and 312 deletions

View File

@@ -4,8 +4,24 @@
*/
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
import { connectSSE, disconnectSSE } from "./sse.js";
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
import {
appendEvent,
getActivityMode,
removeLoading,
resetReplayState,
renderReplayPendingRequests,
setAutomationActivity,
showLoading,
} from "./render.js";
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
import {
createAutomationState,
getAutomationActivity,
getAutomationIndicator,
reduceAutomationState,
renderAutomationIcon,
shouldPulseAutomationIndicator,
} from "./automation.js";
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
// ============================================================
@@ -16,6 +32,8 @@ let currentSessionId = null;
let currentSessionStatus = null;
let dashboardInterval = null;
let cachedEnvs = [];
let automationState = createAutomationState();
let automationPulseTimer = null;
function generateMessageUuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -24,6 +42,82 @@ function generateMessageUuid() {
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function renderAutomationIndicator() {
const indicatorEl = document.getElementById("session-automation");
if (!indicatorEl) return;
const indicator = getAutomationIndicator(automationState);
if (!indicator.visible) {
indicatorEl.className = "automation-pill hidden";
indicatorEl.dataset.pulsing = "false";
indicatorEl.innerHTML = "";
indicatorEl.removeAttribute("title");
return;
}
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
if (indicatorEl.dataset.pulsing === "true") {
indicatorEl.classList.add("is-pulsing");
}
indicatorEl.innerHTML = `
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
<span class="automation-pill-label">${esc(indicator.label)}</span>
`;
indicatorEl.title = indicator.title;
}
function syncAutomationUI() {
renderAutomationIndicator();
setAutomationActivity(getAutomationActivity(automationState));
}
function stopAutomationPulse() {
if (automationPulseTimer) {
clearTimeout(automationPulseTimer);
automationPulseTimer = null;
}
const indicatorEl = document.getElementById("session-automation");
if (indicatorEl) {
indicatorEl.dataset.pulsing = "false";
indicatorEl.classList.remove("is-pulsing");
}
}
function pulseAutomationIndicator() {
if (!getAutomationIndicator(automationState).visible) return;
stopAutomationPulse();
const indicatorEl = document.getElementById("session-automation");
if (!indicatorEl) return;
indicatorEl.dataset.pulsing = "true";
indicatorEl.classList.add("is-pulsing");
automationPulseTimer = setTimeout(() => {
indicatorEl.dataset.pulsing = "false";
indicatorEl.classList.remove("is-pulsing");
automationPulseTimer = null;
}, 1200);
}
function resetAutomationIndicator() {
automationState = createAutomationState();
stopAutomationPulse();
syncAutomationUI();
}
function applyAutomationEvent(event, { replay = false } = {}) {
automationState = reduceAutomationState(automationState, event);
syncAutomationUI();
if (!replay && shouldPulseAutomationIndicator(event)) {
pulseAutomationIndicator();
}
}
function applyAutomationSnapshot(snapshot) {
if (snapshot === undefined) return;
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
}
// ============================================================
// Router
// ============================================================
@@ -75,7 +169,7 @@ function applySessionStatus(status) {
if (closed) {
removeLoading();
window.__updateActionBtn?.(false);
window.__updateActionBtn?.("idle");
}
}
@@ -86,6 +180,7 @@ function handleSessionEvent(event) {
disconnectSSE();
}
}
applyAutomationEvent(event);
appendEvent(event);
}
@@ -104,7 +199,9 @@ async function syncClosedSessionState(err, actionLabel) {
const session = await apiFetchSession(currentSessionId);
applySessionStatus(session.status);
if (isClosedSessionStatus(session.status)) {
appendEvent({ type: "session_status", payload: { status: session.status } });
const closedEvent = { type: "session_status", payload: { status: session.status } };
applyAutomationEvent(closedEvent);
appendEvent(closedEvent);
return;
}
} catch {
@@ -159,6 +256,7 @@ async function handleRoute() {
// Default: /code → dashboard
currentSessionId = null;
currentSessionStatus = null;
resetAutomationIndicator();
showPage("dashboard");
disconnectSSE();
renderDashboard();
@@ -233,6 +331,8 @@ function stopDashboardRefresh() {
async function renderSessionDetail(id) {
currentSessionId = id;
resetAutomationIndicator();
let session = null;
// Reset task state for new session and init panel
resetTaskState();
@@ -240,7 +340,7 @@ async function renderSessionDetail(id) {
if (taskPanelEl) initTaskPanel(taskPanelEl);
try {
const session = await apiFetchSession(id);
session = await apiFetchSession(id);
document.getElementById("session-title").textContent = session.title || session.id;
document.getElementById("session-id").textContent = session.id;
document.getElementById("session-env").textContent = session.environment_id || "";
@@ -254,6 +354,7 @@ async function renderSessionDetail(id) {
document.getElementById("event-stream").innerHTML = "";
document.getElementById("permission-area").innerHTML = "";
document.getElementById("permission-area").classList.add("hidden");
applyAutomationSnapshot(session?.automation_state);
// Load historical events before connecting to live stream
resetReplayState();
@@ -262,6 +363,7 @@ async function renderSessionDetail(id) {
const { events } = await apiFetchSessionHistory(id);
if (events && events.length > 0) {
for (const event of events) {
applyAutomationEvent(event, { replay: true });
appendEvent(event, { replay: true });
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
}
@@ -273,7 +375,9 @@ async function renderSessionDetail(id) {
renderReplayPendingRequests();
if (isClosedSessionStatus(currentSessionStatus)) {
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
applyAutomationEvent(closedEvent);
appendEvent(closedEvent);
disconnectSSE();
return;
}
@@ -291,17 +395,20 @@ function setupControlBar() {
const iconSend = document.getElementById("action-icon-send");
const iconStop = document.getElementById("action-icon-stop");
function setBtnState(loading) {
actionBtn.classList.toggle("loading", loading);
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
iconSend.classList.toggle("hidden", loading);
iconStop.classList.toggle("hidden", !loading);
function setBtnState(mode) {
const working = mode === "working";
actionBtn.classList.toggle("loading", working);
actionBtn.dataset.mode = mode || "idle";
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
iconSend.classList.toggle("hidden", working);
iconStop.classList.toggle("hidden", !working);
}
window.__updateActionBtn = setBtnState;
setBtnState(getActivityMode());
actionBtn.addEventListener("click", () => {
if (isLoading()) {
if (getActivityMode() === "working") {
doInterrupt();
} else {
sendMessage();
@@ -319,7 +426,6 @@ async function doInterrupt() {
btn.disabled = true;
try {
await apiInterrupt(currentSessionId);
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
} catch (err) {
await syncClosedSessionState(err, "Interrupt failed");
} finally {
@@ -460,11 +566,28 @@ window._submitAnswers = async function (requestId, btn) {
function removePermissionPrompt(btn) {
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
const requestId = prompt?.dataset?.requestId || null;
if (prompt) prompt.remove();
if (requestId) {
const stream = document.getElementById("event-stream");
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
if (row.dataset.pendingRequestId === requestId) row.remove();
});
}
const area = document.getElementById("permission-area");
if (area && area.children.length === 0) area.classList.add("hidden");
}
function appendLocalSystemMessage(text) {
const stream = document.getElementById("event-stream");
if (!stream) return;
const row = document.createElement("div");
row.className = "msg-row system";
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
stream.appendChild(row);
stream.scrollTop = stream.scrollHeight;
}
// ============================================================
// ExitPlanMode interactions
// ============================================================
@@ -509,6 +632,7 @@ window._submitPlanResponse = async function (requestId, btn) {
...(feedback ? { message: feedback } : {}),
});
removePermissionPrompt(btn);
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
} else {
// Approval with permission mode
const modeMap = {