diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 1d9a1da061..8ab4c908ab 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -6,9 +6,12 @@ Routes: """ import asyncio +import json import logging +import re +from html import escape import posixpath -from urllib.parse import unquote +from urllib.parse import unquote, urljoin, urlsplit import aiohttp from fastapi import APIRouter, Depends, Request, Response, WebSocket @@ -48,6 +51,78 @@ STRIPPED_RESPONSE_HEADERS = frozenset( ) +def _port_browser_html(body: bytes, prefix: str, port: int) -> bytes: + """Keep port links inside the proxy and report navigation from sandboxed frames.""" + config = json.dumps({'prefix': prefix, 'origin': f'http://localhost:{port}'}).replace( + '<', '\\u003c' + ) + script = r""" +(() => { + const config = __CONFIG__; + const remote = (value = location.href) => { + const url = new URL(value, location.href); + return url.pathname.startsWith(config.prefix) + ? config.origin + '/' + url.pathname.slice(config.prefix.length) + url.search + url.hash + : url.href; + }; + const destination = value => new URL(value, remote()); + const proxy = value => { + const url = destination(value); + return url.origin === config.origin ? config.prefix + url.pathname.slice(1) + url.search + url.hash : url.href; + }; + const report = () => parent.postMessage({type: 'terminal-browser-state', url: remote(), title: document.title}, '*'); + for (const method of ['pushState', 'replaceState']) { + const original = history[method].bind(history); + history[method] = (state, title, value) => { + try { const result = original(state, title, value == null ? value : proxy(value)); queueMicrotask(report); return result; } + catch (error) { + if (error.name !== 'SecurityError' || value == null) throw error; + parent.postMessage({type: 'terminal-browser-navigate', url: destination(value).href, replace: method === 'replaceState'}, '*'); + } + }; + } + addEventListener('popstate', report); + addEventListener('hashchange', report); + document.addEventListener('click', event => { + const link = event.target.closest?.('a[href]'); + if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.hasAttribute('download')) return; + const url = destination(remote(link.href)); + if (!/^https?:$/.test(url.protocol)) return; + event.preventDefault(); + parent.postMessage({type: 'terminal-browser-navigate', url: url.href}, '*'); + }, true); + document.addEventListener('submit', event => { + const form = event.target; + if (form instanceof HTMLFormElement) form.action = proxy(form.getAttribute('action') || remote()); + }, true); + const nativeFetch = window.fetch.bind(window); + window.fetch = (input, init) => nativeFetch(input instanceof Request ? new Request(proxy(input.url), input) : proxy(input), init); + const nativeOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, ...rest) { return nativeOpen.call(this, method, proxy(url), ...rest); }; + addEventListener('DOMContentLoaded', () => { + new MutationObserver(report).observe(document.querySelector('title') || document.head || document.documentElement, {childList:true,subtree:true,characterData:true}); + report(); + }); + report(); +})(); +""".replace('__CONFIG__', config) + html = body.decode('utf-8', errors='replace') + # Root-relative assets otherwise resolve against WebUI instead of the port. + html = re.sub( + r"""(\b(?:href|src|action)\s*=\s*["'])/(?!/)""", + lambda match: match[1] + escape(prefix, quote=True), + html, + flags=re.I, + ) + bridge = '' + head = re.search(r']*>', html, re.I) + if head: + html = html[:head.end()] + bridge + html[head.end():] + else: + html = bridge + html + return html.encode('utf-8') + + def _sanitize_proxy_path(path: str) -> str | None: """Sanitize a proxy path to prevent directory traversal / SSRF. @@ -222,6 +297,29 @@ async def proxy_terminal( if TERMINAL_PROXY_HEADERS: filtered_headers.update(TERMINAL_PROXY_HEADERS) + port_match = re.match(r'^proxy/(\d+)/', safe_path) + port_prefix = None + if port_match: + port_prefix = request.url.path[ + :request.url.path.index('/proxy/') + len(port_match[0]) + 1 + ] + location = upstream_response.headers.get('location') + if location: + remote_origin = f'http://localhost:{port_match[1]}' + destination = urlsplit( + urljoin(f'{remote_origin}/{safe_path[len(port_match[0]):]}', location) + ) + if destination.netloc == urlsplit(remote_origin).netloc: + filtered_headers = { + key: value for key, value in filtered_headers.items() + if key.lower() != 'location' + } + filtered_headers['Location'] = ( + port_prefix + destination.path.lstrip('/') + + (f'?{destination.query}' if destination.query else '') + + (f'#{destination.fragment}' if destination.fragment else '') + ) + # Stream binary responses directly if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): @@ -238,6 +336,8 @@ async def proxy_terminal( # Buffer text/JSON responses response_body = await upstream_response.read() + if port_prefix and 'text/html' in upstream_content_type: + response_body = _port_browser_html(response_body, port_prefix, int(port_match[1])) status_code = upstream_response.status await upstream_response.release() await session.close() diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index a5f9564818..d05c30a4dd 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -126,7 +126,7 @@ import MessageInput from '$lib/components/chat/MessageInput.svelte'; import Messages from '$lib/components/chat/Messages.svelte'; import Navbar from '$lib/components/chat/Navbar.svelte'; - import ChatControls from './ChatControls.svelte'; + import ChatTabs from './ChatTabs.svelte'; import EventConfirmDialog from '../common/ConfirmDialog.svelte'; import ElicitationDialog from './ElicitationDialog.svelte'; import DeleteConfirmDialog from '../common/ConfirmDialog.svelte'; @@ -5647,7 +5647,7 @@ {#if !embedded} - ({ id, kind, label: id }); - -describe('chat panel tabs', () => { - it('reuses an open file and keeps files from different terminals separate', () => { - const a = tab(fileTabId('server-a', '/readme.md')); - const b = tab(fileTabId('server-b', '/readme.md')); - let state = openTab(openTab(emptyTabs(), a), b); - state = openTab(state, a); - expect(state.tabs).toHaveLength(2); - expect(state.activeId).toBe(a.id); - }); - it('keeps independent shells and returns to the most recently used tab on close', () => { - let state = openTab( - openTab(openTab(emptyTabs(), tab('files', 'files')), tab('shell-1', 'terminal')), - tab('shell-2', 'terminal') - ); - state = activateTab(state, 'files'); - state = activateTab(state, 'shell-2'); - state = removeTab(state, 'shell-2'); - expect(state.activeId).toBe('files'); - expect(state.tabs.map((item) => item.id)).toEqual(['files', 'shell-1']); - }); - it('closes a feature view without automatically reopening it and can reopen it explicitly', () => { - let state = openTab(emptyTabs(), tab('artifacts', 'artifacts')); - state = removeTab(state, 'artifacts'); - expect(state).toEqual(emptyTabs()); - state = openTab(state, tab('artifacts', 'artifacts')); - expect(state.activeId).toBe('artifacts'); - }); - it('closing an inactive tab leaves the active tab selected', () => { - const state = removeTab(openTab(openTab(emptyTabs(), tab('a')), tab('b')), 'a'); - expect(state.activeId).toBe('b'); - expect(state.history).toEqual([]); - }); -}); diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatTabs.svelte similarity index 86% rename from src/lib/components/chat/ChatControls.svelte rename to src/lib/components/chat/ChatTabs.svelte index cb11d88963..cbe22930ee 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatTabs.svelte @@ -35,7 +35,7 @@ removeTab, fileTabId, type PanelTab - } from './ChatControls/tabs'; + } from './ChatTabs/tabs'; import { resolveTerminalConnection, terminalRequest, @@ -44,15 +44,16 @@ import Dropdown from '../common/Dropdown.svelte'; import DropdownMenu from '../common/DropdownMenu.svelte'; import Icon from './FileNav/Icon.svelte'; - import FileTypeIcon from './FileNav/FileTypeIcon.svelte'; - import UntitledFile from './ChatControls/UntitledFile.svelte'; + import { fileIconName } from './FileNav/fileIcon'; + import UntitledFile from './ChatTabs/UntitledFile.svelte'; + import Browser from './ChatTabs/Browser.svelte'; import XTerminal from './XTerminal.svelte'; import CallPanel from './MessageInput/CallPanel.svelte'; import Drawer from '../common/Drawer.svelte'; import MobileSwipePanel from '../common/MobileSwipePanel.svelte'; import ResizableSidePanel from '../common/ResizableSidePanel.svelte'; import Artifacts from './Artifacts.svelte'; - import Embeds from './ChatControls/Embeds.svelte'; + import Embeds from './ChatTabs/Embeds.svelte'; import FileNav from './FileNav.svelte'; import PyodideFileNav from './PyodideFileNav.svelte'; import Overview from './Overview.svelte'; @@ -110,22 +111,43 @@ let selectedConnection: TerminalConnection | null = null; let terminalEnabled = false; let featureVersion = 0; + let releasePanel: (target: HTMLElement) => void = () => {}; + const preservePanel = (target: HTMLElement) => ({ destroy: () => releasePanel(target) }); // Move a single mounted panel between desktop, mobile, and its hidden parking spot. const placePanel = (node: HTMLElement, target: HTMLElement | null) => { const home = node.parentElement!; const move = (destination: HTMLElement | null) => { - const parent = destination ?? home; - if (node.parentElement !== parent) parent.appendChild(node); + const parent = (destination ?? home) as HTMLElement & { + moveBefore?: (node: Node, before: Node | null) => void; + }; + if (node.parentElement === parent) return; + if (parent.moveBefore && node.isConnected && parent.isConnected) + parent.moveBefore(node, null); + else parent.appendChild(node); }; - move(target); - return { update: move, destroy: () => node.remove() }; + releasePanel = (target) => { + if (node.parentElement === target) move(null); + }; + const update = (destination: HTMLElement | null) => { + if (!destination) { + move(null); + return; + } + // Wait until the drawer has attached its destination to the document. + void tick().then(() => { + if ($showTabs && panelTarget === destination && destination.isConnected) move(destination); + }); + }; + update(target); + return { update, destroy: () => node.remove() }; }; const tabIcon = (kind: PanelTab['kind']) => ({ files: 'folder', file: 'page', terminal: 'terminal', + browser: 'browser', artifacts: 'code', preview: 'browser', voice: 'microphone', @@ -210,6 +232,41 @@ connection: { ...selectedConnection } }); }; + const openBrowser = (port?: number, selector = $selectedTerminalId, baseUrl?: string) => { + const url = port ? `http://localhost:${port}/` : undefined; + const existing = + url && + tabs.tabs.find( + (tab) => tab.kind === 'browser' && tab.terminalId === selector && tab.path === url + ); + if (existing) { + selectTab(existing.id); + return; + } + const connection = selector + ? resolveTerminalConnection( + selector, + $terminalServers ?? [], + directTerminals, + localStorage.token ?? '' + ) + : null; + if (selector && !terminalAccessible(selector)) return; + const terminal = ($terminalServers ?? []).find((item) => item.id === selector); + const proxyBase = + baseUrl ?? + (connection && chatContextNeedsSavedChat(terminal) && chatId + ? `${connection.baseUrl}/chats/${encodeURIComponent(chatId)}` + : connection?.baseUrl); + openPanel({ + id: crypto.randomUUID(), + kind: 'browser', + label: port ? `localhost:${port}` : $i18n.t('Browser'), + path: url, + terminalId: selector ?? undefined, + browserBaseUrl: proxyBase + }); + }; const closeTab = async (id: string) => { if (closing.has(id)) return; closing.add(id); @@ -344,13 +401,19 @@ const pruneTabs = () => { for (const tab of tabs.tabs) { const available = tab.terminalId - ? terminalAccessible(tab.terminalId) - : availableViews.some((view) => view.kind === tab.kind && view.available); - if (!available) tabs = removeTab(tabs, tab.id); + ? !!$selectedTerminalId && terminalAccessible(tab.terminalId) + : tab.kind === 'browser' || + availableViews.some((view) => view.kind === tab.kind && view.available); + if (!available) { + tabs = removeTab(tabs, tab.id); + delete dirtyFiles[tab.id]; + delete fileContexts[tab.id]; + } } }; $: { availableViews; + $selectedTerminalId; $terminalServers; $settings; $config; @@ -473,6 +536,7 @@ } const handleMediaQuery = (event: MediaQueryList | MediaQueryListEvent) => { + if (panelTarget && largeScreen !== event.matches) releasePanel(panelTarget); largeScreen = event.matches; }; @@ -484,6 +548,9 @@ }; onMount(() => { + const unsubscribeVisibility = showTabs.subscribe((visible) => { + if (!visible && panelTarget) releasePanel(panelTarget); + }); const mediaQuery = window.matchMedia('(min-width: 768px)'); mediaQuery.addEventListener('change', handleMediaQuery); handleMediaQuery(mediaQuery); @@ -505,6 +572,7 @@ document.addEventListener('mouseup', onMouseUp); return () => { + unsubscribeVisibility(); isDestroyed = true; mounted = false; if (!largeScreen) { @@ -555,8 +623,8 @@
@@ -587,7 +655,7 @@ on:click={() => selectTab(tab.id)} > {#if tab.kind === 'file'} - + {:else} {/if} @@ -625,6 +693,9 @@ {#if terminalEnabled}{/if} + {#each availableViews.filter((view) => view.kind !== 'files' && view.kind !== 'voice' && view.available) as view} + {/each} +
navigate()}> + +
+ + {#if onClose}{/if} +
+ {#if error}{/if} +
+ {#if overlay}
{/if} + {#if frameUrl} + {#key frameKey}{/key} + {:else}
+ {$i18n.t('Enter a URL to browse.')} +
{/if} +
+ diff --git a/src/lib/components/chat/ChatControls/Embeds.svelte b/src/lib/components/chat/ChatTabs/Embeds.svelte similarity index 100% rename from src/lib/components/chat/ChatControls/Embeds.svelte rename to src/lib/components/chat/ChatTabs/Embeds.svelte diff --git a/src/lib/components/chat/ChatControls/UntitledFile.svelte b/src/lib/components/chat/ChatTabs/UntitledFile.svelte similarity index 100% rename from src/lib/components/chat/ChatControls/UntitledFile.svelte rename to src/lib/components/chat/ChatTabs/UntitledFile.svelte diff --git a/src/lib/components/chat/ChatControls/tabs.ts b/src/lib/components/chat/ChatTabs/tabs.ts similarity index 97% rename from src/lib/components/chat/ChatControls/tabs.ts rename to src/lib/components/chat/ChatTabs/tabs.ts index ad9c56fd31..b35746c157 100644 --- a/src/lib/components/chat/ChatControls/tabs.ts +++ b/src/lib/components/chat/ChatTabs/tabs.ts @@ -5,6 +5,7 @@ export type PanelTabKind = | 'files' | 'file' | 'terminal' + | 'browser' | 'artifacts' | 'preview' | 'voice' @@ -19,6 +20,7 @@ export type PanelTab = { dirty?: boolean; untitled?: boolean; connection?: TerminalConnection; + browserBaseUrl?: string; context?: TerminalContext | null; }; export type PanelTabs = { tabs: PanelTab[]; activeId: string; history: string[] }; diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 7437e9d186..813c88daef 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -16,7 +16,6 @@ } from '$lib/stores'; import { getCwd, - getTerminalConfig, getFileMatches, listFiles, readFile, @@ -54,7 +53,7 @@ import FileTypeIcon from './FileNav/FileTypeIcon.svelte'; import BulkActionBar from './FileNav/BulkActionBar.svelte'; import PortList from './FileNav/PortList.svelte'; - import PortPreview from './FileNav/PortPreview.svelte'; + import Browser from './ChatTabs/Browser.svelte'; import TerminalDock from './TerminalDock.svelte'; // Directory and preview state belong to this tab, including its terminal connection. @@ -74,6 +73,7 @@ export let showTerminalDock = true; export let onOpenFile: ((path: string, page?: number | null) => void) | null = null; export let onBrowseDirectory: ((path: string) => void) | null = null; + export let onOpenPort: ((port: number, baseUrl: string) => void) | null = null; $: effectiveTerminalId = terminalId ?? $selectedTerminalId; export let chatId: string | null = null; @@ -268,12 +268,12 @@ await tick(); }; - // ── Terminal panel state ──────────────────────────────────────────── + // ── Sessions panel state ──────────────────────────────────────────── let terminalExpanded = false; + let hasSessions = false; let terminalHeight = 200; // px, default when expanded let isDraggingHandle = false; let containerEl: HTMLElement; - let terminalEnabled = true; type ComparisonFile = { name: string; path: string; size?: number }; let pinnedComparison: ComparisonFile | null = null; let comparison: { @@ -359,6 +359,15 @@ let loadingDirs: Set = new Set(); let directoryMenu: { x: number; y: number } | null = null; let searchQuery = ''; + let showSearch = false; + let searchInput: HTMLInputElement; + const toggleSearch = async () => { + showSearch = !showSearch; + if (!showSearch) searchQuery = ''; + await tick(); + if (showSearch) searchInput?.focus(); + else containerEl?.querySelector('[data-file-search-toggle]')?.focus(); + }; let matchResults: TerminalFileMatch[] | null = null; let matchLoading = false; let matchLoadingMore = false; @@ -694,12 +703,6 @@ entries = []; resetTreeState(); (async () => { - if (terminalChanged) { - const config = await getTerminalConfig(terminal.url, terminal.key); - if (disposed) return; - terminalEnabled = config?.features?.terminal !== false; - } - savedPath = applyCwd(await getCwd(terminal.url, terminal.key, chatId ?? undefined)); loadDir(savedPath); })(); @@ -1916,10 +1919,6 @@ loading = true; void (async () => { - // Discover server features on initial mount - const config = await getTerminalConfig(terminal.url, terminal.key); - terminalEnabled = config?.features?.terminal !== false; - const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); if (disposed) return; const serverPath = applyCwd(serverCwd); @@ -2100,6 +2099,8 @@ breadcrumbs={buildBreadcrumbs(currentPath)} {selectedFile} {loading} + {showSearch} + onToggleSearch={toggleSearch} writable={currentWritable} canGoBack={!initialFile && canGoBack} canGoForward={!initialFile && canGoForward} @@ -2132,7 +2133,7 @@ {#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing} + + {/if} - - - - - - - - - - - - - -
- -
- - - - - - - - - - - - - - {#if isLoading} -
-
-
- {/if} - - -
- {#if overlay} -
- {/if} - {#key iframeKey} -