feat: open terminal integration

This commit is contained in:
Timothy Jaeryang Baek
2026-02-27 13:08:59 -06:00
parent 7ea6afdf95
commit 4737e1f118
15 changed files with 767 additions and 64 deletions
+57
View File
@@ -172,6 +172,63 @@ export const setToolServerConnections = async (token: string, connections: objec
return res;
};
export const getTerminalServerConnections = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setTerminalServerConnections = async (token: string, connections: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...connections
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const verifyToolServerConnection = async (token: string, connection: object) => {
let error = null;
+20
View File
@@ -5,6 +5,26 @@ export type FileEntry = {
modified?: number;
};
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type TerminalServer = {
id: string;
url: string;
name: string;
};
export const getTerminalServers = async (
token: string
): Promise<TerminalServer[]> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, {
headers: {
Authorization: `Bearer ${token}`
}
}).catch(() => null);
if (!res || !res.ok) return [];
return res.json().catch(() => []);
};
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
@@ -11,6 +11,7 @@
export let show = false;
export let edit = false;
export let admin = false;
export let connection: {
url: string;
key: string;
@@ -236,7 +237,10 @@
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
{#if admin}
<option value="session">{$i18n.t('Session')}</option>
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select>
</div>
@@ -259,6 +263,12 @@
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid';
import { getModels as _getModels } from '$lib/apis';
const dispatch = createEventDispatcher();
@@ -12,16 +13,34 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Cloud from '$lib/components/icons/Cloud.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getToolServerConnections,
setToolServerConnections,
getTerminalServerConnections,
setTerminalServerConnections
} from '$lib/apis/configs';
export let saveSettings: Function;
let servers = null;
let showConnectionModal = false;
// Terminal server admin connections
let terminalConnections: { id: string; url: string; key: string; name: string }[] = [];
let showAddTerminalModal = false;
let editTerminalIdx: number | null = null;
let showDeleteTerminalConfirm = false;
let deleteTerminalIdx: number | null = null;
const addConnectionHandler = async (server) => {
servers = [...servers, server];
await updateHandler();
@@ -32,7 +51,6 @@
TOOL_SERVER_CONNECTIONS: servers
}).catch((err) => {
toast.error($i18n.t('Failed to save connections'));
return null;
});
@@ -41,14 +59,92 @@
}
};
const saveTerminalServers = async () => {
const res = await setTerminalServerConnections(localStorage.token, {
TERMINAL_SERVER_CONNECTIONS: terminalConnections
}).catch((err) => {
toast.error($i18n.t('Failed to save terminal servers'));
return null;
});
if (res) {
toast.success($i18n.t('Terminal servers saved'));
}
};
const addTerminalConnection = (server: { url: string; key: string; name?: string }) => {
terminalConnections = [
...terminalConnections,
{ id: uuidv4(), url: server.url, key: server.key, name: server.name ?? '' }
];
saveTerminalServers();
};
const updateTerminalConnection = (
idx: number,
updated: { url: string; key: string; name?: string }
) => {
terminalConnections = terminalConnections.map((c, i) =>
i === idx ? { ...c, url: updated.url, key: updated.key, name: updated.name ?? '' } : c
);
saveTerminalServers();
};
const removeTerminalConnection = (idx: number) => {
terminalConnections = terminalConnections.filter((_, i) => i !== idx);
saveTerminalServers();
};
onMount(async () => {
const res = await getToolServerConnections(localStorage.token);
servers = res.TOOL_SERVER_CONNECTIONS;
// Load terminal server connections
try {
const terminalRes = await getTerminalServerConnections(localStorage.token);
if (terminalRes?.TERMINAL_SERVER_CONNECTIONS) {
terminalConnections = terminalRes.TERMINAL_SERVER_CONNECTIONS;
}
} catch {
// Not configured yet
}
});
</script>
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<AddTerminalServerModal
admin
bind:show={showAddTerminalModal}
edit={editTerminalIdx !== null}
connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null}
onSubmit={(c) => {
if (editTerminalIdx !== null) {
updateTerminalConnection(editTerminalIdx, c);
editTerminalIdx = null;
} else {
addTerminalConnection(c);
}
}}
onDelete={() => {
if (editTerminalIdx !== null) {
deleteTerminalIdx = editTerminalIdx;
showDeleteTerminalConfirm = true;
editTerminalIdx = null;
}
}}
/>
<ConfirmDialog
bind:show={showDeleteTerminalConfirm}
on:confirm={() => {
if (deleteTerminalIdx !== null) {
removeTerminalConnection(deleteTerminalIdx);
deleteTerminalIdx = null;
}
}}
/>
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
@@ -64,9 +160,6 @@
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5 flex flex-col w-full justify-between">
<!-- {$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: 'server?.url'
})} -->
<div class="flex justify-between items-center mb-0.5">
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
@@ -98,9 +191,98 @@
{/each}
</div>
<div class="my-1.5">
{#if servers.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No tool server connections configured.')}
</div>
{/if}
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
</div>
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
<div class="mb-2.5 flex flex-col w-full">
<div class="flex justify-between items-center mb-1">
<div class="flex items-center gap-2">
<div class="font-medium">{$i18n.t('Open Terminal')}</div>
<span
class="text-[0.65rem] font-medium uppercase px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
>{$i18n.t('Experimental')}</span
>
</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button
class="px-1"
on:click={() => {
editTerminalIdx = null;
showAddTerminalModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each terminalConnections as connection, idx}
<div class="flex w-full gap-2 items-center">
<Tooltip className="w-full relative" content={''} placement="top-start">
<div class="flex w-full">
<div class="flex-1 relative flex gap-1.5 items-center">
<Tooltip content={$i18n.t('Terminal')}>
<Cloud className="size-4" strokeWidth="1.5" />
</Tooltip>
<div class="capitalize outline-hidden w-full bg-transparent text-sm">
{connection.name || connection.url || $i18n.t('New Terminal')}
</div>
</div>
</div>
</Tooltip>
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Configure')}>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
editTerminalIdx = idx;
showAddTerminalModal = true;
}}
type="button"
>
<Cog6 />
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
{#if terminalConnections.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No terminal connections configured.')}
</div>
{/if}
<div class="mt-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
{$i18n.t(
'Connect to Open Terminal instances. All users will have access to file browsing and terminal tools through these servers.'
)}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
<a
class="underline"
href="https://github.com/open-webui/open-terminal"
target="_blank">{$i18n.t('Learn more about Open Terminal')} ↗</a
>
</div>
</div>
</div>
+8 -18
View File
@@ -45,7 +45,9 @@
showEmbeds
} from '$lib/stores';
import { getCwd } from '$lib/apis/terminal';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
convertMessagesToHistory,
copyToClipboard,
@@ -2128,21 +2130,9 @@
});
}
// Build terminal servers with current CWD injected into run_command descriptions
const terminalServersWithCwd = await (async () => {
const terminals = JSON.parse(JSON.stringify($terminalServers ?? []));
const configs = ($settings?.terminalServers ?? []).filter((s) => s.enabled);
await Promise.all(
configs.map(async (t) => {
const cwd = await getCwd(t.url, t.key ?? '').catch(() => null);
if (!cwd) return;
const server = terminals.find((s) => s.url === t.url);
const spec = server?.specs?.find((s) => s.name === 'run_command');
if (spec) spec.description += `\n\nThe current working directory is: ${cwd}`;
})
);
return terminals;
})();
// Determine the active terminal (first accessible backend terminal, or user-configured)
const activeTerminal = $terminalServers?.[0] ?? null;
const activeTerminalId = activeTerminal?.id ?? null;
const res = await generateOpenAIChatCompletion(
localStorage.token,
@@ -2166,11 +2156,11 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: activeTerminalId ?? undefined,
tool_servers: [
...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
...terminalServersWithCwd
)
],
features: getFeatures(),
variables: {
+3 -1
View File
@@ -10,6 +10,7 @@
import { onDestroy, onMount, tick, getContext } from 'svelte';
import {
terminalServers,
mobile,
showControls,
showCallOverlay,
@@ -58,7 +59,8 @@
// Tab state for Controls+Files panel
let activeTab: 'controls' | 'files' | 'overview' = savedTab;
$: savedTab = activeTab;
$: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url;
$: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url
|| $terminalServers.length > 0;
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
$: if (!hasMessages && activeTab === 'overview') activeTab = 'controls';
+6 -4
View File
@@ -6,7 +6,8 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import { settings, showFileNavPath } from '$lib/stores';
import { terminalServers, settings, showFileNavPath } from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
getCwd,
listFiles,
@@ -58,9 +59,10 @@
let showDeleteConfirm = false;
let shiftKey = false;
$: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
$: terminalUrl = activeTerminal?.url ?? '';
$: terminalKey = activeTerminal?.key ?? '';
$: firstTerminal = $terminalServers?.[0] ?? null;
$: activeUserTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
$: terminalUrl = firstTerminal?.url ?? activeUserTerminal?.url ?? '';
$: terminalKey = firstTerminal?.key ?? activeUserTerminal?.key ?? '';
$: configured = !!terminalUrl;
$: breadcrumbs = currentPath
+31 -12
View File
@@ -12,9 +12,10 @@
import { getModels, getToolServersData, getVersionUpdates } from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { getBanners } from '$lib/apis/configs';
import { getTerminalServers } from '$lib/apis/terminal';
import { getUserSettings } from '$lib/apis/users';
import { WEBUI_VERSION } from '$lib/constants';
import { WEBUI_VERSION, WEBUI_API_BASE_URL } from '$lib/constants';
import { compareVersion } from '$lib/utils';
import {
@@ -144,22 +145,40 @@
config: { enable: true }
}))
);
terminalServersData = terminalServersData.filter((data) => {
if (!data || data.error) {
toast.error(
$i18n.t(`Failed to connect to {{URL}} terminal server`, {
URL: data?.url
})
);
return false;
}
return true;
});
terminalServersData = terminalServersData
.filter((data) => {
if (!data || data.error) {
toast.error(
$i18n.t(`Failed to connect to {{URL}} terminal server`, {
URL: data?.url
})
);
return false;
}
return true;
})
.map((data, i) => ({
...data,
key: enabledTerminals[i]?.key ?? ''
}));
terminalServers.set(terminalServersData);
} else {
terminalServers.set([]);
}
// Fetch terminal servers the user has access to (for FileNav + terminal_id)
const backendTerminals = await getTerminalServers(localStorage.token);
if (backendTerminals.length > 0) {
// Store with proxy URL and session key for FileNav file browsing
const terminalEntries = backendTerminals.map((t) => ({
id: t.id,
url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`,
name: t.name,
key: localStorage.token
}));
terminalServers.update((existing) => [...existing, ...terminalEntries]);
}
};
const setBanners = async () => {