- Auto-connect WebSocket on page load in service console - Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.) - UpdateConfig/ConfigUpdated protocol messages for remote config save - save_to_path() for SgClawSettings serialization - ConfigUpdated handler in sg_claw_client binary - Protocol serialization tests for new message types - HTML test assertions for auto-connect and settings UI - Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs 🤖 Generated with [Qoder][https://qoder.com]
836 lines
24 KiB
HTML
836 lines
24 KiB
HTML
<!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 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"),
|
||
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;
|
||
}
|
||
|
||
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();
|
||
|
||
// 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>
|