Files
claw/frontend/service-console/sg_claw_service_console.html
木炎 0dd655712c feat: add standalone service chat console
Provide a local HTML console that reuses the existing service websocket so task entry stays outside the browser-helper runtime path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:26:15 +08:00

533 lines
13 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;
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 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;
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() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
socket.close();
return;
}
const url = elements.wsUrl.value.trim() || defaultWsUrl;
elements.wsUrl.value = url;
const nextSocket = new WebSocket(url);
socket = nextSocket;
updateUiState();
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return;
}
appendRow("status", "service websocket connected");
updateUiState();
});
nextSocket.addEventListener("close", () => {
if (socket === nextSocket) {
socket = null;
}
appendRow("status", "service websocket disconnected");
updateUiState();
});
nextSocket.addEventListener("error", () => {
appendRow("error", "service websocket error");
});
nextSocket.addEventListener("message", handleMessage);
}
function handleMessage(event) {
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;
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>