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:
532
frontend/service-console/sg_claw_service_console.html
Normal file
532
frontend/service-console/sg_claw_service_console.html
Normal 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>
|
||||
21
tests/service_console_html_test.rs
Normal file
21
tests/service_console_html_test.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn service_console_html_stays_on_service_ws_boundary() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let html_path = manifest_dir
|
||||
.join("frontend")
|
||||
.join("service-console")
|
||||
.join("sg_claw_service_console.html");
|
||||
let source = fs::read_to_string(&html_path).expect("service console html should exist");
|
||||
|
||||
assert!(source.contains("ws://127.0.0.1:42321"));
|
||||
assert!(source.contains("submit_task"));
|
||||
assert!(!source.contains("/sgclaw/browser-helper.html"));
|
||||
assert!(!source.contains("/sgclaw/callback/ready"));
|
||||
assert!(!source.contains("/sgclaw/callback/events"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/next"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/ack"));
|
||||
assert!(!source.contains("ws://127.0.0.1:12345"));
|
||||
}
|
||||
Reference in New Issue
Block a user