Files
claw/frontend/service-console/sg_claw_service_console.html

849 lines
25 KiB
HTML
Raw Permalink 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);
}
}
/* Settings modal elements */
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.92);
color: var(--text);
font: inherit;
outline: none;
cursor: pointer;
}
select:focus {
border-color: rgba(15, 118, 110, 0.5);
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
}
@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>
<button id="settingsBtn" class="ghost-btn" style="margin-top: 8px;">⚙ 设置</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 class="field">
<label for="pageUrlInput">页面 URL可选</label>
<input id="pageUrlInput" placeholder="例如https://www.zhihu.com 或真实业务页地址" />
</div>
<div class="field">
<label for="pageTitleInput">页面标题(可选)</label>
<input id="pageTitleInput" placeholder="例如:知乎 - 热榜" />
</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>
<!-- Settings Modal -->
<div id="settingsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: var(--panel); border-radius: 20px; padding: 28px; width: min(520px, 90%); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);">
<h3 style="margin: 0 0 20px; font-size: 1.2rem;">sgClaw 配置</h3>
<div class="field">
<label for="settingApiKey">API 密钥 *</label>
<input id="settingApiKey" type="password" placeholder="输入模型 API 密钥" />
</div>
<div class="field">
<label for="settingBaseUrl">模型服务地址 *</label>
<input id="settingBaseUrl" type="url" placeholder="例如https://api.deepseek.com" />
</div>
<div class="field">
<label for="settingModel">模型名称 *</label>
<input id="settingModel" type="text" placeholder="例如deepseek-chat" />
</div>
<div class="field">
<label for="settingSkillsDir">Skills 目录路径</label>
<input id="settingSkillsDir" type="text" placeholder="例如D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills" />
</div>
<div class="field">
<label for="settingDirectSubmitSkill">直接提交技能</label>
<input id="settingDirectSubmitSkill" type="text" placeholder="例如tq-lineloss-report.collect_lineloss" />
</div>
<div class="field">
<label for="settingRuntimeProfile">运行模式</label>
<select id="settingRuntimeProfile" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
<option value="browser-attached">browser-attached</option>
<option value="browser-heavy">browser-heavy</option>
<option value="general-assistant">general-assistant</option>
</select>
</div>
<div class="field">
<label for="settingBrowserBackend">浏览器后端</label>
<select id="settingBrowserBackend" style="width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); font: inherit;">
<option value="super-rpa">super-rpa</option>
<option value="agent-browser">agent-browser</option>
<option value="rust-native">rust-native</option>
<option value="computer-use">computer-use</option>
<option value="auto">auto</option>
</select>
</div>
<div id="settingsValidation" style="color: var(--error); font-size: 0.92rem; min-height: 1.4em; margin: 10px 0;"></div>
<div style="display: flex; gap: 12px; margin-top: 16px;">
<button id="settingsSaveBtn" class="primary-btn" style="flex: 1;">保存</button>
<button id="settingsCancelBtn" class="ghost-btn" style="flex: 1;">取消</button>
</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"),
pageUrlInput: document.getElementById("pageUrlInput"),
pageTitleInput: document.getElementById("pageTitleInput"),
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;
case "config_updated":
handleConfigResponse(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;
}
const pageUrl = elements.pageUrlInput.value.trim();
const pageTitle = elements.pageTitleInput.value.trim();
setValidation("");
socket.send(JSON.stringify({
type: "submit_task",
instruction,
conversation_id: "",
messages: [],
page_url: pageUrl,
page_title: pageTitle
}));
}
elements.connectBtn.addEventListener("click", connectOrDisconnectService);
elements.sendBtn.addEventListener("click", sendTask);
elements.instructionInput.addEventListener("input", () => {
if (elements.instructionInput.value.trim()) {
setValidation("");
}
});
updateUiState();
// Auto-connect on page load
window.addEventListener("DOMContentLoaded", () => {
connectOrDisconnectService(true);
});
// Settings modal state
const settingsElements = {
modal: document.getElementById("settingsModal"),
apiKey: document.getElementById("settingApiKey"),
baseUrl: document.getElementById("settingBaseUrl"),
model: document.getElementById("settingModel"),
skillsDir: document.getElementById("settingSkillsDir"),
directSubmitSkill: document.getElementById("settingDirectSubmitSkill"),
runtimeProfile: document.getElementById("settingRuntimeProfile"),
browserBackend: document.getElementById("settingBrowserBackend"),
validation: document.getElementById("settingsValidation"),
saveBtn: document.getElementById("settingsSaveBtn"),
cancelBtn: document.getElementById("settingsCancelBtn"),
};
let settingsOpenBtn = null;
function openSettingsModal() {
settingsElements.apiKey.value = "";
settingsElements.baseUrl.value = "";
settingsElements.model.value = "";
settingsElements.skillsDir.value = "";
settingsElements.directSubmitSkill.value = "";
settingsElements.runtimeProfile.value = "browser-attached";
settingsElements.browserBackend.value = "super-rpa";
settingsElements.validation.textContent = "";
settingsElements.modal.style.display = "flex";
}
function closeSettingsModal() {
settingsElements.modal.style.display = "none";
}
function validateSettings() {
const apiKey = settingsElements.apiKey.value.trim();
const baseUrl = settingsElements.baseUrl.value.trim();
const model = settingsElements.model.value.trim();
if (!apiKey) {
return "API 密钥不能为空";
}
if (!model) {
return "模型名称不能为空";
}
if (!baseUrl) {
return "模型服务地址不能为空";
}
try {
new URL(baseUrl);
} catch {
return "模型服务地址格式无效,请输入有效的 URL";
}
return "";
}
function saveSettings() {
const error = validateSettings();
if (error) {
settingsElements.validation.textContent = error;
return;
}
if (!socket || socket.readyState !== WebSocket.OPEN) {
settingsElements.validation.textContent = "请先连接服务";
return;
}
settingsElements.validation.textContent = "";
settingsElements.saveBtn.disabled = true;
settingsElements.saveBtn.textContent = "保存中...";
const config = {
apiKey: settingsElements.apiKey.value.trim(),
baseUrl: settingsElements.baseUrl.value.trim(),
model: settingsElements.model.value.trim(),
};
const skillsDir = settingsElements.skillsDir.value.trim();
if (skillsDir) config.skillsDir = skillsDir;
const directSubmitSkill = settingsElements.directSubmitSkill.value.trim();
if (directSubmitSkill) config.directSubmitSkill = directSubmitSkill;
config.runtimeProfile = settingsElements.runtimeProfile.value;
config.browserBackend = settingsElements.browserBackend.value;
socket.send(JSON.stringify({
type: "update_config",
config,
}));
}
function handleConfigResponse(message) {
settingsElements.saveBtn.disabled = false;
settingsElements.saveBtn.textContent = "保存";
if (message.success) {
settingsElements.validation.textContent = message.message;
settingsElements.validation.style.color = "var(--success)";
setTimeout(closeSettingsModal, 2000);
} else {
settingsElements.validation.textContent = message.message;
settingsElements.validation.style.color = "var(--error)";
}
}
// Event listeners for settings
settingsOpenBtn = document.getElementById("settingsBtn");
settingsOpenBtn.addEventListener("click", openSettingsModal);
settingsElements.cancelBtn.addEventListener("click", closeSettingsModal);
settingsElements.saveBtn.addEventListener("click", saveSettings);
settingsElements.modal.addEventListener("click", (e) => {
if (e.target === settingsElements.modal) {
closeSettingsModal();
}
});
</script>
</body>
</html>