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