mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
b51e68fbb3
commit
3bd0cdd0f3
11 changed files with 369 additions and 26 deletions
|
|
@ -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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export type ListeningPort = {
|
|||
|
||||
export type TerminalFeatures = {
|
||||
terminal?: boolean;
|
||||
browser?: boolean;
|
||||
desktop?: boolean;
|
||||
git?: boolean;
|
||||
events?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
166
src/lib/components/chat/ChatTabs/Desktop.svelte
Normal file
166
src/lib/components/chat/ChatTabs/Desktop.svelte
Normal 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>
|
||||
|
|
@ -6,6 +6,7 @@ export type PanelTabKind =
|
|||
| 'file'
|
||||
| 'terminal'
|
||||
| 'browser'
|
||||
| 'desktop'
|
||||
| 'artifacts'
|
||||
| 'preview'
|
||||
| 'voice'
|
||||
|
|
|
|||
12
src/lib/types/novnc.d.ts
vendored
Normal file
12
src/lib/types/novnc.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue