Merge 31896803686adc6f9853bdd0503ec478158ba5ce into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c

This commit is contained in:
yihuan zhao 2026-03-21 05:32:51 +00:00 committed by GitHub
commit dc1ca05c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2558 additions and 140 deletions

20
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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"
}
}

View File

@ -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>
`;
}

View File

@ -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({

View File

@ -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;
};

View File

@ -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);
}

File diff suppressed because it is too large Load Diff

View 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: 50pxFlex 居中是相对于剩余空间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();
}
}

View File

@ -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);
};

View File

@ -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",

View File

@ -41,6 +41,9 @@ export type UiSettings = {
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
borderRadius: number; // Corner roundness (0100, 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
View 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;
}
}

View File

@ -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
View 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);
});
}
});
},
};
}