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}