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
69395d4f50
commit
b51e68fbb3
9 changed files with 719 additions and 49 deletions
|
|
@ -22,7 +22,6 @@ from fastapi import (
|
|||
applications,
|
||||
status,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -221,7 +220,7 @@ from open_webui.utils import logger
|
|||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_write_access
|
||||
from open_webui.utils.actions import chat_action as chat_action_handler
|
||||
from open_webui.utils.asgi_middleware import AppHTTPMiddleware
|
||||
from open_webui.utils.asgi_middleware import AppHTTPMiddleware, BrowserCORSMiddleware
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
from open_webui.utils.auth import (
|
||||
create_admin_user,
|
||||
|
|
@ -874,7 +873,7 @@ app.add_middleware(AppHTTPMiddleware)
|
|||
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
BrowserCORSMiddleware,
|
||||
allow_origins=CORS_ALLOW_ORIGIN,
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from html import escape
|
||||
import posixpath
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
from urllib.parse import quote, unquote, urljoin, urlsplit
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, Request, Response, WebSocket
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from open_webui.config import TERMINAL_PROXY_HEADERS
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_TOOL_SERVERS
|
||||
|
|
@ -23,6 +24,7 @@ from open_webui.models.config import Config
|
|||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.utils.auth import get_verified_user, get_verified_user_by_token
|
||||
from open_webui.utils.browser import browser_access, browser_ticket
|
||||
from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.terminals import (
|
||||
|
|
@ -43,7 +45,7 @@ log = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf')
|
||||
STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf', 'text/event-stream')
|
||||
ADMIN_API_PATHS = ('api/v1/policies', 'api/v1/status', 'api/v1/terminals')
|
||||
# Drop the upstream's server and date: uvicorn adds its own and forwarding both duplicates them.
|
||||
STRIPPED_RESPONSE_HEADERS = frozenset(
|
||||
|
|
@ -159,6 +161,112 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
|||
return cleaned
|
||||
|
||||
|
||||
@router.api_route(
|
||||
'/{server_id}/browser/view/{sid}/{ticket}/{path:path}',
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
|
||||
)
|
||||
async def browser_http(request: Request, server_id: str, sid: str, ticket: str, path: str):
|
||||
user, data = await browser_access(server_id, sid, ticket)
|
||||
request.state.browser_auth = data['relay']
|
||||
request.scope['path_params']['chat_id'] = data.get('chat', '')
|
||||
return await proxy_terminal(server_id, f'browser/view/{sid}/{data["capability"]}/{path}', request, user)
|
||||
|
||||
|
||||
async def relay_from_browser(ws, upstream):
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg['type'] == 'websocket.disconnect':
|
||||
return
|
||||
if msg.get('bytes') is not None:
|
||||
await upstream.send_bytes(msg['bytes'])
|
||||
elif msg.get('text') is not None:
|
||||
await upstream.send_str(msg['text'])
|
||||
|
||||
|
||||
async def relay_to_browser(ws, upstream):
|
||||
async for msg in upstream:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await ws.send_text(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||
await ws.send_bytes(msg.data)
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
async def watch_access(server_id, sid, ticket):
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
current_user, _ = await browser_access(server_id, sid, ticket)
|
||||
latest = await Config.get('terminal_server.connections', []) or []
|
||||
current = next((c for c in latest if c.get('id') == server_id), None)
|
||||
if (
|
||||
not current
|
||||
or not current.get('enabled', True)
|
||||
or not ENABLE_TOOL_SERVERS
|
||||
or not await has_connection_access(current_user, current)
|
||||
or not terminal_context_available(current, 'chat')
|
||||
):
|
||||
return
|
||||
|
||||
|
||||
@router.websocket('/{server_id}/browser/view/{sid}/{ticket}/{kind}')
|
||||
async def browser_ws(ws: WebSocket, server_id: str, sid: str, ticket: str, kind: str):
|
||||
try:
|
||||
user, data = await browser_access(server_id, sid, ticket)
|
||||
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')
|
||||
or not ENABLE_TOOL_SERVERS
|
||||
or not connection
|
||||
or not connection.get('enabled', True)
|
||||
or not await has_connection_access(user, connection)
|
||||
or not terminal_context_available(connection, 'chat')
|
||||
):
|
||||
raise HTTPException(403, 'Browser access denied')
|
||||
chat = data.get('chat', '')
|
||||
context = terminal_context_id(connection, {'chat_id': chat}, 'chat')
|
||||
if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context:
|
||||
raise HTTPException(403, 'Saved chat required')
|
||||
except HTTPException:
|
||||
await ws.close(code=4001)
|
||||
return
|
||||
await ws.accept()
|
||||
base = get_terminal_server_url(connection).replace('https://', 'wss://').replace('http://', 'ws://')
|
||||
url = f'{base}/browser/view/{sid}/{quote(data["capability"], safe="")}/{kind}'
|
||||
headers = {'X-User-Id': user.id, 'X-Session-Id': chat}
|
||||
if context:
|
||||
headers[TERMINAL_CONTEXT_HEADER] = context
|
||||
if data['relay'].get('authorization'):
|
||||
headers['Authorization'] = data['relay']['authorization']
|
||||
tasks = []
|
||||
try:
|
||||
async with aiohttp.ClientSession(cookies=data['relay'].get('cookies', {}), trust_env=True) as client:
|
||||
async with client.ws_connect(
|
||||
url,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
max_msg_size=16 * 1024 * 1024,
|
||||
) as upstream:
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(relay_from_browser(ws, upstream)),
|
||||
asyncio.create_task(relay_to_browser(ws, upstream)),
|
||||
asyncio.create_task(watch_access(server_id, sid, ticket)),
|
||||
]
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
except aiohttp.WSServerHandshakeError as exc:
|
||||
await ws.close(code=4004 if exc.status in (401, 403, 404) else 1011)
|
||||
except Exception as exc:
|
||||
log.debug('Browser relay disconnected: %s', type(exc).__name__)
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
with suppress(Exception):
|
||||
await ws.close(code=1011)
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
|
||||
"""Return terminal servers the authenticated user has access to."""
|
||||
|
|
@ -247,7 +355,12 @@ async def proxy_terminal(
|
|||
cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {}
|
||||
auth_type = connection.get('auth_type', 'bearer')
|
||||
|
||||
if auth_type == 'bearer':
|
||||
browser_auth = getattr(request.state, 'browser_auth', None)
|
||||
if browser_auth is not None:
|
||||
cookies = browser_auth.get('cookies', {})
|
||||
if browser_auth.get('authorization'):
|
||||
headers['Authorization'] = browser_auth['authorization']
|
||||
elif auth_type == 'bearer':
|
||||
headers.update(bearer_auth_header(connection.get('key', '')))
|
||||
elif auth_type == 'session':
|
||||
headers.update(bearer_auth_header(request.state.token.credentials))
|
||||
|
|
@ -266,6 +379,11 @@ async def proxy_terminal(
|
|||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
# auth_type == "none": no Authorization header
|
||||
|
||||
if browser_auth is not None:
|
||||
for name in ('accept', 'origin', 'access-control-request-method', 'access-control-request-headers', 'last-event-id', 'range'):
|
||||
if value := request.headers.get(name):
|
||||
headers[name] = value
|
||||
|
||||
content_type = request.headers.get('content-type')
|
||||
if content_type:
|
||||
headers['Content-Type'] = content_type
|
||||
|
|
@ -336,6 +454,11 @@ async def proxy_terminal(
|
|||
|
||||
# Buffer text/JSON responses
|
||||
response_body = await upstream_response.read()
|
||||
if safe_path.startswith('browser/sessions') and upstream_response.status < 300 and request.method in ('GET', 'POST', 'PATCH'):
|
||||
browser_session = json.loads(response_body)
|
||||
if isinstance(browser_session, dict) and browser_session.get('id') and browser_session.get('token'):
|
||||
browser_session['token'] = browser_ticket(server_id, user.id, session_id or '', browser_session, headers, cookies)
|
||||
response_body = json.dumps(browser_session).encode()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -42,12 +42,22 @@ from open_webui.internal.db import ScopedSession
|
|||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
from open_webui.utils.security_headers import set_security_headers
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserCORSMiddleware(CORSMiddleware):
|
||||
async def __call__(self, scope, receive, send):
|
||||
# Sandboxed pages have an opaque origin; capability routes handle their own CORS.
|
||||
if re.match(r'^/api/v1/terminals/[^/]+/browser/view/', scope.get('path', '')):
|
||||
await self.app(scope, receive, send)
|
||||
else:
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
class AppHTTPMiddleware:
|
||||
"""Open WebUI's pure-ASGI HTTP middleware.
|
||||
|
||||
|
|
|
|||
42
backend/open_webui/utils/browser.py
Normal file
42
backend/open_webui/utils/browser.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from fastapi import HTTPException
|
||||
from open_webui.utils.auth import create_token, decode_token, get_verified_user_by_id
|
||||
|
||||
|
||||
def browser_ticket(server_id, user_id, chat_id, session, headers=None, cookies=None):
|
||||
from open_webui.utils.oauth import encrypt_data
|
||||
|
||||
relay = encrypt_data(
|
||||
{
|
||||
'authorization': (headers or {}).get('Authorization'),
|
||||
'cookies': cookies or {},
|
||||
}
|
||||
)
|
||||
return create_token(
|
||||
{
|
||||
'purpose': 'terminal-browser',
|
||||
'server': server_id,
|
||||
'user': user_id,
|
||||
'chat': chat_id,
|
||||
'session': session['id'],
|
||||
'capability': session['token'],
|
||||
'relay': relay,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def browser_access(server_id, sid, ticket):
|
||||
data = decode_token(ticket)
|
||||
if (
|
||||
not data
|
||||
or data.get('purpose') != 'terminal-browser'
|
||||
or data.get('server') != server_id
|
||||
or data.get('session') != sid
|
||||
):
|
||||
raise HTTPException(401, 'Browser session expired')
|
||||
user = await get_verified_user_by_id(data.get('user'))
|
||||
if user is None:
|
||||
raise HTTPException(401, 'Browser session expired')
|
||||
from open_webui.utils.oauth import decrypt_data
|
||||
|
||||
data['relay'] = decrypt_data(data['relay'])
|
||||
return user, data
|
||||
56
src/lib/apis/terminal/browser.ts
Normal file
56
src/lib/apis/terminal/browser.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { terminalRequest, type TerminalConnection } from './index';
|
||||
|
||||
export type BrowserSession = {
|
||||
id: string;
|
||||
token: string;
|
||||
url: string;
|
||||
title: string;
|
||||
mode: 'proxy' | 'chrome';
|
||||
chrome_available: boolean;
|
||||
};
|
||||
|
||||
export const createBrowserSession = (
|
||||
connection: TerminalConnection,
|
||||
chatId: string | null,
|
||||
url = ''
|
||||
) =>
|
||||
terminalRequest<BrowserSession>(connection, chatId, '/browser/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
export const updateBrowserSession = (
|
||||
connection: TerminalConnection,
|
||||
chatId: string | null,
|
||||
id: string,
|
||||
fields: Partial<Pick<BrowserSession, 'url' | 'title' | 'mode'>>
|
||||
) =>
|
||||
terminalRequest<BrowserSession>(
|
||||
connection,
|
||||
chatId,
|
||||
`/browser/sessions/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields)
|
||||
}
|
||||
);
|
||||
|
||||
export const closeBrowserSession = (
|
||||
connection: TerminalConnection,
|
||||
chatId: string | null,
|
||||
id: string
|
||||
) =>
|
||||
terminalRequest(connection, chatId, `/browser/sessions/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
keepalive: true
|
||||
});
|
||||
|
||||
export const browserViewUrl = (connection: TerminalConnection, session: BrowserSession) =>
|
||||
`${connection.baseUrl.replace(/\/$/, '')}/browser/view/${encodeURIComponent(session.id)}/${encodeURIComponent(session.token)}`;
|
||||
|
||||
export function browserFrameUrl(prefix: string, value: string) {
|
||||
const url = new URL(value);
|
||||
return `${prefix}/frame/${url.protocol.slice(0, -1)}/${encodeURIComponent(url.host)}${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
|
@ -183,7 +183,16 @@ export const terminalRequest = async <T>(
|
|||
...options.headers
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(`Terminal request failed (${response.status})`);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.error === 'string'
|
||||
? body.error
|
||||
: `Terminal request failed (${response.status})`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -250,8 +250,13 @@
|
|||
connection: { ...selectedConnection }
|
||||
});
|
||||
};
|
||||
const openBrowser = (port?: number, selector = $selectedTerminalId, baseUrl?: string) => {
|
||||
const url = port ? `http://localhost:${port}/` : undefined;
|
||||
const openBrowser = (
|
||||
port?: number,
|
||||
selector = $selectedTerminalId,
|
||||
baseUrl?: string,
|
||||
initialUrl?: string
|
||||
) => {
|
||||
const url = initialUrl ?? (port ? `http://localhost:${port}/` : undefined);
|
||||
const existing =
|
||||
url &&
|
||||
tabs.tabs.find(
|
||||
|
|
@ -825,6 +830,7 @@
|
|||
{chatId}
|
||||
active={!!$showTabs && tabs.activeId === tab.id}
|
||||
initialUrl={tab.path ?? ''}
|
||||
onOpenBrowser={(url) => openBrowser(undefined, tab.terminalId, tab.browserBaseUrl, url)}
|
||||
overlay={dragged}
|
||||
onNavigate={(path, label) => {
|
||||
tabs = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import Dropdown from '../../common/Dropdown.svelte';
|
||||
import DropdownMenu from '../../common/DropdownMenu.svelte';
|
||||
import Switch from '../../common/Switch.svelte';
|
||||
import ChromeBrowser from './ChromeBrowser.svelte';
|
||||
import {
|
||||
createBrowserSession,
|
||||
updateBrowserSession,
|
||||
closeBrowserSession,
|
||||
browserViewUrl,
|
||||
browserFrameUrl,
|
||||
type BrowserSession
|
||||
} from '$lib/apis/terminal/browser';
|
||||
import { getPortProxyUrl, type TerminalConnection } from '$lib/apis/terminal';
|
||||
import PortList from '../FileNav/PortList.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
|
@ -14,7 +26,57 @@
|
|||
export let initialUrl = '';
|
||||
export let overlay = false;
|
||||
export let onNavigate: (url: string, label: string) => void = () => {};
|
||||
export let onOpenBrowser: (url: string) => void = () => {};
|
||||
export let onClose: (() => void) | null = null;
|
||||
let session: BrowserSession | null = null;
|
||||
let sessionChatId = chatId;
|
||||
let sessionConnection = connection;
|
||||
let chrome: ChromeBrowser;
|
||||
let switching = false;
|
||||
let deviceMode: 'desktop' | 'mobile' = 'desktop';
|
||||
let optionsOpen = false;
|
||||
let quality: 'low' | 'balanced' | 'crisp' = 'balanced';
|
||||
let chromeBack = false;
|
||||
let chromeForward = false;
|
||||
let frameReady = false;
|
||||
$: viewUrl = session && sessionConnection ? browserViewUrl(sessionConnection, session) : '';
|
||||
const persist = (url: string, title: string) => {
|
||||
if (session && sessionConnection)
|
||||
void updateBrowserSession(sessionConnection, sessionChatId, session.id, { url, title }).catch(
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
const switchMode = async (mode: 'proxy' | 'chrome', remember = true) => {
|
||||
if (!session || !sessionConnection || switching || session.mode === mode) return;
|
||||
switching = true;
|
||||
try {
|
||||
session = await updateBrowserSession(sessionConnection, sessionChatId, session.id, {
|
||||
mode,
|
||||
url: currentUrl
|
||||
});
|
||||
frameReady = false;
|
||||
if (session.mode === 'proxy') {
|
||||
history = [];
|
||||
historyIndex = -1;
|
||||
if (currentUrl)
|
||||
frameUrl = browserFrameUrl(browserViewUrl(sessionConnection, session), currentUrl);
|
||||
}
|
||||
error = '';
|
||||
if (remember) localStorage.setItem('browserMode', mode);
|
||||
} catch (reason) {
|
||||
error = reason instanceof Error ? reason.message : String(reason);
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
};
|
||||
const frameCommand = (command: string, url?: string) =>
|
||||
iframe?.contentWindow?.postMessage({ type: 'terminal-browser-command', command, url }, '*');
|
||||
const reload = () =>
|
||||
session?.mode === 'chrome'
|
||||
? chrome?.command('reload')
|
||||
: session
|
||||
? frameCommand('reload')
|
||||
: navigate(currentUrl, false);
|
||||
let urlInput = initialUrl;
|
||||
let currentUrl = '';
|
||||
let frameUrl = '';
|
||||
|
|
@ -23,10 +85,12 @@
|
|||
let addressInput: HTMLInputElement;
|
||||
let history: string[] = [];
|
||||
let historyIndex = -1;
|
||||
let replaceHistory = false;
|
||||
let error = '';
|
||||
let connectionStatus = '';
|
||||
let loading = false;
|
||||
$: canGoBack = historyIndex > 0;
|
||||
$: canGoForward = historyIndex < history.length - 1;
|
||||
$: canGoBack = session?.mode === 'chrome' ? chromeBack : historyIndex > 0;
|
||||
$: canGoForward = session?.mode === 'chrome' ? chromeForward : historyIndex < history.length - 1;
|
||||
|
||||
const address = (value: string): URL => {
|
||||
const input = value.trim();
|
||||
|
|
@ -45,12 +109,18 @@
|
|||
? Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
||||
: null;
|
||||
const source = (url: URL) => {
|
||||
if (session && sessionConnection)
|
||||
return browserFrameUrl(browserViewUrl(sessionConnection, session), url.href);
|
||||
const port = localPort(url);
|
||||
if (port === null) return url.href;
|
||||
if (!baseUrl) throw new Error($i18n.t('Select a terminal to open localhost URLs.'));
|
||||
return getPortProxyUrl(baseUrl, port, url.pathname.slice(1) + url.search + url.hash);
|
||||
};
|
||||
const remember = (url: string) => {
|
||||
const remember = (url: string, replace = false) => {
|
||||
if (replace && historyIndex >= 0) {
|
||||
history[historyIndex] = url;
|
||||
return;
|
||||
}
|
||||
if (history[historyIndex] === url) return;
|
||||
history = [...history.slice(0, historyIndex + 1), url];
|
||||
historyIndex = history.length - 1;
|
||||
|
|
@ -62,9 +132,15 @@
|
|||
const src = source(url);
|
||||
currentUrl = url.href;
|
||||
urlInput = url.href;
|
||||
if (addHistory) remember(url.href);
|
||||
frameUrl = src;
|
||||
frameKey++;
|
||||
if (!session && addHistory) remember(url.href);
|
||||
if (session?.mode === 'proxy') replaceHistory = !addHistory;
|
||||
if (session?.mode === 'chrome') chrome?.command('navigate', { url: url.href });
|
||||
else if (session && frameReady) frameCommand(addHistory ? 'navigate' : 'replace', url.href);
|
||||
else {
|
||||
frameUrl = src;
|
||||
frameKey++;
|
||||
}
|
||||
persist(url.href, url.host);
|
||||
loading = true;
|
||||
error = '';
|
||||
onNavigate(url.href, url.host);
|
||||
|
|
@ -73,13 +149,24 @@
|
|||
}
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (session?.mode === 'chrome') {
|
||||
chrome?.command(step < 0 ? 'back' : 'forward');
|
||||
return;
|
||||
}
|
||||
const next = historyIndex + step;
|
||||
if (next < 0 || next >= history.length) return;
|
||||
if (session) {
|
||||
historyIndex = next;
|
||||
frameCommand(step < 0 ? 'back' : 'forward');
|
||||
return;
|
||||
}
|
||||
historyIndex = next;
|
||||
navigate(history[next], false);
|
||||
};
|
||||
const loaded = () => {
|
||||
loading = false;
|
||||
frameReady = true;
|
||||
if (session) return;
|
||||
try {
|
||||
const location = iframe.contentWindow?.location;
|
||||
if (!location || location.href === 'about:blank') return;
|
||||
|
|
@ -104,21 +191,26 @@
|
|||
}
|
||||
};
|
||||
const receiveNavigation = (event: MessageEvent) => {
|
||||
if (event.source !== iframe?.contentWindow || !baseUrl || !currentUrl) return;
|
||||
if (event.source !== iframe?.contentWindow || !currentUrl) return;
|
||||
const expectedOrigin = new URL(frameUrl, window.location.origin).origin;
|
||||
if (event.origin !== 'null' && event.origin !== expectedOrigin) return;
|
||||
const data = event.data;
|
||||
if (!data || typeof data.url !== 'string') return;
|
||||
try {
|
||||
const url = address(data.url);
|
||||
if (data.type === 'terminal-browser-popup') {
|
||||
onOpenBrowser(url.href);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'terminal-browser-navigate') {
|
||||
if (data.replace && historyIndex >= 0) history[historyIndex] = url.href;
|
||||
navigate(url.href, !data.replace);
|
||||
} else if (data.type === 'terminal-browser-state') {
|
||||
if (url.origin !== new URL(currentUrl).origin) return;
|
||||
if (!session && url.origin !== new URL(currentUrl).origin) return;
|
||||
if (currentUrl !== url.href || document.activeElement !== addressInput) urlInput = url.href;
|
||||
currentUrl = url.href;
|
||||
urlInput = url.href;
|
||||
remember(url.href);
|
||||
remember(url.href, replaceHistory || data.replace === true);
|
||||
replaceHistory = false;
|
||||
persist(url.href, typeof data.title === 'string' ? data.title : url.host);
|
||||
onNavigate(
|
||||
url.href,
|
||||
typeof data.title === 'string' && data.title.trim() ? data.title : url.host
|
||||
|
|
@ -129,52 +221,170 @@
|
|||
}
|
||||
};
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
const release = () => {
|
||||
if (session && sessionConnection)
|
||||
void closeBrowserSession(sessionConnection, sessionChatId, session.id).catch(() => {});
|
||||
};
|
||||
const heartbeat = setInterval(() => {
|
||||
if (session && sessionConnection)
|
||||
void updateBrowserSession(sessionConnection, sessionChatId, session.id, {}).catch(() => {});
|
||||
}, 60_000);
|
||||
window.addEventListener('message', receiveNavigation);
|
||||
if (initialUrl) navigate(initialUrl);
|
||||
else addressInput?.focus();
|
||||
return () => window.removeEventListener('message', receiveNavigation);
|
||||
window.addEventListener('pagehide', release);
|
||||
void (async () => {
|
||||
if (sessionConnection) {
|
||||
try {
|
||||
const created = await createBrowserSession(sessionConnection, sessionChatId, initialUrl);
|
||||
if (disposed) {
|
||||
await closeBrowserSession(sessionConnection, sessionChatId, created.id);
|
||||
return;
|
||||
}
|
||||
session = created;
|
||||
currentUrl = created.url || initialUrl;
|
||||
const savedMode = localStorage.getItem('browserMode');
|
||||
const mode =
|
||||
savedMode === 'proxy' || savedMode === 'chrome'
|
||||
? savedMode
|
||||
: created.chrome_available
|
||||
? 'chrome'
|
||||
: 'proxy';
|
||||
if (mode !== 'chrome' || created.chrome_available) {
|
||||
await switchMode(mode, false);
|
||||
}
|
||||
if (disposed) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
} catch (reason) {
|
||||
error = 'Browser sessions unavailable. Update Open Terminal. ' + String(reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (initialUrl) navigate(initialUrl);
|
||||
else addressInput?.focus();
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener('message', receiveNavigation);
|
||||
window.removeEventListener('pagehide', release);
|
||||
clearInterval(heartbeat);
|
||||
release();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex shrink-0 items-center gap-1 px-2 pt-0 pb-1.5">
|
||||
{#each [{ name: 'Back', icon: 'chevron-left', disabled: !canGoBack, action: () => move(-1) }, { name: 'Forward', icon: 'chevron-right', disabled: !canGoForward, action: () => move(1) }, { name: 'Refresh', icon: 'refresh', disabled: !currentUrl, action: () => navigate(currentUrl, false) }] as button}
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div class="flex min-w-0 shrink-0 items-center gap-1 px-1.5 py-[3px] sm:px-2">
|
||||
{#each [{ name: 'Back', icon: 'chevron-left', disabled: !canGoBack, action: () => move(-1) }, { name: 'Forward', icon: 'chevron-right', disabled: !canGoForward, action: () => move(1) }, { name: 'Refresh', icon: 'refresh', disabled: !currentUrl, action: reload }] as button}
|
||||
<Tooltip content={$i18n.t(button.name)}
|
||||
><button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/6 dark:hover:text-gray-300"
|
||||
disabled={button.disabled}
|
||||
aria-label={$i18n.t(button.name)}
|
||||
on:click={button.action}
|
||||
><Icon
|
||||
name={button.icon}
|
||||
size={11}
|
||||
strokeWidth={1.4}
|
||||
size={12}
|
||||
class={button.icon === 'refresh' && loading ? 'animate-spin' : ''}
|
||||
/></button
|
||||
></Tooltip
|
||||
>
|
||||
{/each}
|
||||
<form class="min-w-0 flex-1" on:submit|preventDefault={() => navigate()}>
|
||||
<form class="relative min-w-0 flex-1" on:submit|preventDefault={() => navigate()}>
|
||||
<input
|
||||
bind:this={addressInput}
|
||||
bind:value={urlInput}
|
||||
aria-label={$i18n.t('URL')}
|
||||
placeholder={$i18n.t('Enter URL')}
|
||||
placeholder="https://example.com"
|
||||
spellcheck="false"
|
||||
class="h-6 w-full rounded-lg bg-gray-50 px-2 text-xs text-gray-700 outline-none dark:bg-gray-850 dark:text-gray-200"
|
||||
class="h-7 w-full min-w-0 rounded-full bg-transparent py-1 pr-8 pl-3 text-left text-xs font-normal text-gray-700 outline-none transition-colors hover:bg-gray-100/60 focus:bg-gray-100 sm:px-8 sm:text-center dark:text-gray-300 dark:hover:bg-white/5 dark:focus:bg-white/8"
|
||||
/>
|
||||
<Tooltip content={$i18n.t('Open in new tab')}
|
||||
><button
|
||||
type="button"
|
||||
disabled={!currentUrl && !frameUrl}
|
||||
aria-label={$i18n.t('Open in new tab')}
|
||||
class="absolute top-1/2 right-1 z-10 grid h-5 w-5 -translate-y-1/2 place-items-center rounded-full text-xs text-gray-500 hover:bg-gray-200/60 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/8"
|
||||
on:click={() =>
|
||||
window.open(
|
||||
session
|
||||
? localPort(new URL(currentUrl)) !== null
|
||||
? browserFrameUrl(viewUrl, currentUrl)
|
||||
: currentUrl
|
||||
: frameUrl,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)}>↗</button
|
||||
></Tooltip
|
||||
>
|
||||
</form>
|
||||
<Tooltip content={$i18n.t('Open in new tab')}
|
||||
><button
|
||||
type="button"
|
||||
disabled={!frameUrl}
|
||||
aria-label={$i18n.t('Open in new tab')}
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
on:click={() => window.open(frameUrl, '_blank', 'noopener,noreferrer')}
|
||||
><Icon name="external-link" size={11} strokeWidth={1.4} /></button
|
||||
></Tooltip
|
||||
>
|
||||
{#if session}
|
||||
<Dropdown align="end" bind:show={optionsOpen}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Browser options')}
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/6 dark:hover:text-gray-300"
|
||||
><Icon name="three-dots" size={12} /></button
|
||||
>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="w-48 font-normal">
|
||||
{#each session.chrome_available ? ['proxy', 'chrome'] : ['proxy'] as mode}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={session.mode === mode}
|
||||
disabled={switching}
|
||||
on:click={() => {
|
||||
optionsOpen = false;
|
||||
void switchMode(mode as 'proxy' | 'chrome');
|
||||
}}
|
||||
class="font-normal disabled:opacity-40"
|
||||
>
|
||||
<span class="flex-1 text-left"
|
||||
>{mode === 'chrome' ? 'Chrome' : $i18n.t('Proxy')}</span
|
||||
>
|
||||
{#if session.mode === mode}<Icon name="check" size={12} />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if session.mode === 'chrome'}
|
||||
<hr class="border-gray-100 dark:border-gray-800" />
|
||||
{#each [{ value: 'low', label: 'Low' }, { value: 'balanced', label: 'Balanced' }, { value: 'crisp', label: 'Crisp' }] as item}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={quality === item.value}
|
||||
on:click={() => {
|
||||
quality = item.value as typeof quality;
|
||||
optionsOpen = false;
|
||||
}}
|
||||
class="font-normal"
|
||||
>
|
||||
<span class="flex-1 text-left">{$i18n.t(item.label)}</span>
|
||||
{#if quality === item.value}<Icon name="check" size={12} />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<hr class="border-gray-100 dark:border-gray-800" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={deviceMode === 'mobile'}
|
||||
class="font-normal"
|
||||
on:click={() => (deviceMode = deviceMode === 'mobile' ? 'desktop' : 'mobile')}
|
||||
>
|
||||
<span class="flex-1 whitespace-nowrap text-left">{$i18n.t('Mobile view')}</span>
|
||||
<div class="shrink-0" inert>
|
||||
<Switch state={deviceMode === 'mobile'} />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
|
||||
{#if onClose}<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Close')}
|
||||
|
|
@ -182,21 +392,50 @@
|
|||
on:click={onClose}><Icon name="xmark" size={11} /></button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if error}<p role="alert" class="px-2 py-1 text-xs text-red-500">{error}</p>{/if}
|
||||
{#if error}
|
||||
<p role="alert" class="px-2 py-1 text-xs text-red-500">{error}</p>
|
||||
{:else if connectionStatus}
|
||||
<p role="status" class="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(connectionStatus)}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
{#if overlay}<div class="absolute inset-0 z-10"></div>{/if}
|
||||
{#if frameUrl}
|
||||
{#if session?.mode === 'chrome'}
|
||||
<div class="h-full" class:hidden={!currentUrl}>
|
||||
<ChromeBrowser
|
||||
bind:this={chrome}
|
||||
streamUrl={`${viewUrl}/stream`}
|
||||
{active}
|
||||
mobile={deviceMode === 'mobile'}
|
||||
{quality}
|
||||
onError={(message) => (error = message)}
|
||||
onStatus={(message) => (connectionStatus = message)}
|
||||
onState={(state) => {
|
||||
if (!state.url) return;
|
||||
if (currentUrl !== state.url || document.activeElement !== addressInput)
|
||||
urlInput = state.url;
|
||||
currentUrl = state.url;
|
||||
chromeBack = state.canGoBack;
|
||||
chromeForward = state.canGoForward;
|
||||
loading = false;
|
||||
onNavigate(state.url, state.title.trim() || new URL(state.url).host);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else if frameUrl}
|
||||
{#key frameKey}<iframe
|
||||
bind:this={iframe}
|
||||
src={frameUrl}
|
||||
title={$i18n.t('Browser')}
|
||||
class="h-full w-full border-0 bg-white"
|
||||
sandbox="allow-scripts{($settings?.terminalPreviewAllowSameOrigin ?? false)
|
||||
class="block h-full w-full border-0 bg-white dark:bg-gray-900"
|
||||
sandbox="allow-scripts{!session && ($settings?.terminalPreviewAllowSameOrigin ?? false)
|
||||
? ' allow-same-origin'
|
||||
: ''} allow-forms allow-popups allow-modals allow-downloads"
|
||||
on:load={loaded}
|
||||
></iframe>{/key}
|
||||
{:else}<div class="flex h-full overflow-y-auto px-6 pt-8 pb-20">
|
||||
{/if}
|
||||
{#if !currentUrl && !frameUrl}<div class="flex h-full overflow-y-auto px-6 pt-8 pb-20">
|
||||
<div class="m-auto w-full max-w-xs">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<Icon name="browser" size={18} strokeWidth={1.4} class="text-gray-400" />
|
||||
|
|
|
|||
186
src/lib/components/chat/ChatTabs/ChromeBrowser.svelte
Normal file
186
src/lib/components/chat/ChatTabs/ChromeBrowser.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
export let streamUrl: string;
|
||||
export let active = true;
|
||||
export let mobile = false;
|
||||
export let quality: 'low' | 'balanced' | 'crisp' = 'balanced';
|
||||
export let onState: (state: {
|
||||
url: string;
|
||||
title: string;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}) => void;
|
||||
export let onError: (error: string) => void = () => {};
|
||||
export let onStatus: (status: string) => void = () => {};
|
||||
let surface: HTMLDivElement;
|
||||
let frame = '';
|
||||
let width = 1024;
|
||||
let height = 768;
|
||||
let socket: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let retry: ReturnType<typeof setTimeout>;
|
||||
let attempts = 0;
|
||||
let hasConnected = false;
|
||||
let pendingNavigation: string | null = null;
|
||||
export const command = (type: string, values: Record<string, unknown> = {}) => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type, ...values }));
|
||||
} else if (type === 'navigate') {
|
||||
pendingNavigation = values.url as string;
|
||||
}
|
||||
};
|
||||
const resize = () => {
|
||||
if (!surface) return;
|
||||
const bounds = surface.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
width = mobile ? 390 : Math.max(240, Math.round(bounds.width));
|
||||
height = mobile ? 844 : Math.max(200, Math.round(bounds.height));
|
||||
command('viewport', { width, height, mobile });
|
||||
};
|
||||
$: if (socket) command('visibility', { visible: active });
|
||||
$: if (surface && active) {
|
||||
mobile;
|
||||
resize();
|
||||
}
|
||||
$: if (socket) command('quality', { quality });
|
||||
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 (!image) return;
|
||||
const box = image.getBoundingClientRect();
|
||||
event.preventDefault();
|
||||
if (type === 'mousePressed') surface.focus();
|
||||
command('mouse', {
|
||||
event: type,
|
||||
x: Math.max(0, Math.min(width, ((event.clientX - box.left) * width) / box.width)),
|
||||
y: Math.max(0, Math.min(height, ((event.clientY - box.top) * height) / box.height)),
|
||||
button: ['left', 'middle', 'right'][event.button] ?? 'none',
|
||||
buttons: event.buttons,
|
||||
clickCount: type === 'mouseMoved' ? 0 : event.detail || 1,
|
||||
modifiers: modifiers(event)
|
||||
});
|
||||
};
|
||||
const key = (event: KeyboardEvent, type: string) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') return;
|
||||
event.preventDefault();
|
||||
const editing =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
(
|
||||
{ a: 'selectAll', c: 'copy', x: 'cut', z: event.shiftKey ? 'redo' : 'undo' } as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
)[event.key.toLowerCase()];
|
||||
command('key', {
|
||||
event: type,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
windowsVirtualKeyCode: event.keyCode,
|
||||
modifiers: modifiers(event),
|
||||
...(editing ? { commands: [editing] } : {}),
|
||||
text:
|
||||
type === 'keyDown' && event.key.length === 1 && !event.ctrlKey && !event.metaKey
|
||||
? event.key
|
||||
: undefined
|
||||
});
|
||||
};
|
||||
onMount(() => {
|
||||
onStatus('Connecting…');
|
||||
const connect = () => {
|
||||
const url = new URL(streamUrl, window.location.origin);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(url);
|
||||
socket.onopen = () => {
|
||||
onError('');
|
||||
resize();
|
||||
command('quality', { quality });
|
||||
command('visibility', { visible: active });
|
||||
if (pendingNavigation) {
|
||||
command('navigate', { url: pendingNavigation });
|
||||
pendingNavigation = null;
|
||||
}
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'frame' || message.type === 'state') {
|
||||
hasConnected = true;
|
||||
attempts = 0;
|
||||
onStatus('');
|
||||
}
|
||||
if (message.type === 'frame') frame = `data:image/jpeg;base64,${message.data}`;
|
||||
if (message.type === 'state') {
|
||||
onState(message);
|
||||
onError(message.error || '');
|
||||
}
|
||||
} catch {
|
||||
/* Ignore malformed stream messages. */
|
||||
}
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (disposed) return;
|
||||
if (event.code >= 4000 && event.code < 5000) {
|
||||
onStatus('');
|
||||
onError(
|
||||
event.code === 4000
|
||||
? 'Browser opened in another viewer.'
|
||||
: 'Browser session expired. Reopen this tab.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
onError('');
|
||||
onStatus(hasConnected ? 'Reconnecting…' : 'Connecting…');
|
||||
retry = setTimeout(connect, Math.min(1000 * 2 ** attempts++, 10000));
|
||||
};
|
||||
};
|
||||
connect();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(surface);
|
||||
return () => {
|
||||
disposed = true;
|
||||
onStatus('');
|
||||
clearTimeout(retry);
|
||||
observer.disconnect();
|
||||
socket?.close();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<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:paste={(event) => {
|
||||
event.preventDefault();
|
||||
command('paste', { text: event.clipboardData?.getData('text/plain') ?? '' });
|
||||
}}
|
||||
on:mousedown={(event) => mouse(event, 'mousePressed')}
|
||||
on:mouseup={(event) => mouse(event, 'mouseReleased')}
|
||||
on:mousemove={(event) => mouse(event, 'mouseMoved')}
|
||||
on:contextmenu|preventDefault
|
||||
on:wheel|preventDefault={(event) => {
|
||||
const box = surface.querySelector('img')?.getBoundingClientRect();
|
||||
if (!box) return;
|
||||
command('mouse', {
|
||||
event: 'mouseWheel',
|
||||
x: ((event.clientX - box.left) * width) / box.width,
|
||||
y: ((event.clientY - box.top) * height) / box.height,
|
||||
deltaX: event.deltaX,
|
||||
deltaY: event.deltaY,
|
||||
modifiers: modifiers(event)
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if frame}<img
|
||||
src={frame}
|
||||
alt="Browser page"
|
||||
draggable="false"
|
||||
class="max-h-full max-w-full select-none"
|
||||
style:aspect-ratio={`${width}/${height}`}
|
||||
/>{/if}
|
||||
</div>
|
||||
Loading…
Add table
Reference in a new issue