Files
claw/frontend/service-console/sg_claw_service_console.html
木炎 57b9be733d fix: harden service websocket reconnect flows
Stabilize the service console and callback-host websocket paths so idle disconnects and mid-task client drops no longer wedge task execution or spam repeated commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:34:34 +08:00

633 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>sgClaw Service Console</title>
<style>
:root {
--bg: #f3efe4;
--panel: rgba(255, 252, 247, 0.88);
--panel-strong: #fffaf2;
--text: #1f2329;
--muted: #636b74;
--line: rgba(31, 35, 41, 0.12);
--accent: #0f766e;
--accent-strong: #115e59;
--warn: #b45309;
--error: #b42318;
--success: #166534;
--shadow: 0 24px 60px rgba(34, 42, 53, 0.14);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
radial-gradient(circle at right, rgba(180, 83, 9, 0.14), transparent 28%),
linear-gradient(160deg, #f5f0e6 0%, #eef5f4 56%, #f7f3eb 100%);
padding: 24px;
}
.shell {
width: min(1040px, 100%);
margin: 0 auto;
background: var(--panel);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 28px;
box-shadow: var(--shadow);
overflow: hidden;
}
.hero {
padding: 28px 28px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 250, 242, 0.96), rgba(237, 246, 243, 0.92));
}
.hero h1 {
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
letter-spacing: 0.02em;
}
.hero p {
margin: 10px 0 0;
max-width: 60ch;
color: var(--muted);
line-height: 1.6;
}
.content {
display: grid;
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
gap: 0;
}
.sidebar,
.stream-panel {
padding: 24px;
}
.sidebar {
border-right: 1px solid var(--line);
background: rgba(255, 255, 255, 0.38);
}
.section-label {
margin: 0 0 14px;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.field {
margin-bottom: 18px;
}
.field label {
display: block;
margin-bottom: 8px;
font-size: 0.92rem;
color: var(--muted);
}
input,
textarea,
button {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
font: inherit;
}
input,
textarea {
background: rgba(255, 255, 255, 0.92);
color: var(--text);
padding: 14px 16px;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
input:focus,
textarea:focus {
border-color: rgba(15, 118, 110, 0.5);
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
}
textarea {
min-height: 180px;
resize: vertical;
line-height: 1.6;
}
button {
border: 0;
padding: 14px 16px;
font-weight: 700;
cursor: pointer;
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.primary-btn {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #f6fffd;
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
}
.ghost-btn {
background: rgba(255, 255, 255, 0.9);
color: var(--text);
border: 1px solid var(--line);
}
.status-card {
display: grid;
gap: 8px;
padding: 16px;
border-radius: 20px;
background: var(--panel-strong);
border: 1px solid var(--line);
margin-bottom: 18px;
}
.state-chip {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 700;
background: rgba(99, 107, 116, 0.12);
color: var(--muted);
}
.state-chip[data-state="connected"] {
background: rgba(22, 101, 52, 0.12);
color: var(--success);
}
.state-chip[data-state="connecting"] {
background: rgba(180, 83, 9, 0.12);
color: var(--warn);
}
.validation {
min-height: 1.4em;
margin: 10px 0 14px;
color: var(--error);
font-size: 0.92rem;
}
.stream-panel {
display: grid;
grid-template-rows: auto minmax(320px, 1fr);
gap: 18px;
}
.stream-head {
display: flex;
justify-content: space-between;
align-items: end;
gap: 16px;
}
.stream-head h2 {
margin: 0;
font-size: 1.35rem;
}
.stream-head p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.stream {
display: grid;
gap: 12px;
align-content: start;
min-height: 320px;
max-height: 70vh;
overflow: auto;
padding: 4px;
}
.empty-state {
padding: 22px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.52);
border: 1px dashed rgba(31, 35, 41, 0.16);
color: var(--muted);
line-height: 1.6;
}
.row {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: start;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(31, 35, 41, 0.08);
animation: rise 180ms ease;
}
.row-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 76px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(99, 107, 116, 0.14);
color: var(--muted);
}
.row.status .row-badge {
background: rgba(15, 118, 110, 0.14);
color: var(--accent-strong);
}
.row.log .row-badge {
background: rgba(57, 91, 163, 0.14);
color: #315aa2;
}
.row.complete .row-badge {
background: rgba(22, 101, 52, 0.14);
color: var(--success);
}
.row.error .row-badge {
background: rgba(180, 35, 24, 0.14);
color: var(--error);
}
.row-text {
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
body {
padding: 16px;
}
.content {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.stream {
max-height: none;
}
}
</style>
</head>
<body>
<div class="shell" id="app">
<div class="hero">
<h1>sgClaw Service Console</h1>
<p>直接连接现有 service websocket提交自然语言任务并持续查看 service 返回的状态、日志和完成结果。</p>
</div>
<div class="content">
<div class="sidebar">
<p class="section-label">Connection</p>
<div class="status-card">
<span id="connectionState" class="state-chip" data-state="disconnected">未连接</span>
<span>默认地址使用现有 service websocket。</span>
</div>
<div class="field">
<label for="wsUrl">WebSocket 地址</label>
<input id="wsUrl" value="ws://127.0.0.1:42321" />
</div>
<button id="connectBtn" class="ghost-btn">连接</button>
<p class="section-label" style="margin-top: 26px;">Composer</p>
<div class="field">
<label for="instructionInput">任务内容</label>
<textarea id="instructionInput" placeholder="例如:打开百度"></textarea>
</div>
<div id="validationText" class="validation"></div>
<button id="sendBtn" class="primary-btn" disabled>发送任务</button>
</div>
<div class="stream-panel">
<div class="stream-head">
<div>
<p class="section-label">Service Stream</p>
<h2>消息流</h2>
<p>只展示本地连接状态与现有 service message。</p>
</div>
</div>
<div id="messageStream" class="stream">
<div class="empty-state" id="emptyState">尚无消息。先连接 service websocket再发送一条自然语言任务。</div>
</div>
</div>
</div>
</div>
<script>
const defaultWsUrl = "ws://127.0.0.1:42321";
const elements = {
wsUrl: document.getElementById("wsUrl"),
connectBtn: document.getElementById("connectBtn"),
connectionState: document.getElementById("connectionState"),
messageStream: document.getElementById("messageStream"),
instructionInput: document.getElementById("instructionInput"),
validationText: document.getElementById("validationText"),
sendBtn: document.getElementById("sendBtn"),
emptyState: document.getElementById("emptyState")
};
let socket = null;
let reconnectTimer = null;
let connectTimeoutTimer = null;
let heartbeatTimer = null;
let shouldReconnect = false;
let lastHeartbeatAt = 0;
const reconnectDelayMs = 1500;
const reconnectCloseCode = 4000;
const reconnectCloseReason = "manual_disconnect";
const heartbeatIntervalMs = 15000;
const heartbeatTimeoutMs = 30000;
const connectTimeoutMs = 5000;
function appendRow(kind, text) {
if (elements.emptyState) {
elements.emptyState.remove();
elements.emptyState = null;
}
const row = document.createElement("div");
row.className = "row " + kind;
const badge = document.createElement("span");
badge.className = "row-badge";
badge.textContent = kind;
const content = document.createElement("p");
content.className = "row-text";
content.textContent = text;
row.appendChild(badge);
row.appendChild(content);
elements.messageStream.appendChild(row);
elements.messageStream.scrollTop = elements.messageStream.scrollHeight;
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function clearConnectTimeoutTimer() {
if (connectTimeoutTimer) {
clearTimeout(connectTimeoutTimer);
connectTimeoutTimer = null;
}
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function startHeartbeat() {
stopHeartbeat();
lastHeartbeatAt = Date.now();
heartbeatTimer = setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
if (Date.now() - lastHeartbeatAt > heartbeatTimeoutMs) {
appendRow("error", "heartbeat missed, forcing reconnect");
const activeSocket = socket;
socket = null;
stopHeartbeat();
clearConnectTimeoutTimer();
activeSocket.close();
scheduleReconnect();
return;
}
socket.send(JSON.stringify({ type: "ping" }));
}, heartbeatIntervalMs);
}
function scheduleReconnect() {
clearReconnectTimer();
clearConnectTimeoutTimer();
if (!shouldReconnect) {
return;
}
appendRow("status", "service websocket disconnected, retrying");
reconnectTimer = setTimeout(() => connectOrDisconnectService(true), reconnectDelayMs);
updateUiState();
}
function setValidation(message) {
elements.validationText.textContent = message;
}
function updateUiState() {
const readyState = socket ? socket.readyState : WebSocket.CLOSED;
const connected = readyState === WebSocket.OPEN;
const connecting = readyState === WebSocket.CONNECTING || Boolean(reconnectTimer);
let stateText = "未连接";
let stateValue = "disconnected";
if (connected) {
stateText = "已连接";
stateValue = "connected";
} else if (connecting) {
stateText = "连接中";
stateValue = "connecting";
}
elements.connectBtn.textContent = connected || connecting ? "断开" : "连接";
elements.sendBtn.disabled = !connected;
elements.connectionState.textContent = stateText;
elements.connectionState.dataset.state = stateValue;
}
function connectOrDisconnectService(forceConnect = false) {
if (!forceConnect && socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
shouldReconnect = false;
clearReconnectTimer();
clearConnectTimeoutTimer();
stopHeartbeat();
socket.close(reconnectCloseCode, reconnectCloseReason);
return;
}
clearReconnectTimer();
clearConnectTimeoutTimer();
const url = elements.wsUrl.value.trim() || defaultWsUrl;
elements.wsUrl.value = url;
shouldReconnect = true;
const nextSocket = new WebSocket(url);
socket = nextSocket;
updateUiState();
connectTimeoutTimer = setTimeout(() => {
if (socket !== nextSocket || nextSocket.readyState !== WebSocket.CONNECTING) {
return;
}
appendRow("error", "service websocket connect timed out");
socket = null;
nextSocket.close();
scheduleReconnect();
}, connectTimeoutMs);
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return;
}
clearReconnectTimer();
clearConnectTimeoutTimer();
lastHeartbeatAt = Date.now();
startHeartbeat();
appendRow("status", "service websocket connected");
updateUiState();
});
nextSocket.addEventListener("close", (event) => {
if (socket !== nextSocket) {
return;
}
socket = null;
clearConnectTimeoutTimer();
stopHeartbeat();
const manualClose = event.code === reconnectCloseCode || event.reason === reconnectCloseReason;
if (manualClose) {
shouldReconnect = false;
appendRow("status", "service websocket disconnected");
updateUiState();
return;
}
scheduleReconnect();
});
nextSocket.addEventListener("error", () => {
if (socket !== nextSocket) {
return;
}
appendRow("error", "service websocket error");
});
nextSocket.addEventListener("message", handleMessage);
}
function handleMessage(event) {
lastHeartbeatAt = Date.now();
let message;
try {
message = JSON.parse(event.data);
} catch (_error) {
appendRow("error", "invalid service message: " + event.data);
return;
}
switch (message.type) {
case "status_changed":
appendRow("status", message.state);
break;
case "log_entry":
appendRow("log", message.message);
break;
case "task_complete":
appendRow(message.success ? "complete" : "error", message.summary);
break;
case "busy":
appendRow("error", message.message);
break;
case "pong":
break;
default:
appendRow("error", "unknown service message: " + event.data);
}
}
function sendTask() {
const instruction = elements.instructionInput.value.trim();
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
if (!instruction) {
setValidation("请输入任务内容。");
return;
}
setValidation("");
socket.send(JSON.stringify({
type: "submit_task",
instruction,
conversation_id: "",
messages: [],
page_url: "",
page_title: ""
}));
}
elements.connectBtn.addEventListener("click", connectOrDisconnectService);
elements.sendBtn.addEventListener("click", sendTask);
elements.instructionInput.addEventListener("input", () => {
if (elements.instructionInput.value.trim()) {
setValidation("");
}
});
updateUiState();
</script>
</body>
</html>