This commit is contained in:
赵一寰 2026-03-13 16:48:50 +08:00
parent 5b06619c67
commit 2712bb0f3d
6 changed files with 466 additions and 115 deletions

View File

@ -283,6 +283,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

@ -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`
<div style="flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 12px 16px 32px; overflow: hidden; gap: 24px;">
${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 ?? "",
})}
</div>
${
state.showClawComputer
? html`
<resizable-divider
mode="pixels"
orientation="vertical"
.initialWidth=${state.clawComputerWidth}
.minWidth=${400}
.maxWidth=${900}
@resize=${(e: CustomEvent) => {
if (e.detail.width !== undefined) {
state.setClawComputerWidth(e.detail.width);
}
}}
></resizable-divider>
<div class="claw-computer-panel" style="width: ${state.clawComputerWidth}px;">
<div class="claw-computer-header">
<span>🖥 </span>
<button class="claw-computer-close" @click=${() => state.toggleClawComputer()}>×</button>
</div>
<claw-computer-panel
.vncUrl=${"127.0.0.1:5901"}
.vncToken=${"123456"}
></claw-computer-panel>
</div>
`
: nothing
}
`
: nothing
}
${
state.tab === "config"
? renderConfig({

View File

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

View File

@ -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<HTMLDivElement> = createRef<HTMLDivElement>();
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`
<div class="container">
<h2>Claw Computer - VNC </h2>
<div class="controls">
<label>VNC :</label>
<input type="password" placeholder="留空 = 無密碼"
.value=${this.password}
@input=${(e: Event) => {
this.password = (e.target as HTMLInputElement).value;
}}
@keydown=${this.handlePasswordKeydown} />
<div class="btn-group">
<button @click=${this.connect} ?disabled=${this.isConnected}></button>
<button @click=${this.disconnect} ?disabled=${!this.isConnected}></button>
<button @click=${this.toggleFullscreen}></button>
</div>
<div style="margin-top:12px;">
<strong></strong>
<button class=${this.isFitted ? "active" : ""} @click=${() => this.setFitMode(true)}></button>
<button class=${!this.isFitted ? "active" : ""} @click=${() => this.setFitMode(false)}>1:1 </button>
</div>
<div class="status" style="color: ${this.isConnected ? "#4caf50" : "#aaa"}">
${this.status}
</div>
</div>
<div class="screen-container">
<div ${ref(this.screenRef)} class="screen"></div>
</div>
</div>
`;
}
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();
}
}

33
ui/src/ui/server/proxy.ts Normal file
View File

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

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