feat: experimental open terminal integration

This commit is contained in:
Timothy Jaeryang Baek 2026-02-25 15:15:53 -06:00
parent f0c71e5a6d
commit 636ab99ad8
26 changed files with 1583 additions and 361 deletions

View File

@ -48,10 +48,12 @@ We appreciate the community's interest in identifying potential vulnerabilities.
5. **Remediation is required**:
Along with the PoC, you must provide **either**:
1. **A patch/PR**, **or**
2. **a remediation plan** ("actionable steps") that a maintainer can apply without guesswork.
Your remediation guidance can include, for example:
- The **likely root cause** (what's wrong and where)
- The **location(s)** to change (file/module/function names if known)
- The **recommended fix approach** (validation/sanitization rules, auth checks, safe defaults, etc.)

View File

@ -0,0 +1,114 @@
export type FileEntry = {
name: string;
type: 'file' | 'directory';
size?: number;
modified?: number;
};
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
const json = await res.json().catch(() => null);
return json?.cwd ?? null;
};
export const listFiles = async (
baseUrl: string,
apiKey: string,
path: string = '/'
): Promise<FileEntry[] | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal listFiles error:', err);
return null;
});
return res?.entries ?? null;
};
export const readFile = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch((err) => {
console.error('open-terminal readFile error:', err);
return null;
});
if (!res || !res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
if (contentType.startsWith('image/') || contentType.startsWith('application/octet')) {
// Binary — return a placeholder
return `[Binary file: ${contentType}]`;
}
// Text files: endpoint returns JSON { path, total_lines, content }
// Binary image files: endpoint returns raw bytes (handled above)
const json = await res.json().catch(() => null);
return json?.content ?? null;
};
export const downloadFileBlob = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
const filename = path.split('/').pop() ?? 'file';
if (contentType.includes('application/json')) {
const json = await res.json().catch(() => null);
const blob = new Blob([json?.content ?? ''], { type: 'text/plain' });
return { blob, filename };
}
const blob = await res.blob();
return { blob, filename };
};
export const uploadToTerminal = async (
baseUrl: string,
apiKey: string,
directory: string,
file: File
): Promise<{ path: string; size: number } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
const body = new FormData();
body.append('file', file);
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal uploadToTerminal error:', err);
return null;
});
return res;
};

View File

@ -0,0 +1,297 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let edit = false;
export let connection: {
url: string;
key: string;
name?: string;
path?: string;
enabled: boolean;
} | null = null;
export let onSubmit: (c: {
url: string;
key: string;
name?: string;
path?: string;
enabled: boolean;
}) => void = () => {};
export let onDelete: () => void = () => {};
let url = '';
let key = '';
let name = '';
let auth_type = 'bearer';
let path = '/openapi.json';
let showAdvanced = false;
const init = () => {
if (connection) {
url = connection.url;
key = connection.key;
name = connection.name ?? '';
auth_type = connection.auth_type ?? 'bearer';
path = connection.path ?? '/openapi.json';
} else {
url = '';
key = '';
name = '';
auth_type = 'bearer';
path = '/openapi.json';
}
};
$: if (show) {
init();
}
const submitHandler = () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
// Remove trailing slash
url = url.replace(/\/$/, '');
const result = {
url,
key,
name,
path,
auth_type,
enabled: connection?.enabled ?? false
};
onSubmit(result);
show = false;
};
</script>
<Modal size="sm" bind:show>
<div>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class="text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Terminal Connection')}
{:else}
{$i18n.t('Add Terminal Connection')}
{/if}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="terminal-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-name"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('My Terminal')}
autocomplete="off"
/>
</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">
<label
for="terminal-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder="http://localhost:9900"
required
autocomplete="off"
/>
</div>
</div>
</div>
<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={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? '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('Advanced')}
</button>
{#if showAdvanced}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-1 items-center">
<div class="flex-1 flex items-center">
<label for="openapi-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="openapi-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Auth')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<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 session credentials to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<div></div>
<div class="flex gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</Modal>

View File

@ -542,80 +542,80 @@
</button>
{#if showAdvanced}
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{/if}
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}
{/if}
<div class="flex gap-2 mt-2">

View File

@ -73,10 +73,7 @@
const isPublicModel = (model) => {
return (model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
(g) => g.principal_type === 'user' && g.principal_id === '*' && g.permission === 'read'
);
};

View File

@ -39,6 +39,7 @@
artifactContents,
tools,
toolServers,
terminalServers,
functions,
selectedFolder,
pinnedChats,
@ -140,6 +141,26 @@
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
// Auto-inject terminal servers into selected tool IDs so they act like toggled-on tools
$: if ($terminalServers && $terminalServers.length > 0) {
const terminalIds = $terminalServers.map((_, i) => `direct_server:terminal_${i}`);
const missingIds = terminalIds.filter((id) => !selectedToolIds.includes(id));
if (missingIds.length > 0) {
selectedToolIds = [...selectedToolIds, ...missingIds];
}
}
// Remove disabled terminal servers from selectedToolIds automatically
$: if (selectedToolIds.length > 0) {
const terminalIds = ($terminalServers ?? []).map((_, i) => `direct_server:terminal_${i}`);
const invalidTerminalIds = selectedToolIds.filter(
(id) => id.startsWith('direct_server:terminal_') && !terminalIds.includes(id)
);
if (invalidTerminalIds.length > 0) {
selectedToolIds = selectedToolIds.filter((id) => !invalidTerminalIds.includes(id));
}
}
let showCommands = false;
let generating = false;
@ -319,6 +340,20 @@
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
)
];
} else if (
$settings?.tools &&
$settings.tools.some((id) => !id.startsWith('direct_server:terminal_'))
) {
selectedToolIds = $settings.tools;
} else {
// Don't wipe existing terminal servers if no default tool IDs
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
// Auto-inject terminal servers
if ($terminalServers && $terminalServers.length > 0) {
const terminalIds = $terminalServers.map((_, i) => `direct_server:terminal_${i}`);
selectedToolIds = [...new Set([...selectedToolIds, ...terminalIds])];
}
// Set Default Filters (Toggleable only)
@ -2113,9 +2148,12 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
tool_servers: ($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
tool_servers: [
...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
...($terminalServers ?? [])
],
features: getFeatures(),
variables: {
...getPromptVariables(

View File

@ -1,23 +1,35 @@
<script context="module">
let savedTab = 'controls';
</script>
<script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import { slide } from 'svelte/transition';
import { Pane, PaneResizer } from 'paneforge';
import { v4 as uuidv4 } from 'uuid';
import { onDestroy, onMount, tick } from 'svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
import {
mobile,
showControls,
showCallOverlay,
showOverview,
showArtifacts,
showEmbeds
showEmbeds,
settings
} from '$lib/stores';
import { uploadFile } from '$lib/apis/files';
import { toast } from 'svelte-sonner';
import Controls from './Controls/Controls.svelte';
import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte';
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
import FileNav from './FileNav.svelte';
const i18n = getContext('i18n');
export let history;
export let models = [];
@ -39,9 +51,55 @@
let mediaQuery;
let largeScreen = false;
let dragged = false;
let minSize = 0;
// Tab state for Controls+Files panel
let activeTab: 'controls' | 'files' = savedTab as 'controls' | 'files';
$: savedTab = activeTab;
$: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url;
// Attach a terminal file to the chat input
const handleTerminalAttach = async (blob: Blob, name: string, contentType: string) => {
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
file: '',
id: null,
url: '',
name,
collection_name: '',
status: 'uploading',
error: '',
itemId: tempItemId,
size: blob.size
};
files = [...files, fileItem];
try {
const file = new File([blob], name, { type: contentType || 'application/octet-stream' });
const uploaded = await uploadFile(localStorage.token, file);
if (!uploaded) throw new Error('Upload failed');
const idx = files.findIndex((f) => f.itemId === tempItemId);
if (idx !== -1) {
files[idx] = {
...fileItem,
status: 'uploaded',
file: uploaded,
id: uploaded.id,
url: `${uploaded.id}`,
collection_name: uploaded?.meta?.collection_name
};
files = files;
}
toast.success($i18n.t('File attached to chat'));
} catch (e) {
files = files.filter((f) => f.itemId !== tempItemId);
toast.error($i18n.t('Failed to attach file'));
}
};
export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) {
const container = document.getElementById('chat-container');
@ -57,7 +115,6 @@
const handleMediaQuery = async (e) => {
if (e.matches) {
largeScreen = true;
if ($showCallOverlay) {
showCallOverlay.set(false);
await tick();
@ -65,7 +122,6 @@
}
} else {
largeScreen = false;
if ($showCallOverlay) {
showCallOverlay.set(false);
await tick();
@ -75,36 +131,25 @@
}
};
const onMouseDown = (event) => {
const onMouseDown = () => {
dragged = true;
};
const onMouseUp = (event) => {
const onMouseUp = () => {
dragged = false;
};
onMount(() => {
// listen to resize 1024px
mediaQuery = window.matchMedia('(min-width: 1024px)');
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
// Select the container element you want to observe
const container = document.getElementById('chat-container');
// initialize the minSize based on the container width
minSize = Math.floor((350 / container.clientWidth) * 100);
// Create a new ResizeObserver instance
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
// calculate the percentage of 350px
const percentage = (350 / width) * 100;
// set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage);
minSize = Math.floor((350 / width) * 100);
if ($showControls) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize);
@ -112,15 +157,11 @@
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
if (size < minSize) {
pane.resize(minSize);
}
if (size < minSize) pane.resize(minSize);
}
}
}
});
// Start observing the container's size changes
resizeObserver.observe(container);
document.addEventListener('mousedown', onMouseDown);
@ -129,7 +170,6 @@
onDestroy(() => {
showControls.set(false);
mediaQuery.removeEventListener('change', handleMediaQuery);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mouseup', onMouseUp);
@ -140,33 +180,26 @@
showOverview.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
if ($showCallOverlay) {
showCallOverlay.set(false);
}
if ($showCallOverlay) showCallOverlay.set(false);
};
$: if (!chatId) {
closeHandler();
}
$: if (!chatId) closeHandler();
// Helper: is a "special" full-screen panel active?
$: specialPanel = $showCallOverlay || $showOverview || $showArtifacts || $showEmbeds;
</script>
{#if !largeScreen}
{#if $showControls}
<Drawer
show={$showControls}
onClose={() => {
showControls.set(false);
}}
onClose={() => showControls.set(false)}
className="min-h-[100dvh] !bg-white dark:!bg-gray-850"
>
<div
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
? ' h-screen w-full'
: 'px-4 py-3'} h-full"
>
<div class="h-full flex flex-col">
{#if $showCallOverlay}
<div
class=" h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
class="h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
>
<CallOverlay
bind:files
@ -175,9 +208,7 @@
{modelId}
{chatId}
{eventTarget}
on:close={() => {
showControls.set(false);
}}
on:close={() => showControls.set(false)}
/>
</div>
{:else if $showEmbeds}
@ -192,34 +223,74 @@
const node = e.node;
showMessage(node.data.message, true);
}}
onClose={() => {
showControls.set(false);
}}
onClose={() => showControls.set(false)}
/>
{/await}
{:else}
<Controls
on:close={() => {
showControls.set(false);
}}
{models}
bind:chatFiles
bind:params
/>
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex gap-1">
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{#if hasTerminal}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'files')}
>
{$i18n.t('Files')}
</button>
{/if}
</div>
<button
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
class="flex-1 overflow-y-auto min-h-0 {activeTab === 'controls' ? 'px-3 pt-1' : ''}"
>
{#if activeTab === 'files' && hasTerminal}
<FileNav onAttach={handleTerminalAttach} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{/if}
</div>
</div>
{/if}
</div>
</Drawer>
{/if}
{:else}
<!-- if $showControls -->
{#if $showControls}
<PaneResizer
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</PaneResizer>
{/if}
@ -229,31 +300,25 @@
defaultSize={0}
onResize={(size) => {
if ($showControls && pane.isExpanded()) {
if (size < minSize) {
pane.resize(minSize);
}
if (size < minSize) pane.resize(minSize);
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
// save the size in pixels to localStorage
const container = document.getElementById('chat-container');
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
}
}
}}
onCollapse={() => {
showControls.set(false);
}}
onCollapse={() => showControls.set(false)}
collapsible={true}
class=" z-10 bg-white dark:bg-gray-850"
class="z-10 bg-white dark:bg-gray-850"
>
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div
class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
class="w-full {specialPanel && !$showCallOverlay
? ' '
: 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'bg-white dark:shadow-lg dark:bg-gray-850'} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
@ -265,9 +330,7 @@
{modelId}
{chatId}
{eventTarget}
on:close={() => {
showControls.set(false);
}}
on:close={() => showControls.set(false)}
/>
</div>
{:else if $showEmbeds}
@ -285,23 +348,64 @@
} else {
history.messages[node.data.message.id].favorite = null;
}
showMessage(node.data.message, true);
}}
onClose={() => {
showControls.set(false);
}}
onClose={() => showControls.set(false)}
/>
{/await}
{:else}
<Controls
on:close={() => {
showControls.set(false);
}}
{models}
bind:chatFiles
bind:params
/>
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex gap-1">
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{#if hasTerminal}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'files')}
>
{$i18n.t('Files')}
</button>
{/if}
</div>
<button
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
class="flex-1 overflow-y-auto min-h-0 {activeTab === 'controls' ? 'px-3 pt-1' : ''}"
>
{#if activeTab === 'files' && hasTerminal}
<FileNav onAttach={handleTerminalAttach} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{/if}
</div>
</div>
{/if}
</div>
</div>

View File

@ -13,26 +13,29 @@
export let models = [];
export let chatFiles = [];
export let params = {};
export let embed = false;
let showValves = false;
</script>
<div class=" dark:text-white">
<div class=" flex items-center justify-between dark:text-gray-100 mb-2">
<div class=" text-md self-center font-primary">{$i18n.t('Controls')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close chat controls')}
on:click={() => {
dispatch('close');
}}
>
<XMark className="size-3.5" />
</button>
</div>
{#if !embed}
<div class=" flex items-center justify-between dark:text-gray-100 mb-2">
<div class=" text-md self-center font-primary">{$i18n.t('Controls')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close chat controls')}
on:click={() => {
dispatch('close');
}}
>
<XMark className="size-3.5" />
</button>
</div>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)}
<div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5">
<div class=" dark:text-gray-200 text-sm py-0.5 px-0.5">
{#if chatFiles.length > 0}
<Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full">
<div class="flex flex-col gap-1 mt-1.5" slot="content">

View File

@ -0,0 +1,368 @@
<script context="module">
// Persists across mount/unmount cycles (module-level, not per-instance)
let savedPath = '/';
</script>
<script lang="ts">
import { getContext, onMount, onDestroy, tick, afterUpdate } from 'svelte';
import { settings } from '$lib/stores';
import {
getCwd,
listFiles,
readFile,
downloadFileBlob,
uploadToTerminal,
type FileEntry
} from '$lib/apis/terminal';
import Folder from '../icons/Folder.svelte';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n');
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
let currentPath = savedPath;
let entries: FileEntry[] = [];
let loading = false;
let error: string | null = null;
let selectedFile: string | null = null;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
let filePdfUrl: string | null = null;
let fileLoading = false;
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
let isDragOver = false;
let uploading = false;
let breadcrumbEl: HTMLDivElement;
$: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
$: terminalUrl = activeTerminal?.url ?? '';
$: terminalKey = activeTerminal?.key ?? '';
$: configured = !!terminalUrl;
$: breadcrumbs = currentPath
.split('/')
.filter(Boolean)
.reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
return acc;
},
[{ label: '/', path: '/' }]
);
// Scroll breadcrumb to the end after every DOM update
afterUpdate(() => {
if (breadcrumbEl) breadcrumbEl.scrollLeft = breadcrumbEl.scrollWidth;
});
const clearFilePreview = () => {
fileContent = null;
if (fileImageUrl) {
URL.revokeObjectURL(fileImageUrl);
fileImageUrl = null;
}
if (filePdfUrl) {
URL.revokeObjectURL(filePdfUrl);
filePdfUrl = null;
}
};
const loadDir = async (path: string) => {
if (!configured) return;
loading = true;
error = null;
selectedFile = null;
clearFilePreview();
currentPath = path;
savedPath = path;
const result = await listFiles(terminalUrl, terminalKey, path);
loading = false;
if (result === null) {
error =
'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
entries = [];
} else {
entries = result.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
};
const openEntry = async (entry: FileEntry) => {
if (entry.type === 'directory') {
await loadDir(`${currentPath}${entry.name}/`);
} else {
const filePath = `${currentPath}${entry.name}`;
selectedFile = filePath;
fileLoading = true;
clearFilePreview();
if (isImage(filePath)) {
const result = await downloadFileBlob(terminalUrl, terminalKey, filePath);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminalUrl, terminalKey, filePath);
if (result)
filePdfUrl = URL.createObjectURL(
new Blob([await result.blob.arrayBuffer()], { type: 'application/pdf' })
);
} else {
fileContent = await readFile(terminalUrl, terminalKey, filePath);
}
fileLoading = false;
}
};
const downloadFile = async (path: string) => {
const result = await downloadFileBlob(terminalUrl, terminalKey, path);
if (!result) return;
const url = URL.createObjectURL(result.blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
};
const formatSize = (bytes?: number) => {
if (bytes === undefined) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const handleDragOver = (e: DragEvent) => {
if (selectedFile) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
isDragOver = true;
};
const handleDragLeave = () => {
isDragOver = false;
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
isDragOver = false;
if (selectedFile) return;
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
if (!droppedFiles.length || !configured) return;
uploading = true;
for (const file of droppedFiles) {
await uploadToTerminal(terminalUrl, terminalKey, currentPath, file);
}
uploading = false;
await loadDir(currentPath);
};
onMount(async () => {
if (!configured) return;
// On first ever open, resolve the server's CWD instead of defaulting to /
if (savedPath === '/') {
const cwd = await getCwd(terminalUrl, terminalKey);
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
}
loadDir(savedPath);
});
onDestroy(() => {
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
if (filePdfUrl) URL.revokeObjectURL(filePdfUrl);
});
</script>
{#if !configured}
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center gap-3">
<Folder className="size-10 text-gray-300 dark:text-gray-600" />
<div class="text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No Terminal connection configured.')}
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Add your Open Terminal URL and API key in Settings → Integrations.')}
</div>
</div>
{:else}
<div
class="flex flex-col h-full min-h-0 relative"
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
role="region"
aria-label={$i18n.t('File browser')}
>
{#if isDragOver}
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 dark:bg-gray-850/80 backdrop-blur-sm pointer-events-none gap-1.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-5 text-gray-400 dark:text-gray-500"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg>
<span class="text-xs text-gray-400 dark:text-gray-500">{currentPath}</span>
</div>
{/if}
<!-- Breadcrumb — always visible, scrolls to end -->
<div
bind:this={breadcrumbEl}
class="flex items-center px-2 pb-1.5 shrink-0 overflow-x-auto scrollbar-hidden"
>
{#each breadcrumbs as crumb, i}
{#if i > 1}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span
>
{/if}
<button
class="text-xs shrink-0 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition
{!selectedFile && i === breadcrumbs.length - 1
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}"
on:click={() => loadDir(crumb.path)}
>
{crumb.label}
</button>
{/each}
{#if selectedFile}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
<span
class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300 truncate max-w-[120px]"
>
{selectedFile.split('/').pop()}
</span>
{/if}
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto min-h-0 relative">
{#if selectedFile !== null}
<!-- Floating download button -->
<button
class="absolute top-2 right-2 z-10 p-1.5 rounded-lg bg-white/80 dark:bg-gray-850/80 backdrop-blur-sm shadow-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={() => downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</button>
<!-- File preview -->
{#if fileLoading}
<div class="flex justify-center pt-8"><Spinner className="size-5" /></div>
{:else if fileImageUrl !== null}
<img
src={fileImageUrl}
alt={selectedFile?.split('/').pop()}
class="w-full h-auto object-contain p-3"
/>
{:else if filePdfUrl !== null}
<embed src={filePdfUrl} type="application/pdf" class="w-full h-full min-h-[400px]" />
{:else if fileContent !== null}
<pre
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed p-3">{fileContent}</pre>
{:else}
<div class="text-sm text-gray-400 text-center pt-8">
{$i18n.t('Could not read file.')}
</div>
{/if}
{:else}
<!-- Directory listing -->
{#if uploading}
<div class="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
<Spinner className="size-4" />
{$i18n.t('Uploading...')}
</div>
{:else if loading}
<div class="flex justify-center pt-8"><Spinner className="size-5" /></div>
{:else if error}
<div class="p-4 text-xs text-red-500 dark:text-red-400">{error}</div>
{:else if entries.length === 0}
<div class="p-4 text-xs text-gray-400 text-center">
{$i18n.t('Empty — drop files here to upload')}
</div>
{:else}
<ul>
{#each entries as entry}
<li>
<button
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition text-left"
draggable={entry.type === 'file'}
on:dragstart={(e) => {
if (entry.type !== 'file') return;
e.dataTransfer?.setData(
'application/x-terminal-file',
JSON.stringify({
path: `${currentPath}${entry.name}`,
name: entry.name,
url: terminalUrl,
key: terminalKey
})
);
}}
on:click={() => openEntry(entry)}
>
{#if entry.type === 'directory'}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4 shrink-0 text-gray-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{/if}
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
{entry.name}
</span>
{#if entry.type === 'file' && entry.size !== undefined}
<span class="text-xs text-gray-400 shrink-0">{formatSize(entry.size)}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{/if}

View File

@ -69,6 +69,7 @@
import Tooltip from '../common/Tooltip.svelte';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import Spinner from '../common/Spinner.svelte';
import XMark from '../icons/XMark.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte';
@ -78,7 +79,7 @@
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
import Voice from '../icons/Voice.svelte';
import Terminal from '../icons/Terminal.svelte';
import Cloud from '../icons/Cloud.svelte';
import IntegrationsMenu from './MessageInput/IntegrationsMenu.svelte';
import Component from '../icons/Component.svelte';
import PlusAlt from '../icons/PlusAlt.svelte';
@ -102,6 +103,7 @@
export let autoScroll = false;
export let generating = false;
export let uploadPending = false;
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
@ -1619,10 +1621,12 @@
{/if}
<div class="ml-1 flex gap-1.5">
{#if (selectedToolIds ?? []).length > 0}
{#if (selectedToolIds ?? []).filter((id) => !id.startsWith('direct_server:terminal_')).length > 0}
<Tooltip
content={$i18n.t('{{COUNT}} Available Tools', {
COUNT: selectedToolIds.length
COUNT: (selectedToolIds ?? []).filter(
(id) => !id.startsWith('direct_server:terminal_')
).length
})}
>
<button
@ -1636,7 +1640,9 @@
<Wrench className="size-4" strokeWidth="1.75" />
<span class="text-sm">
{selectedToolIds.length}
{(selectedToolIds ?? []).filter(
(id) => !id.startsWith('direct_server:terminal_')
).length}
</span>
</button>
</Tooltip>
@ -1732,7 +1738,7 @@
? 'm-1'
: 'focus:outline-hidden rounded-full'}"
>
<Terminal className="size-3.5" strokeWidth="2" />
<Cloud className="size-3.5" strokeWidth="2" />
<div class="hidden group-hover:block">
<XMark className="size-4" strokeWidth="1.75" />
@ -1787,6 +1793,24 @@
{/if}
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<!-- Active Terminal Indicator (Always On) -->
{@const activeTerminal = ($settings?.terminalServers ?? []).find(
(s) => s.enabled
)}
{#if activeTerminal}
<div class="flex items-end mr-0.5">
<div
class="flex items-center gap-1.5 px-2.5 py-1 text-sm hover:bg-gray-50 hover:dark:bg-gray-850 transition-all rounded-lg cursor-pointer select-none max-w-[120px] sm:max-w-[200px] truncate"
>
<Cloud className="size-3 shrink-0" strokeWidth="2" />
<span class="truncate"
>{activeTerminal.name ||
activeTerminal.url.replace(/^https?:\/\//, '')}</span
>
</div>
</div>
{/if}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<button
@ -1901,27 +1925,35 @@
</div>
{:else}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<Tooltip
content={uploadPending
? $i18n.t('Waiting for upload...')
: $i18n.t('Send message')}
>
<button
id="send-message-button"
class="{!(prompt === '' && files.length === 0)
class="{!(prompt === '' && files.length === 0) || uploadPending
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === '' && files.length === 0}
disabled={(prompt === '' && files.length === 0) || uploadPending}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
{#if uploadPending}
<Spinner className="size-5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>

View File

@ -4,7 +4,15 @@
import { fly } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores';
import {
config,
user,
tools as _tools,
mobile,
settings,
toolServers,
terminalServers
} from '$lib/stores';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { getTools } from '$lib/apis/tools';
@ -88,7 +96,9 @@
}
}
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools).includes(id));
selectedToolIds = selectedToolIds.filter(
(id) => Object.keys(tools).includes(id) || id.startsWith('direct_server:terminal_')
);
};
</script>

View File

@ -56,9 +56,7 @@
});
const parseTokens = () => {
tokens = marked.lexer(
replaceTokens(processResponseContent(content), model?.name, $user?.name)
);
tokens = marked.lexer(replaceTokens(processResponseContent(content), model?.name, $user?.name));
};
// Throttle parsing to once per animation frame while streaming

View File

@ -63,7 +63,7 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Stream Chat Response')}
</div>
<button
@ -100,7 +100,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Stream Delta Chunk Size')}
</div>
<button
@ -156,7 +156,7 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Function Calling')}
</div>
<button
@ -185,7 +185,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Reasoning Tags')}
</div>
<button
@ -250,7 +250,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Seed')}
</div>
@ -295,7 +295,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Stop Sequence')}
</div>
@ -339,7 +339,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Temperature')}
</div>
<button
@ -394,7 +394,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{$i18n.t('Reasoning Effort')}
</div>
<button
@ -437,7 +437,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'logit_bias'}
</div>
<button
@ -482,7 +482,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'max_tokens'}
</div>
@ -537,7 +537,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'top_k'}
</div>
<button
@ -592,7 +592,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'top_p'}
</div>
@ -648,7 +648,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'min_p'}
</div>
<button
@ -703,7 +703,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'frequency_penalty'}
</div>
@ -759,7 +759,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'presence_penalty'}
</div>
@ -813,7 +813,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'mirostat'}
</div>
<button
@ -868,7 +868,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'mirostat_eta'}
</div>
<button
@ -923,7 +923,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'mirostat_tau'}
</div>
@ -977,7 +977,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'repeat_last_n'}
</div>
@ -1033,7 +1033,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'tfs_z'}
</div>
@ -1089,7 +1089,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'repeat_penalty'}
</div>
@ -1146,7 +1146,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'use_mmap'}
</div>
<button
@ -1186,7 +1186,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'use_mlock'}
</div>
@ -1229,7 +1229,7 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'think'} ({$i18n.t('Ollama')})
</div>
<button
@ -1282,7 +1282,7 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'format'} ({$i18n.t('Ollama')})
</div>
<button
@ -1321,7 +1321,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'num_keep'} ({$i18n.t('Ollama')})
</div>
@ -1374,7 +1374,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'num_ctx'} ({$i18n.t('Ollama')})
</div>
@ -1429,7 +1429,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'num_batch'} ({$i18n.t('Ollama')})
</div>
@ -1485,7 +1485,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'num_thread'} ({$i18n.t('Ollama')})
</div>
@ -1541,7 +1541,7 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'num_gpu'} ({$i18n.t('Ollama')})
</div>
@ -1597,7 +1597,7 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
{'keep_alive'} ({$i18n.t('Ollama')})
</div>
<button
@ -1633,7 +1633,7 @@
{#each Object.keys(params?.custom_params ?? {}) as key}
<div class=" py-0.5 w-full justify-between mb-1">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div class=" self-center text-xs">
<input
type="text"
class=" text-xs w-full bg-transparent outline-none"

View File

@ -0,0 +1,89 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Plus from '$lib/components/icons/Plus.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Connection from './Terminals/Connection.svelte';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
export let servers: {
url: string;
key: string;
name?: string;
path?: string;
enabled: boolean;
}[] = [];
export let onChange: (servers: typeof servers) => void = () => {};
let showAddModal = false;
const addServer = (server: (typeof servers)[0]) => {
servers = [...servers, server];
onChange(servers);
};
const enableServer = (idx: number) => {
servers = servers.map((s, i) => ({ ...s, enabled: i === idx }));
onChange(servers);
};
const updateServer = (idx: number, updated: (typeof servers)[0]) => {
servers = servers.map((s, i) => (i === idx ? updated : s));
onChange(servers);
};
const deleteServer = (idx: number) => {
servers = servers.filter((_, i) => i !== idx);
onChange(servers);
};
</script>
<AddTerminalServerModal bind:show={showAddModal} onSubmit={(server) => addServer(server)} />
<div>
<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={() => (showAddModal = true)}
type="button"
aria-label={$i18n.t('Add Connection')}
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each servers as server, idx}
<Connection
bind:connection={server}
onSubmit={(updated) => updateServer(idx, updated)}
onDelete={() => deleteServer(idx)}
onEnable={() => enableServer(idx)}
/>
{/each}
</div>
{#if servers.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No terminal connections configured.')}
<a
href="https://github.com/open-webui/open-terminal"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-gray-700 dark:hover:text-gray-200"
>
{$i18n.t('Learn more')}
</a>
</div>
{/if}
</div>

View File

@ -0,0 +1,76 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
import Cloud from '$lib/components/icons/Cloud.svelte';
export let connection = { url: '', key: '', name: '', path: '/openapi.json', enabled: false };
export let onSubmit: (c: typeof connection) => void = () => {};
export let onDelete: () => void = () => {};
export let onEnable: () => void = () => {};
let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script>
<AddTerminalServerModal
edit
bind:show={showConfigModal}
{connection}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(c) => {
connection = c;
onSubmit(c);
}}
/>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
showConfigModal = false;
}}
/>
<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 {!connection.enabled ? 'opacity-50' : ''}"
>
<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={() => {
showConfigModal = true;
}}
type="button"
>
<Cog6 />
</button>
</Tooltip>
<Tooltip content={connection.enabled ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch state={connection.enabled} on:change={() => onEnable()} />
</Tooltip>
</div>
</div>

View File

@ -1,24 +1,25 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { getModels as _getModels, getToolServersData } from '$lib/apis';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { getToolServersData } from '$lib/apis';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { models, settings, toolServers, user } from '$lib/stores';
import { settings, toolServers, terminalServers } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
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 Connection from './Tools/Connection.svelte';
import Terminals from './Integrations/Terminals.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let saveSettings: Function;
let servers = null;
let terminalServerConfigs: { url: string; key: string; name?: string; enabled: boolean }[] = [];
let showConnectionModal = false;
const addConnectionHandler = async (server) => {
@ -28,27 +29,44 @@
const updateHandler = async () => {
await saveSettings({
toolServers: servers
toolServers: servers,
terminalServers: terminalServerConfigs
});
let toolServersData = await getToolServersData($settings?.toolServers ?? []);
toolServersData = toolServersData.filter((data) => {
if (data.error) {
toast.error(
$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: data?.url
})
$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { URL: data?.url })
);
return false;
}
return true;
});
toolServers.set(toolServersData);
// Refresh terminal servers store
const activeTerminals = terminalServerConfigs.filter((s) => s.enabled);
if (activeTerminals.length > 0) {
let terminalServersData = await getToolServersData(
activeTerminals.map((t) => ({
url: t.url,
auth_type: t.auth_type ?? 'bearer',
key: t.key ?? '',
path: t.path ?? '/openapi.json',
config: { enable: true }
}))
);
terminalServersData = terminalServersData.filter((data) => data && !data.error);
terminalServers.set(terminalServersData);
} else {
terminalServers.set([]);
}
};
onMount(async () => {
servers = $settings?.toolServers ?? [];
terminalServerConfigs = $settings?.terminalServers ?? [];
});
</script>
@ -61,24 +79,19 @@
updateHandler();
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full">
<div class="overflow-y-scroll scrollbar-hidden h-full">
{#if servers !== null}
<div class="">
<div>
<div class="pr-1.5">
<!-- {$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: 'server?.url'
})} -->
<div class="">
<div class="flex justify-between items-center mb-0.5">
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<Tooltip content={$i18n.t('Add Connection')}>
<button
aria-label={$i18n.t(`Add Connection`)}
aria-label={$i18n.t('Add Connection')}
class="px-1"
on:click={() => {
showConnectionModal = true;
}}
on:click={() => (showConnectionModal = true)}
type="button"
>
<Plus />
@ -91,9 +104,7 @@
<Connection
bind:connection={server}
direct
onSubmit={() => {
updateHandler();
}}
onSubmit={() => updateHandler()}
onDelete={() => {
servers = servers.filter((_, i) => i !== idx);
updateHandler();
@ -105,8 +116,7 @@
<div class="my-1.5">
<div
class={`text-xs
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
<br />
@ -116,14 +126,38 @@
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-300 mb-2">
<div class="text-xs text-gray-600 dark:text-gray-300 mb-2">
<a
class="underline"
href="https://github.com/open-webui/openapi-servers"
target="_blank">{$i18n.t('Learn more about OpenAPI tool servers.')}</a
target="_blank">{$i18n.t('Learn more about OpenAPI tool servers.')}</a
>
</div>
</div>
<hr class="border-gray-100/50 dark:border-gray-850/50 my-4" />
<div class="pr-1.5">
<Terminals bind:servers={terminalServerConfigs} onChange={() => updateHandler()} />
<div class="mt-1.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(
'Connect to Open Terminal instances to browse files and use them as always-on tools. Only one can be active at a time.'
)}
</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>
</div>
{:else}
<div class="flex h-full justify-center">

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { models, config, toolServers, tools } from '$lib/stores';
import { models, config, toolServers, tools, terminalServers } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';

View File

@ -115,7 +115,8 @@
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === "admin"}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
/>
</div>

View File

@ -837,7 +837,8 @@
bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === "admin"}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
$user?.role === 'admin'}
onChange={async () => {
try {
await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []);

View File

@ -332,7 +332,7 @@
accessRoles={preset ? ['read', 'write'] : ['read']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === "admin"}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === 'admin'}
onChange={async () => {
if (edit && model?.id) {
try {

View File

@ -564,51 +564,51 @@
<!-- Users -->
{#if shareUsers}
{#each selectedUsers as user}
<div
class="flex items-center gap-3 justify-between text-sm w-full transition border-b border-gray-50 dark:border-gray-850 pb-2 last:border-0"
>
<div class="flex items-center gap-2 w-full flex-1">
<img
class="rounded-full size-5 object-cover"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt={user.name ?? user.id}
/>
<div class="w-full">
<Tooltip content={user.email} placement="top-start">
<div class="truncate text-sm">{user.name ?? user.id}</div>
</Tooltip>
{#each selectedUsers as user}
<div
class="flex items-center gap-3 justify-between text-sm w-full transition border-b border-gray-50 dark:border-gray-850 pb-2 last:border-0"
>
<div class="flex items-center gap-2 w-full flex-1">
<img
class="rounded-full size-5 object-cover"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt={user.name ?? user.id}
/>
<div class="w-full">
<Tooltip content={user.email} placement="top-start">
<div class="truncate text-sm">{user.name ?? user.id}</div>
</Tooltip>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('user', user.id);
}
}}
>
{#if writeUserIds.includes(user.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('user', user.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('user', user.id);
}
}}
>
{#if writeUserIds.includes(user.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('user', user.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
{/each}
{/each}
{/if}
{#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0}

View File

@ -52,7 +52,12 @@
}}
>
<div class="flex flex-col w-full h-full pb-2">
<MemberSelector bind:userIds bind:groupIds includeGroups={true} includeUsers={shareUsers} />
<MemberSelector
bind:userIds
bind:groupIds
includeGroups={true}
includeUsers={shareUsers}
/>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">

View File

@ -239,64 +239,65 @@
{/if}
{#if includeUsers}
<div class="text-xs text-gray-500 mb-1 mx-1">
{$i18n.t('Users')}
</div>
<div class="text-xs text-gray-500 mb-1 mx-1">
{$i18n.t('Users')}
</div>
<div>
{#each users as user, userIdx (user.id)}
{#if user?.id !== $_user?.id}
<button
class=" dark:border-gray-850 text-xs flex items-center justify-between w-full"
type="button"
on:click={() => {
if ((userIds ?? []).includes(user.id)) {
userIds = userIds.filter((id) => id !== user.id);
delete selectedUsers[user.id];
} else {
userIds = [...userIds, user.id];
selectedUsers[user.id] = user;
}
}}
>
<div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1">
<div class="flex items-center gap-2">
<ProfilePreview {user} side="right" align="center" sideOffset={6}>
<img
class="rounded-2xl w-6 h-6 object-cover flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
<div>
{#each users as user, userIdx (user.id)}
{#if user?.id !== $_user?.id}
<button
class=" dark:border-gray-850 text-xs flex items-center justify-between w-full"
type="button"
on:click={() => {
if ((userIds ?? []).includes(user.id)) {
userIds = userIds.filter((id) => id !== user.id);
delete selectedUsers[user.id];
} else {
userIds = [...userIds, user.id];
selectedUsers[user.id] = user;
}
}}
>
<div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1">
<div class="flex items-center gap-2">
<ProfilePreview {user} side="right" align="center" sideOffset={6}>
<img
class="rounded-2xl w-6 h-6 object-cover flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
/>
</ProfilePreview>
<Tooltip content={user.email} placement="top-start">
<div class="font-medium truncate">{user.name}</div>
</Tooltip>
{#if user?.is_active}
<div>
<span class="relative flex size-1.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span
class="relative inline-flex size-1.5 rounded-full bg-green-500"
></span>
</span>
</div>
{/if}
</div>
</div>
<div class="px-3 py-1">
<div class=" translate-y-0.5">
<Checkbox
state={(userIds ?? []).includes(user.id) ? 'checked' : 'unchecked'}
/>
</ProfilePreview>
<Tooltip content={user.email} placement="top-start">
<div class="font-medium truncate">{user.name}</div>
</Tooltip>
{#if user?.is_active}
<div>
<span class="relative flex size-1.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span class="relative inline-flex size-1.5 rounded-full bg-green-500"
></span>
</span>
</div>
{/if}
</div>
</div>
</div>
<div class="px-3 py-1">
<div class=" translate-y-0.5">
<Checkbox
state={(userIds ?? []).includes(user.id) ? 'checked' : 'unchecked'}
/>
</div>
</div>
</button>
{/if}
{/each}
</div>
</button>
{/if}
{/each}
</div>
{/if}
</div>
</div>

View File

@ -69,6 +69,7 @@ export const skills = writable(null);
export const functions = writable(null);
export const toolServers = writable([]);
export const terminalServers = writable([]);
export const banners: Writable<Banner[]> = writable([]);
@ -90,6 +91,7 @@ export const showEmbeds = writable(false);
export const showOverview = writable(false);
export const showArtifacts = writable(false);
export const showCallOverlay = writable(false);
export const showFileNav = writable(false);
export const artifactCode = writable(null);
export const artifactContents = writable(null);

View File

@ -32,6 +32,7 @@
showChangelog,
temporaryChatEnabled,
toolServers,
terminalServers,
showSearch,
showSidebar
} from '$lib/stores';
@ -128,6 +129,34 @@
return true;
});
toolServers.set(toolServersData);
// Inject enabled terminal servers as always-on tool servers
const enabledTerminals = ($settings?.terminalServers ?? []).filter((s) => s.enabled);
if (enabledTerminals.length > 0) {
let terminalServersData = await getToolServersData(
enabledTerminals.map((t) => ({
url: t.url,
auth_type: t.auth_type ?? 'bearer',
key: t.key ?? '',
path: t.path ?? '/openapi.json',
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;
});
terminalServers.set(terminalServersData);
} else {
terminalServers.set([]);
}
};
const setBanners = async () => {

View File

@ -30,7 +30,8 @@
toolServers,
playingNotificationSound,
channels,
channelId
channelId,
terminalServers
} from '$lib/stores';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@ -283,8 +284,28 @@
};
const executeTool = async (data, cb) => {
const toolServer = $settings?.toolServers?.find((server) => server.url === data.server?.url);
const toolServerData = $toolServers?.find((server) => server.url === data.server?.url);
let toolServer = $settings?.toolServers?.find((server) => server.url === data.server?.url);
let toolServerData = $toolServers?.find((server) => server.url === data.server?.url);
// Also check terminal servers if not found in regular tool servers
if (!toolServer) {
const terminalServer = ($settings?.terminalServers ?? []).find(
(server) => server.url === data.server?.url
);
if (terminalServer) {
toolServer = {
url: terminalServer.url,
auth_type: terminalServer.auth_type ?? 'bearer',
key: terminalServer.key ?? '',
path: terminalServer.path ?? '/openapi.json'
};
}
}
// Check terminal server data if not found in regular tool servers data
if (!toolServerData) {
toolServerData = $terminalServers?.find((server) => server.url === data.server?.url);
}
console.log('executeTool', data, toolServer);