diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts
index 0a2003fac34..c13ba076a43 100644
--- a/ui/src/ui/app-render.helpers.ts
+++ b/ui/src/ui/app-render.helpers.ts
@@ -283,6 +283,26 @@ export function renderChatControls(state: AppViewState) {
>
${renderCronFilterIcon(hiddenCronCount)}
+
`;
}
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index 74644f07708..871f4b55244 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -1316,127 +1316,163 @@ 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,
- 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`
+
+
+
+ ${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,
+ 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) => {
- state.setSessionKey(key);
- state.chatMessages = [];
- void loadChatHistory(state);
- void state.loadAssistantIdentity();
- },
- 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) => {
+ state.setSessionKey(key);
+ state.chatMessages = [];
+ void loadChatHistory(state);
+ void state.loadAssistantIdentity();
+ },
+ 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 ?? "",
+ })}
+
+
+ ${
+ state.showClawComputer
+ ? html`
+ {
+ if (e.detail.width !== undefined) {
+ state.setClawComputerWidth(e.detail.width);
+ }
+ }}
+ >
+
+
+
+
+ `
+ : nothing
+ }
+ `
: nothing
}
+
${
state.tab === "config"
? renderConfig({
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index b659c195754..ea61387d21b 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -364,4 +364,8 @@ export type AppViewState = {
handleOpenSidebar: (content: string) => void;
handleCloseSidebar: () => void;
handleSplitRatioChange: (ratio: number) => void;
+ showClawComputer: boolean;
+ toggleClawComputer: () => void;
+ clawComputerWidth: number;
+ setClawComputerWidth: (width: number) => void;
};
diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts
new file mode 100644
index 00000000000..a6312e054f6
--- /dev/null
+++ b/ui/src/ui/components/claw-computer-panel.ts
@@ -0,0 +1,236 @@
+// ui/src/ui/components/claw-computer-panel.ts
+import { LitElement, html, css } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { createRef, ref, Ref } from "lit/directives/ref.js";
+
+interface RFBInstance {
+ disconnect(): void;
+ addEventListener(event: string, callback: (e?: unknown) => void): void;
+ scaleViewport: boolean;
+ clipViewport: boolean;
+ resize?(): void;
+}
+
+@customElement("claw-computer-panel")
+export class ClawComputerPanel extends LitElement {
+ @state() status = "🖥️ Claw Computer 未配置";
+ @state() isConnected = false;
+ @state() isFitted = true;
+ @state() password = "";
+
+ private rfb: RFBInstance | null = null;
+ private RFBConstructor: unknown = null;
+ private screenRef: Ref = createRef();
+
+ static styles = css`
+ :host {
+ display: block;
+ height: 100%;
+ background: #0a0a0a;
+ color: #eee;
+ font-family: system-ui, sans-serif;
+ }
+ .container {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ }
+ h2 {
+ text-align: center;
+ margin: 0 0 16px 0;
+ color: #ddd;
+ }
+ .controls {
+ background: #1a1a1a;
+ padding: 16px;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ border: 1px solid #333;
+ }
+ label {
+ display: block;
+ margin-bottom: 6px;
+ font-weight: 500;
+ }
+ input {
+ width: 100%;
+ padding: 10px;
+ background: #222;
+ border: 1px solid #444;
+ color: #eee;
+ border-radius: 6px;
+ margin-bottom: 12px;
+ }
+ .btn-group {
+ display: flex;
+ gap: 8px;
+ margin: 12px 0;
+ flex-wrap: wrap;
+ }
+ button {
+ padding: 10px 16px;
+ background: #0066cc;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: bold;
+ }
+ button:hover:not(:disabled) {
+ background: #007fff;
+ }
+ button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ .active {
+ background: #0088ff !important;
+ }
+ .screen-container {
+ flex: 1;
+ min-height: 400px;
+ background: #000;
+ border: 2px solid #444;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ }
+ .screen {
+ width: 100%;
+ height: 100%;
+ }
+ .status {
+ margin-top: 8px;
+ text-align: center;
+ font-weight: bold;
+ min-height: 24px;
+ }
+ `;
+
+ render() {
+ return html`
+
+
Claw Computer - VNC 遠端畫面
+
+
+
{
+ this.password = (e.target as HTMLInputElement).value;
+ }}
+ @keydown=${this.handlePasswordKeydown} />
+
+
+
+
+
+
+ 視窗縮放模式:
+
+
+
+
+ ${this.status}
+
+
+
+
+ `;
+ }
+
+ private async loadRFB() {
+ if (this.RFBConstructor) {
+ return;
+ }
+ const module = await import("@novnc/novnc/lib/rfb.js");
+ this.RFBConstructor = (module as unknown as { default?: unknown }).default || module;
+ }
+
+ private connect = async () => {
+ await this.loadRFB();
+
+ const url = "ws://localhost:8081";
+ if (this.rfb) {
+ this.rfb.disconnect();
+ }
+
+ this.status = "正在連接...";
+
+ const screen = this.screenRef.value;
+ if (!screen) {
+ return;
+ }
+
+ const Constructor = this.RFBConstructor as new (
+ target: HTMLElement,
+ url: string,
+ options?: unknown,
+ ) => RFBInstance;
+ this.rfb = new Constructor(screen, url, {
+ credentials: { password: this.password || undefined },
+ resizeSession: true,
+ clipViewport: true,
+ scaleViewport: this.isFitted,
+ });
+
+ this.rfb.addEventListener("connect", () => {
+ this.isConnected = true;
+ this.status = "已連線成功 ✓(改變視窗大小會自動適配)";
+ setTimeout(() => this.rfb?.resize?.(), 100);
+ });
+
+ this.rfb.addEventListener("disconnect", () => {
+ this.isConnected = false;
+ this.status = "連線中斷";
+ this.rfb = null;
+ });
+ };
+
+ private disconnect = () => {
+ if (this.rfb) {
+ this.rfb.disconnect();
+ }
+ };
+
+ private setFitMode(fitted: boolean) {
+ this.isFitted = fitted;
+ if (this.rfb) {
+ this.rfb.scaleViewport = fitted;
+ this.rfb.clipViewport = true;
+ setTimeout(() => this.rfb?.resize?.(), 50);
+ }
+ }
+
+ private handleResize = () => {
+ if (this.rfb && this.isConnected) {
+ setTimeout(() => this.rfb?.resize?.(), 80);
+ }
+ };
+
+ private toggleFullscreen = () => {
+ const container = this.shadowRoot?.querySelector(".screen-container");
+ if (container) {
+ void (container as HTMLElement).requestFullscreen?.();
+ }
+ };
+
+ private handlePasswordKeydown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ void this.connect();
+ }
+ };
+
+ firstUpdated() {
+ window.addEventListener("resize", this.handleResize);
+ void this.loadRFB();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener("resize", this.handleResize);
+ this.disconnect();
+ }
+}
diff --git a/ui/src/ui/server/proxy.ts b/ui/src/ui/server/proxy.ts
new file mode 100644
index 00000000000..c4e47296eef
--- /dev/null
+++ b/ui/src/ui/server/proxy.ts
@@ -0,0 +1,33 @@
+import http from "http";
+// server/proxy.ts
+import { WebSocket, WebSocketServer } from "ws";
+
+const PORT = 8081;
+const TARGET_VNC = "ws://10.75.171.0:25900"; // ← 改成你的真實 VNC 位址
+
+const server = http.createServer();
+const wss = new WebSocketServer({ server });
+
+wss.on("connection", (clientWs) => {
+ console.log("[Proxy] Client connected");
+
+ const targetWs = new WebSocket(TARGET_VNC);
+
+ targetWs.on("open", () => console.log("[Proxy] 已連線到真實 VNC"));
+
+ clientWs.on("message", (data) => targetWs.readyState === WebSocket.OPEN && targetWs.send(data));
+ targetWs.on("message", (data) => clientWs.readyState === WebSocket.OPEN && clientWs.send(data));
+
+ const cleanup = () => {
+ targetWs.close();
+ clientWs.close();
+ };
+ clientWs.on("close", cleanup);
+ targetWs.on("close", cleanup);
+ clientWs.on("error", cleanup);
+ targetWs.on("error", cleanup);
+});
+
+server.listen(PORT, () => {
+ console.log(`✅ noVNC Proxy 啟動成功 → ws://localhost:${PORT}`);
+});
diff --git a/ui/src/ui/types/novnc.d.ts b/ui/src/ui/types/novnc.d.ts
new file mode 100644
index 00000000000..98524632afd
--- /dev/null
+++ b/ui/src/ui/types/novnc.d.ts
@@ -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;
+ }
+}