diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index a9b11f00f5..1ce815905a 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -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, diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index ed7fe31561..3ed8de5322 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -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 ( + connection: TerminalConnection, + chatId: string | null, + path: string, + options: RequestInit = {} +): Promise => { + 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 => ({ Authorization: `Bearer ${apiKey.trim()}` }); diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 90113e18f5..3fc12237d3 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -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 @@ {/if} - - - - {#if terminalExpanded} -
- -
- {/if} + {/key} {/if} diff --git a/src/lib/components/chat/TerminalDock.svelte b/src/lib/components/chat/TerminalDock.svelte new file mode 100644 index 0000000000..63cca95310 --- /dev/null +++ b/src/lib/components/chat/TerminalDock.svelte @@ -0,0 +1,337 @@ + + +
+
+ {#if !expanded} + + {:else} +
+ {#if !shellDismissed} +
+ + {#if shellOpened}{/if} +
+ {:else} + + {/if} + {#each tabs as tab (tab.id)} +
+ + {#if tab.status !== 'running' || !tab.available}{/if} +
+ {/each} +
+ {/if} + +
+
+ {#if error}
+ {error} +
{/if} +
+ {#if connection && shellOpened} +
+ +
+ {/if} + {#if connection} + {#each tabs as tab (tab.id)} + {#if tab.loaded || activeId === tab.id} +
+ +
+ {/if} + {/each} + {/if} +
+
+
+ + diff --git a/src/lib/components/chat/XTerminal.svelte b/src/lib/components/chat/XTerminal.svelte index f67bcca965..492d842402 100644 --- a/src/lib/components/chat/XTerminal.svelte +++ b/src/lib/components/chat/XTerminal.svelte @@ -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 | null = null; + let resizeObserver: ResizeObserver; + let pingInterval: ReturnType; + 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 = { 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 = { 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; });