mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
refac
This commit is contained in:
parent
8faaf2cd1e
commit
009999f363
12 changed files with 334 additions and 32 deletions
|
|
@ -20,7 +20,14 @@ 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
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from open_webui.utils.terminals import (
|
||||
TERMINAL_CONTEXT_HEADER,
|
||||
get_terminal_server_url,
|
||||
terminal_context_available,
|
||||
terminal_context_config,
|
||||
terminal_context_id,
|
||||
terminal_contexts,
|
||||
)
|
||||
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
|
|
@ -78,6 +85,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user
|
|||
'id': connection.get('id', ''),
|
||||
'url': connection.get('url', ''),
|
||||
'name': connection.get('name', ''),
|
||||
'contexts': terminal_contexts(connection),
|
||||
}
|
||||
for connection in connections
|
||||
if connection.get('enabled', True) and await has_connection_access(user, connection, user_group_ids)
|
||||
|
|
@ -126,6 +134,13 @@ async def proxy_terminal(
|
|||
session_id = request.headers.get('x-session-id')
|
||||
if session_id:
|
||||
headers['X-Session-Id'] = session_id
|
||||
if not terminal_context_available(connection, 'chat'):
|
||||
return JSONResponse({'error': 'Terminal server is not available in chats'}, status_code=403)
|
||||
context_id = terminal_context_id(connection, {'chat_id': session_id}, 'chat')
|
||||
if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id:
|
||||
return JSONResponse({'error': 'A saved chat is required for this terminal'}, status_code=409)
|
||||
if context_id:
|
||||
headers[TERMINAL_CONTEXT_HEADER] = context_id
|
||||
cookies = {}
|
||||
auth_type = connection.get('auth_type', 'bearer')
|
||||
|
||||
|
|
@ -218,8 +233,8 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
|
||||
message after connecting.
|
||||
|
||||
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*
|
||||
with an appropriate error code.
|
||||
Returns ``(user, connection, chat_id)`` on success, or ``None`` after
|
||||
closing *ws* with an appropriate error code.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
|
@ -260,7 +275,11 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
await ws.close(code=4003, reason='Access denied')
|
||||
return None
|
||||
|
||||
return user, connection
|
||||
chat_id = payload.get('chat_id', '')
|
||||
if not terminal_context_available(connection, 'chat'):
|
||||
await ws.close(code=4003, reason='Terminal server is not available in chats')
|
||||
return None
|
||||
return user, connection, chat_id if isinstance(chat_id, str) else ''
|
||||
|
||||
|
||||
@router.websocket('/{server_id}/api/terminals/{session_id}')
|
||||
|
|
@ -280,7 +299,7 @@ async def ws_terminal(
|
|||
result = await _resolve_authenticated_connection(ws, server_id)
|
||||
if result is None:
|
||||
return
|
||||
user, connection = result
|
||||
user, connection, chat_id = result
|
||||
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
|
|
@ -293,6 +312,13 @@ async def ws_terminal(
|
|||
upstream_params = {}
|
||||
# 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 = {}
|
||||
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
|
||||
if context_id:
|
||||
upstream_headers[TERMINAL_CONTEXT_HEADER] = context_id
|
||||
|
||||
import urllib.parse
|
||||
|
||||
|
|
@ -308,7 +334,11 @@ async def ws_terminal(
|
|||
opened = False
|
||||
session = aiohttp.ClientSession()
|
||||
try:
|
||||
async with session.ws_connect(upstream_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as upstream:
|
||||
async with session.ws_connect(
|
||||
upstream_url,
|
||||
headers=upstream_headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as upstream:
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
from urllib.parse import quote
|
||||
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
|
||||
TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id'
|
||||
TERMINAL_CONTEXT_DEFAULT = 'default'
|
||||
TERMINAL_CONTEXT_TYPES = {'chat', 'automation'}
|
||||
TERMINAL_CONTEXT_ID_SOURCES = {'chat': 'chat_id', 'automation': 'automation_id'}
|
||||
|
||||
|
||||
def is_terminal_orchestrator(connection: dict) -> bool:
|
||||
"""Return whether this connection points at Terminals, not raw Open Terminal."""
|
||||
return connection.get('server_type') == 'orchestrator' or bool(connection.get('policy_id'))
|
||||
|
||||
|
||||
def get_terminal_server_url(connection: dict) -> str:
|
||||
"""Return the upstream base URL for a terminal connection.
|
||||
|
|
@ -14,3 +26,81 @@ def get_terminal_server_url(connection: dict) -> str:
|
|||
if policy_id:
|
||||
return f'{base_url}/p/{quote(policy_id, safe="")}'
|
||||
return base_url
|
||||
|
||||
|
||||
def terminal_context_config(connection: dict, context: str) -> dict | bool:
|
||||
"""Return config for an OpenWebUI terminal context.
|
||||
|
||||
Missing config is legacy behavior: available, shared default terminal.
|
||||
"""
|
||||
if not is_terminal_orchestrator(connection):
|
||||
return {}
|
||||
|
||||
contexts = (connection.get('config') or {}).get('contexts')
|
||||
if not isinstance(contexts, dict):
|
||||
return {}
|
||||
|
||||
value = contexts.get(context, {})
|
||||
if value is False:
|
||||
return False
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def terminal_context_available(connection: dict, context: str) -> bool:
|
||||
"""Return whether this terminal is exposed in an OpenWebUI context."""
|
||||
if context not in TERMINAL_CONTEXT_TYPES:
|
||||
return False
|
||||
return terminal_context_config(connection, context) is not False
|
||||
|
||||
|
||||
def terminal_context_id(
|
||||
connection: dict,
|
||||
metadata: dict | None = None,
|
||||
context: str = 'chat',
|
||||
) -> str | None:
|
||||
"""Return the terminal runtime context for trusted request metadata."""
|
||||
if not is_terminal_orchestrator(connection) or not terminal_context_available(connection, context):
|
||||
return None
|
||||
|
||||
config = terminal_context_config(connection, context)
|
||||
context_id_source = config.get('context_id') if isinstance(config, dict) else None
|
||||
if not context_id_source or context_id_source == TERMINAL_CONTEXT_DEFAULT:
|
||||
return None
|
||||
|
||||
if context_id_source != TERMINAL_CONTEXT_ID_SOURCES.get(context):
|
||||
return None
|
||||
|
||||
metadata = metadata or {}
|
||||
|
||||
if context == 'automation':
|
||||
automation_id = metadata.get('automation_id')
|
||||
return f'automation:{automation_id}' if automation_id else None
|
||||
|
||||
chat_id = metadata.get('chat_id')
|
||||
if context == 'chat' and chat_id and is_saved_chat_id(chat_id):
|
||||
return f'chat:{chat_id}'
|
||||
return None
|
||||
|
||||
|
||||
def terminal_contexts(connection: dict) -> dict:
|
||||
"""Return normalized sparse context config for clients."""
|
||||
if not is_terminal_orchestrator(connection):
|
||||
return {}
|
||||
|
||||
contexts = (connection.get('config') or {}).get('contexts')
|
||||
if not isinstance(contexts, dict):
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for context, value in contexts.items():
|
||||
if context not in TERMINAL_CONTEXT_TYPES:
|
||||
continue
|
||||
if value is False:
|
||||
result[context] = False
|
||||
elif isinstance(value, dict):
|
||||
context_id_source = value.get('context_id')
|
||||
if context_id_source in {TERMINAL_CONTEXT_DEFAULT, TERMINAL_CONTEXT_ID_SOURCES[context]}:
|
||||
result[context] = {'context_id': context_id_source}
|
||||
else:
|
||||
result[context] = {}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -106,7 +106,13 @@ from open_webui.utils.headers import get_custom_headers, include_user_info_heade
|
|||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from open_webui.utils.terminals import (
|
||||
TERMINAL_CONTEXT_HEADER,
|
||||
get_terminal_server_url,
|
||||
terminal_context_available,
|
||||
terminal_context_config,
|
||||
terminal_context_id,
|
||||
)
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
|
|
@ -1361,10 +1367,25 @@ async def get_terminal_tools(
|
|||
|
||||
# Use chat_id as the per-session key for cwd tracking
|
||||
metadata = extra_params.get('__metadata__', {})
|
||||
terminal_context = 'automation' if metadata.get('automation_id') else 'chat'
|
||||
if not terminal_context_available(connection, terminal_context):
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' is not available for {terminal_context}")
|
||||
|
||||
session_id = metadata.get('chat_id')
|
||||
if session_id:
|
||||
headers['X-Session-Id'] = session_id
|
||||
|
||||
context_id = terminal_context_id(connection, metadata, terminal_context)
|
||||
config = terminal_context_config(connection, terminal_context)
|
||||
if (
|
||||
isinstance(config, dict)
|
||||
and config.get('context_id') in {'chat_id', 'automation_id'}
|
||||
and not context_id
|
||||
):
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' requires a saved {terminal_context} context")
|
||||
if context_id:
|
||||
headers[TERMINAL_CONTEXT_HEADER] = context_id
|
||||
|
||||
# Fetch live with the user's credentials so prompt changes apply without a restart
|
||||
terminal_cwd, system_prompt = await asyncio.gather(
|
||||
get_terminal_cwd(server_data['url'], headers, cookies),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type TerminalServer = {
|
|||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
contexts?: Record<string, false | { context_id?: string }>;
|
||||
};
|
||||
|
||||
export const getTerminalServers = async (token: string): Promise<TerminalServer[]> => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@
|
|||
let auth_type = 'bearer';
|
||||
let path = '/openapi.json';
|
||||
let enabled = false;
|
||||
let chatContextMode: 'default' | 'chat_id' | 'off' = 'default';
|
||||
let automationContextMode: 'default' | 'automation_id' | 'off' = 'default';
|
||||
let showAdvanced = false;
|
||||
let showOrchestratorAdvanced = false;
|
||||
let showAccessControlModal = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let accessGrants: any[] = [];
|
||||
|
|
@ -82,6 +85,19 @@
|
|||
// Restore policy state
|
||||
serverType = connection?.server_type ?? (connection?.policy_id ? 'orchestrator' : null);
|
||||
policyId = connection?.policy_id ?? '';
|
||||
const contexts = serverType === 'orchestrator' ? (connection?.config?.contexts ?? {}) : {};
|
||||
chatContextMode =
|
||||
contexts?.chat === false
|
||||
? 'off'
|
||||
: contexts?.chat?.context_id === 'chat_id'
|
||||
? 'chat_id'
|
||||
: 'default';
|
||||
automationContextMode =
|
||||
contexts?.automation === false
|
||||
? 'off'
|
||||
: contexts?.automation?.context_id === 'automation_id'
|
||||
? 'automation_id'
|
||||
: 'default';
|
||||
|
||||
const p: Record<string, any> = {};
|
||||
policyImage = p.image ?? '';
|
||||
|
|
@ -110,6 +126,8 @@
|
|||
path = '/openapi.json';
|
||||
enabled = false;
|
||||
accessGrants = [];
|
||||
chatContextMode = 'default';
|
||||
automationContextMode = 'default';
|
||||
|
||||
serverType = null;
|
||||
policyId = '';
|
||||
|
|
@ -347,6 +365,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
const contexts: Record<string, false | { context_id: string }> = {};
|
||||
if (chatContextMode === 'off') contexts.chat = false;
|
||||
else if (chatContextMode === 'chat_id') contexts.chat = { context_id: 'chat_id' };
|
||||
if (automationContextMode === 'off') contexts.automation = false;
|
||||
else if (automationContextMode === 'automation_id') {
|
||||
contexts.automation = { context_id: 'automation_id' };
|
||||
}
|
||||
const useContexts = !direct && serverType === 'orchestrator' && Object.keys(contexts).length > 0;
|
||||
|
||||
const result = {
|
||||
...(!direct && id.trim() ? { id: id.trim() } : {}),
|
||||
url,
|
||||
|
|
@ -356,7 +383,8 @@
|
|||
auth_type,
|
||||
enabled: enabled,
|
||||
config: {
|
||||
...(!direct ? { access_grants: accessGrants } : {})
|
||||
...(!direct ? { access_grants: accessGrants } : {}),
|
||||
...(useContexts ? { contexts } : {})
|
||||
},
|
||||
// Policy fields
|
||||
...(serverType ? { server_type: serverType } : {}),
|
||||
|
|
@ -506,6 +534,59 @@
|
|||
|
||||
<!-- Policy section (orchestrator only, admin only) -->
|
||||
{#if serverType === 'orchestrator' && !direct}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
||||
on:click={() => (showOrchestratorAdvanced = !showOrchestratorAdvanced)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform {showOrchestratorAdvanced ? 'rotate-90' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Orchestrator')}
|
||||
</button>
|
||||
|
||||
{#if showOrchestratorAdvanced}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Terminal Contexts')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<label for="terminal-chat-context">{$i18n.t('Chat')}</label>
|
||||
<select
|
||||
id="terminal-chat-context"
|
||||
class={`text-xs ${selectClass}`}
|
||||
bind:value={chatContextMode}
|
||||
>
|
||||
<option value="default">{$i18n.t('Shared')}</option>
|
||||
<option value="chat_id">{$i18n.t('Per chat')}</option>
|
||||
<option value="off">{$i18n.t('Off')}</option>
|
||||
</select>
|
||||
<label for="terminal-automation-context">{$i18n.t('Automation')}</label>
|
||||
<select
|
||||
id="terminal-automation-context"
|
||||
class={`text-xs ${selectClass}`}
|
||||
bind:value={automationContextMode}
|
||||
>
|
||||
<option value="default">{$i18n.t('Shared')}</option>
|
||||
<option value="automation_id">{$i18n.t('Per automation')}</option>
|
||||
<option value="off">{$i18n.t('Off')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
|
|
@ -727,6 +808,7 @@
|
|||
{refreshing ? $i18n.t('Refreshing...') : $i18n.t('Refresh Terminals')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@
|
|||
id: t.id,
|
||||
url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`,
|
||||
name: t.name,
|
||||
key: localStorage.token
|
||||
key: localStorage.token,
|
||||
contexts: t.contexts ?? {}
|
||||
}));
|
||||
terminalServers.set([...existingDirectTerminals, ...systemEntries] as any);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
import FileNav from './FileNav.svelte';
|
||||
import PyodideFileNav from './PyodideFileNav.svelte';
|
||||
import Overview from './Overview.svelte';
|
||||
import { isSavedChatId } from '$lib/utils/chatId';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -72,11 +73,26 @@
|
|||
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
|
||||
|
||||
$: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true);
|
||||
$: showFilesTab =
|
||||
const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {};
|
||||
const chatContextAvailable = (terminal: any) => chatContext(terminal) !== false;
|
||||
const chatContextNeedsSavedChat = (terminal: any) =>
|
||||
chatContext(terminal)?.context_id === 'chat_id';
|
||||
$: selectedSystemTerminal = ($terminalServers ?? []).find(
|
||||
(t) => t.id && t.id === $selectedTerminalId
|
||||
);
|
||||
$: selectedSystemTerminalAvailable =
|
||||
selectedSystemTerminal &&
|
||||
chatContextAvailable(selectedSystemTerminal) &&
|
||||
!(chatContextNeedsSavedChat(selectedSystemTerminal) && !isSavedChatId(chatId));
|
||||
$: terminalFilesAvailable = !!(
|
||||
($selectedTerminalId &&
|
||||
(($terminalServers ?? []).some((t) => t.id && t.id === $selectedTerminalId) ||
|
||||
$user?.role === 'admin' ||
|
||||
($user?.permissions?.features?.direct_tool_servers ?? true))) ||
|
||||
(selectedSystemTerminalAvailable ||
|
||||
(!selectedSystemTerminal &&
|
||||
($user?.role === 'admin' ||
|
||||
($user?.permissions?.features?.direct_tool_servers ?? true)))))
|
||||
);
|
||||
$: showFilesTab =
|
||||
terminalFilesAvailable ||
|
||||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
|
||||
$: showOverviewTab = hasMessages;
|
||||
|
||||
|
|
@ -94,13 +110,13 @@
|
|||
}
|
||||
|
||||
// Auto-switch to Files tab when display_file is triggered
|
||||
$: if ($showFileNavPath) {
|
||||
$: if ($showFileNavPath && terminalFilesAvailable) {
|
||||
activeTab = 'files';
|
||||
showControls.set(true);
|
||||
}
|
||||
|
||||
// Auto-open Files tab when a terminal is selected (suppress panel open when full-screen)
|
||||
$: if ($selectedTerminalId && showFilesTab) {
|
||||
$: if ($selectedTerminalId && terminalFilesAvailable) {
|
||||
activeTab = 'files';
|
||||
if (largeScreen) {
|
||||
showControls.set($settings?.showFilesOnTerminalSelect ?? true);
|
||||
|
|
@ -374,7 +390,7 @@
|
|||
showMessage(node.data.message, true);
|
||||
}}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} {chatId} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav />
|
||||
|
|
@ -523,7 +539,7 @@
|
|||
showMessage(node.data.message, true);
|
||||
}}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav overlay={dragged} />
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
import Document from '../icons/Document.svelte';
|
||||
import PenAlt from '../icons/PenAlt.svelte';
|
||||
import ZoomReset from '../icons/ZoomReset.svelte';
|
||||
import { isSavedChatId, isTemporaryChatId } from '$lib/utils/chatId';
|
||||
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
|
|
@ -231,11 +232,22 @@
|
|||
|
||||
// ── Terminal resolution ──────────────────────────────────────────────
|
||||
let selectedTerminal: { url: string; key: string } | null = null;
|
||||
let terminalChatContextPending = false;
|
||||
let terminalChatContextHidden = false;
|
||||
|
||||
const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {};
|
||||
|
||||
const getTerminal = (): { url: string; key: string } | null => {
|
||||
const systemTerminal = $selectedTerminalId
|
||||
? (($terminalServers ?? []).find((t) => t.id === $selectedTerminalId) ?? null)
|
||||
: ($terminalServers?.[0] ?? null);
|
||||
const chatConfig = chatContext(systemTerminal);
|
||||
const chatScoped = !!systemTerminal && chatConfig?.context_id === 'chat_id';
|
||||
terminalChatContextHidden =
|
||||
!!systemTerminal && (chatConfig === false || (chatScoped && isTemporaryChatId(chatId)));
|
||||
terminalChatContextPending =
|
||||
chatScoped && !terminalChatContextHidden && !isSavedChatId(chatId);
|
||||
if (terminalChatContextHidden || terminalChatContextPending) return null;
|
||||
|
||||
const userTerminal = ($settings?.terminalServers ?? []).find(
|
||||
(s) => s.url === $selectedTerminalId
|
||||
|
|
@ -929,7 +941,13 @@
|
|||
const onBlur = () => (shiftKey = false);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !selectedFile && selectedTerminal && !loading) {
|
||||
if (
|
||||
document.visibilityState === 'visible' &&
|
||||
!selectedFile &&
|
||||
selectedTerminal &&
|
||||
!terminalChatContextPending &&
|
||||
!loading
|
||||
) {
|
||||
loadDir(currentPath);
|
||||
}
|
||||
};
|
||||
|
|
@ -972,7 +990,16 @@
|
|||
|
||||
<svelte:window on:keydown={handleKeydown} on:click={handleWindowClick} />
|
||||
|
||||
{#if !selectedTerminal}
|
||||
{#if terminalChatContextHidden}
|
||||
<div class="hidden"></div>
|
||||
{:else if terminalChatContextPending}
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<Folder className="size-6 text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Start the chat to use this terminal.')}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !selectedTerminal}
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<Folder className="size-6 text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import { settings, showSettings, terminalServers, selectedTerminalId, user } from '$lib/stores';
|
||||
import {
|
||||
chatId as activeChatId,
|
||||
settings,
|
||||
showSettings,
|
||||
terminalServers,
|
||||
selectedTerminalId,
|
||||
user
|
||||
} from '$lib/stores';
|
||||
import { getToolServersData } from '$lib/apis';
|
||||
import { isTemporaryChatId } from '$lib/utils/chatId';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
|
|
@ -13,7 +21,14 @@
|
|||
|
||||
export let show = false;
|
||||
|
||||
$: systemTerminals = ($terminalServers ?? []).filter((t) => t.id);
|
||||
const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {};
|
||||
|
||||
$: systemTerminals = ($terminalServers ?? []).filter(
|
||||
(t) =>
|
||||
t.id &&
|
||||
chatContext(t) !== false &&
|
||||
!(isTemporaryChatId($activeChatId) && chatContext(t)?.context_id === 'chat_id')
|
||||
);
|
||||
$: directTerminals = ($settings?.terminalServers ?? []).filter((s) => s.url);
|
||||
|
||||
const refreshTerminalServersStore = async (servers: typeof directTerminals) => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
import { terminalServers, settings, selectedTerminalId, user } from '$lib/stores';
|
||||
import { terminalServers, settings, selectedTerminalId } from '$lib/stores';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let overlay = false;
|
||||
export let chatId: string | null = null;
|
||||
|
|
@ -24,13 +21,19 @@
|
|||
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Resolve the active terminal server's info for the WebSocket URL
|
||||
const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => {
|
||||
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 };
|
||||
return {
|
||||
serverId: systemMatch.id,
|
||||
baseUrl: WEBUI_API_BASE_URL
|
||||
};
|
||||
}
|
||||
|
||||
// Direct terminal (user-configured, matched by URL)
|
||||
|
|
@ -58,6 +61,7 @@
|
|||
let sessionId: string;
|
||||
let wsUrl: string;
|
||||
let authToken: string;
|
||||
let authChatId = '';
|
||||
|
||||
if (info.serverId === '__direct__') {
|
||||
// Direct connection to open-terminal
|
||||
|
|
@ -98,6 +102,7 @@
|
|||
|
||||
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
|
||||
authChatId = chatId ?? '';
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
|
@ -106,7 +111,12 @@
|
|||
ws.onopen = () => {
|
||||
// First-message auth (no token in URL)
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: authToken.trim() }));
|
||||
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));
|
||||
}
|
||||
connected = true;
|
||||
connecting = false;
|
||||
|
|
@ -259,7 +269,7 @@
|
|||
};
|
||||
|
||||
// Reconnect when the selected terminal changes
|
||||
$: if ($selectedTerminalId !== undefined && term) {
|
||||
$: if (($selectedTerminalId, chatId, term)) {
|
||||
// Clear the terminal screen and reconnect to the new server
|
||||
disconnect();
|
||||
term.clear();
|
||||
|
|
@ -282,5 +292,9 @@
|
|||
</script>
|
||||
|
||||
<div class="h-full min-h-0 relative">
|
||||
<div bind:this={terminalEl} class="absolute inset-0 px-0.5" class:pointer-events-none={overlay} />
|
||||
<div
|
||||
bind:this={terminalEl}
|
||||
class="absolute inset-0 px-0.5"
|
||||
class:pointer-events-none={overlay}
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const TEMPORARY_CHAT_ID_PREFIX = 'temporary:';
|
||||
const LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:'; // Legacy temporary chat prefix.
|
||||
const CHANNEL_CHAT_ID_PREFIX = 'channel:';
|
||||
|
||||
export const createTemporaryChatId = (sessionId: string | undefined) =>
|
||||
`${TEMPORARY_CHAT_ID_PREFIX}${sessionId}`;
|
||||
|
|
@ -8,3 +9,6 @@ export const isTemporaryChatId = (chatId: string | null | undefined) =>
|
|||
!!chatId &&
|
||||
(chatId.startsWith(TEMPORARY_CHAT_ID_PREFIX) ||
|
||||
chatId.startsWith(LEGACY_TEMPORARY_CHAT_ID_PREFIX));
|
||||
|
||||
export const isSavedChatId = (chatId: string | null | undefined) =>
|
||||
!!chatId && !isTemporaryChatId(chatId) && !chatId.startsWith(CHANNEL_CHAT_ID_PREFIX);
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@
|
|||
id: t.id,
|
||||
url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`,
|
||||
name: t.name,
|
||||
key: localStorage.token
|
||||
key: localStorage.token,
|
||||
contexts: t.contexts ?? {}
|
||||
}))
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue