Merge 31896803686adc6f9853bdd0503ec478158ba5ce into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c
This commit is contained in:
commit
dc1ca05c36
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@ -641,9 +641,18 @@ importers:
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
'@lit-labs/signals':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
'@lit/context':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
'@noble/ed25519':
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
'@novnc/novnc':
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0
|
||||
dompurify:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
@ -654,6 +663,9 @@ importers:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4
|
||||
devDependencies:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)
|
||||
@ -669,6 +681,9 @@ importers:
|
||||
vitest:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
|
||||
packages:
|
||||
|
||||
@ -2068,6 +2083,9 @@ packages:
|
||||
resolution: {integrity: sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@novnc/novnc@1.6.0':
|
||||
resolution: {integrity: sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==}
|
||||
|
||||
'@octokit/app@16.1.2':
|
||||
resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==}
|
||||
engines: {node: '>= 20'}
|
||||
@ -8758,6 +8776,8 @@ snapshots:
|
||||
|
||||
'@nolyfill/domexception@1.0.28': {}
|
||||
|
||||
'@novnc/novnc@1.6.0': {}
|
||||
|
||||
'@octokit/app@16.1.2':
|
||||
dependencies:
|
||||
'@octokit/auth-app': 8.2.0
|
||||
|
||||
@ -802,6 +802,28 @@ export function createGatewayHttpServer(opts: {
|
||||
rateLimiter,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "debug-image",
|
||||
run: async () => {
|
||||
if (requestPath === "/api/debug-image") {
|
||||
try {
|
||||
const fs = await import("node:fs");
|
||||
const img = fs.readFileSync(
|
||||
"/home/fish/.openclaw/workspace/linux-desktop-control/images/desktop_screeshot_20260318_181311_annotated.png",
|
||||
);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.end(img);
|
||||
} catch {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sessions-kill",
|
||||
run: () =>
|
||||
@ -977,6 +999,58 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
if (scopedCanvas.rewrittenUrl) {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === "/api/debug-vnc") {
|
||||
const target = url.searchParams.get("target");
|
||||
let host = process.env.OPENCLAW_VNC_HOST || "localhost";
|
||||
let port = parseInt(process.env.OPENCLAW_VNC_PORT || "5900", 10);
|
||||
if (target) {
|
||||
if (target.includes(":")) {
|
||||
const parts = target.split(":");
|
||||
host = parts[0] || host;
|
||||
const p = parseInt(parts[1] || "", 10);
|
||||
if (!isNaN(p)) {
|
||||
port = p;
|
||||
}
|
||||
} else {
|
||||
host = target;
|
||||
}
|
||||
}
|
||||
|
||||
const net = await import("node:net");
|
||||
// Reuse the same wss for upgrading, but handle connection directly
|
||||
// Or better yet, we can create an ad-hoc WebSocketServer just for this upgrade
|
||||
// to avoid mixing with gateway clients.
|
||||
const { WebSocketServer: VncWss } = await import("ws");
|
||||
const vncWss = new VncWss({ noServer: true, perMessageDeflate: false });
|
||||
vncWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const tcpSocket = net.connect(port, host);
|
||||
tcpSocket.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
ws.on("message", (data) => {
|
||||
if (!tcpSocket.writable) {
|
||||
return;
|
||||
}
|
||||
if (Buffer.isBuffer(data)) {
|
||||
tcpSocket.write(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
tcpSocket.write(Buffer.concat(data));
|
||||
} else {
|
||||
tcpSocket.write(Buffer.from(data));
|
||||
}
|
||||
});
|
||||
ws.on("close", () => tcpSocket.end());
|
||||
tcpSocket.on("close", () => ws.close());
|
||||
tcpSocket.on("error", () => ws.close());
|
||||
ws.on("error", () => tcpSocket.end());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasHost) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === CANVAS_WS_PATH) {
|
||||
|
||||
@ -31,14 +31,6 @@
|
||||
"resolvedPath": "extensions/imessage/runtime-api.js",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-matrix.ts",
|
||||
"line": 4,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/matrix/runtime-api.js",
|
||||
"resolvedPath": "extensions/matrix/runtime-api.js",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 10,
|
||||
|
||||
@ -9,16 +9,21 @@
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lit-labs/signals": "^0.2.0",
|
||||
"@lit/context": "^1.1.6",
|
||||
"@noble/ed25519": "3.0.1",
|
||||
"@novnc/novnc": "^1.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lit": "^3.3.2",
|
||||
"marked": "^17.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser-playwright": "4.1.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"playwright": "^1.58.2",
|
||||
"vite": "8.0.0",
|
||||
"vitest": "4.1.0"
|
||||
"vitest": "4.1.0",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,6 +326,26 @@ export function renderChatControls(state: AppViewState) {
|
||||
>
|
||||
${renderCronFilterIcon(hiddenCronCount)}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${state.showClawComputer ? "active" : ""}"
|
||||
@click=${() => state.toggleClawComputer()}
|
||||
title="远程桌面"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -380,6 +380,11 @@ export function renderApp(state: AppViewState) {
|
||||
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
|
||||
: rawDeliveryToSuggestions;
|
||||
|
||||
const gatewayHttpUrl = state.settings.gatewayUrl
|
||||
.replace(/^ws:\/\//i, "http://")
|
||||
.replace(/^wss:\/\//i, "https://");
|
||||
const debugImageUrl = `${gatewayHttpUrl}/api/debug-image`;
|
||||
|
||||
return html`
|
||||
${renderCommandPalette({
|
||||
open: state.paletteOpen,
|
||||
@ -596,7 +601,7 @@ export function renderApp(state: AppViewState) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
state.tab === "config"
|
||||
state.tab === "config" || state.tab === "chat"
|
||||
? nothing
|
||||
: html`<section class="content-header">
|
||||
<div>
|
||||
@ -638,7 +643,31 @@ export function renderApp(state: AppViewState) {
|
||||
overviewLogLines: state.overviewLogLines,
|
||||
showGatewayToken: state.overviewShowGatewayToken,
|
||||
showGatewayPassword: state.overviewShowGatewayPassword,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
vncConfigDirty: state.vncConfigDirty,
|
||||
onSettingsChange: (next) => {
|
||||
// Only mark dirty if VNC fields changed
|
||||
const vncChanged =
|
||||
next.vncWsUrl !== state.settings.vncWsUrl ||
|
||||
next.vncPassword !== state.settings.vncPassword ||
|
||||
next.vncTarget !== state.settings.vncTarget;
|
||||
|
||||
if (vncChanged) {
|
||||
state.vncConfigDirty = true;
|
||||
}
|
||||
|
||||
// For VNC fields, we update local state but don't persist immediately
|
||||
// This allows the Save button to be the trigger for persistence
|
||||
// For other fields, we persist immediately as before
|
||||
if (vncChanged) {
|
||||
state.settings = next;
|
||||
} else {
|
||||
state.applySettings(next);
|
||||
}
|
||||
},
|
||||
onSaveVncConfig: () => {
|
||||
state.applySettings(state.settings);
|
||||
state.vncConfigDirty = false;
|
||||
},
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
@ -1341,125 +1370,201 @@ export function renderApp(state: AppViewState) {
|
||||
|
||||
${
|
||||
state.tab === "chat"
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
showToolCalls,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
fallbackStatus: state.fallbackStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
streamSegments: state.chatStreamSegments,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
getDraft: () => state.chatMessage,
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||
onClearHistory: async () => {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("sessions.reset", { key: state.sessionKey });
|
||||
state.chatMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
await loadChatHistory(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
},
|
||||
agentsList: state.agentsList,
|
||||
currentAgentId: resolvedAgentId ?? "main",
|
||||
onAgentChange: (agentId: string) => {
|
||||
state.sessionKey = buildAgentMainSessionKey({ agentId });
|
||||
? html`
|
||||
<div style="flex: 1; display: flex; flex-direction: row; min-height: 0; overflow: hidden;">
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 12px 16px 32px; overflow: hidden; gap: 24px;">
|
||||
<section class="content-header">
|
||||
<div>
|
||||
${renderChatSessionSelect(state)}
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||
${renderChatControls(state)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
${renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
showToolCalls,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
fallbackStatus: state.fallbackStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
streamSegments: state.chatStreamSegments,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
getDraft: () => state.chatMessage,
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||
onClearHistory: async () => {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("sessions.reset", { key: state.sessionKey });
|
||||
state.chatMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: state.sessionKey,
|
||||
lastActiveSessionKey: state.sessionKey,
|
||||
});
|
||||
void loadChatHistory(state);
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onNavigateToAgent: () => {
|
||||
state.agentsSelectedId = resolvedAgentId;
|
||||
state.setTab("agents" as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSessionSelect: (key: string) => {
|
||||
switchChatSession(state, key);
|
||||
},
|
||||
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
|
||||
onScrollToBottom: () => state.scrollToBottom(),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
basePath: state.basePath ?? "",
|
||||
})
|
||||
await loadChatHistory(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
},
|
||||
agentsList: state.agentsList,
|
||||
currentAgentId: resolvedAgentId ?? "main",
|
||||
onAgentChange: (agentId: string) => {
|
||||
state.sessionKey = buildAgentMainSessionKey({ agentId });
|
||||
state.chatMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: state.sessionKey,
|
||||
lastActiveSessionKey: state.sessionKey,
|
||||
});
|
||||
void loadChatHistory(state);
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onNavigateToAgent: () => {
|
||||
state.agentsSelectedId = resolvedAgentId;
|
||||
state.setTab("agents" as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSessionSelect: (key: string) => {
|
||||
switchChatSession(state, key);
|
||||
},
|
||||
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
|
||||
onScrollToBottom: () => state.scrollToBottom(),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
basePath: state.basePath ?? "",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<resizable-divider
|
||||
mode="pixels"
|
||||
side="right"
|
||||
orientation="vertical"
|
||||
.initialWidth=${state.clawComputerWidth}
|
||||
.minWidth=${300}
|
||||
.maxWidth=${3000}
|
||||
@resize=${(e: CustomEvent) => {
|
||||
if (e.detail.width !== undefined) {
|
||||
state.setClawComputerWidth(e.detail.width);
|
||||
}
|
||||
}}
|
||||
style=${state.showClawComputer ? "" : "display: none;"}
|
||||
></resizable-divider>
|
||||
<div
|
||||
class="claw-computer-panel"
|
||||
style="
|
||||
width: ${state.clawComputerWidth}px;
|
||||
margin-right: ${state.showClawComputer ? 0 : -state.clawComputerWidth}px;
|
||||
opacity: ${state.showClawComputer ? 1 : 0};
|
||||
transition: margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
"
|
||||
>
|
||||
${
|
||||
state.activeClawTool === "images"
|
||||
? html`
|
||||
<claw-image-panel
|
||||
.enabled=${state.showClawComputer}
|
||||
.activeTool=${state.activeClawTool}
|
||||
.imageUrl=${debugImageUrl}
|
||||
@tool-change=${(e: CustomEvent) => state.setActiveClawTool(e.detail.tool)}
|
||||
@close=${() => state.toggleClawComputer()}
|
||||
@float=${() => state.setClawComputerWidth(0)}
|
||||
@dock=${() => state.setClawComputerWidth(600)}
|
||||
style="flex: 1; min-height: 0;"
|
||||
></claw-image-panel>
|
||||
`
|
||||
: html`
|
||||
<claw-computer-panel
|
||||
.enabled=${state.showClawComputer}
|
||||
.activeTool=${state.activeClawTool}
|
||||
.vncUrl=${state.settings.vncWsUrl}
|
||||
.vncTarget=${state.settings.vncTarget}
|
||||
.password=${state.settings.vncPassword}
|
||||
@tool-change=${(e: CustomEvent) => state.setActiveClawTool(e.detail.tool)}
|
||||
@close=${() => state.toggleClawComputer()}
|
||||
@float=${() => state.setClawComputerWidth(0)}
|
||||
@dock=${() => state.setClawComputerWidth(600)}
|
||||
style="flex: 1; min-height: 0;"
|
||||
></claw-computer-panel>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
|
||||
${
|
||||
state.tab === "config"
|
||||
? renderConfig({
|
||||
|
||||
@ -303,6 +303,7 @@ export type AppViewState = {
|
||||
streamMode: boolean;
|
||||
overviewShowGatewayToken: boolean;
|
||||
overviewShowGatewayPassword: boolean;
|
||||
vncConfigDirty: boolean;
|
||||
overviewLogLines: string[];
|
||||
overviewLogCursor: number;
|
||||
client: GatewayBrowserClient | null;
|
||||
@ -369,4 +370,10 @@ export type AppViewState = {
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
showClawComputer: boolean;
|
||||
toggleClawComputer: () => void;
|
||||
clawComputerWidth: number;
|
||||
activeClawTool: string;
|
||||
setClawComputerWidth: (width: number) => void;
|
||||
setActiveClawTool: (tool: string) => void;
|
||||
};
|
||||
|
||||
@ -88,6 +88,8 @@ import type {
|
||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
import "./components/claw-computer-panel.ts";
|
||||
import "./components/claw-image-panel.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -175,6 +177,10 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sidebarError: string | null = null;
|
||||
@state() splitRatio = this.settings.splitRatio;
|
||||
|
||||
@state() showClawComputer = false;
|
||||
@state() clawComputerWidth = 600;
|
||||
@state() activeClawTool = "vnc"; // vnc, browser, images
|
||||
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<Record<string, unknown>> = [];
|
||||
@state() devicesLoading = false;
|
||||
@ -724,6 +730,18 @@ export class OpenClawApp extends LitElement {
|
||||
this.applySettings({ ...this.settings, splitRatio: newRatio });
|
||||
}
|
||||
|
||||
toggleClawComputer() {
|
||||
this.showClawComputer = !this.showClawComputer;
|
||||
}
|
||||
|
||||
setClawComputerWidth(width: number) {
|
||||
this.clawComputerWidth = width;
|
||||
}
|
||||
|
||||
setActiveClawTool(tool: string) {
|
||||
this.activeClawTool = tool;
|
||||
}
|
||||
|
||||
render() {
|
||||
return renderApp(this as unknown as AppViewState);
|
||||
}
|
||||
|
||||
1045
ui/src/ui/components/claw-computer-panel.ts
Normal file
1045
ui/src/ui/components/claw-computer-panel.ts
Normal file
File diff suppressed because it is too large
Load Diff
905
ui/src/ui/components/claw-image-panel.ts
Normal file
905
ui/src/ui/components/claw-image-panel.ts
Normal file
@ -0,0 +1,905 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, state, property } from "lit/decorators.js";
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
@customElement("claw-image-panel")
|
||||
export class ClawImagePanel extends LitElement {
|
||||
// Floating window state
|
||||
@state() private isFloating = false;
|
||||
@state() private dockedOffsetY = 0;
|
||||
@state() private dockedOffsetX = 0;
|
||||
@state() private floatingRect = { x: 0, y: 0, width: 600, height: 400 };
|
||||
|
||||
private screenRef: Ref<HTMLDivElement> = createRef<HTMLDivElement>();
|
||||
private dragStart = { x: 0, y: 0 };
|
||||
private initialRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
offsetY: 0,
|
||||
offsetX: 0,
|
||||
containerHeight: 0,
|
||||
containerTop: 0,
|
||||
containerLeft: 0,
|
||||
containerWidth: 0,
|
||||
};
|
||||
private isDragging = false;
|
||||
private isResizing = false;
|
||||
private resizeEdge = "";
|
||||
private aspectRatio = 1;
|
||||
// 记录拖拽开始时鼠标相对于拖拽句柄的位置(点 A)
|
||||
private dragAnchor = { x: 0, y: 0 };
|
||||
// 拖拽过程中的临时悬浮状态
|
||||
private tempIsFloating = false;
|
||||
// canvas 的比例(用于悬浮模式缩放时保持)
|
||||
private canvasRatio = 16 / 9;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private readonly TOOLBAR_SPACE = 50;
|
||||
|
||||
@property({ type: String }) activeTool = "images"; // vnc, browser, images
|
||||
@property({ type: String }) imageUrl = "";
|
||||
|
||||
@property({ type: Boolean }) enabled = false;
|
||||
|
||||
private setupResizeObserver() {
|
||||
if (this.resizeObserver) {
|
||||
return;
|
||||
}
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.clampDockedOffset();
|
||||
});
|
||||
this.resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
private cleanupResizeObserver() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clampDockedOffset() {
|
||||
if (this.isFloating || !this.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenElement = this.shadowRoot.querySelector(".screen");
|
||||
if (!screenElement) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const screen = screenElement as HTMLElement;
|
||||
|
||||
const hostHeight = this.offsetHeight;
|
||||
const screenHeight = screen.offsetHeight;
|
||||
|
||||
// 计算允许的最大垂直偏移量(上下边界)
|
||||
// 默认居中时,screenTop = (hostHeight - screenHeight) / 2
|
||||
// maxUp = -screenTop (移动到最顶部)
|
||||
// maxDown = screenTop (移动到最底部)
|
||||
// 注意:这里的 hostHeight 实际上是 screen-container 的 content-box 高度(因为有 padding)
|
||||
// 但 offsetHeight 返回的是包含 padding 的高度。
|
||||
// 不过,我们这里的逻辑主要是为了限制偏移量。
|
||||
|
||||
// 由于我们给容器加了 padding-top: 50px,Flex 居中是相对于剩余空间(Content Box)的。
|
||||
// 只要我们限制 screen 不超出 Content Box,就自然避开了工具栏。
|
||||
|
||||
// 获取容器的实际可用高度(减去 padding)
|
||||
const style = getComputedStyle(this.shadowRoot.querySelector(".screen-container")!);
|
||||
const paddingTop = parseFloat(style.paddingTop) || 0;
|
||||
const contentHeight = hostHeight - paddingTop;
|
||||
|
||||
// 计算在 Content Box 内的居中位置的顶部距离
|
||||
const centerTop = (contentHeight - screenHeight) / 2;
|
||||
|
||||
const maxOffset = Math.max(0, centerTop);
|
||||
|
||||
// 如果当前偏移量超出了允许范围,进行钳制
|
||||
if (Math.abs(this.dockedOffsetY) > maxOffset) {
|
||||
this.dockedOffsetY = Math.max(-maxOffset, Math.min(maxOffset, this.dockedOffsetY));
|
||||
}
|
||||
}
|
||||
|
||||
updated(_changedProperties: Map<string, unknown>) {
|
||||
// No connection logic needed
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--bg-accent);
|
||||
color: var(--text);
|
||||
font-family: system-ui, sans-serif;
|
||||
--vnc-border-color: var(--border);
|
||||
--vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:host {
|
||||
--vnc-border-color: color-mix(in srgb, var(--border), black 15%);
|
||||
--vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:host {
|
||||
--vnc-border-color: color-mix(in srgb, var(--border), white 15%);
|
||||
--vnc-window-bg: color-mix(in srgb, var(--bg-accent), white 5%);
|
||||
}
|
||||
}
|
||||
|
||||
:host([theme="light"]) {
|
||||
--vnc-border-color: color-mix(in srgb, var(--border), black 15%);
|
||||
--vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%);
|
||||
}
|
||||
:host([theme="dark"]) {
|
||||
--vnc-border-color: color-mix(in srgb, var(--border), white 15%);
|
||||
--vnc-window-bg: color-mix(in srgb, var(--bg-accent), white 5%);
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-accent);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Reserve space for top toolbar */
|
||||
padding-top: 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.screen {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5.4px;
|
||||
padding-top: 32.4px;
|
||||
background: var(--vnc-window-bg);
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--vnc-border-color, var(--border)),
|
||||
0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen > *:not(.drag-handle):not(.window-controls):not(.resize-handle) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.screen.dragging > *:not(.drag-handle):not(.window-controls):not(.resize-handle) {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.screen.dragging canvas {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
position: absolute;
|
||||
top: 10.8px;
|
||||
left: 10.8px;
|
||||
display: flex;
|
||||
gap: 7.2px;
|
||||
z-index: 25;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
width: 10.8px;
|
||||
height: 10.8px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
transition:
|
||||
transform 0.1s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
.window-control:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.window-control.maximize {
|
||||
background-color: #27c93f;
|
||||
border-color: #1aab29;
|
||||
}
|
||||
|
||||
.window-control.close {
|
||||
background-color: #ff5f56;
|
||||
border-color: #e0443e;
|
||||
}
|
||||
|
||||
.window-control.minimize {
|
||||
background-color: #ffbd2e;
|
||||
}
|
||||
|
||||
.screen canvas {
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
outline: none;
|
||||
display: block;
|
||||
margin: auto !important;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.status-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--card);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.screen.floating {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
top: 0;
|
||||
left: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Top Toolbar */
|
||||
.top-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toolbar-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32.4px;
|
||||
cursor: grab;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 30;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.resize-handle.top {
|
||||
top: -5px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.bottom {
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.left {
|
||||
left: -5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.right {
|
||||
right: -5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.top-left {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.resize-handle.top-right {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: nesw-resize;
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.resize-handle.bottom-left {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: nesw-resize;
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.resize-handle.bottom-right {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 35;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
const displayFloating = this.isDragging ? this.tempIsFloating : this.isFloating;
|
||||
const screenStyle = displayFloating
|
||||
? `transform: translate(${this.floatingRect.x}px, ${this.floatingRect.y}px); width: ${this.floatingRect.width}px; height: ${this.floatingRect.height}px;`
|
||||
: `transform: translate(${this.dockedOffsetX}px, ${this.dockedOffsetY}px);`;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="screen-container">
|
||||
<!-- Top Toolbar (Dock Mode Icons) - Fixed at top of container -->
|
||||
${
|
||||
!displayFloating
|
||||
? html`
|
||||
<div class="top-toolbar">
|
||||
<button
|
||||
class="toolbar-btn ${this.activeTool === "vnc" ? "active" : ""}"
|
||||
@click=${() => this.setActiveTool("vnc")}
|
||||
title="Remote Desktop"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn ${this.activeTool === "browser" ? "active" : ""}"
|
||||
@click=${() => this.setActiveTool("browser")}
|
||||
title="Browser"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" x2="22" y1="12" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn ${this.activeTool === "images" ? "active" : ""}"
|
||||
@click=${() => this.setActiveTool("images")}
|
||||
title="Images"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
|
||||
<div
|
||||
${ref(this.screenRef)}
|
||||
class="screen ${displayFloating ? "floating" : ""}"
|
||||
style="${screenStyle}"
|
||||
>
|
||||
<img src="${this.imageUrl}" style="max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: auto;" />
|
||||
<div class="drag-handle" @mousedown=${this.handleDragStart}></div>
|
||||
${
|
||||
displayFloating
|
||||
? html`
|
||||
<div class="resize-handle top" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "top")}></div>
|
||||
<div class="resize-handle bottom" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "bottom")}></div>
|
||||
<div class="resize-handle left" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "left")}></div>
|
||||
<div class="resize-handle right" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "right")}></div>
|
||||
<div class="resize-handle top-left" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "top-left")}></div>
|
||||
<div class="resize-handle top-right" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "top-right")}></div>
|
||||
<div class="resize-handle bottom-left" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "bottom-left")}></div>
|
||||
<div class="resize-handle bottom-right" @mousedown=${(e: MouseEvent) => this.handleResizeStart(e, "bottom-right")}></div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<div class="window-controls">
|
||||
<div class="window-control close" @click=${this.handleClose}></div>
|
||||
<div class="window-control minimize"></div>
|
||||
<div class="window-control maximize" @click=${this.toggleFullscreen}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClose = () => {
|
||||
this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
|
||||
};
|
||||
|
||||
private setActiveTool(tool: string) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("tool-change", { detail: { tool }, bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
private handleDragStart = (e: MouseEvent) => {
|
||||
if (this.isResizing) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 先清理旧的监听器,防止重复添加
|
||||
this.cleanupDragListeners();
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
this.tempIsFloating = this.isFloating; // 初始化临时状态
|
||||
|
||||
const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null;
|
||||
const dragHandle = this.shadowRoot?.querySelector(".drag-handle") as HTMLDivElement | null;
|
||||
if (screen && dragHandle) {
|
||||
// 拖拽过程中,暂时禁用 screen 内部所有元素的 pointer-events
|
||||
// 这样即使鼠标移动到 VNC 窗口内部,也不会被 canvas 捕获
|
||||
screen.classList.add("dragging");
|
||||
|
||||
// 记录鼠标相对于拖拽句柄的位置(点 A)
|
||||
const dragHandleRect = dragHandle.getBoundingClientRect();
|
||||
this.dragAnchor = {
|
||||
x: e.clientX - dragHandleRect.left,
|
||||
y: e.clientY - dragHandleRect.top,
|
||||
};
|
||||
|
||||
const rect = screen.getBoundingClientRect();
|
||||
// 获取 claw-computer-panel 本身的尺寸
|
||||
const hostRect = this.getBoundingClientRect();
|
||||
|
||||
// 使用 offsetWidth/offsetHeight 获取实际尺寸
|
||||
const screenWidth = screen.offsetWidth;
|
||||
const screenHeight = screen.offsetHeight;
|
||||
const hostHeight = this.offsetHeight;
|
||||
|
||||
this.initialRect = {
|
||||
x: this.isFloating ? this.floatingRect.x : rect.left,
|
||||
y: this.isFloating ? this.floatingRect.y : rect.top,
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
offsetY: this.dockedOffsetY,
|
||||
offsetX: this.dockedOffsetX,
|
||||
containerHeight: hostHeight,
|
||||
containerTop: hostRect.top,
|
||||
containerLeft: hostRect.left,
|
||||
containerWidth: this.offsetWidth,
|
||||
};
|
||||
|
||||
if (!this.isFloating) {
|
||||
this.floatingRect = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
};
|
||||
|
||||
// 打印尺寸信息,用于调试
|
||||
const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
console.log("=== 切换到悬浮模式时的尺寸信息 ===");
|
||||
console.log("screen 宽度:", screenWidth);
|
||||
console.log("screen 高度:", screenHeight);
|
||||
console.log("screen 比例:", screenWidth / screenHeight);
|
||||
console.log("canvas 宽度:", canvas.clientWidth);
|
||||
console.log("canvas 高度:", canvas.clientHeight);
|
||||
console.log("canvas 比例:", canvas.clientWidth / canvas.clientHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", this.handleDragMove, { capture: true, passive: false });
|
||||
window.addEventListener("mouseup", this.handleDragEnd, { capture: true });
|
||||
};
|
||||
|
||||
private handleDragMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 安全检查:如果鼠标按钮不再按下,就停止拖拽
|
||||
if (e.buttons !== 1) {
|
||||
this.cleanupDragListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dx = e.clientX - this.dragStart.x;
|
||||
const dy = e.clientY - this.dragStart.y;
|
||||
|
||||
// 获取我们自己的左边界
|
||||
const hostRect = this.getBoundingClientRect();
|
||||
|
||||
// 根据鼠标位置更新临时悬浮状态
|
||||
if (e.clientX < hostRect.left) {
|
||||
// 鼠标在左侧,临时切换到悬浮模式
|
||||
if (!this.tempIsFloating) {
|
||||
this.tempIsFloating = true;
|
||||
// 计算新的悬浮窗口位置,保持鼠标相对于拖拽句柄的位置(点 A)
|
||||
const newX = e.clientX - this.dragAnchor.x;
|
||||
const newY = e.clientY - this.dragAnchor.y;
|
||||
|
||||
// 更新 floatingRect
|
||||
this.floatingRect = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: this.initialRect.width,
|
||||
height: this.initialRect.height,
|
||||
};
|
||||
|
||||
// 更新 initialRect,使其基于新的悬浮位置
|
||||
this.initialRect = {
|
||||
...this.initialRect,
|
||||
x: newX,
|
||||
y: newY,
|
||||
};
|
||||
|
||||
// 更新 dragStart,使其从当前位置继续拖拽
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
} else {
|
||||
// 鼠标在右侧,临时切换回停靠模式
|
||||
if (this.tempIsFloating) {
|
||||
this.tempIsFloating = false;
|
||||
// 重置停靠模式的偏移量
|
||||
this.dockedOffsetY = this.initialRect.offsetY;
|
||||
this.dockedOffsetX = 0;
|
||||
// 更新 dragStart,使其从当前位置继续拖拽
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.tempIsFloating) {
|
||||
let newX = this.initialRect.x + dx;
|
||||
let newY = this.initialRect.y + dy;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// 限制悬浮窗口不超出屏幕边界
|
||||
newX = Math.max(0, Math.min(newX, windowWidth - this.initialRect.width));
|
||||
newY = Math.max(0, Math.min(newY, windowHeight - this.initialRect.height));
|
||||
|
||||
this.floatingRect = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: this.initialRect.width,
|
||||
height: this.initialRect.height,
|
||||
};
|
||||
} else {
|
||||
// 如果没有超过分割线,继续停靠模式的拖拽
|
||||
// 每次都直接获取当前尺寸
|
||||
const hostHeight = this.offsetHeight;
|
||||
const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null;
|
||||
const screenHeight = screen?.offsetHeight || 0;
|
||||
|
||||
// 计算最高和最低不能超过多少
|
||||
// 获取容器的实际可用高度(减去 padding)
|
||||
const container = this.shadowRoot?.querySelector(".screen-container");
|
||||
const style = container ? getComputedStyle(container) : null;
|
||||
const paddingTop = style ? parseFloat(style.paddingTop) || 0 : 50;
|
||||
const contentHeight = hostHeight - paddingTop;
|
||||
|
||||
// 当 screen 居中时,初始顶部位置是 (contentHeight - screenHeight) / 2
|
||||
const centerTop = (contentHeight - screenHeight) / 2;
|
||||
|
||||
// 允许的最上偏移量 (offset = -centerTop)
|
||||
// 目标位置是 PADDING_TOP
|
||||
const maxUpOffset = -centerTop;
|
||||
|
||||
// 允许的最下偏移量 (offset = centerTop)
|
||||
// 保持原来的逻辑,底部贴合容器底部
|
||||
const maxDownOffset = centerTop;
|
||||
|
||||
let newOffsetY = this.initialRect.offsetY + dy;
|
||||
|
||||
// 使用计算出来的边界限制
|
||||
newOffsetY = Math.max(maxUpOffset, Math.min(newOffsetY, maxDownOffset));
|
||||
|
||||
this.dockedOffsetY = newOffsetY;
|
||||
|
||||
// 禁止左右移动
|
||||
this.dockedOffsetX = 0;
|
||||
}
|
||||
};
|
||||
|
||||
private cleanupDragListeners = () => {
|
||||
this.isDragging = false;
|
||||
|
||||
// 移除拖拽过程中添加的类,恢复 pointer-events
|
||||
const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null;
|
||||
if (screen) {
|
||||
screen.classList.remove("dragging");
|
||||
}
|
||||
|
||||
try {
|
||||
window.removeEventListener("mousemove", this.handleDragMove, { capture: true });
|
||||
window.removeEventListener("mouseup", this.handleDragEnd, { capture: true });
|
||||
} catch (e) {
|
||||
console.error("Error removing drag event listeners:", e);
|
||||
}
|
||||
};
|
||||
|
||||
private handleDragEnd = (e: MouseEvent) => {
|
||||
// 根据松手位置决定是否永久切换到悬浮模式
|
||||
const hostRect = this.getBoundingClientRect();
|
||||
const isDroppingInDockArea = e.clientX >= hostRect.left;
|
||||
|
||||
if (!isDroppingInDockArea) {
|
||||
// 在左侧(悬浮区域)松手
|
||||
if (!this.isFloating) {
|
||||
// 原本是停靠,现在变悬浮 -> 触发 float
|
||||
this.isFloating = true;
|
||||
this.dispatchEvent(new CustomEvent("float", { bubbles: true, composed: true }));
|
||||
}
|
||||
} else {
|
||||
// 在右侧(停靠区域)松手
|
||||
if (this.isFloating) {
|
||||
// 原本是悬浮,现在变停靠 -> 触发 dock
|
||||
this.isFloating = false;
|
||||
this.dockedOffsetX = 0;
|
||||
this.dispatchEvent(new CustomEvent("dock", { bubbles: true, composed: true }));
|
||||
} else {
|
||||
// 原本是停靠,现在还是停靠 -> 只是调整了垂直位置,或者是点击
|
||||
// 不需要触发 dock 事件,以免重置宽度
|
||||
this.isFloating = false;
|
||||
this.dockedOffsetX = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.cleanupDragListeners();
|
||||
};
|
||||
|
||||
private cleanupResizeListeners = () => {
|
||||
this.isResizing = false;
|
||||
try {
|
||||
window.removeEventListener("mousemove", this.handleResizeMove);
|
||||
window.removeEventListener("mouseup", this.handleResizeEnd);
|
||||
} catch (e) {
|
||||
console.error("Error removing resize event listeners:", e);
|
||||
}
|
||||
};
|
||||
|
||||
private handleResizeStart = (e: MouseEvent, edge: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 先清理旧的监听器,防止重复添加
|
||||
this.cleanupResizeListeners();
|
||||
|
||||
this.isResizing = true;
|
||||
this.resizeEdge = edge;
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
|
||||
// 每次开始缩放时,获取最新的 canvas 比例
|
||||
const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null;
|
||||
if (canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0) {
|
||||
this.canvasRatio = canvas.clientWidth / canvas.clientHeight;
|
||||
}
|
||||
|
||||
this.initialRect = {
|
||||
x: this.floatingRect.x,
|
||||
y: this.floatingRect.y,
|
||||
width: this.floatingRect.width,
|
||||
height: this.floatingRect.height,
|
||||
offsetY: 0,
|
||||
offsetX: 0,
|
||||
containerHeight: 0,
|
||||
containerTop: 0,
|
||||
containerLeft: 0,
|
||||
containerWidth: 0,
|
||||
};
|
||||
this.aspectRatio = this.floatingRect.width / this.floatingRect.height;
|
||||
|
||||
window.addEventListener("mousemove", this.handleResizeMove);
|
||||
window.addEventListener("mouseup", this.handleResizeEnd);
|
||||
};
|
||||
|
||||
private handleResizeMove = (e: MouseEvent) => {
|
||||
if (!this.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 安全检查:如果鼠标按钮不再按下,就停止调整大小
|
||||
if (e.buttons !== 1) {
|
||||
this.cleanupResizeListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const dx = e.clientX - this.dragStart.x;
|
||||
const dy = e.clientY - this.dragStart.y;
|
||||
|
||||
// 考虑到窗口有边框和标题栏,实际的 canvas 内容区域要减去这些 padding
|
||||
// 左右 padding: 5.4px * 2 = 10.8px
|
||||
// 上下 padding: 32.4px (标题栏) + 5.4px (底边) = 37.8px
|
||||
const PADDING_X = 10.8;
|
||||
const PADDING_Y = 37.8;
|
||||
|
||||
let { x, y, width, height } = this.initialRect;
|
||||
|
||||
// 1. 先计算出用户拖拽后的预期宽高
|
||||
if (this.resizeEdge.includes("right")) {
|
||||
width += dx;
|
||||
}
|
||||
if (this.resizeEdge.includes("left")) {
|
||||
x += dx;
|
||||
width -= dx;
|
||||
}
|
||||
if (this.resizeEdge.includes("bottom")) {
|
||||
height += dy;
|
||||
}
|
||||
if (this.resizeEdge.includes("top")) {
|
||||
y += dy;
|
||||
height -= dy;
|
||||
}
|
||||
|
||||
// 2. 根据 canvas 的比例强制修正窗口尺寸,确保窗口能仅仅贴合 VNC
|
||||
let canvasW = width - PADDING_X;
|
||||
let canvasH = height - PADDING_Y;
|
||||
|
||||
if (this.resizeEdge === "top" || this.resizeEdge === "bottom") {
|
||||
// 如果只拖拽上下边,以高度为基准计算宽度
|
||||
canvasW = canvasH * this.canvasRatio;
|
||||
width = canvasW + PADDING_X;
|
||||
if (this.resizeEdge.includes("left")) {
|
||||
x = this.initialRect.x + (this.initialRect.width - width);
|
||||
}
|
||||
} else {
|
||||
// 左右边或者对角线,统一以宽度为基准计算高度
|
||||
canvasH = canvasW / this.canvasRatio;
|
||||
height = canvasH + PADDING_Y;
|
||||
if (this.resizeEdge.includes("top")) {
|
||||
y = this.initialRect.y + (this.initialRect.height - height);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最小尺寸限制
|
||||
if (width < 200) {
|
||||
width = 200;
|
||||
canvasW = width - PADDING_X;
|
||||
canvasH = canvasW / this.canvasRatio;
|
||||
height = canvasH + PADDING_Y;
|
||||
if (this.resizeEdge.includes("left")) {
|
||||
x = this.initialRect.x + (this.initialRect.width - width);
|
||||
}
|
||||
if (this.resizeEdge.includes("top")) {
|
||||
y = this.initialRect.y + (this.initialRect.height - height);
|
||||
}
|
||||
}
|
||||
// 注意:这里不再单独限制 height,因为 height 已经和 width 绑定了
|
||||
|
||||
this.floatingRect = { x, y, width, height };
|
||||
};
|
||||
|
||||
private handleResizeEnd = () => {
|
||||
this.cleanupResizeListeners();
|
||||
};
|
||||
|
||||
private handleResize = () => {
|
||||
// No-op for image
|
||||
};
|
||||
|
||||
private toggleFullscreen = () => {
|
||||
const container = this.shadowRoot?.querySelector(".screen-container");
|
||||
if (container) {
|
||||
void (container as HTMLElement).requestFullscreen?.();
|
||||
}
|
||||
};
|
||||
|
||||
private handleWindowBlur = () => {
|
||||
this.cleanupDragListeners();
|
||||
this.cleanupResizeListeners();
|
||||
};
|
||||
|
||||
firstUpdated() {
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
window.addEventListener("blur", this.handleWindowBlur);
|
||||
this.setupResizeObserver();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
window.removeEventListener("blur", this.handleWindowBlur);
|
||||
this.cleanupDragListeners();
|
||||
this.cleanupResizeListeners();
|
||||
this.cleanupResizeObserver();
|
||||
}
|
||||
}
|
||||
@ -11,9 +11,16 @@ export class ResizableDivider extends LitElement {
|
||||
@property({ type: Number }) minRatio = 0.4;
|
||||
@property({ type: Number }) maxRatio = 0.7;
|
||||
|
||||
@property({ type: String }) mode: "ratio" | "pixels" = "ratio";
|
||||
@property({ type: String }) side: "left" | "right" = "left";
|
||||
@property({ type: Number }) initialWidth = 0;
|
||||
@property({ type: Number }) minWidth = 0;
|
||||
@property({ type: Number }) maxWidth = Infinity;
|
||||
|
||||
private isDragging = false;
|
||||
private startX = 0;
|
||||
private startRatio = 0;
|
||||
private startWidth = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
@ -60,8 +67,21 @@ export class ResizableDivider extends LitElement {
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX;
|
||||
this.startRatio = this.splitRatio;
|
||||
this.startWidth = this.initialWidth;
|
||||
this.classList.add("dragging");
|
||||
|
||||
// Add a global overlay to prevent iframe/other elements from capturing mouse events
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "resize-overlay";
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.zIndex = "9999";
|
||||
overlay.style.cursor = "col-resize";
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
|
||||
@ -78,8 +98,29 @@ export class ResizableDivider extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const deltaX = e.clientX - this.startX;
|
||||
|
||||
if (this.mode === "pixels") {
|
||||
let newWidth = this.startWidth;
|
||||
if (this.side === "left") {
|
||||
newWidth += deltaX;
|
||||
} else {
|
||||
newWidth -= deltaX;
|
||||
}
|
||||
|
||||
newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth));
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("resize", {
|
||||
detail: { width: newWidth },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const deltaRatio = deltaX / containerWidth;
|
||||
|
||||
let newRatio = this.startRatio + deltaRatio;
|
||||
@ -98,6 +139,11 @@ export class ResizableDivider extends LitElement {
|
||||
this.isDragging = false;
|
||||
this.classList.remove("dragging");
|
||||
|
||||
const overlay = document.getElementById("resize-overlay");
|
||||
if (overlay) {
|
||||
document.body.removeChild(overlay);
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
|
||||
@ -134,6 +134,8 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
vncTarget: "localhost:5900",
|
||||
vncWsUrl: "wss://gateway.example:8443/api/debug-vnc",
|
||||
sessionsByGateway: {
|
||||
"wss://gateway.example:8443/openclaw": {
|
||||
sessionKey: "agent",
|
||||
@ -250,6 +252,8 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
vncTarget: "localhost:5900",
|
||||
vncWsUrl: "wss://gateway.example:8443/api/debug-vnc",
|
||||
});
|
||||
expect(loadSettings()).toMatchObject({
|
||||
gatewayUrl: gwUrl,
|
||||
@ -269,6 +273,8 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
vncTarget: "localhost:5900",
|
||||
vncWsUrl: "wss://gateway.example:8443/api/debug-vnc",
|
||||
sessionsByGateway: {
|
||||
[gwUrl]: {
|
||||
sessionKey: "main",
|
||||
|
||||
@ -41,6 +41,9 @@ export type UiSettings = {
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
borderRadius: number; // Corner roundness (0–100, default 50)
|
||||
locale?: string;
|
||||
vncWsUrl?: string;
|
||||
vncPassword?: string;
|
||||
vncTarget?: string;
|
||||
};
|
||||
|
||||
function isViteDevPage(): boolean {
|
||||
@ -191,6 +194,8 @@ export function loadSettings(): UiSettings {
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
vncWsUrl: `${defaultUrl.replace(/\/$/, "")}/api/debug-vnc`,
|
||||
vncTarget: "localhost:5900",
|
||||
borderRadius: 50,
|
||||
};
|
||||
|
||||
@ -256,6 +261,9 @@ export function loadSettings(): UiSettings {
|
||||
? parsed.borderRadius
|
||||
: defaults.borderRadius,
|
||||
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
|
||||
vncWsUrl: typeof parsed.vncWsUrl === "string" ? parsed.vncWsUrl : defaults.vncWsUrl,
|
||||
vncPassword: typeof parsed.vncPassword === "string" ? parsed.vncPassword : undefined,
|
||||
vncTarget: typeof parsed.vncTarget === "string" ? parsed.vncTarget : defaults.vncTarget,
|
||||
};
|
||||
if ("token" in parsed) {
|
||||
persistSettings(settings);
|
||||
@ -317,6 +325,9 @@ function persistSettings(next: UiSettings) {
|
||||
borderRadius: next.borderRadius,
|
||||
sessionsByGateway,
|
||||
...(next.locale ? { locale: next.locale } : {}),
|
||||
vncWsUrl: next.vncWsUrl,
|
||||
vncPassword: next.vncPassword,
|
||||
vncTarget: next.vncTarget,
|
||||
};
|
||||
const serialized = JSON.stringify(persisted);
|
||||
try {
|
||||
|
||||
22
ui/src/ui/types/novnc.d.ts
vendored
Normal file
22
ui/src/ui/types/novnc.d.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
// novnc.d.ts
|
||||
declare module "@novnc/novnc/lib/rfb.js" {
|
||||
export default class RFB {
|
||||
constructor(target: HTMLElement, url: string, options?: unknown);
|
||||
addEventListener(event: string, callback: (e?: unknown) => void): void;
|
||||
disconnect(): void;
|
||||
scaleViewport: boolean;
|
||||
clipViewport: boolean;
|
||||
resize?(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@novnc/novnc" {
|
||||
export default class RFB {
|
||||
constructor(target: HTMLElement, url: string, options?: unknown);
|
||||
addEventListener(event: string, callback: (e?: unknown) => void): void;
|
||||
disconnect(): void;
|
||||
scaleViewport: boolean;
|
||||
clipViewport: boolean;
|
||||
resize?(): void;
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,9 @@ export type OverviewProps = {
|
||||
overviewLogLines: string[];
|
||||
showGatewayToken: boolean;
|
||||
showGatewayPassword: boolean;
|
||||
vncConfigDirty?: boolean;
|
||||
onSettingsChange: (next: UiSettings) => void;
|
||||
onSaveVncConfig?: () => void;
|
||||
onPasswordChange: (next: string) => void;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
onToggleGatewayTokenVisibility: () => void;
|
||||
@ -304,24 +306,13 @@ export function renderOverview(props: OverviewProps) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button class="btn" @click=${() => props.onConnect()}>${t("common.connect")}</button>
|
||||
<button class="btn" @click=${() => props.onRefresh()}>${t("common.refresh")}</button>
|
||||
<span class="muted">${
|
||||
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
|
||||
}</span>
|
||||
</div>
|
||||
${
|
||||
!props.connected
|
||||
? html`
|
||||
<div class="login-gate__help" style="margin-top: 16px;">
|
||||
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
|
||||
<ol class="login-gate__steps">
|
||||
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
|
||||
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
|
||||
<li>${t("overview.connection.step3")}</li>
|
||||
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
|
||||
</ol>
|
||||
<div class="login-gate" style="margin-top: 24px;">
|
||||
<button class="btn primary large" @click=${props.onConnect}>
|
||||
${t("overview.connection.connect")}
|
||||
</button>
|
||||
<div class="login-gate__docs">
|
||||
${t("overview.connection.docsHint")}
|
||||
<a
|
||||
@ -337,6 +328,65 @@ export function renderOverview(props: OverviewProps) {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Remote Desktop</div>
|
||||
<div class="card-sub">Configure VNC connection details</div>
|
||||
<div class="ov-access-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>WebSocket URL</span>
|
||||
<input
|
||||
.value=${props.settings.vncWsUrl ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, vncWsUrl: v });
|
||||
}}
|
||||
placeholder="ws://localhost:8081"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Target (Host:Port)</span>
|
||||
<input
|
||||
.value=${props.settings.vncTarget ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, vncTarget: v });
|
||||
}}
|
||||
placeholder="localhost:5900"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.settings.vncPassword ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
props.onSettingsChange({ ...props.settings, vncPassword: v });
|
||||
}}
|
||||
placeholder="VNC Password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top: 16px; display: flex; justify-content: flex-start;">
|
||||
<button
|
||||
class="btn primary"
|
||||
style=${
|
||||
props.vncConfigDirty
|
||||
? "background-color: #2196f3; border-color: #2196f3;"
|
||||
: "background-color: #666; border-color: #666; cursor: default; opacity: 0.8;"
|
||||
}
|
||||
@click=${() => {
|
||||
if (props.vncConfigDirty && props.onSaveVncConfig) {
|
||||
props.onSaveVncConfig();
|
||||
}
|
||||
}}
|
||||
?disabled=${!props.vncConfigDirty}
|
||||
>
|
||||
${props.vncConfigDirty ? "Save Config" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">${t("overview.snapshot.title")}</div>
|
||||
<div class="card-sub">${t("overview.snapshot.subtitle")}</div>
|
||||
|
||||
92
ui/vnc-proxy-plugin.ts
Normal file
92
ui/vnc-proxy-plugin.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import * as net from "net";
|
||||
import type { PluginOption } from "vite";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
|
||||
// Use environment variables or hardcoded defaults from the external script
|
||||
const VNC_HOST = process.env.OPENCLAW_VNC_HOST || "localhost";
|
||||
const VNC_PORT = parseInt(process.env.OPENCLAW_VNC_PORT || "5900", 10);
|
||||
const WS_PATH = "/vnc";
|
||||
|
||||
export function vncProxyPlugin(): PluginOption {
|
||||
return {
|
||||
name: "openclaw-vnc-proxy",
|
||||
configureServer(server) {
|
||||
// Create a WebSocket server that shares the Vite HTTP server
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
path: WS_PATH,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
|
||||
console.log(`🚀 [Proxy] VNC WebSocket proxy injected at ${WS_PATH}`);
|
||||
console.log(` Forwarding to: ${VNC_HOST}:${VNC_PORT}`);
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const target = url.searchParams.get("target");
|
||||
|
||||
let host = VNC_HOST;
|
||||
let port = VNC_PORT;
|
||||
|
||||
if (target) {
|
||||
if (target.includes(":")) {
|
||||
const parts = target.split(":");
|
||||
host = parts[0];
|
||||
const p = parseInt(parts[1], 10);
|
||||
if (!isNaN(p)) {
|
||||
port = p;
|
||||
}
|
||||
} else {
|
||||
host = target;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[VNC Proxy] Client connected to ${WS_PATH}, forwarding to ${host}:${port}`);
|
||||
|
||||
const tcpSocket = net.connect(port, host);
|
||||
|
||||
tcpSocket.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("message", (data: RawData) => {
|
||||
if (!tcpSocket.writable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(data)) {
|
||||
tcpSocket.write(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
tcpSocket.write(Buffer.concat(data));
|
||||
} else {
|
||||
tcpSocket.write(Buffer.from(data));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => tcpSocket.end());
|
||||
tcpSocket.on("close", () => ws.close());
|
||||
|
||||
tcpSocket.on("error", (e) => {
|
||||
console.error("[VNC Proxy] TCP Error:", e.message);
|
||||
ws.close();
|
||||
});
|
||||
ws.on("error", (e) => {
|
||||
console.error("[VNC Proxy] WebSocket Error:", e.message);
|
||||
tcpSocket.end();
|
||||
});
|
||||
});
|
||||
|
||||
// Hook into Vite's HTTP server upgrade event
|
||||
server.httpServer?.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === WS_PATH) {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user