This commit is contained in:
Timothy Jaeryang Baek 2026-11-02 01:16:43 +09:00
parent b51e68fbb3
commit 3bd0cdd0f3
11 changed files with 369 additions and 26 deletions

View file

@ -216,7 +216,7 @@ async def browser_ws(ws: WebSocket, server_id: str, sid: str, ticket: str, kind:
connections = await Config.get('terminal_server.connections', []) or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if (
kind not in ('ws', 'stream')
kind not in ('ws', 'stream', 'vnc')
or not ENABLE_TOOL_SERVERS
or not connection
or not connection.get('enabled', True)
@ -559,6 +559,11 @@ async def ws_events(ws: WebSocket, server_id: str):
await _proxy_terminal_websocket(ws, server_id)
@router.websocket('/{server_id}/desktop/stream')
async def desktop_ws(ws: WebSocket, server_id: str):
await _proxy_terminal_websocket(ws, server_id, stream_path='desktop/stream')
@router.websocket('/{server_id}/execute')
async def ws_processes(ws: WebSocket, server_id: str):
await _proxy_terminal_websocket(ws, server_id, stream_path='execute')

7
package-lock.json generated
View file

@ -19,6 +19,7 @@
"@joplin/turndown-plugin-gfm": "^1.0.62",
"@mediapipe/tasks-vision": "^0.10.17",
"@modelcontextprotocol/ext-apps": "2.0.4",
"@novnc/novnc": "^1.7.0",
"@pixiv/three-vrm": "3.5.5",
"@pixiv/three-vrm-animation": "3.5.5",
"@pyscript/core": "^0.4.32",
@ -2656,6 +2657,12 @@
"node": ">= 8"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",

View file

@ -71,6 +71,7 @@
"@joplin/turndown-plugin-gfm": "^1.0.62",
"@mediapipe/tasks-vision": "^0.10.17",
"@modelcontextprotocol/ext-apps": "2.0.4",
"@novnc/novnc": "^1.7.0",
"@pixiv/three-vrm": "3.5.5",
"@pixiv/three-vrm-animation": "3.5.5",
"@pyscript/core": "^0.4.32",

View file

@ -81,6 +81,8 @@ export type ListeningPort = {
export type TerminalFeatures = {
terminal?: boolean;
browser?: boolean;
desktop?: boolean;
git?: boolean;
events?: boolean;
};

View file

@ -53,6 +53,7 @@
import { fileIconName } from './FileNav/fileIcon';
import UntitledFile from './ChatTabs/UntitledFile.svelte';
import Browser from './ChatTabs/Browser.svelte';
import Desktop from './ChatTabs/Desktop.svelte';
import XTerminal from './XTerminal.svelte';
import CallPanel from './MessageInput/CallPanel.svelte';
import Drawer from '../common/Drawer.svelte';
@ -109,14 +110,16 @@
let dirtyFiles: Record<string, boolean> = {};
let fileContexts: Record<string, TerminalContext | null> = {};
let panelTarget: HTMLElement | null = null;
let initialized = false;
let tabsChatId = chatId;
let hasOpenedTabs = false;
let terminalSequence = 0;
let commentTarget: { comment_id?: string } = {};
let closing = new Set<string>();
let navigationApproved = false;
let selectedConnection: TerminalConnection | null = null;
let terminalEnabled = false;
let desktopEnabled = false;
let browserEnabled = false;
let featureVersion = 0;
let releasePanel: (target: HTMLElement) => void = () => {};
const preservePanel = (target: HTMLElement) => ({ destroy: () => releasePanel(target) });
@ -155,6 +158,7 @@
file: 'page',
terminal: 'terminal',
browser: 'browser',
desktop: 'monitor',
artifacts: 'code',
preview: 'browser',
voice: 'microphone',
@ -187,7 +191,7 @@
showTabs.set(true);
};
const openPanel = (tab: PanelTab) => {
initialized = true;
hasOpenedTabs = true;
tabs = openTab(tabs, tab);
showTabs.set(true);
};
@ -250,12 +254,24 @@
connection: { ...selectedConnection }
});
};
const openDesktop = () => {
if (!desktopEnabled || !selectedConnection) return;
openPanel({
id: JSON.stringify(['desktop', selectedConnection.selector]),
kind: 'desktop',
label: $i18n.t('Desktop'),
terminalId: selectedConnection.selector,
connection: { ...selectedConnection }
});
};
const openBrowser = (
port?: number,
selector = $selectedTerminalId,
baseUrl?: string,
initialUrl?: string
) => {
if (!selector || !terminalAccessible(selector)) return;
if (selector === $selectedTerminalId && !browserEnabled) return;
const url = initialUrl ?? (port ? `http://localhost:${port}/` : undefined);
const existing =
url &&
@ -274,7 +290,7 @@
localStorage.token ?? ''
)
: null;
if (selector && !terminalAccessible(selector)) return;
if (!connection) return;
const terminal = ($terminalServers ?? []).find((item) => item.id === selector);
const proxyBase =
baseUrl ??
@ -467,8 +483,7 @@
for (const tab of tabs.tabs) {
const available = tab.terminalId
? !!$selectedTerminalId && terminalAccessible(tab.terminalId)
: tab.kind === 'browser' ||
availableViews.some((view) => view.kind === tab.kind && view.available);
: availableViews.some((view) => view.kind === tab.kind && view.available);
if (!available) {
tabs = removeTab(tabs, tab.id);
delete dirtyFiles[tab.id];
@ -489,6 +504,8 @@
const checkTerminal = async () => {
const version = ++featureVersion;
terminalEnabled = false;
desktopEnabled = false;
browserEnabled = false;
selectedConnection =
terminalFilesAvailable && mounted
? resolveTerminalConnection(
@ -500,12 +517,14 @@
: null;
if (!selectedConnection) return;
try {
const result = await terminalRequest<{ features?: { terminal?: boolean } }>(
selectedConnection,
chatId,
'/api/config'
);
if (version === featureVersion) terminalEnabled = result.features?.terminal !== false;
const result = await terminalRequest<{
features?: { terminal?: boolean; desktop?: boolean; browser?: boolean };
}>(selectedConnection, chatId, '/api/config');
if (version === featureVersion) {
terminalEnabled = result.features?.terminal !== false;
desktopEnabled = result.features?.desktop === true;
browserEnabled = result.features?.browser === true;
}
} catch {
/* Files remain available when interactive terminals are disabled. */
}
@ -521,18 +540,29 @@
}
$: if (chatId !== tabsChatId) {
if (tabsChatId !== null || !chatId) {
if (tabsChatId || !chatId) {
tabs = emptyTabs();
hasOpenedTabs = false;
dirtyFiles = {};
fileContexts = {};
initialized = false;
commentTarget = {};
}
tabsChatId = chatId;
}
$: if ($showTabs === true && !initialized && showFilesTab) {
initialized = true;
openFiles();
$: if ($showTabs === true && !tabs.tabs.length) {
const options = [
...availableViews.filter((view) => view.available).map((view) => () => {
if (view.kind === 'files') openFiles();
else openFeature(view.kind as 'artifacts' | 'preview' | 'voice' | 'comments');
}),
...(terminalFilesAvailable ? [newFile] : []),
...(terminalEnabled ? [newTerminal] : []),
...(browserEnabled ? [openBrowser] : []),
...(desktopEnabled ? [openDesktop] : [])
];
if (terminalFilesAvailable || (!hasOpenedTabs && showFilesTab)) openFiles();
else if (options.length === 1) options[0]();
hasOpenedTabs = true;
}
$: if ($showTabs === 'files' && showFilesTab) openFiles();
$: if (
@ -760,15 +790,23 @@
>
<div slot="content">
<DropdownMenu className="w-48">
{#if showFilesTab && !tabs.tabs.some((tab) => tab.id === ($selectedTerminalId ? `files:${$selectedTerminalId}` : 'files'))}
<button on:click={() => openFiles()}
><Icon name="folder" size={14} />{$i18n.t('Files')}</button
>
{/if}
{#if terminalFilesAvailable}<button on:click={newFile}
><Icon name="page" size={14} />{$i18n.t('New File')}</button
>{/if}
{#if terminalEnabled}<button on:click={newTerminal}
><Icon name="terminal" size={14} />{$i18n.t('New Terminal')}</button
>{/if}
<button on:click={() => openBrowser()}
><Icon name="browser" size={14} />{$i18n.t('Browser')}</button
>
{#if browserEnabled}<button on:click={() => openBrowser()}
><Icon name="browser" size={14} />{$i18n.t('Browser')}</button
>{/if}
{#if desktopEnabled}<button on:click={openDesktop}
><Icon name="monitor" size={14} />{$i18n.t('Desktop')}</button
>{/if}
{#each availableViews.filter((view) => view.kind !== 'files' && view.kind !== 'voice' && view.available) as view}
<button
on:click={() =>
@ -823,6 +861,13 @@
onCountChange={setCommentCount}
/>
{/key}
{:else if tab.kind === 'desktop' && tab.connection}
<Desktop
connection={tab.connection}
{chatId}
active={!!$showTabs && tabs.activeId === tab.id}
overlay={dragged}
/>
{:else if tab.kind === 'browser'}
<Browser
baseUrl={tab.browserBaseUrl ?? ''}

View file

@ -297,6 +297,7 @@
<input
bind:this={addressInput}
bind:value={urlInput}
on:click={() => addressInput.select()}
aria-label={$i18n.t('URL')}
placeholder="https://example.com"
spellcheck="false"

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import type RFB from '@novnc/novnc';
export let streamUrl: string;
export let active = true;
export let mobile = false;
@ -14,6 +15,12 @@
export let onStatus: (status: string) => void = () => {};
let surface: HTMLDivElement;
let frame = '';
let transport = '';
let vncSurface: HTMLDivElement;
let viewer: RFB | null = null;
let vncSocket: WebSocket | null = null;
let vncGeneration = 0;
let vncRetry: ReturnType<typeof setTimeout>;
let width = 1024;
let height = 768;
let socket: WebSocket | null = null;
@ -22,6 +29,58 @@
let attempts = 0;
let hasConnected = false;
let pendingNavigation: string | null = null;
const pressedModifiers = new Map<string, number>();
const releaseModifiers = () => {
for (const [code, keysym] of pressedModifiers) viewer?.sendKey(keysym, code, false);
pressedModifiers.clear();
};
const stopVnc = () => {
vncGeneration++;
clearTimeout(vncRetry);
releaseModifiers();
viewer?.disconnect();
viewer = null;
vncSocket?.close();
vncSocket = null;
};
const startVnc = async () => {
if (!active || disposed || vncSocket || transport !== 'vnc' || !vncSurface) return;
const generation = ++vncGeneration;
onError('');
onStatus('Connecting…');
try {
const { default: RFB } = await import('@novnc/novnc');
if (generation !== vncGeneration) return;
const url = new URL(streamUrl.replace(/\/stream$/, '/vnc'), window.location.origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const channel = new WebSocket(url);
vncSocket = channel;
channel.onopen = () => {
if (generation !== vncGeneration) return;
viewer = new RFB(vncSurface, channel);
viewer.scaleViewport = true;
viewer.resizeSession = false;
viewer.qualityLevel = { low: 4, balanced: 6, crisp: 9 }[quality];
viewer.addEventListener('connect', () => onStatus(''));
};
channel.onclose = (event) => {
if (generation !== vncGeneration) return;
stopVnc();
if (event.code >= 4000 && event.code < 5000) {
onStatus('');
onError('Browser opened in another viewer. Reopen this tab.');
} else if (active && !disposed) {
onStatus('Reconnecting…');
vncRetry = setTimeout(startVnc, 2000);
}
};
} catch (error) {
if (generation === vncGeneration) {
onStatus('');
onError(error instanceof Error ? error.message : String(error));
}
}
};
export const command = (type: string, values: Record<string, unknown> = {}) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type, ...values }));
@ -43,10 +102,14 @@
resize();
}
$: if (socket) command('quality', { quality });
$: if (viewer) viewer.qualityLevel = { low: 4, balanced: 6, crisp: 9 }[quality];
$: if (active && transport === 'vnc' && vncSurface) void startVnc();
$: if (!active) stopVnc();
const modifiers = (e: MouseEvent | KeyboardEvent) =>
(e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
const mouse = (event: MouseEvent, type: string) => {
const image = surface.querySelector('img');
if (transport === 'vnc') return;
const image = surface.querySelector(transport === 'vnc' ? 'canvas' : 'img');
if (!image) return;
const box = image.getBoundingClientRect();
event.preventDefault();
@ -62,6 +125,17 @@
});
};
const key = (event: KeyboardEvent, type: string) => {
if (transport === 'vnc') {
// Keep browser editing shortcuts on CDP; VNC handles pointer input directly.
event.stopPropagation();
const keysym = { Shift: 0xffe1, Control: 0xffe3, Alt: 0xffe9, Meta: 0xffeb }[event.key];
if (keysym) {
const down = type === 'keyDown';
viewer?.sendKey(keysym, event.code, down);
if (down) pressedModifiers.set(event.code, keysym);
else pressedModifiers.delete(event.code);
}
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') return;
event.preventDefault();
const editing =
@ -107,10 +181,11 @@
if (message.type === 'frame' || message.type === 'state') {
hasConnected = true;
attempts = 0;
onStatus('');
if (message.type === 'frame' || message.transport !== 'vnc') onStatus('');
}
if (message.type === 'frame') frame = `data:image/jpeg;base64,${message.data}`;
if (message.type === 'state') {
transport = message.transport ?? 'jpeg';
onState(message);
onError(message.error || '');
}
@ -120,6 +195,8 @@
};
socket.onclose = (event) => {
if (disposed) return;
stopVnc();
transport = '';
if (event.code >= 4000 && event.code < 5000) {
onStatus('');
onError(
@ -142,19 +219,23 @@
onStatus('');
clearTimeout(retry);
observer.disconnect();
stopVnc();
socket?.close();
};
});
</script>
<svelte:window on:blur={releaseModifiers} />
<div
bind:this={surface}
class="flex h-full w-full items-start justify-center overflow-hidden outline-none"
tabindex="0"
role="application"
aria-label="Browser page"
on:keydown={(event) => key(event, 'keyDown')}
on:keyup={(event) => key(event, 'keyUp')}
on:keydown|capture={(event) => key(event, 'keyDown')}
on:keyup|capture={(event) => key(event, 'keyUp')}
on:blur|capture={releaseModifiers}
on:paste={(event) => {
event.preventDefault();
command('paste', { text: event.clipboardData?.getData('text/plain') ?? '' });
@ -164,7 +245,10 @@
on:mousemove={(event) => mouse(event, 'mouseMoved')}
on:contextmenu|preventDefault
on:wheel|preventDefault={(event) => {
const box = surface.querySelector('img')?.getBoundingClientRect();
if (transport === 'vnc') return;
const box = surface
.querySelector(transport === 'vnc' ? 'canvas' : 'img')
?.getBoundingClientRect();
if (!box) return;
command('mouse', {
event: 'mouseWheel',
@ -176,7 +260,9 @@
});
}}
>
{#if frame}<img
{#if transport === 'vnc'}
<div bind:this={vncSurface} class="vnc h-full w-full"></div>
{:else if frame}<img
src={frame}
alt="Browser page"
draggable="false"
@ -184,3 +270,12 @@
style:aspect-ratio={`${width}/${height}`}
/>{/if}
</div>
<style>
.vnc > :global(div) {
background: transparent !important;
}
.vnc :global(canvas) {
margin: 0 auto !important;
}
</style>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import type RFB from '@novnc/novnc';
import Icon from '../FileNav/Icon.svelte';
import { terminalRequest, type TerminalConnection } from '$lib/apis/terminal';
import { openTerminalSocket } from '$lib/apis/terminal/socket';
const i18n = getContext<any>('i18n');
export let connection: TerminalConnection;
export let chatId: string | null = null;
export let active = true;
export let overlay = false;
let mounted = false;
let container: HTMLDivElement;
let fullscreen = false;
let fullscreenEnabled = false;
let surface: HTMLDivElement;
let socket: WebSocket | null = null;
let viewer: RFB | null = null;
let error = '';
let status = '';
let generation = 0;
let connecting = false;
let retry: ReturnType<typeof setTimeout>;
const toggleFullscreen = async () => {
try {
if (document.fullscreenElement === container) await document.exitFullscreen();
else await container.requestFullscreen();
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : String(reason));
}
};
const disconnect = () => {
generation++;
connecting = false;
clearTimeout(retry);
viewer?.disconnect();
viewer = null;
socket?.close();
socket = null;
};
const connect = async () => {
if (!mounted || !active || socket || connecting) return;
connecting = true;
const version = ++generation;
error = '';
status = $i18n.t('Connecting…');
try {
const { default: RFB } = await import('@novnc/novnc');
await terminalRequest(connection, chatId, '/desktop');
if (version !== generation) return;
socket = openTerminalSocket(connection, chatId, '/desktop/stream', (channel) => {
if (version !== generation) return;
viewer = new RFB(surface, channel);
viewer.scaleViewport = true;
viewer.resizeSession = false;
viewer.qualityLevel = 6;
viewer.addEventListener('connect', () => {
status = '';
});
viewer.addEventListener('securityfailure', () => {
error = $i18n.t('Desktop connection failed.');
});
});
socket.addEventListener('close', (event) => {
if (version !== generation) return;
viewer?.disconnect();
viewer = null;
socket = null;
if (event.code >= 4000 && event.code < 5000) {
error = $i18n.t('Desktop access denied. Reopen this tab.');
status = '';
} else {
status = $i18n.t('Reconnecting…');
retry = setTimeout(connect, 2000);
}
});
} catch (reason) {
if (version === generation) {
error = reason instanceof Error ? reason.message : String(reason);
status = '';
}
} finally {
if (version === generation) connecting = false;
}
};
const paste = (text: string, shift = false) => {
if (!viewer) return;
viewer.clipboardPasteFrom(text);
viewer.sendKey(0xffe3, 'ControlLeft', true);
if (shift) viewer.sendKey(0xffe1, 'ShiftLeft', true);
viewer.sendKey(0x76, 'KeyV');
if (shift) viewer.sendKey(0xffe1, 'ShiftLeft', false);
viewer.sendKey(0xffe3, 'ControlLeft', false);
};
$: if (mounted) {
if (active) void connect();
else disconnect();
}
onMount(() => {
mounted = true;
fullscreenEnabled = document.fullscreenEnabled;
return () => {
mounted = false;
disconnect();
};
});
</script>
<svelte:document
on:fullscreenchange={() => (fullscreen = document.fullscreenElement === container)}
/>
<div bind:this={container} class="relative h-full w-full min-h-0 bg-white dark:bg-gray-900">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={surface}
class="h-full w-full overflow-hidden"
role="application"
aria-label={$i18n.t('Desktop')}
on:paste={(event) => {
event.preventDefault();
paste(event.clipboardData?.getData('text/plain') ?? '');
}}
on:keydown|capture={(event) => {
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 'v' &&
navigator.clipboard?.readText
) {
event.preventDefault();
event.stopPropagation();
void navigator.clipboard
.readText()
.then((text) => paste(text, event.shiftKey))
.catch(() => {});
}
}}
></div>
{#if status || error}
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-white/90 px-4 text-center text-sm text-gray-500 dark:bg-gray-900/90"
>
<span>{error || status}</span>
{#if error}<button
class="text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
on:click={connect}>{$i18n.t('Retry')}</button
>{/if}
</div>
{/if}
{#if fullscreenEnabled}
<button
type="button"
class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-lg bg-white/90 text-gray-600 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-gray-900/90 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label={fullscreen ? $i18n.t('Exit full screen') : $i18n.t('Open in full screen')}
title={fullscreen ? $i18n.t('Exit full screen') : $i18n.t('Open in full screen')}
on:click={toggleFullscreen}
>
<Icon name={fullscreen ? 'collapse' : 'expand'} size={16} />
</button>
{/if}
{#if overlay}<div class="absolute inset-0"></div>{/if}
</div>

View file

@ -6,6 +6,7 @@ export type PanelTabKind =
| 'file'
| 'terminal'
| 'browser'
| 'desktop'
| 'artifacts'
| 'preview'
| 'voice'

12
src/lib/types/novnc.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
declare module '@novnc/novnc' {
export default class RFB extends EventTarget {
constructor(target: HTMLElement, channel: WebSocket);
scaleViewport: boolean;
resizeSession: boolean;
qualityLevel: number;
viewOnly: boolean;
disconnect(): void;
clipboardPasteFrom(text: string): void;
sendKey(keysym: number, code: string, down?: boolean): void;
}
}

View file

@ -19,6 +19,10 @@ export default defineConfig({
src: 'src/lib/encryption/vendor/THIRD_PARTY_LICENSES.txt',
dest: 'licenses/encryption'
},
{
src: ['node_modules/@novnc/novnc/docs/LICENSE*', 'node_modules/@novnc/novnc/AUTHORS'],
dest: 'licenses/novnc'
},
{
src: 'node_modules/onnxruntime-web/dist/*.jsep.*',
@ -33,8 +37,12 @@ export default defineConfig({
APP_BUILD_CHANNEL: JSON.stringify(buildInfo.channel)
},
build: {
target: 'es2022',
sourcemap: true
},
optimizeDeps: {
esbuildOptions: { target: 'es2022' }
},
server: {
proxy: {
'/api': {