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>
This commit is contained in:
木炎
2026-04-06 15:26:15 +08:00
parent 6068a8228b
commit 0dd655712c
2 changed files with 553 additions and 0 deletions

View File

@@ -0,0 +1,532 @@
<!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>