mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-26 17:30:27 -04:00
refac
This commit is contained in:
@@ -326,7 +326,7 @@ async def ws_terminal(
|
||||
# For orchestrator-backed servers, pass user_id
|
||||
upstream_params['user_id'] = user.id
|
||||
context_id = terminal_context_id(connection, {'chat_id': chat_id}, 'chat')
|
||||
upstream_headers = {}
|
||||
upstream_headers = {'X-User-Id': user.id, 'X-Session-Id': chat_id}
|
||||
if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id:
|
||||
await ws.close(code=4003, reason='A saved chat is required for this terminal')
|
||||
return
|
||||
@@ -362,6 +362,8 @@ async def ws_terminal(
|
||||
await upstream.send_str(_json.dumps({'type': 'auth', 'token': key}))
|
||||
elif auth_type == 'session' and is_terminal_orchestrator(connection):
|
||||
await upstream.send_str(_json.dumps({'type': 'auth', 'token': token}))
|
||||
else:
|
||||
await upstream.send_str(_json.dumps({'type': 'auth', 'token': ''}))
|
||||
|
||||
await publish_event(
|
||||
app,
|
||||
|
||||
@@ -62,6 +62,63 @@ export type TerminalCwd = {
|
||||
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export type TerminalConnection = {
|
||||
selector: string;
|
||||
baseUrl: string;
|
||||
key: string;
|
||||
system: boolean;
|
||||
};
|
||||
export type TerminalProcess = {
|
||||
id: string;
|
||||
command: string;
|
||||
status: 'running' | 'done' | 'killed';
|
||||
exit_code: number | null;
|
||||
};
|
||||
export type TerminalProcessOutput = TerminalProcess & {
|
||||
output: { type: string; data: string }[];
|
||||
next_offset: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export const resolveTerminalConnection = (
|
||||
selector: string | null,
|
||||
servers: any[],
|
||||
directServers: any[],
|
||||
token: string
|
||||
): TerminalConnection | null => {
|
||||
if (!selector) return null;
|
||||
if (servers.some((server) => server.id === selector)) {
|
||||
return {
|
||||
selector,
|
||||
baseUrl: `${WEBUI_API_BASE_URL}/terminals/${encodeURIComponent(selector)}`,
|
||||
key: token,
|
||||
system: true
|
||||
};
|
||||
}
|
||||
const direct = directServers.find((server) => server.url === selector);
|
||||
return direct
|
||||
? { selector, baseUrl: direct.url.replace(/\/$/, ''), key: direct.key ?? '', system: false }
|
||||
: null;
|
||||
};
|
||||
|
||||
export const terminalRequest = async <T>(
|
||||
connection: TerminalConnection,
|
||||
chatId: string | null,
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${connection.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.key.trim()}`,
|
||||
...(chatId ? { 'X-Session-Id': chatId } : {}),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(`Terminal request failed (${response.status})`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const bearerHeaders = (apiKey: string): Record<string, string> => ({
|
||||
Authorization: `Bearer ${apiKey.trim()}`
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
import BulkActionBar from './FileNav/BulkActionBar.svelte';
|
||||
import PortList from './FileNav/PortList.svelte';
|
||||
import PortPreview from './FileNav/PortPreview.svelte';
|
||||
import XTerminal from './XTerminal.svelte';
|
||||
import TerminalDock from './TerminalDock.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -68,14 +68,8 @@
|
||||
let terminalHeight = 200; // px, default when expanded
|
||||
let isDraggingHandle = false;
|
||||
let containerEl: HTMLElement;
|
||||
let terminalConnected = false;
|
||||
let terminalConnecting = false;
|
||||
let terminalEnabled = true;
|
||||
|
||||
const toggleTerminal = () => {
|
||||
terminalExpanded = !terminalExpanded;
|
||||
};
|
||||
|
||||
const onHandleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingHandle = true;
|
||||
@@ -2050,42 +2044,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toggle header (full-width button) -->
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-2 py-1 mb-0.5 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100"
|
||||
on:click={toggleTerminal}
|
||||
>
|
||||
<Icon name="terminal" size={14} strokeWidth={1.4} class="shrink-0" />
|
||||
<span class="font-normal">{$i18n.t('Terminal')}</span>
|
||||
|
||||
{#if terminalExpanded}
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors {terminalConnected
|
||||
? 'bg-emerald-500'
|
||||
: terminalConnecting
|
||||
? 'bg-yellow-500 animate-pulse'
|
||||
: 'bg-gray-400'}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Icon
|
||||
name="chevron-up"
|
||||
size={12}
|
||||
strokeWidth={1.4}
|
||||
class="ml-auto transition-transform {terminalExpanded ? 'rotate-180' : ''}"
|
||||
{#key JSON.stringify([chatId, $selectedTerminalId])}
|
||||
<TerminalDock
|
||||
overlay={overlay || isDraggingHandle}
|
||||
bind:expanded={terminalExpanded}
|
||||
height={terminalHeight}
|
||||
{chatId}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if terminalExpanded}
|
||||
<div style="height: {terminalHeight}px" class="min-h-0">
|
||||
<XTerminal
|
||||
overlay={overlay || isDraggingHandle}
|
||||
bind:connected={terminalConnected}
|
||||
bind:connecting={terminalConnecting}
|
||||
{chatId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { settings, terminalServers, selectedTerminalId } from '$lib/stores';
|
||||
import {
|
||||
resolveTerminalConnection,
|
||||
terminalRequest,
|
||||
type TerminalConnection,
|
||||
type TerminalProcess,
|
||||
type TerminalProcessOutput
|
||||
} from '$lib/apis/terminal';
|
||||
import XTerminal from './XTerminal.svelte';
|
||||
import Icon from './FileNav/Icon.svelte';
|
||||
|
||||
export let chatId: string | null = null;
|
||||
export let expanded = false;
|
||||
export let height = 200;
|
||||
export let overlay = false;
|
||||
export let connected = false;
|
||||
export let connecting = false;
|
||||
export let running = 0;
|
||||
|
||||
const i18n = getContext<Readable<{ t: (key: string) => string }>>('i18n');
|
||||
type Tab = TerminalProcess & {
|
||||
offset: number;
|
||||
loaded: boolean;
|
||||
available: boolean;
|
||||
finished: boolean;
|
||||
};
|
||||
let connection: TerminalConnection | null = null;
|
||||
let tabs: Tab[] = [];
|
||||
let activeId = 'shell';
|
||||
let shellOpened = false;
|
||||
let shellDismissed = false;
|
||||
let error = '';
|
||||
let panes: Record<string, XTerminal> = {};
|
||||
const dismissed = new Set<string>();
|
||||
const controller = new AbortController();
|
||||
let polling = false;
|
||||
let disposed = false;
|
||||
|
||||
$: if (expanded && activeId === 'shell') shellOpened = true;
|
||||
$: running = tabs.filter((tab) => tab.status === 'running' && tab.available).length;
|
||||
|
||||
async function poll() {
|
||||
if (!connection || polling || disposed || document.hidden) return;
|
||||
polling = true;
|
||||
try {
|
||||
const processes = await terminalRequest<TerminalProcess[]>(connection, chatId, '/execute', {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (disposed) return;
|
||||
error = '';
|
||||
const live = new Map(processes.map((process) => [process.id, process]));
|
||||
tabs = tabs.map((tab) => ({ ...tab, ...live.get(tab.id), available: live.has(tab.id) }));
|
||||
const known = new Set(tabs.map((tab) => tab.id));
|
||||
for (const process of processes) {
|
||||
if (!known.has(process.id) && !dismissed.has(process.id)) {
|
||||
tabs = [
|
||||
...tabs,
|
||||
{ ...process, offset: 0, loaded: false, available: true, finished: false }
|
||||
];
|
||||
}
|
||||
}
|
||||
const tab = tabs.find((tab) => tab.id === activeId);
|
||||
if (expanded && tab?.available && !tab.finished) {
|
||||
await tick();
|
||||
if (!panes[tab.id]) return;
|
||||
const result = await terminalRequest<TerminalProcessOutput>(
|
||||
connection,
|
||||
chatId,
|
||||
`/execute/${encodeURIComponent(tab.id)}/status?wait=0&offset=${tab.offset}&tail=1000`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (disposed || !panes[tab.id]) return;
|
||||
if (result.truncated) panes[tab.id].write('\r\n[Earlier output omitted]\r\n');
|
||||
panes[tab.id].write(result.output.map((entry) => entry.data).join(''));
|
||||
tabs = tabs.map((item) =>
|
||||
item.id === tab.id
|
||||
? {
|
||||
...item,
|
||||
status: result.status,
|
||||
exit_code: result.exit_code,
|
||||
offset: result.next_offset,
|
||||
loaded: true,
|
||||
finished: result.status === 'done'
|
||||
}
|
||||
: item
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!disposed) error = String(cause);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
expanded = true;
|
||||
activeId = id;
|
||||
if (id === 'shell') {
|
||||
shellOpened = true;
|
||||
shellDismissed = false;
|
||||
}
|
||||
tick().then(poll);
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
dismissed.add(id);
|
||||
tabs = tabs.filter((tab) => tab.id !== id);
|
||||
delete panes[id];
|
||||
if (activeId === id) activeId = shellOpened ? 'shell' : (tabs[0]?.id ?? '');
|
||||
}
|
||||
|
||||
function closeShell() {
|
||||
shellOpened = false;
|
||||
shellDismissed = true;
|
||||
connected = false;
|
||||
connecting = false;
|
||||
activeId = tabs[0]?.id ?? '';
|
||||
}
|
||||
|
||||
function navigate(event: KeyboardEvent) {
|
||||
const ids = [...(!shellDismissed ? ['shell'] : []), ...tabs.map((tab) => tab.id)];
|
||||
const index = ids.indexOf(activeId);
|
||||
let next: string | undefined;
|
||||
if (event.key === 'ArrowRight') next = ids[(index + 1) % ids.length];
|
||||
if (event.key === 'ArrowLeft') next = ids[(index - 1 + ids.length) % ids.length];
|
||||
if (event.key === 'Home') next = ids[0];
|
||||
if (event.key === 'End') next = ids.at(-1);
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
select(next);
|
||||
(event.currentTarget as HTMLElement)
|
||||
.querySelector<HTMLButtonElement>(`[data-tab="${next}"]`)
|
||||
?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
connection = resolveTerminalConnection(
|
||||
$selectedTerminalId,
|
||||
$terminalServers ?? [],
|
||||
($settings as { terminalServers?: { url: string; key?: string }[] })?.terminalServers ?? [],
|
||||
localStorage.getItem('token') ?? ''
|
||||
);
|
||||
poll();
|
||||
const interval = setInterval(poll, 1000);
|
||||
document.addEventListener('visibilitychange', poll);
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', poll);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terminal-dock min-h-0">
|
||||
<div
|
||||
class="flex h-7 min-w-0 items-center border-b border-black/5 text-[11px] text-gray-500 dark:border-white/5 dark:text-gray-400"
|
||||
>
|
||||
{#if !expanded}
|
||||
<button
|
||||
on:click={() => (expanded = true)}
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-1.5 px-2 text-left hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<Icon name="terminal" size={13} />
|
||||
<span>{$i18n.t('Terminal')}</span>
|
||||
{#if running}<span class="tabular-nums text-gray-400">{running}</span>{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
role="tablist"
|
||||
tabindex="-1"
|
||||
aria-label={$i18n.t('Terminal')}
|
||||
on:keydown={navigate}
|
||||
class="terminal-tabs flex h-full min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||
>
|
||||
{#if !shellDismissed}
|
||||
<div
|
||||
class="terminal-tab group flex shrink-0 items-center border-b"
|
||||
class:selected={activeId === 'shell'}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
data-tab="shell"
|
||||
aria-selected={activeId === 'shell'}
|
||||
tabindex={activeId === 'shell' ? 0 : -1}
|
||||
on:click={() => select('shell')}
|
||||
class="tab-button flex h-full items-center gap-1.5 pl-2 pr-1"
|
||||
title={$i18n.t('Shell')}
|
||||
>
|
||||
<Icon name="terminal" size={12} /><span>{$i18n.t('Shell')}</span>
|
||||
</button>
|
||||
{#if shellOpened}<button
|
||||
on:click={closeShell}
|
||||
class="tab-close mr-1 flex h-5 w-4 items-center justify-center"
|
||||
title={$i18n.t('Close terminal')}
|
||||
aria-label={$i18n.t('Close terminal')}><Icon name="xmark" size={10} /></button
|
||||
>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="tab-button flex w-7 shrink-0 items-center justify-center"
|
||||
on:click={() => select('shell')}
|
||||
title={$i18n.t('Open terminal')}
|
||||
aria-label={$i18n.t('Open terminal')}><Icon name="plus" size={12} /></button
|
||||
>
|
||||
{/if}
|
||||
{#each tabs as tab (tab.id)}
|
||||
<div
|
||||
class="terminal-tab group flex shrink-0 items-center border-b"
|
||||
class:selected={activeId === tab.id}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
data-tab={tab.id}
|
||||
aria-selected={activeId === tab.id}
|
||||
tabindex={activeId === tab.id ? 0 : -1}
|
||||
on:click={() => select(tab.id)}
|
||||
class="tab-button flex h-full items-center gap-1.5 px-2"
|
||||
title={`${tab.command} (${!tab.available ? 'Unavailable' : tab.status === 'running' ? 'Running' : `Exit ${tab.exit_code ?? tab.status}`})`}
|
||||
>
|
||||
<span
|
||||
class="h-[5px] w-[5px] shrink-0 rounded-full"
|
||||
class:bg-emerald-500={tab.available && tab.status === 'running'}
|
||||
class:bg-red-400={tab.exit_code !== null && tab.exit_code !== 0}
|
||||
class:bg-gray-400={!tab.available || (tab.status !== 'running' && !tab.exit_code)}
|
||||
></span>
|
||||
<span class="max-w-28 truncate">{tab.command}</span>
|
||||
</button>
|
||||
{#if tab.status !== 'running' || !tab.available}<button
|
||||
on:click={() => dismiss(tab.id)}
|
||||
class="tab-close mr-1 flex h-5 w-4 items-center justify-center"
|
||||
title={$i18n.t('Dismiss')}
|
||||
aria-label={`${$i18n.t('Dismiss')} ${tab.command}`}
|
||||
><Icon name="xmark" size={10} /></button
|
||||
>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => (expanded = !expanded)}
|
||||
class="tab-button flex h-7 w-7 shrink-0 items-center justify-center"
|
||||
aria-expanded={expanded}
|
||||
title={$i18n.t(expanded ? 'Collapse terminal' : 'Expand terminal')}
|
||||
aria-label={$i18n.t(expanded ? 'Collapse terminal' : 'Expand terminal')}
|
||||
>
|
||||
<Icon name={expanded ? 'chevron-down' : 'chevron-up'} size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div style:height={`${height}px`} class="flex min-h-0 flex-col bg-black" class:hidden={!expanded}>
|
||||
{#if error}<div class="shrink-0 truncate px-2 text-xs text-red-400" title={error}>
|
||||
{error}
|
||||
</div>{/if}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
{#if connection && shellOpened}
|
||||
<div class="absolute inset-0" class:hidden={activeId !== 'shell'}>
|
||||
<XTerminal
|
||||
{connection}
|
||||
{chatId}
|
||||
{overlay}
|
||||
active={expanded && activeId === 'shell'}
|
||||
bind:connected
|
||||
bind:connecting
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if connection}
|
||||
{#each tabs as tab (tab.id)}
|
||||
{#if tab.loaded || activeId === tab.id}
|
||||
<div class="absolute inset-0" class:hidden={activeId !== tab.id}>
|
||||
<XTerminal
|
||||
bind:this={panes[tab.id]}
|
||||
{connection}
|
||||
{chatId}
|
||||
{overlay}
|
||||
readOnly
|
||||
active={expanded && activeId === tab.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.terminal-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.terminal-tab {
|
||||
border-color: transparent;
|
||||
}
|
||||
.terminal-tab.selected {
|
||||
border-color: currentColor;
|
||||
color: var(--color-gray-900, #171717);
|
||||
}
|
||||
:global(.dark) .terminal-tab.selected {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.tab-button,
|
||||
.tab-close {
|
||||
outline: none;
|
||||
}
|
||||
.terminal-dock .tab-button:focus-visible,
|
||||
.terminal-dock .tab-close:focus-visible {
|
||||
outline: 1px solid currentColor;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.tab-button:hover,
|
||||
.tab-close:hover {
|
||||
color: var(--color-gray-900, #171717);
|
||||
}
|
||||
:global(.dark) .tab-button:hover,
|
||||
:global(.dark) .tab-close:hover {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.tab-close {
|
||||
opacity: 0;
|
||||
}
|
||||
.terminal-tab:hover .tab-close,
|
||||
.terminal-tab:focus-within .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,185 +4,99 @@
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { terminalRequest, type TerminalConnection } from '$lib/apis/terminal';
|
||||
|
||||
import { terminalServers, settings, selectedTerminalId } from '$lib/stores';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export let overlay = false;
|
||||
export let connection: TerminalConnection;
|
||||
export let chatId: string | null = null;
|
||||
export let overlay = false;
|
||||
export let active = true;
|
||||
export let readOnly = false;
|
||||
export let connected = false;
|
||||
export let connecting = false;
|
||||
|
||||
let terminalEl: HTMLDivElement;
|
||||
let term: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let fitAddon: FitAddon;
|
||||
let ws: WebSocket | null = null;
|
||||
export let connected = false;
|
||||
export let connecting = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let resizeObserver: ResizeObserver;
|
||||
let pingInterval: ReturnType<typeof setInterval>;
|
||||
let destroyed = false;
|
||||
let sessionId = '';
|
||||
|
||||
// Resolve the active terminal server's info for the WebSocket URL
|
||||
const getTerminalInfo = (): {
|
||||
serverId: string;
|
||||
baseUrl: string;
|
||||
} | null => {
|
||||
// System terminal (admin-configured, has an `id`)
|
||||
const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id);
|
||||
const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId);
|
||||
if (systemMatch) {
|
||||
// For system terminals, WS goes through the Open WebUI backend proxy
|
||||
return {
|
||||
serverId: systemMatch.id,
|
||||
baseUrl: WEBUI_API_BASE_URL
|
||||
};
|
||||
}
|
||||
export function write(output: string) {
|
||||
if (!term || destroyed) return;
|
||||
const following = term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
term.write(output, () => {
|
||||
if (following) term?.scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
// Direct terminal (user-configured, matched by URL)
|
||||
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
||||
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
||||
if (directMatch) {
|
||||
// For direct terminals, construct WS URL from the server URL directly
|
||||
return { serverId: '__direct__', baseUrl: directMatch.url };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
if (ws) disconnect();
|
||||
|
||||
const info = getTerminalInfo();
|
||||
if (!info) return;
|
||||
function fit() {
|
||||
if (active && terminalEl?.clientWidth && terminalEl?.clientHeight) fitAddon?.fit();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
connecting = true;
|
||||
|
||||
const token = localStorage.getItem('token') ?? '';
|
||||
|
||||
try {
|
||||
let sessionId: string;
|
||||
let wsUrl: string;
|
||||
let authToken: string;
|
||||
let authChatId = '';
|
||||
|
||||
if (info.serverId === '__direct__') {
|
||||
// Direct connection to open-terminal
|
||||
const base = info.baseUrl.replace(/\/$/, '');
|
||||
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
||||
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
||||
const apiKey = directMatch?.key ?? '';
|
||||
authToken = apiKey;
|
||||
|
||||
// Create session
|
||||
const createHeaders: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (chatId) createHeaders['X-Session-Id'] = chatId;
|
||||
const res = await fetch(`${base}/api/terminals`, {
|
||||
method: 'POST',
|
||||
headers: createHeaders
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
||||
const session = await res.json();
|
||||
sessionId = session.id;
|
||||
|
||||
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
wsUrl = `${wsBase}/api/terminals/${sessionId}`;
|
||||
} else {
|
||||
// System terminal — proxy through Open WebUI backend
|
||||
const base = info.baseUrl.replace(/\/$/, '');
|
||||
authToken = token;
|
||||
|
||||
// Create session via proxy
|
||||
const proxyHeaders: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||
if (chatId) proxyHeaders['X-Session-Id'] = chatId;
|
||||
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
|
||||
method: 'POST',
|
||||
headers: proxyHeaders
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
||||
const session = await res.json();
|
||||
sessionId = session.id;
|
||||
|
||||
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
|
||||
authChatId = chatId ?? '';
|
||||
const session = await terminalRequest<{ id: string }>(connection, chatId, '/api/terminals', {
|
||||
method: 'POST'
|
||||
});
|
||||
sessionId = session.id;
|
||||
if (destroyed) {
|
||||
await terminalRequest(
|
||||
connection,
|
||||
chatId,
|
||||
`/api/terminals/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
const url = new URL(
|
||||
`${connection.baseUrl}/api/terminals/${encodeURIComponent(sessionId)}`,
|
||||
location.href
|
||||
);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
// First-message auth (no token in URL)
|
||||
if (ws) {
|
||||
const authPayload: { type: string; token: string; chat_id?: string } = {
|
||||
type: 'auth',
|
||||
token: authToken.trim()
|
||||
};
|
||||
if (authChatId) authPayload.chat_id = authChatId;
|
||||
ws.send(JSON.stringify(authPayload));
|
||||
}
|
||||
ws?.send(
|
||||
JSON.stringify({ type: 'auth', token: connection.key.trim(), chat_id: chatId ?? '' })
|
||||
);
|
||||
connected = true;
|
||||
connecting = false;
|
||||
// Focus the terminal so it receives keyboard input immediately
|
||||
term?.focus();
|
||||
// Send initial resize
|
||||
if (term && ws) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
// Keepalive ping to prevent idle timeout from proxies/LBs
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
fit();
|
||||
if (active) term?.focus();
|
||||
if (term) ws?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (term) {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
term.write(event.data);
|
||||
}
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) term?.write(new Uint8Array(event.data));
|
||||
else write(event.data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
connecting = false;
|
||||
if (term) {
|
||||
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
|
||||
}
|
||||
clearInterval(pingInterval);
|
||||
write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
connected = false;
|
||||
connecting = false;
|
||||
write('\r\n\x1b[31m[Terminal connection failed]\x1b[0m\r\n');
|
||||
};
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
connecting = false;
|
||||
if (term) {
|
||||
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
|
||||
}
|
||||
write(`\r\n\x1b[31m[${error}]\x1b[0m\r\n`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
connecting = false;
|
||||
};
|
||||
|
||||
const initTerminal = () => {
|
||||
if (!terminalEl || term) return;
|
||||
$: if (active && term) requestAnimationFrame(fit);
|
||||
|
||||
onMount(() => {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorBlink: !readOnly,
|
||||
disableStdin: readOnly,
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
@@ -190,104 +104,40 @@
|
||||
background: '#000000',
|
||||
foreground: '#c0c0c0',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#444444',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#000000',
|
||||
red: '#cd0000',
|
||||
green: '#00cd00',
|
||||
yellow: '#cdcd00',
|
||||
blue: '#0000ee',
|
||||
magenta: '#cd00cd',
|
||||
cyan: '#00cdcd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#7f7f7f',
|
||||
brightRed: '#ff0000',
|
||||
brightGreen: '#00ff00',
|
||||
brightYellow: '#ffff00',
|
||||
brightBlue: '#5c5cff',
|
||||
brightMagenta: '#ff00ff',
|
||||
brightCyan: '#00ffff',
|
||||
brightWhite: '#ffffff'
|
||||
selectionBackground: '#444444'
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalEl);
|
||||
|
||||
// Fit after a frame so the container has dimensions
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon?.fit();
|
||||
});
|
||||
|
||||
// Forward keystrokes to WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Forward binary data (e.g. paste with special chars)
|
||||
term.onBinary((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const buffer = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
buffer[i] = data.charCodeAt(i) & 0xff;
|
||||
}
|
||||
ws.send(buffer);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure all key events are processed by xterm.js and not intercepted
|
||||
// by the browser or surrounding UI (fixes vi/vim keystroke handling).
|
||||
term.attachCustomKeyEventHandler(() => true);
|
||||
|
||||
// Handle resize
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch container size changes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon?.fit();
|
||||
});
|
||||
});
|
||||
resizeObserver = new ResizeObserver(fit);
|
||||
resizeObserver.observe(terminalEl);
|
||||
|
||||
// Connection is handled by the reactive block below (which fires
|
||||
// when `term` is set here), so we intentionally do NOT call
|
||||
// connect() to avoid creating a duplicate WebSocket whose onclose
|
||||
// handler would write a spurious "[Connection closed]" message.
|
||||
};
|
||||
|
||||
// Reconnect when the selected terminal changes
|
||||
$: if (($selectedTerminalId, chatId, term)) {
|
||||
// Clear the terminal screen and reconnect to the new server
|
||||
disconnect();
|
||||
term.clear();
|
||||
if ($selectedTerminalId) {
|
||||
requestAnimationFrame(fit);
|
||||
if (!readOnly) {
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
|
||||
});
|
||||
term.onBinary((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN)
|
||||
ws.send(Uint8Array.from(data, (char) => char.charCodeAt(0) & 0xff));
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (active && ws?.readyState === WebSocket.OPEN)
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
});
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initTerminal();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disconnect();
|
||||
destroyed = true;
|
||||
clearInterval(pingInterval);
|
||||
ws?.close();
|
||||
resizeObserver?.disconnect();
|
||||
term?.dispose();
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user