diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index d5e1d2db6f..e7e442e9d1 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -3043,7 +3043,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): if direct_tool_servers: for tool_server in direct_tool_servers: - system_prompt = tool_server.pop('system_prompt', None) + if tool_server.get('is_terminal') is True and not terminal_capability: + continue + system_prompt = tool_server.get('system_prompt') if system_prompt: form_data['messages'] = add_or_update_system_message( system_prompt, @@ -3051,7 +3053,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): append=True, ) - tool_specs = tool_server.pop('specs', []) + tool_specs = tool_server.get('specs', []) for tool in tool_specs: tools_dict[tool['name']] = { @@ -3107,6 +3109,50 @@ async def process_chat_payload(request, form_data, user, metadata, model): if name not in tools_dict: tools_dict[name] = tool_dict + # Only advertise user-shell tools when the originating browser has a connected shell. + shell_tools = { + name: tool + for name, tool in tools_dict.items() + if name in {'read_user_terminal', 'send_user_terminal_input'} + and (tool.get('type') == 'terminal' or tool.get('server', {}).get('is_terminal') is True) + } + selected = { + name + for name, tool in shell_tools.items() + if terminal_id + and ( + tool.get('tool_id') == f'terminal:{terminal_id}' + or (tool.get('direct') and tool.get('server', {}).get('url') == terminal_id) + ) + } + connected = False + if ( + selected + and event_caller + and metadata.get('session_id') + and metadata.get('chat_id') + and not metadata.get('automation_id') + and not metadata.get('internal') + ): + try: + state = await asyncio.wait_for( + event_caller( + { + 'type': 'request:terminal:state', + 'data': {'terminal_id': terminal_id, 'session_id': metadata['session_id']}, + } + ), + timeout=2, + ) + connected = isinstance(state, dict) and state.get('connected') is True + except Exception: + # Old/disconnected browsers cannot confirm availability; other tools still work. + pass + + for name in shell_tools: + if not connected or name not in selected: + tools_dict.pop(name) + if tools_dict: # Always store resolved tools in metadata so downstream consumers # (e.g. pipe functions) can access all tools including MCP and builtins. diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 99b0e8a0f3..fe577388ca 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -3579,7 +3579,11 @@ (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) ), // Direct terminal servers — always included when enabled (not routed through selectedToolIds) - ...($terminalServers ?? []).filter((t) => !t.id) + ...(terminalEnabled + ? ($terminalServers ?? []) + .filter((server) => !server.id) + .map((server) => ({ ...server, is_terminal: true })) + : []) ], features: getFeatures(), variables: { diff --git a/src/lib/components/chat/XTerminal.svelte b/src/lib/components/chat/XTerminal.svelte index 492d842402..d9163812ed 100644 --- a/src/lib/components/chat/XTerminal.svelte +++ b/src/lib/components/chat/XTerminal.svelte @@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; import { terminalRequest, type TerminalConnection } from '$lib/apis/terminal'; + import { connectedUserTerminals } from '$lib/stores'; export let connection: TerminalConnection; export let chatId: string | null = null; @@ -22,6 +23,17 @@ let pingInterval: ReturnType; let destroyed = false; let sessionId = ''; + const terminalOwner = Symbol(); + + function disconnect() { + connectedUserTerminals.update((entries) => { + entries.delete(terminalOwner); + return entries; + }); + connected = false; + connecting = false; + clearInterval(pingInterval); + } export function write(output: string) { if (!term || destroyed) return; @@ -59,9 +71,14 @@ ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; ws.onopen = () => { + if (destroyed) return; ws?.send( JSON.stringify({ type: 'auth', token: connection.key.trim(), chat_id: chatId ?? '' }) ); + if (connection.selector && chatId) { + const shell = { terminalId: connection.selector, chatId }; + connectedUserTerminals.update((entries) => entries.set(terminalOwner, shell)); + } connected = true; connecting = false; fit(); @@ -72,21 +89,23 @@ }, 25000); }; ws.onmessage = (event) => { + if (destroyed) return; if (event.data instanceof ArrayBuffer) term?.write(new Uint8Array(event.data)); else write(event.data); }; ws.onclose = () => { - connected = false; - connecting = false; - clearInterval(pingInterval); + if (destroyed) return; + disconnect(); write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n'); }; ws.onerror = () => { - connecting = false; + if (destroyed) return; + disconnect(); write('\r\n\x1b[31m[Terminal connection failed]\x1b[0m\r\n'); }; } catch (error) { - connecting = false; + if (destroyed) return; + disconnect(); write(`\r\n\x1b[31m[${error}]\x1b[0m\r\n`); } } @@ -133,7 +152,7 @@ onDestroy(() => { destroyed = true; - clearInterval(pingInterval); + disconnect(); ws?.close(); resizeObserver?.disconnect(); term?.dispose(); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index e1db1daf59..00fb0d887f 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -146,6 +146,9 @@ export type FileNavOpenRequest = string | { path: string; page?: number | null } export const showFileNavPath: Writable = writable(null); export const showFileNavDir: Writable = writable(null); export const selectedTerminalId: Writable = writable(null); +export const connectedUserTerminals = writable( + new Map() +); export const artifactCode = writable(null); export const artifactContents = writable(null); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 094cea1deb..71221fba94 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -31,6 +31,7 @@ channels, channelId, terminalServers, + connectedUserTerminals, showControls, showFileNavPath, showFileNavDir, @@ -556,6 +557,19 @@ }; const chatEventHandler = async (event, cb) => { + // Answer this session's availability check even when another chat is active. + if ( + event?.data?.type === 'request:terminal:state' && + event.data.data?.session_id === $socket?.id + ) { + cb?.({ + connected: [...$connectedUserTerminals.values()].some( + (shell) => + shell.terminalId === event.data.data?.terminal_id && shell.chatId === event.chat_id + ) + }); + return; + } const chat = $page.url.pathname.includes(`/c/${event.chat_id}`); // Skip events from temporary chats that are not the current chat.