From 90319593d0834e0a672f5c401857c2cdf7f5b484 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 1 Apr 2026 00:35:11 -0500 Subject: [PATCH] refac --- backend/open_webui/models/automations.py | 6 + backend/open_webui/routers/automations.py | 18 +++ backend/open_webui/utils/automations.py | 71 ++++++++++++ src/lib/apis/automations/index.ts | 6 + src/lib/components/AutomationModal.svelte | 130 +++++++++++++++++++++- 5 files changed, 228 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 9aa808c72..fcb133a90 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -57,10 +57,16 @@ class AutomationRun(Base): #################### +class AutomationTerminalConfig(BaseModel): + server_id: str + cwd: Optional[str] = None + + class AutomationData(BaseModel): prompt: str model_id: str rrule: str + terminal: Optional[AutomationTerminalConfig] = None class AutomationModel(BaseModel): diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 06822d563..94d6178ca 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -91,6 +91,15 @@ async def create_new_automation( detail=str(e), ) + # Validate terminal server exists if linked + if form_data.data.terminal and form_data.data.terminal.server_id: + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + if not any(c.get('id') == form_data.data.terminal.server_id for c in connections): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Terminal server not found', + ) + tz = user.timezone automation = Automations.insert( user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db @@ -139,6 +148,15 @@ async def update_automation_by_id( detail=str(e), ) + # Validate terminal server exists if linked + if form_data.data.terminal and form_data.data.terminal.server_id: + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + if not any(c.get('id') == form_data.data.terminal.server_id for c in connections): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Terminal server not found', + ) + tz = user.timezone updated = Automations.update_by_id( id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index ba2d75582..5fa67744e 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -211,6 +211,66 @@ def _resolve_model_filter_ids(app, model_id: str) -> list[str]: return list(filter_ids) if filter_ids else [] +async def _set_terminal_cwd( + app, server_id: str, user, cwd: str, chat_id: str +) -> None: + """Set the working directory on a terminal server via the proxy. + + Routes through the open-webui terminal proxy endpoint so that + auth headers, orchestrator policy routing, and X-User-Id are + handled correctly — same path the frontend uses. + """ + import aiohttp + + connections = getattr( + getattr(app, 'state', None), 'config', None + ) + if connections is None: + return + connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or [] + connection = next((c for c in connections if c.get('id') == server_id), None) + if connection is None: + log.warning(f'Terminal server {server_id} not found for CWD set') + return + + base_url = (connection.get('url') or '').rstrip('/') + if not base_url: + return + + # Build target URL — route through orchestrator policy if configured + policy_id = connection.get('policy_id') + if connection.get('server_type') == 'orchestrator' and policy_id: + target_url = f'{base_url}/p/{policy_id}/files/cwd' + else: + target_url = f'{base_url}/files/cwd' + + headers = {'Content-Type': 'application/json', 'X-User-Id': user.id} + if chat_id: + headers['X-Session-Id'] = chat_id + + auth_type = connection.get('auth_type', 'bearer') + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + + try: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=10) + ) as session: + async with session.post( + target_url, + json={'path': cwd}, + headers=headers, + ) as resp: + if resp.status != 200: + body = await resp.text() + log.warning( + f'Failed to set terminal CWD to {cwd}: ' + f'HTTP {resp.status} — {body[:200]}' + ) + except Exception as e: + log.warning(f'Failed to set terminal CWD: {e}') + + async def execute_automation(app, automation: AutomationModel) -> None: """Execute an automation through the full chat completion pipeline. @@ -226,6 +286,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: prompt = prompt_template(automation.data["prompt"], user) model_id = automation.data["model_id"] + terminal_config = automation.data.get("terminal") # Generate proper UUIDs for messages (same as frontend) user_msg_id = str(uuid4()) @@ -291,6 +352,14 @@ async def execute_automation(app, automation: AutomationModel) -> None: features = _resolve_model_features(app, model_id) filter_ids = _resolve_model_filter_ids(app, model_id) + # If a terminal is linked, set the CWD before building the payload + terminal_id = None + if terminal_config and terminal_config.get("server_id"): + terminal_id = terminal_config["server_id"] + cwd = terminal_config.get("cwd") + if cwd: + await _set_terminal_cwd(app, terminal_id, user, cwd, chat.id) + # Build the same payload the frontend sends to /api/chat/completions form_data = { "model": model_id, @@ -308,6 +377,8 @@ async def execute_automation(app, automation: AutomationModel) -> None: form_data["features"] = features if filter_ids: form_data["filter_ids"] = filter_ids + if terminal_id: + form_data["terminal_id"] = terminal_id # Call the full chat completion pipeline (same as POST /api/chat/completions). # The handler reference is stored on app.state to avoid circular imports. diff --git a/src/lib/apis/automations/index.ts b/src/lib/apis/automations/index.ts index 5974823d6..30aeb85a1 100644 --- a/src/lib/apis/automations/index.ts +++ b/src/lib/apis/automations/index.ts @@ -1,9 +1,15 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; +export type AutomationTerminalConfig = { + server_id: string; + cwd?: string; +}; + export type AutomationData = { prompt: string; model_id: string; rrule: string; + terminal?: AutomationTerminalConfig; }; export type AutomationForm = { diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte index 133e67c7e..606d13271 100644 --- a/src/lib/components/AutomationModal.svelte +++ b/src/lib/components/AutomationModal.svelte @@ -8,7 +8,7 @@ import XMark from '$lib/components/icons/XMark.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import Search from '$lib/components/icons/Search.svelte'; - import Check from '$lib/components/icons/Check.svelte'; + import Cloud from '$lib/components/icons/Cloud.svelte'; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -18,6 +18,7 @@ type AutomationForm, type AutomationResponse } from '$lib/apis/automations'; + import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index'; const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); @@ -42,9 +43,19 @@ let loading = false; let showScheduleDropdown = false; let showModelDropdown = false; + let showTerminalDropdown = false; let modelSearch = ''; let customRrule = ''; + // Terminal state + let terminalServers: TerminalServer[] = []; + let terminalServerId = ''; + let terminalCwd = ''; + + $: terminalLabel = terminalServerId + ? terminalServers.find((s) => s.id === terminalServerId)?.name || 'Terminal' + : $i18n.t('Terminal'); + $: modelLabel = model_id ? $models.find((m) => m.id === model_id)?.name || model_id : $i18n.t('Select model'); @@ -198,7 +209,15 @@ data: { prompt: prompt.trim(), model_id: model_id.trim(), - rrule: buildRrule() + rrule: buildRrule(), + ...(terminalServerId + ? { + terminal: { + server_id: terminalServerId, + ...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {}) + } + } + : {}) }, is_active }; @@ -219,13 +238,22 @@ } }; - const init = () => { + const init = async () => { + // Load terminal servers + try { + terminalServers = await getTerminalServers(localStorage.token); + } catch { + terminalServers = []; + } + if (automation) { name = automation.name; prompt = automation.data.prompt; model_id = automation.data.model_id; is_active = automation.is_active; parseRrule(automation.data.rrule); + terminalServerId = automation.data.terminal?.server_id || ''; + terminalCwd = automation.data.terminal?.cwd || ''; } else { name = ''; prompt = ''; @@ -239,8 +267,11 @@ minute = 0; selectedDays = []; monthDay = 1; + terminalServerId = ''; + terminalCwd = ''; } showScheduleDropdown = false; + showTerminalDropdown = false; }; $: if (show) { @@ -513,6 +544,99 @@ + + + {#if terminalServers.length > 0} + + + +
+
+ {$i18n.t('Terminal')} +
+ + {#each terminalServers as server (server.id)} + + {/each} + + {#if terminalServerId} +
+
+ {$i18n.t('Working Directory')} +
+
+ e.stopPropagation()} + /> +
+
+ {/if} +
+
+ {/if}