This commit is contained in:
Timothy Jaeryang Baek 2026-10-10 14:03:26 +04:00
parent 0401b7522f
commit 8d0ff76f2b
26 changed files with 972 additions and 1142 deletions

View file

@ -34,10 +34,11 @@ from open_webui.utils.skill_files import (
MAX_IMPORT_BYTES,
file_bytes,
file_summaries,
load_skill_from_url,
parse_import,
zip_export,
)
from pydantic import BaseModel
from pydantic import BaseModel, HttpUrl
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@ -794,6 +795,23 @@ async def require_import(user, db):
raise HTTPException(403, 'Import permission required')
class LoadUrlForm(BaseModel):
url: HttpUrl
@router.post('/load/url')
async def load_skill_by_url(
form_data: LoadUrlForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await require_import(user, db)
try:
return await load_skill_from_url(str(form_data.url))
except (ValueError, UnicodeError, zipfile.BadZipFile) as error:
raise HTTPException(400, str(error))
@router.post('/import/preview')
async def preview_skill_import(
files: list[UploadFile] = File(...), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)

View file

@ -1,5 +1,6 @@
"""Portable skill snapshots. Paths are virtual; packages are never extracted to disk."""
import asyncio
import base64
import io
import json
@ -8,7 +9,9 @@ import stat
import zipfile
from pathlib import PurePosixPath
from typing import Literal
from urllib.parse import quote, unquote, urljoin, urlsplit
import aiohttp
import yaml
from pydantic import BaseModel, ConfigDict
@ -168,7 +171,9 @@ def file_summaries(files: list[dict]) -> list[dict]:
return [{'path': f['path'], 'size': len(file_bytes(f)), 'encoding': f.get('encoding')} for f in files]
def parse_import(data: bytes, filename: str) -> list[dict]:
def parse_import(
data: bytes, filename: str, *, discover_skills: bool = False, directory: str | None = None
) -> list[dict]:
if len(data) > MAX_IMPORT_BYTES:
raise ValueError('Import exceeds 200 MiB')
if filename.lower().endswith('.json'):
@ -185,6 +190,10 @@ def parse_import(data: bytes, filename: str) -> list[dict]:
raise ValueError('Archives cannot contain links or special files')
if info.is_dir():
continue
if directory is not None:
path = path.partition('/')[2]
if not path.startswith(directory):
continue
if path in entries:
raise ValueError(f'Duplicate archive path: {path}')
total += info.file_size
@ -197,7 +206,7 @@ def parse_import(data: bytes, filename: str) -> list[dict]:
if not roots:
raise ValueError('Archive contains no SKILL.md')
roots = [root for root in roots if not any(root != parent and root.startswith(parent) for parent in roots)]
if any(not any(p.startswith(root) for root in roots) for p in entries):
if not discover_skills and any(not any(p.startswith(root) for root in roots) for p in entries):
raise ValueError('Archive contains files outside skill directories')
packages = [
{'files': [encode_file(p[len(root) :], value) for p, value in entries.items() if p.startswith(root)]}
@ -249,3 +258,72 @@ def zip_export(packages: list[dict]) -> bytes:
for file in package['files']:
archive.writestr(root + '/' + validate_path(file['path']), file_bytes(file))
return output.getvalue()
def skill_import_source(url: str) -> tuple[str, str | None]:
parsed = urlsplit(url.strip())
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('Use an HTTP(S) URL without embedded credentials')
if parsed.hostname.lower() != 'github.com':
return url.strip(), None
parts = parsed.path.strip('/').split('/')
if len(parts) < 2 or any(not re.fullmatch(r'[\w.-]+', part) or part in ('.', '..') for part in parts[:2]):
raise ValueError('Invalid GitHub repository URL')
owner, repo = parts[:2]
repo = repo.removesuffix('.git')
if not repo:
raise ValueError('Invalid GitHub repository URL')
ref, directory = 'HEAD', ''
if len(parts) > 2:
if parts[2] in ('archive', 'releases', 'raw'):
return url.strip(), None
if len(parts) < 4 or parts[2] not in ('tree', 'blob'):
raise ValueError('Use a GitHub repository, folder, or SKILL.md URL')
# shortcut: slash-containing refs must be URL-encoded; use a commit permalink otherwise.
ref = validate_path(unquote(parts[3]))
path = '/'.join(unquote(part) for part in parts[4:])
if parts[2] == 'blob':
if not path or path.split('/')[-1] != 'SKILL.md':
raise ValueError('Select a SKILL.md file or a skill folder')
path = path.removesuffix('SKILL.md').rstrip('/')
directory = validate_path(path) + '/' if path else ''
return f'https://codeload.github.com/{owner}/{repo}/zip/{quote(ref, safe="")}', directory
async def load_skill_from_url(url: str) -> list[dict]:
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
url, directory = skill_import_source(url)
try:
async with asyncio.timeout(60), get_ssrf_safe_session(trust_env=False, store_cookies=False) as session:
for _ in range(6):
target = urlsplit(url)
if target.username or target.password:
raise ValueError('Use a URL without embedded credentials')
await asyncio.to_thread(validate_url, url)
async with session.get(url, allow_redirects=False) as response:
if response.status in (301, 302, 303, 307, 308):
url = urljoin(url, response.headers.get('Location', ''))
continue
if response.status != 200:
raise ValueError('Could not download the skill. Check that the URL is accessible.')
content_type = response.content_type
if content_type == 'text/html':
raise ValueError('The URL returned a web page. Use a raw skill file or a ZIP download link.')
data = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
data.extend(chunk)
if len(data) > MAX_IMPORT_BYTES:
raise ValueError('Import exceeds 200 MiB')
filename = {
'application/json': 'skills.json',
'text/markdown': 'SKILL.md',
}.get(content_type, unquote(urlsplit(url).path))
if data.startswith(b'PK'):
filename = 'skills.zip'
return await asyncio.to_thread(
parse_import, bytes(data), filename, discover_skills=True, directory=directory
)
raise ValueError('Too many redirects while downloading the skill')
except (TimeoutError, aiohttp.ClientError) as error:
raise ValueError('Could not download the skill. Please try again.') from error

View file

@ -416,6 +416,13 @@ export const importSkillBundles = async (token: string, files: File[], decisions
await skillRequest(token, decisions ? '/import' : '/import/preview', { method: 'POST', body })
).json();
};
export const loadSkillByUrl = async (token: string, url: string) =>
(
await skillRequest(token, '/load/url', {
method: 'POST',
body: JSON.stringify({ url })
})
).json();
export const skillError = (error: unknown): string =>
error instanceof Error
? error.message

View file

@ -15,6 +15,16 @@
export let loadUrlHandler: Function = () => {};
export let successMessage: string = '';
export let transformResult = (res) => {
const func = { ...res, id: res.id || nameToId(res.name) };
const frontmatter = extractFrontmatter(res.content);
if (frontmatter?.title) func.name = frontmatter.title;
func.meta = {
...(func.meta ?? {}),
description: frontmatter?.description ?? func.name
};
return func;
};
let loading = false;
let url = '';
@ -41,23 +51,10 @@
toast.success(successMessage);
let func = res;
func.id = func.id || nameToId(func.name);
const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted
if (frontmatter?.title) {
func.name = frontmatter.title;
}
func.meta = {
...(func.meta ?? {}),
description: frontmatter?.description ?? func.name
};
onImport(func);
onImport(transformResult(res));
show = false;
}
loading = false;
};
</script>

View file

@ -13,8 +13,6 @@
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedPromptSuggestions from '$lib/components/workspace/Models/LocalizedPromptSuggestions.svelte';
@ -43,9 +41,6 @@
([_, value]) => value !== null && value !== '' && value !== undefined
);
$: enabledCapabilities = Object.entries(defaultCapabilities ?? {}).filter(([_, value]) => value);
$: availableFeatures = enabledCapabilities
.filter(([key]) => ['web_search', 'code_interpreter', 'image_generation'].includes(key))
.map(([key]) => key);
$: translatedPromptLocales = Object.entries(promptSuggestionsI18n ?? {})
.filter(([_, value]) => Array.isArray(value?.suggestion_prompts))
.map(([locale]) => locale);
@ -204,19 +199,11 @@
on:click={updateDirty}
on:change={updateDirty}
>
<Capabilities bind:capabilities={defaultCapabilities} />
{#if availableFeatures.length > 0}
<div class="mt-4">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{/if}
{#if defaultCapabilities.builtin_tools}
<div class="mt-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
<Capabilities
bind:capabilities={defaultCapabilities}
bind:defaultFeatureIds
bind:builtinTools
/>
</div>
{/if}
</div>

View file

@ -151,7 +151,7 @@
value = { ...value, '': '' };
}}
>
<Plus className={compact ? 'size-3' : 'size-4'} /><span
<Plus className="size-3" strokeWidth="1.5" /><span
>{compact ? $i18n.t('Add parameter') : $i18n.t('Add Custom Parameter')}</span
>
</button>

View file

@ -12,6 +12,9 @@
/** Alignment: 'start' | 'end' */
export let align = 'start';
/** Optional positioning anchor within a larger clickable trigger. */
export let anchorElement: HTMLElement | null = null;
/** Close when clicking outside */
export let closeOnOutsideClick = true;
@ -114,7 +117,7 @@
function positionContentDefault() {
if (!triggerEl || !contentEl) return;
const rect = triggerEl.getBoundingClientRect();
const rect = (anchorElement ?? triggerEl).getBoundingClientRect();
resolvedMaxHeight = maxHeight;
contentEl.style.position = 'fixed';
@ -163,7 +166,7 @@
function positionContentVisualViewport() {
if (!triggerEl || !contentEl) return;
const rect = triggerEl.getBoundingClientRect();
const rect = (anchorElement ?? triggerEl).getBoundingClientRect();
const viewport = visualViewportRect();
const viewportRight = viewport.left + viewport.width;
const viewportBottom = viewport.top + viewport.height;

View file

@ -1,10 +1,7 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { user } from '$lib/stores';
import TypeaheadSelector from './TypeaheadSelector.svelte';
type Action = {
id: string;
name?: string;
@ -13,99 +10,30 @@
description?: string;
};
};
const i18n = getContext('i18n') as any;
const i18n = getContext<any>('i18n');
export let actions: Action[] = [];
export let selectedActionIds: string[] = [];
$: selectableActions = actions.filter((action) => !action.is_global);
$: selectedActions = actions.filter(
(action) => action.is_global || selectedActionIds.includes(action.id)
);
const toggleAction = (action: Action) => {
selectedActionIds = selectedActionIds.includes(action.id)
? selectedActionIds.filter((id) => id !== action.id)
: [...selectedActionIds, action.id];
};
export let disabled = false;
</script>
{#if actions.length > 0}
<div>
<div class="flex w-full items-center gap-2 mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Actions')}
</div>
{#if selectableActions.length > 0}
<TypeaheadSelector
id="model-actions-selector"
items={selectableActions.map((action) => ({
...action,
description: action.meta?.description
}))}
selectedIds={selectedActionIds}
placeholder={$i18n.t('Search actions')}
triggerLabel={$i18n.t('Select Action')}
emptyLabel={$i18n.t('No actions found')}
variant="dropdown"
on:select={(e) => {
toggleAction(e.detail);
}}
on:enableall={(e) => {
selectedActionIds = [
...new Set([...selectedActionIds, ...e.detail.map((action) => action.id)])
];
}}
/>
{/if}
</div>
<div class="flex flex-col">
<div class=" flex items-center flex-wrap mt-1">
{#each selectedActions as action, actionIdx (action.id)}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={resolveLocalizedResource(action, $i18n.language)}
state="checked"
disabled={action.is_global}
on:change={(e) => {
if (!action.is_global && e.detail === 'unchecked') {
selectedActionIds = selectedActionIds.filter((id) => id !== action.id);
}
}}
/>
</div>
<div class="py-0.5 text-xs capitalize font-normal text-gray-900 dark:text-gray-100">
<Tooltip
content={resolveLocalizedResource(action, $i18n.language, 'description') ||
action.id}
>
{resolveLocalizedResource(action, $i18n.language)}
</Tooltip>
</div>
</div>
{/each}
{#if selectedActionIds.length > 0}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedActionIds = [];
}}
>
{$i18n.t('Disable all')}
</button>
{/if}
</div>
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To select actions here, add them to the "Functions" workspace first.')}
</div>
</div>
{/if}
<TypeaheadSelector
id="model-actionsselector"
label={$i18n.t('Actions')}
items={actions}
selectedIds={selectedActionIds}
{disabled}
placeholder={$i18n.t('Search actions')}
emptyLabel={$i18n.t('No actions found')}
emptyHint={$i18n.t('Add actions in the Functions workspace.')}
workspaceHref={$user?.role === 'admin' ? '/workspace/functions' : ''}
variant="dropdown"
on:select={(e) => {
selectedActionIds = selectedActionIds.includes(e.detail.id)
? selectedActionIds.filter((id) => id !== e.detail.id)
: [...selectedActionIds, e.detail.id];
}}
on:enableall={(e) => {
selectedActionIds = [...new Set([...selectedActionIds, ...e.detail.map((item) => item.id)])];
}}
on:clear={() => (selectedActionIds = [])}
/>

View file

@ -2,9 +2,8 @@
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
import ModelSettingToggle from './ModelSettingToggle.svelte';
import ModelSettingsSection from './ModelSettingsSection.svelte';
const i18n: Writable<i18nType> = getContext('i18n');
@ -83,6 +82,8 @@
$: allTools = Object.keys(toolLabels ?? {}) as Array<keyof typeof toolLabels>;
export let builtinTools: Record<string, boolean> = {};
export let disabled = false;
$: enabledTools = allTools.filter((key) => builtinTools[key] !== false);
const setBuiltinTool = (tool: keyof typeof toolLabels, checked: boolean) => {
if (checked) {
@ -94,34 +95,27 @@
};
</script>
<div>
<div class="mb-1.5 text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('settings.admin.models.builtinTools.title')}
</div>
<div class="grid grid-cols-1 gap-x-5 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{#each allTools as tool}
<div class="flex min-h-6 items-center gap-2.5">
<Checkbox
ariaLabel={$i18n.t(toolLabels[tool].label)}
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
on:change={(e) => {
setBuiltinTool(tool, e.detail === 'checked');
}}
/>
<button
type="button"
class="min-w-0 cursor-pointer text-left text-xs font-normal text-gray-900 dark:text-gray-100"
on:click={() => setBuiltinTool(tool, builtinTools[tool] === false)}
>
<Tooltip
as="span"
className="block min-w-0"
content={marked.parse(toolLabels[tool].description)}
>
<span class="block truncate">{$i18n.t(toolLabels[tool].label)}</span>
</Tooltip>
</button>
</div>
<ModelSettingsSection label={$i18n.t('settings.admin.models.builtinTools.title')}>
<span slot="summary" class="flex min-w-0 items-center gap-1 text-gray-900 dark:text-gray-100">
<span class="min-w-0 [overflow-wrap:anywhere]"
>{enabledTools
.slice(0, 3)
.map((key) => toolLabels[key].label)
.join(', ') || $i18n.t('None')}</span
>
{#if enabledTools.length > 3}<span class="shrink-0 text-gray-500"
>+{enabledTools.length - 3}</span
>{/if}
</span>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each allTools as key}
<ModelSettingToggle
label={toolLabels[key].label}
description={toolLabels[key].description}
checked={builtinTools[key] !== false}
{disabled}
on:change={(e) => setBuiltinTool(key, e.detail)}
/>
{/each}
</div>
</div>
</ModelSettingsSection>

View file

@ -2,9 +2,9 @@
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
import ModelSettingsSection from './ModelSettingsSection.svelte';
import ModelSettingToggle from './ModelSettingToggle.svelte';
import BuiltinTools from './BuiltinTools.svelte';
const i18n: Writable<i18nType> = getContext('i18n');
@ -64,48 +64,78 @@
export let capabilities: Partial<Record<Capability, boolean>> = {};
const setCapability = (capability: Capability, checked: boolean) => {
capabilities[capability] = checked;
capabilities = capabilities;
export let defaultFeatureIds: string[] = [];
export let builtinTools: Record<string, boolean> = {};
export let disabled = false;
const featureKeys: Capability[] = ['web_search', 'image_generation', 'code_interpreter'];
$: visibleCapabilities = (Object.keys(capabilityLabels) as Capability[]).filter(
(key) => key !== 'file_context' || capabilities.file_upload
);
$: enabledCapabilityIds = visibleCapabilities.filter((key) => capabilities[key]);
$: availableFeatures = featureKeys.filter((key) => capabilities[key]);
$: enabledDefaultIds = availableFeatures.filter((key) => defaultFeatureIds.includes(key));
const setDefault = (key: string, enabled: boolean) => {
defaultFeatureIds = enabled
? [...new Set([...defaultFeatureIds, key])]
: defaultFeatureIds.filter((id) => id !== key);
};
// Hide file_context when file_upload is disabled
$: visibleCapabilities = (Object.keys(capabilityLabels) as Capability[]).filter((cap) => {
if (cap === 'file_context' && !capabilities.file_upload) {
return false;
}
return true;
});
</script>
<div>
<div class="mb-1.5 text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('settings.admin.models.capabilities.title')}
</div>
<div class="grid grid-cols-1 gap-x-5 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{#each visibleCapabilities as capability}
<div class="flex min-h-6 items-center gap-2.5">
<Checkbox
ariaLabel={$i18n.t(capabilityLabels[capability].label)}
state={capabilities[capability] ? 'checked' : 'unchecked'}
on:change={(e) => {
setCapability(capability, e.detail === 'checked');
}}
<div class="space-y-0.5">
<ModelSettingsSection label={$i18n.t('settings.admin.models.capabilities.title')}>
<span slot="summary" class="flex min-w-0 items-center gap-1 text-gray-900 dark:text-gray-100">
<span class="min-w-0 [overflow-wrap:anywhere]"
>{enabledCapabilityIds
.slice(0, 3)
.map((key) => capabilityLabels[key].label)
.join(', ') || $i18n.t('None')}</span
>
{#if enabledCapabilityIds.length > 3}<span class="shrink-0 text-gray-500"
>+{enabledCapabilityIds.length - 3}</span
>{/if}
</span>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each visibleCapabilities as key}
<ModelSettingToggle
label={capabilityLabels[key].label}
description={capabilityLabels[key].description}
bind:checked={capabilities[key]}
{disabled}
/>
<button
type="button"
class="min-w-0 cursor-pointer text-left text-xs font-normal text-gray-900 dark:text-gray-100"
on:click={() => setCapability(capability, !capabilities[capability])}
{/each}
</div>
</ModelSettingsSection>
{#if availableFeatures.length}
<ModelSettingsSection label={$i18n.t('settings.admin.models.defaultFeatures.title')}>
<span slot="summary" class="flex min-w-0 items-center gap-1 text-gray-900 dark:text-gray-100">
<span class="min-w-0 [overflow-wrap:anywhere]"
>{enabledDefaultIds
.slice(0, 3)
.map((key) => capabilityLabels[key].label)
.join(', ') || $i18n.t('None')}</span
>
<Tooltip
as="span"
className="block min-w-0"
content={marked.parse(capabilityLabels[capability].description)}
>
<span class="block truncate">{$i18n.t(capabilityLabels[capability].label)}</span>
</Tooltip>
</button>
{#if enabledDefaultIds.length > 3}<span class="shrink-0 text-gray-500"
>+{enabledDefaultIds.length - 3}</span
>{/if}
</span>
<p class="mb-1 px-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Start enabled in new chats')}
</p>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each availableFeatures as key}
<ModelSettingToggle
label={capabilityLabels[key].label}
checked={defaultFeatureIds.includes(key)}
{disabled}
on:change={(e) => setDefault(key, e.detail)}
/>
{/each}
</div>
{/each}
</div>
</ModelSettingsSection>
{/if}
{#if capabilities.builtin_tools}
<BuiltinTools bind:builtinTools {disabled} />
{/if}
</div>

View file

@ -1,75 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
const i18n: Writable<i18nType> = getContext('i18n');
let featureLabels;
$: featureLabels = {
web_search: {
label: $i18n.t('settings.admin.models.defaultFeatures.webSearch.label'),
description: $i18n.t('settings.admin.models.defaultFeatures.webSearch.description')
},
image_generation: {
label: $i18n.t('settings.admin.models.defaultFeatures.imageGeneration.label'),
description: $i18n.t('settings.admin.models.defaultFeatures.imageGeneration.description')
},
code_interpreter: {
label: $i18n.t('settings.admin.models.defaultFeatures.codeInterpreter.label'),
description: $i18n.t('settings.admin.models.defaultFeatures.codeInterpreter.description')
}
};
type Feature = keyof typeof featureLabels;
export let availableFeatures: string[] = ['web_search', 'image_generation', 'code_interpreter'];
export let featureIds: string[] = [];
const getFeatureLabel = (feature: string) => featureLabels[feature as Feature];
const setFeature = (feature: string, checked: boolean) => {
if (checked) {
if (!featureIds.includes(feature)) {
featureIds = [...featureIds, feature];
}
} else {
featureIds = featureIds.filter((id) => id !== feature);
}
};
</script>
<div>
<div class="mb-1.5 text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('settings.admin.models.defaultFeatures.title')}
</div>
<div class="grid grid-cols-1 gap-x-5 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{#each availableFeatures as feature}
<div class="flex min-h-6 items-center gap-2.5">
<Checkbox
ariaLabel={$i18n.t(getFeatureLabel(feature).label)}
state={featureIds.includes(feature) ? 'checked' : 'unchecked'}
on:change={(e) => {
setFeature(feature, e.detail === 'checked');
}}
/>
<button
type="button"
class="min-w-0 cursor-pointer text-left text-xs font-normal text-gray-900 dark:text-gray-100"
on:click={() => setFeature(feature, !featureIds.includes(feature))}
>
<Tooltip
as="span"
className="block min-w-0"
content={marked.parse(getFeatureLabel(feature).description)}
>
<span class="block truncate">{$i18n.t(getFeatureLabel(feature).label)}</span>
</Tooltip>
</button>
</div>
{/each}
</div>
</div>

View file

@ -1,107 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import TypeaheadSelector from './TypeaheadSelector.svelte';
type Filter = {
id: string;
name?: string;
meta?: {
description?: string;
};
};
const i18n = getContext('i18n') as any;
export let filters: Filter[] = [];
export let selectedFilterIds: string[] = [];
$: selectedFilters = filters.filter((filter) => selectedFilterIds.includes(filter.id));
const toggleFilter = (filter: Filter) => {
selectedFilterIds = selectedFilterIds.includes(filter.id)
? selectedFilterIds.filter((id) => id !== filter.id)
: [...selectedFilterIds, filter.id];
};
</script>
<div>
<div class="flex w-full items-center gap-2 mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Default Filters')}
</div>
{#if filters.length > 0}
<TypeaheadSelector
id="model-default-filters-selector"
items={filters.map((filter) => ({
...filter,
description: filter.meta?.description
}))}
selectedIds={selectedFilterIds}
placeholder={$i18n.t('Search filters')}
triggerLabel={$i18n.t('Select Filter')}
emptyLabel={$i18n.t('No filters found')}
variant="dropdown"
on:select={(e) => {
toggleFilter(e.detail);
}}
on:enableall={(e) => {
selectedFilterIds = [
...new Set([...selectedFilterIds, ...e.detail.map((filter) => filter.id)])
];
}}
/>
{/if}
</div>
<div class="flex flex-col">
{#if filters.length > 0}
<div class=" flex items-center flex-wrap mt-1">
{#each selectedFilters as filter (filter.id)}
{@const isSelected = selectedFilterIds.includes(filter.id)}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={filter.name}
state={isSelected ? 'checked' : 'unchecked'}
on:change={(e) => {
if (e.detail === 'checked') {
if (!selectedFilterIds.includes(filter.id)) {
selectedFilterIds = [...selectedFilterIds, filter.id];
}
} else {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
}
}}
/>
</div>
<div class="py-0.5 text-xs capitalize font-normal text-gray-900 dark:text-gray-100">
<Tooltip content={filter.meta?.description ?? filter.id}>
{filter.name}
</Tooltip>
</div>
</div>
{/each}
{#if selectedFilters.length > 0}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedFilterIds = [];
}}
>
{$i18n.t('Disable all')}
</button>
{/if}
</div>
{/if}
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To select default filters here, enable toggleable filters for this model first.')}
</div>
</div>

View file

@ -1,119 +1,42 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { user } from '$lib/stores';
import TypeaheadSelector from './TypeaheadSelector.svelte';
type Filter = {
id: string;
name?: string;
is_global?: boolean;
meta?: {
description?: string;
toggle?: boolean;
};
};
const i18n = getContext('i18n') as any;
const i18n = getContext<any>('i18n');
export let filters: Filter[] = [];
export let selectedFilterIds: string[] = [];
$: selectableFilters = filters.filter((filter) => !filter.is_global);
$: selectedFilters = filters.filter(
(filter) => filter.is_global || selectedFilterIds.includes(filter.id)
);
const toggleFilter = (filter: Filter) => {
selectedFilterIds = selectedFilterIds.includes(filter.id)
? selectedFilterIds.filter((id) => id !== filter.id)
: [...selectedFilterIds, filter.id];
};
export let defaultFilterIds: string[] = [];
export let disabled = false;
</script>
{#if filters.length > 0}
<div>
<div class="flex w-full items-center gap-2 mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Filters')}
</div>
{#if selectableFilters.length > 0}
<TypeaheadSelector
id="model-filters-selector"
items={selectableFilters.map((filter) => ({
...filter,
description: filter.meta?.description
}))}
selectedIds={selectedFilterIds}
placeholder={$i18n.t('Search filters')}
triggerLabel={$i18n.t('Select Filter')}
emptyLabel={$i18n.t('No filters found')}
variant="dropdown"
on:select={(e) => {
toggleFilter(e.detail);
}}
on:enableall={(e) => {
selectedFilterIds = [
...new Set([...selectedFilterIds, ...e.detail.map((filter) => filter.id)])
];
}}
/>
{/if}
</div>
<!-- TODO: Filter order matters -->
<div class="flex flex-col">
<div class=" flex items-center flex-wrap mt-1">
{#each selectedFilters as filter (filter.id)}
{@const isSelected = filter.is_global || selectedFilterIds.includes(filter.id)}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={resolveLocalizedResource(filter, $i18n.language)}
state={isSelected ? 'checked' : 'unchecked'}
disabled={filter.is_global}
on:change={(e) => {
if (filter.is_global) return;
if (e.detail === 'checked') {
if (!selectedFilterIds.includes(filter.id)) {
selectedFilterIds = [...selectedFilterIds, filter.id];
}
} else {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
}
}}
/>
</div>
<div class="py-0.5 text-xs capitalize font-normal text-gray-900 dark:text-gray-100">
<Tooltip
content={resolveLocalizedResource(filter, $i18n.language, 'description') ||
filter.id}
>
{resolveLocalizedResource(filter, $i18n.language)}
</Tooltip>
</div>
</div>
{/each}
{#if selectedFilterIds.length > 0}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedFilterIds = [];
}}
>
{$i18n.t('Disable all')}
</button>
{/if}
</div>
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To select filters here, add them to the "Functions" workspace first.')}
</div>
</div>
{/if}
<TypeaheadSelector
id="model-filtersselector"
label={$i18n.t('Filters')}
items={filters}
selectedIds={selectedFilterIds}
bind:defaultIds={defaultFilterIds}
{disabled}
placeholder={$i18n.t('Search filters')}
emptyLabel={$i18n.t('No filters found')}
emptyHint={$i18n.t('Add filters in the Functions workspace.')}
workspaceHref={$user?.role === 'admin' ? '/workspace/functions' : ''}
variant="dropdown"
on:select={(e) => {
selectedFilterIds = selectedFilterIds.includes(e.detail.id)
? selectedFilterIds.filter((id) => id !== e.detail.id)
: [...selectedFilterIds, e.detail.id];
}}
on:enableall={(e) => {
selectedFilterIds = [...new Set([...selectedFilterIds, ...e.detail.map((item) => item.id)])];
}}
on:clear={() => (selectedFilterIds = [])}
/>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { config, knowledge, settings, user } from '$lib/stores';
import { config, settings, user } from '$lib/stores';
import KnowledgeSelector from './Knowledge/KnowledgeSelector.svelte';
import FileItemModal from '$lib/components/common/FileItemModal.svelte';
@ -13,14 +13,17 @@
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { uploadFile } from '$lib/apis/files';
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
export let selectedItems = [];
export let disabled = false;
let showPicker = false;
let picker: KnowledgeSelector;
let valueElement: HTMLElement | null = null;
const i18n = getContext('i18n');
let loaded = false;
@ -36,6 +39,7 @@
}
const uploadFileHandler = async (file, fullContext: boolean = false) => {
if (disabled) return null;
if ($user?.role !== 'admin' && !($user?.permissions?.chat?.file_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload files.'));
return null;
@ -169,133 +173,149 @@
}}
/>
<div>
<div class="mb-2">
<div class="flex w-full items-center gap-2 mb-1">
<div class="min-w-0">
<slot name="label">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Knowledge')}
</div>
</slot>
</div>
{#if loaded}
<KnowledgeSelector
bind:this={picker}
anchorElement={valueElement}
bind:show={showPicker}
{selectedItems}
{disabled}
on:select={(e) => {
if (
!disabled &&
!selectedItems.find((item) => item.id === e.detail.id && item.type === e.detail.type)
) {
selectedItems = [...selectedItems, e.detail];
}
}}
>
<button
type="button"
{disabled}
aria-expanded={showPicker}
class="grid w-full grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1.5 text-left text-xs font-normal disabled:cursor-default sm:grid-cols-[8rem_minmax(0,1fr)_auto]"
>
<span class="text-gray-600 dark:text-gray-400"
><slot name="label">{$i18n.t('Knowledge')}</slot></span
>
<span
bind:this={valueElement}
class="flex min-w-0 items-center gap-1 text-gray-900 dark:text-gray-100"
>
<span class="min-w-0 [overflow-wrap:anywhere]"
>{selectedItems
.slice(0, 3)
.map((item) => item.name || item.id)
.join(', ') || $i18n.t('None')}</span
>
{#if selectedItems.length > 3}<span class="shrink-0 text-gray-500"
>+{selectedItems.length - 3}</span
>{/if}
{#if selectedItems.some((item) => item.status === 'uploading')}<Spinner
className="size-3.5 shrink-0"
/>{/if}
</span>
<ChevronRight className="size-3 text-gray-400" />
</button>
<div slot="selected">
<div class="flex flex-col pb-1">
{#if selectedItems?.length > 0}
<div class="flex flex-col">
{#each selectedItems as file, fileIdx}
<Tooltip content={file.description || file.name || file.id}>
<div
class="flex min-h-7 w-full items-center gap-2 rounded-xl px-2 py-1 text-xs font-normal text-gray-900 hover:bg-gray-50 dark:text-gray-100 dark:hover:bg-gray-800"
>
<button
{disabled}
type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-label={$i18n.t('Edit')}
on:click={() => {
selectedItemIdx = fileIdx;
showPicker = false;
showItemModal = true;
}}
>
<div class="shrink-0 text-gray-500 dark:text-gray-400">
{#if file.status === 'uploading'}
<Spinner className="size-3.5" />
{:else if file.type === 'collection'}
<Database className="size-3.5" />
{:else if file.type === 'note'}
<PageEdit className="size-3.5" />
{:else if file.type === 'chat'}
<ChatBubble className="size-3.5" />
{:else if file.type === 'folder'}
<Folder className="size-3.5" />
{:else}
<DocumentPage className="size-3.5" />
{/if}
</div>
{#if loaded}
<div class="flex shrink-0 items-center gap-2">
<div class="min-w-0">
<KnowledgeSelector
on:select={(e) => {
const item = e.detail;
<div class="min-w-0 truncate">
{file.name || file.id}
</div>
</button>
if (!selectedItems.find((k) => k.id === item.id)) {
selectedItems = [
...selectedItems,
{
...item
}
];
}
}}
>
<div
class="flex min-w-0 items-center bg-transparent text-xs text-gray-500 outline-hidden hover:underline dark:text-gray-400"
>
<span class="truncate">{$i18n.t('Select Knowledge')}</span>
</div>
</KnowledgeSelector>
</div>
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
<button
class="self-center bg-transparent text-xs text-gray-500 hover:underline dark:text-gray-400"
type="button"
aria-label={$i18n.t('Upload Files')}
on:click={() => {
filesInputElement.click();
}}
>
{$i18n.t('Upload')}
</button>
{/if}
</div>
{/if}
</div>
</div>
<div class="flex flex-col mb-1">
{#if selectedItems?.length > 0}
<div class=" flex flex-wrap items-center gap-1.5 mb-2.5">
{#each selectedItems as file, fileIdx}
<Tooltip content={file.description || file.name || file.id}>
<div
class="flex max-w-56 items-center gap-1.5 py-0.5 pr-2 text-xs font-normal text-gray-900 dark:text-gray-100"
>
<button
type="button"
class="flex min-w-0 items-center gap-1.5"
aria-label={$i18n.t('Edit')}
on:click={() => {
selectedItemIdx = fileIdx;
showItemModal = true;
}}
>
<div class="shrink-0 text-gray-500 dark:text-gray-400">
{#if file.status === 'uploading'}
<Spinner className="size-3.5" />
{:else if file.type === 'collection'}
<Database className="size-3.5" />
{:else if file.type === 'note'}
<PageEdit className="size-3.5" />
{:else if file.type === 'chat'}
<ChatBubble className="size-3.5" />
{:else if file.type === 'folder'}
<Folder className="size-3.5" />
{:else}
<DocumentPage className="size-3.5" />
<div class="shrink-0 text-gray-400 dark:text-gray-500">
{$i18n.t('Uploading')}
</div>
{/if}
<button
{disabled}
type="button"
class="flex size-4 shrink-0 items-center justify-center text-gray-400 dark:text-gray-500"
aria-label={$i18n.t('Remove File')}
on:click={() => {
selectedItems = selectedItems.filter((_, idx) => idx !== fileIdx);
}}
>
<XMark className="size-3" />
</button>
</div>
</Tooltip>
{/each}
</div>
{/if}
<div class="min-w-0 truncate">
{file.name || file.id}
</div>
</button>
{#if file.status === 'uploading'}
<div class="shrink-0 text-gray-400 dark:text-gray-500">
{$i18n.t('Uploading')}
</div>
{/if}
<button
type="button"
class="flex size-4 shrink-0 items-center justify-center text-gray-400 dark:text-gray-500"
aria-label={$i18n.t('Remove File')}
on:click={() => {
selectedItems = selectedItems.filter((_, idx) => idx !== fileIdx);
}}
>
<XMark className="size-3" />
</button>
</div>
</Tooltip>
{/each}
<!-- {knowledge} -->
</div>
</div>
<div slot="actions" class="flex w-full items-center gap-3">
{#if selectedItems.length}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedItems = [];
}}
{disabled}
class="text-left hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => (selectedItems = [])}>{$i18n.t('Clear')}</button
>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
<button
type="button"
{disabled}
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => filesInputElement.click()}>{$i18n.t('Upload Files')}</button
>
{/if}
<div class="ml-auto flex items-center gap-3">
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.knowledge}
<a
href="/workspace/knowledge"
target="_blank"
rel="noreferrer"
class="hover:text-gray-900 dark:hover:text-gray-100">{$i18n.t('Manage')}</a
>
{/if}
<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => picker.close()}>{$i18n.t('Done')}</button
>
{$i18n.t('Disable all')}
</button>
</div>
{/if}
<!-- {knowledge} -->
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To attach knowledge base here, add them to the "Knowledge" workspace first.')}
</div>
</div>
</div>
</KnowledgeSelector>
{/if}

View file

@ -1,7 +1,5 @@
<script lang="ts">
import dayjs from 'dayjs';
import { onMount, onDestroy, getContext, createEventDispatcher } from 'svelte';
import { onMount, onDestroy, getContext, createEventDispatcher, tick } from 'svelte';
import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
@ -9,10 +7,7 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
@ -21,8 +16,15 @@
export let onClose: Function = () => {};
let show = false;
export let show = false;
export let anchorElement: HTMLElement | null = null;
export let selectedItems = [];
let dropdown: Dropdown;
export const close = () => dropdown.close();
export let disabled = false;
let inputElement: HTMLInputElement | null = null;
$: if (disabled && show) show = false;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
@ -32,7 +34,10 @@
let items = [];
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
$: items = [...noteItems, ...knowledgeItems, ...fileItems].filter(
(item) =>
!selectedItems.some((selected) => selected.id === item.id && selected.type === item.type)
);
const handleSearchInput = () => {
clearTimeout(searchDebounceTimer);
@ -105,9 +110,18 @@
</script>
<Dropdown
bind:this={dropdown}
{anchorElement}
bind:show
on:change={(e) => {
if (e.detail === false) {
onOpenChange={async (state) => {
if (disabled) {
show = false;
return;
}
if (state) {
await tick();
inputElement?.focus();
} else {
onClose();
query = '';
handleSearchInput();
@ -115,101 +129,71 @@
}}
>
<slot />
<div slot="content">
<div
class="z-[10000] text-black dark:text-white rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-64 p-0.5"
>
<div class=" flex w-full space-x-1.5 px-1.5 pb-0.5">
<div class="flex flex-1">
<div class=" self-center mr-1.5">
<Search className="size-3.5" />
</div>
<input
class="w-full text-[0.8125rem] py-0.5 outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
placeholder={$i18n.t('Search')}
/>
<div
slot="content"
class="flex w-96 max-w-[calc(100vw-2rem)] flex-col rounded-xl border border-gray-200 bg-white p-1 text-gray-900 shadow-lg dark:border-gray-800 dark:bg-gray-850 dark:text-gray-100"
>
<div class="flex items-center gap-2 px-2 py-1">
<Search className="size-3.5 text-gray-500" />
<input
bind:this={inputElement}
bind:value={query}
on:input={handleSearchInput}
class="min-w-0 w-full bg-transparent py-0.5 text-xs placeholder:text-gray-500 dark:placeholder:text-gray-400 outline-hidden"
aria-label={$i18n.t('Search knowledge')}
placeholder={$i18n.t('Search knowledge')}
/>
</div>
<div class="max-h-72 overflow-y-auto">
{#if selectedItems.length}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{$i18n.t('Selected')}
</div>
</div>
<div class="max-h-56 overflow-y-scroll gap-0.5 flex flex-col">
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 pt-4 pb-6">
{$i18n.t('No knowledge found')}
</div>
{:else}
{#each items as item, i}
{#if i === 0 || item?.type !== items[i - 1]?.type}
<div class="px-1.5 text-[0.6875rem] text-gray-500 py-0.5">
{#if item?.type === 'note'}
{$i18n.t('Notes')}
{:else if item?.type === 'collection'}
{$i18n.t('Collections')}
{:else if item?.type === 'file'}
{$i18n.t('Files')}
{/if}
</div>
{/if}
<div
class="min-h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[0.8125rem] bg-transparent transition-colors hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 selected-command-option-button"
>
<button
class="w-full flex-1"
type="button"
on:click={() => {
dispatch('select', item);
show = false;
}}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
{#if item.type === 'note'}
<Tooltip
content={$i18n.t('Note')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<PageEdit className="size-3.5" />
</Tooltip>
{:else if item.type === 'collection'}
<Tooltip
content={$i18n.t('Collection')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<Database className="size-3.5" />
</Tooltip>
{:else if item.type === 'file'}
<Tooltip
content={$i18n.t('File')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<DocumentPage className="size-3.5" />
</Tooltip>
{/if}
<Tooltip
content={item.type === 'note'
? dayjs(item.updated_at / 1000000)
.locale($i18n.language)
.fromNow()
: item.description || decodeString(item?.name)}
placement="top-start"
tippyOptions={{ zIndex: 100000 }}
>
<div class="line-clamp-1 flex-1 text-[0.8125rem] text-left">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
<slot name="selected" />
{/if}
{#if items.length === 0 && (query || !selectedItems.length)}
<div class="px-3 py-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge found')}
{#if !query}<p>
{$i18n.t('Upload files or add sources in the Knowledge workspace.')}
</p>{/if}
</div>
{:else}
{#each items as item, i}
{#if i === 0 || item.type !== items[i - 1].type}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{item.type === 'note'
? $i18n.t('Notes')
: item.type === 'collection'
? $i18n.t('Collections')
: $i18n.t('Files')}
</div>
{/each}
{/if}
</div>
{/if}
<button
type="button"
{disabled}
class="flex min-h-7 w-full items-center gap-2 rounded-xl px-2 py-1 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
on:click={() => {
if (!disabled) dispatch('select', item);
inputElement?.focus();
}}
>
<span class="shrink-0 text-gray-500">
{#if item.type === 'note'}<PageEdit
className="size-3.5"
/>{:else if item.type === 'collection'}<Database
className="size-3.5"
/>{:else}<DocumentPage className="size-3.5" />{/if}
</span>
<span class="truncate">{decodeString(item.name)}</span>
</button>
{/each}
{/if}
</div>
<div
class="mt-1 flex items-center justify-between gap-3 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<slot name="actions" />
</div>
</div>
</Dropdown>

View file

@ -37,9 +37,7 @@
import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte';
import BuiltinTools from './BuiltinTools.svelte';
import ModelSettingsSection from './ModelSettingsSection.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedPromptSuggestions from './LocalizedPromptSuggestions.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
@ -964,7 +962,7 @@
<div class="flex w-full min-w-0 items-center gap-3 py-0.5">
<div
class="relative flex min-w-0 flex-1 items-center gap-3 px-4 pb-2 {backgroundPreview ||
class="relative flex min-w-0 flex-1 items-center gap-3.5 px-4 pb-2 {backgroundPreview ||
info.meta.background_image_url
? '-mt-14 sm:-mt-16'
: '-mt-9'}"
@ -1395,8 +1393,6 @@
</section>
<fieldset disabled={readOnly} class="min-w-0">
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />
<section class="my-3">
<div class="flex w-full items-center justify-between">
{#if editingLocale}
@ -1440,130 +1436,155 @@
{/if}
</section>
<div class="my-3">
<Knowledge bind:selectedItems={knowledge} />
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />
<div class="my-3 space-y-0.5">
<Knowledge bind:selectedItems={knowledge} disabled={readOnly} />
<ToolsSelector
bind:selectedToolIds={toolIds}
tools={$tools ?? []}
disabled={readOnly}
/>
<SkillsSelector
bind:selectedSkillIds={skillIds}
skills={skillsList}
disabled={readOnly}
/>
{#if ($functions ?? []).some((func) => func.type === 'filter')}
<FiltersSelector
bind:selectedFilterIds={filterIds}
bind:defaultFilterIds
filters={($functions ?? []).filter((func) => func.type === 'filter')}
disabled={readOnly}
/>
{/if}
{#if ($functions ?? []).some((func) => func.type === 'action')}
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={($functions ?? []).filter((func) => func.type === 'action')}
disabled={readOnly}
/>
{/if}
</div>
<div class="my-3">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools ?? []} />
</div>
<div class="my-3">
<SkillsSelector bind:selectedSkillIds={skillIds} skills={skillsList} />
</div>
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0}
<div class="my-3">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={($functions ?? []).filter((func) => func.type === 'filter')}
/>
</div>
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-3">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
<div class="space-y-0.5">
<Capabilities
bind:capabilities
bind:defaultFeatureIds
bind:builtinTools
disabled={readOnly}
/>
<ModelSettingsSection
label={$i18n.t('Voice')}
summary={[
$config?.audio?.realtime?.enabled
? voice.voice || $i18n.t('Admin default')
: null,
$config?.audio?.realtime?.enabled || voiceAvatar
? voiceAvatar
? $i18n.t('Custom avatar')
: $i18n.t('Default orb')
: null,
tts.voice
]
.filter(Boolean)
.join(' · ') || $i18n.t('Default')}
>
{#if $config?.audio?.realtime?.enabled}
<div
class="grid min-h-8 grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 px-1 text-xs sm:grid-cols-[8rem_minmax(0,1fr)]"
>
<div class="flex min-w-0 items-center">
<label
for="realtime-voice-input"
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400"
>
{$i18n.t('Realtime Voice')}
</label>
</div>
<TTSVoiceInput
className="w-full font-normal text-gray-900 dark:text-gray-100"
id="realtime-voice"
bind:value={voice.voice}
placeholder={$i18n.t('Admin default')}
/>
</div>
{/if}
{/if}
{#if ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<div class="my-3">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={($functions ?? []).filter((func) => func.type === 'action')}
{#if $config?.audio?.realtime?.enabled || voiceAvatar}
<VoiceAvatarSettings
bind:value={voiceAvatar}
bind:file={avatarFile}
bind:animationFiles
disabled={loading || readOnly}
/>
{/if}
<div
class="grid min-h-8 grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 px-1 text-xs sm:grid-cols-[8rem_minmax(0,1fr)]"
>
<div class="flex min-w-0 items-center">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400"
>
{$i18n.t('TTS Voice')}
</div>
</div>
<TTSVoiceInput
className="w-full font-normal text-gray-900 dark:text-gray-100"
bind:value={tts.voice}
{voices}
placeholder={$i18n.t('e.g. alloy, echo, shimmer')}
/>
</div>
{/if}
{/if}
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />
<div class="my-3">
<Capabilities bind:capabilities />
</div>
{#if Object.keys(capabilities).filter((key) => capabilities[key]).length > 0}
{@const availableFeatures = Object.entries(capabilities)
.filter(
([key, value]) =>
value &&
['web_search', 'code_interpreter', 'image_generation'].includes(key)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="my-3">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{/if}
{/if}
{#if capabilities.builtin_tools}
<div class="my-3">
<BuiltinTools bind:builtinTools />
</div>
{/if}
{#if capabilities.terminal}
<div class="my-3">
</ModelSettingsSection>
{#if capabilities.terminal}
<TerminalSelector bind:terminalId />
</div>
{/if}
{/if}
</div>
</fieldset>
{#if $config?.audio?.realtime?.enabled}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<label
for="realtime-voice-input"
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400"
>
{$i18n.t('Realtime Voice')}
</label>
</div>
<TTSVoiceInput
className="font-normal text-gray-900 dark:text-gray-100"
id="realtime-voice"
bind:value={voice.voice}
placeholder={$i18n.t('Admin default')}
<div class="my-2 text-xs text-gray-400 dark:text-gray-500">
<div class="flex w-full items-center gap-2">
<button
type="button"
class="group flex h-7 min-w-0 flex-1 items-center justify-between gap-3 rounded-sm text-start text-xs focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-expanded={showPreview}
on:click={() => (showPreview = !showPreview)}
>
<span class="font-normal text-gray-600 dark:text-gray-400"
>{$i18n.t('JSON Preview')}</span
>
<span
aria-hidden="true"
class="text-gray-500 transition group-hover:text-gray-700 dark:group-hover:text-gray-300"
>{showPreview ? $i18n.t('Hide') : $i18n.t('Show')}</span
>
</button>
<button
class="px-1.5 py-0.5 text-xs flex rounded-sm transition"
type="button"
on:click={async () => {
const copied = await copyToClipboard(
JSON.stringify(readOnly ? model : modelInfo, null, 2)
);
if (copied) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
{$i18n.t('Copy')}
</button>
</div>
{#if showPreview}
<div class="pt-2">
<textarea
class="w-full bg-transparent text-xs leading-5 outline-hidden resize-none font-normal text-gray-900 dark:text-gray-100"
rows="8"
value={JSON.stringify(readOnly ? model : modelInfo, null, 2)}
disabled
readonly
/>
</div>
{/if}
{#if $config?.audio?.realtime?.enabled || voiceAvatar}
<VoiceAvatarSettings
bind:value={voiceAvatar}
bind:file={avatarFile}
bind:animationFiles
disabled={loading}
/>
{/if}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('TTS Voice')}
</div>
</div>
<TTSVoiceInput
className="font-normal text-gray-900 dark:text-gray-100"
bind:value={tts.voice}
{voices}
placeholder={$i18n.t('e.g. alloy, echo, shimmer')}
/>
</div>
</fieldset>
</div>
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />
@ -1599,51 +1620,6 @@
</button>
</div>
{/if}
<div class="my-2 pb-2 text-xs text-gray-400 dark:text-gray-500">
<div class="mb-2 flex w-full items-center gap-2">
<button
type="button"
class="group flex h-7 min-w-0 flex-1 items-center justify-between gap-3 rounded-sm text-start text-xs focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-expanded={showPreview}
on:click={() => (showPreview = !showPreview)}
>
<span class="font-normal text-gray-600 dark:text-gray-400"
>{$i18n.t('JSON Preview')}</span
>
<span
aria-hidden="true"
class="text-gray-500 transition group-hover:text-gray-700 dark:group-hover:text-gray-300"
>{showPreview ? $i18n.t('Hide') : $i18n.t('Show')}</span
>
</button>
<button
class="px-1.5 py-0.5 text-xs flex rounded-sm transition"
type="button"
on:click={async () => {
const copied = await copyToClipboard(
JSON.stringify(readOnly ? model : modelInfo, null, 2)
);
if (copied) {
toast.success($i18n.t('Copied to clipboard'));
}
}}
>
{$i18n.t('Copy')}
</button>
</div>
{#if showPreview}
<div>
<textarea
class="w-full bg-transparent text-xs leading-5 outline-hidden resize-none font-normal text-gray-900 dark:text-gray-100"
rows="8"
value={JSON.stringify(readOnly ? model : modelInfo, null, 2)}
disabled
readonly
/>
</div>
{/if}
</div>
</div>
</div>
</form>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let label = '';
export let description = '';
export let checked = false;
export let disabled = false;
const dispatch = createEventDispatcher<{ change: boolean }>();
const change = (value: boolean) => {
if (disabled) return;
checked = value;
dispatch('change', checked);
};
</script>
<div
class="flex min-h-8 items-center justify-between gap-3 px-1 text-xs font-normal text-gray-900 dark:text-gray-100"
>
<Tooltip content={description} placement="top-start">
<button
type="button"
class="text-left font-normal disabled:cursor-default disabled:opacity-50"
{disabled}
on:click={() => change(!checked)}>{label}</button
>
</Tooltip>
<Switch state={checked} {disabled} ariaLabel={label} on:change={(e) => change(e.detail)} />
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
export let label = '';
export let summary = '';
export let open = false;
</script>
<details bind:open class="group/setting min-w-0">
<summary
class="grid cursor-pointer list-none grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1.5 text-xs font-normal focus-visible:outline focus-visible:outline-2 sm:grid-cols-[8rem_minmax(0,1fr)_auto] [&::-webkit-details-marker]:hidden"
>
<span class="text-gray-600 dark:text-gray-400">{label}</span>
<slot name="summary">
<span class="truncate text-gray-900 dark:text-gray-100" title={summary}>{summary}</span>
</slot>
<ChevronRight
className="size-3 text-gray-400 transition-transform group-open/setting:rotate-90"
/>
</summary>
{#if open}
<div class="pb-2 pl-3 pt-1"><slot /></div>
{/if}
</details>

View file

@ -1,102 +1,39 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import TypeaheadSelector from './TypeaheadSelector.svelte';
import { getContext } from 'svelte';
import { user } from '$lib/stores';
import TypeaheadSelector from './TypeaheadSelector.svelte';
type Skill = {
id: string;
name?: string;
description?: string;
is_active?: boolean;
};
const i18n = getContext<any>('i18n');
export let skills: Skill[] = [];
export let selectedSkillIds: string[] = [];
const i18n = getContext('i18n') as any;
$: activeSkills = skills.filter((skill) => skill.is_active !== false);
$: selectedSkills = activeSkills.filter((skill) => selectedSkillIds.includes(skill.id));
const toggleSkill = (skill: Skill) => {
selectedSkillIds = selectedSkillIds.includes(skill.id)
? selectedSkillIds.filter((id) => id !== skill.id)
: [...selectedSkillIds, skill.id];
};
export let disabled = false;
</script>
<div>
<div class="flex w-full items-center gap-2 mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Skills')}
</div>
{#if activeSkills.length > 0}
<TypeaheadSelector
id="model-skills-selector"
items={activeSkills}
selectedIds={selectedSkillIds}
placeholder={$i18n.t('Search skills')}
triggerLabel={$i18n.t('Select Skill')}
emptyLabel={$i18n.t('No skills found')}
variant="dropdown"
on:select={(e) => {
toggleSkill(e.detail);
}}
on:enableall={(e) => {
selectedSkillIds = [
...new Set([...selectedSkillIds, ...e.detail.map((skill) => skill.id)])
];
}}
/>
{/if}
</div>
<div class="flex flex-col mb-1">
{#if activeSkills.length > 0}
<div class=" flex items-center flex-wrap mt-1">
{#each selectedSkills as skill, skillIdx (skill.id)}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={resolveLocalizedResource(skill, $i18n.language)}
state="checked"
on:change={(e) => {
if (e.detail === 'unchecked') {
selectedSkillIds = selectedSkillIds.filter((id) => id !== skill.id);
}
}}
/>
</div>
<Tooltip
content={resolveLocalizedResource(skill, $i18n.language, 'description') || skill.id}
>
<div class="py-0.5 text-xs capitalize font-normal text-gray-900 dark:text-gray-100">
{resolveLocalizedResource(skill, $i18n.language)}
</div>
</Tooltip>
</div>
{/each}
{#if selectedSkills.length > 0}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedSkillIds = [];
}}
>
{$i18n.t('Disable all')}
</button>
{/if}
</div>
{/if}
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To select skills here, add them to the "Skills" workspace first.')}
</div>
</div>
<TypeaheadSelector
id="model-skillsselector"
label={$i18n.t('Skills')}
items={skills.filter((skill) => skill.is_active !== false)}
selectedIds={selectedSkillIds}
{disabled}
placeholder={$i18n.t('Search skills')}
emptyLabel={$i18n.t('No skills found')}
emptyHint={$i18n.t('Add skills in the Skills workspace.')}
workspaceHref={$user?.role === 'admin' || $user?.permissions?.workspace?.skills
? '/workspace/skills'
: ''}
variant="dropdown"
on:select={(e) => {
selectedSkillIds = selectedSkillIds.includes(e.detail.id)
? selectedSkillIds.filter((id) => id !== e.detail.id)
: [...selectedSkillIds, e.detail.id];
}}
on:enableall={(e) => {
selectedSkillIds = [...new Set([...selectedSkillIds, ...e.detail.map((item) => item.id)])];
}}
on:clear={() => (selectedSkillIds = [])}
/>

View file

@ -2,7 +2,7 @@
import { getContext, onMount } from 'svelte';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
export let terminalId: string = '';
@ -14,19 +14,22 @@
</script>
{#if terminals.length > 0}
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Terminal')}
</div>
</div>
<select
class="block h-5 w-full py-0 text-xs leading-5 bg-transparent outline-hidden cursor-pointer font-normal text-gray-900 dark:text-gray-100"
bind:value={terminalId}
<label
class="flex min-h-8 items-center justify-between gap-3 px-1 text-xs font-normal text-gray-600 dark:text-gray-400"
>
<option value="">{$i18n.t('None')}</option>
{#each terminals as terminal (terminal.id)}
<option value={terminal.id}>{terminal.name || terminal.id}</option>
{/each}
</select>
<span>{$i18n.t('Default terminal')}</span>
<select
class="min-w-0 max-w-[60%] cursor-pointer bg-transparent py-0 text-xs font-normal text-gray-900 outline-hidden dark:text-gray-100"
bind:value={terminalId}
>
<option value="">{$i18n.t('None')}</option>
{#each terminals as terminal (terminal.id)}
<option value={terminal.id}>{terminal.name || terminal.id}</option>
{/each}
</select>
</label>
{:else}
<p class="px-1 py-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('No terminal servers available')}
</p>
{/if}

View file

@ -1,10 +1,7 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import TypeaheadSelector from './TypeaheadSelector.svelte';
import { getContext } from 'svelte';
import { user } from '$lib/stores';
import TypeaheadSelector from './TypeaheadSelector.svelte';
type Tool = {
id: string;
name?: string;
@ -12,91 +9,32 @@
description?: string;
};
};
const i18n = getContext<any>('i18n');
export let tools: Tool[] = [];
export let selectedToolIds: string[] = [];
const i18n = getContext('i18n') as any;
$: selectedTools = tools.filter((tool) => selectedToolIds.includes(tool.id));
const toggleTool = (tool: Tool) => {
selectedToolIds = selectedToolIds.includes(tool.id)
? selectedToolIds.filter((id) => id !== tool.id)
: [...selectedToolIds, tool.id];
};
export let disabled = false;
</script>
<div>
<div class="flex w-full items-center gap-2 mb-1">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Tools')}
</div>
{#if tools.length > 0}
<TypeaheadSelector
id="model-tools-selector"
items={tools}
selectedIds={selectedToolIds}
placeholder={$i18n.t('Search tools')}
triggerLabel={$i18n.t('Select Tool')}
emptyLabel={$i18n.t('No tools found')}
variant="dropdown"
on:select={(e) => {
toggleTool(e.detail);
}}
on:enableall={(e) => {
selectedToolIds = [...new Set([...selectedToolIds, ...e.detail.map((tool) => tool.id)])];
}}
/>
{/if}
</div>
<div class="flex flex-col mb-1">
{#if tools.length > 0}
<div class=" flex items-center flex-wrap mt-1">
{#each selectedTools as tool, toolIdx (tool.id)}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={resolveLocalizedResource(tool, $i18n.language)}
state="checked"
on:change={(e) => {
if (e.detail === 'unchecked') {
selectedToolIds = selectedToolIds.filter((id) => id !== tool.id);
}
}}
/>
</div>
<Tooltip
content={resolveLocalizedResource(tool, $i18n.language, 'description') || tool.id}
>
<div class="py-0.5 text-xs capitalize font-normal text-gray-900 dark:text-gray-100">
{resolveLocalizedResource(tool, $i18n.language)}
</div>
</Tooltip>
</div>
{/each}
{#if selectedTools.length > 0}
<button
type="button"
class="py-0.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
selectedToolIds = [];
}}
>
{$i18n.t('Disable all')}
</button>
{/if}
</div>
{/if}
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t(
'To select toolkits here, add them to the "Tools" workspace or enable a tool server first.'
)}
</div>
</div>
<TypeaheadSelector
id="model-toolsselector"
label={$i18n.t('Tools')}
items={tools}
selectedIds={selectedToolIds}
{disabled}
placeholder={$i18n.t('Search tools')}
emptyLabel={$i18n.t('No tools found')}
emptyHint={$i18n.t('Add tools in the Tools workspace or connect a tool server.')}
workspaceHref={$user?.role === 'admin' || $user?.permissions?.workspace?.tools
? '/workspace/tools'
: ''}
variant="dropdown"
on:select={(e) => {
selectedToolIds = selectedToolIds.includes(e.detail.id)
? selectedToolIds.filter((id) => id !== e.detail.id)
: [...selectedToolIds, e.detail.id];
}}
on:enableall={(e) => {
selectedToolIds = [...new Set([...selectedToolIds, ...e.detail.map((item) => item.id)])];
}}
on:clear={() => (selectedToolIds = [])}
/>

View file

@ -3,14 +3,18 @@
import { createEventDispatcher, getContext, tick } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import TTSVoiceInput from './TTSVoiceInput.svelte';
import ModelSettingToggle from './ModelSettingToggle.svelte';
type Item = {
id: string;
is_global?: boolean;
name?: string;
description?: string;
meta?: {
description?: string;
toggle?: boolean;
};
};
@ -18,9 +22,14 @@
export let placeholder = '';
export let triggerLabel = '';
export let emptyLabel = '';
export let label = '';
export let emptyHint = '';
export let workspaceHref = '';
export let disabled = false;
export let id = 'typeahead-selector';
export let className = 'w-full';
export let selectedIds: string[] | null = null;
export let defaultIds: string[] | null = null;
export let variant: 'inline' | 'dropdown' = 'inline';
const i18n: any = getContext('i18n');
@ -28,11 +37,15 @@
const dispatch = createEventDispatcher<{
select: Item;
enableall: Item[];
clear: void;
}>();
let value = '';
let show = false;
let dropdown: Dropdown;
let inputElement: HTMLInputElement | null = null;
let valueElement: HTMLElement | null = null;
$: if (disabled && show) show = false;
$: query = value.trim().toLowerCase();
$: matchedItems = (items ?? []).filter((item) => {
const id = item.id.toLowerCase();
@ -49,82 +62,129 @@
);
});
$: selectedItems = [
...new Set([
...items.filter((item) => item.is_global).map((item) => item.id),
...(selectedIds ?? [])
])
].map((id) => items.find((item) => item.id === id) ?? { id, name: id });
$: sortedItems = [...matchedItems].sort(
(a, b) =>
Number(b.is_global || selectedIds?.includes(b.id) || false) -
Number(a.is_global || selectedIds?.includes(a.id) || false)
);
$: summary = selectedItems
.slice(0, 3)
.map((item) => resolveLocalizedResource(item, $i18n.language))
.join(', ');
$: if (variant === 'dropdown' && show) {
tick().then(() => inputElement?.focus());
}
const selectItem = (item: Item) => {
if (disabled || item.is_global) return;
dispatch('select', item);
show = false;
value = '';
if (selectedIds === null) {
show = false;
value = '';
} else {
inputElement?.focus();
}
};
const enableItems = () => {
dispatch('enableall', matchedItems);
show = false;
value = '';
if (disabled) return;
dispatch(
'enableall',
matchedItems.filter((item) => !item.is_global)
);
inputElement?.focus();
};
</script>
{#if variant === 'dropdown'}
<Dropdown
bind:this={dropdown}
anchorElement={valueElement}
bind:show
onOpenChange={(state) => {
if (disabled) show = false;
if (!state) {
value = '';
}
}}
>
<div
class="flex min-w-0 items-center bg-transparent text-xs text-gray-500 outline-hidden hover:underline dark:text-gray-400"
<button
type="button"
{disabled}
aria-expanded={show}
aria-label={label || triggerLabel || placeholder}
class={label
? 'grid w-full grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1.5 text-left text-xs font-normal disabled:cursor-default sm:grid-cols-[8rem_minmax(0,1fr)_auto]'
: 'flex min-w-0 items-center bg-transparent text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}
>
<span class="truncate">{triggerLabel || placeholder}</span>
</div>
{#if label}
<span class="text-gray-600 dark:text-gray-400">{label}</span>
<span
bind:this={valueElement}
class="flex min-w-0 items-center gap-1 text-gray-900 dark:text-gray-100"
>
<span class="min-w-0 [overflow-wrap:anywhere]">{summary || $i18n.t('None')}</span>
{#if selectedItems.length > 3}<span class="shrink-0 text-gray-500"
>+{selectedItems.length - 3}</span
>{/if}
</span>
<ChevronRight className="size-3 text-gray-400" />
{:else}<span class="truncate">{triggerLabel || placeholder}</span>{/if}
</button>
<div slot="content">
<div
class="z-[10000] flex w-96 max-w-[calc(100vw-2rem)] flex-col rounded-xl border border-gray-200 bg-white p-0.5 text-black shadow-lg dark:border-gray-800 dark:bg-gray-850 dark:text-white"
class="flex w-96 max-w-[calc(100vw-2rem)] flex-col rounded-xl border border-gray-200 bg-white p-1 text-gray-900 shadow-lg dark:border-gray-800 dark:bg-gray-850 dark:text-gray-100"
>
<div class="flex w-full space-x-1.5 px-1.5 pb-0.5">
<div class="flex flex-1">
<div class="self-center mr-1.5">
<Search className="size-3.5" />
</div>
<input
bind:this={inputElement}
bind:value
id={`${id}-input`}
class="w-full bg-transparent py-0.5 text-[0.8125rem] outline-hidden"
type="text"
{placeholder}
autocomplete="off"
/>
</div>
<div class="flex items-center gap-2 px-2 py-1">
<Search className="size-3.5 text-gray-500" />
<input
bind:this={inputElement}
bind:value
id={`${id}-input`}
aria-label={placeholder}
class="min-w-0 w-full bg-transparent py-0.5 text-xs placeholder:text-gray-500 dark:placeholder:text-gray-400 outline-hidden"
type="text"
{placeholder}
autocomplete="off"
/>
</div>
<div class="flex max-h-56 flex-col gap-0.5 overflow-y-scroll">
{#if selectedIds !== null && matchedItems.length > 0}
<div class="flex max-h-64 flex-col gap-0.5 overflow-y-auto">
{#if selectedIds !== null && matchedItems.some((item) => !item.is_global)}
<button
type="button"
class="h-[1.6875rem] w-full rounded-xl px-2 text-left text-[0.8125rem] text-gray-700 transition-colors hover:bg-gray-50/40 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-800/40 dark:hover:text-gray-100"
class="min-h-7 w-full rounded-xl px-2 py-1 text-left text-xs text-gray-500 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={enableItems}
>
<span class="truncate"
>{$i18n.t('Enable all ({{COUNT}})', { COUNT: matchedItems.length })}</span
>{$i18n.t('Enable all ({{COUNT}})', {
COUNT: matchedItems.filter((item) => !item.is_global).length
})}</span
>
</button>
{/if}
{#if matchedItems.length === 0}
<div class="pt-4 pb-6 text-center text-xs text-gray-500 dark:text-gray-400">
<div class="px-3 py-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
{emptyLabel || placeholder}
{#if items.length === 0 && emptyHint}<p class="mt-1 leading-5">
{emptyHint}
</p>{/if}
</div>
{:else}
{#each matchedItems as item (item.id)}
{#each sortedItems as item (item.id)}
<button
type="button"
class="flex h-[1.6875rem] w-full items-center justify-between gap-2 rounded-xl px-2 text-left text-[0.8125rem] transition-colors hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 selected-command-option-button"
aria-pressed={selectedIds?.includes(item.id) ?? false}
class="flex min-h-7 w-full items-center justify-between gap-2 rounded-xl px-2 py-1 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800 selected-command-option-button"
disabled={disabled || item.is_global}
aria-pressed={item.is_global || (selectedIds?.includes(item.id) ?? false)}
on:click={() => {
selectItem(item);
}}
@ -132,7 +192,10 @@
<span class="min-w-0 flex-1 truncate"
>{resolveLocalizedResource(item, $i18n.language)}</span
>
{#if selectedIds !== null && selectedIds.includes(item.id)}
{#if item.is_global}<span class="text-[0.6875rem] text-gray-500"
>{$i18n.t('Global')}</span
>{/if}
{#if item.is_global || (selectedIds !== null && selectedIds.includes(item.id))}
<svg
class="size-3.5 shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden="true"
@ -150,9 +213,47 @@
</svg>
{/if}
</button>
{#if defaultIds !== null && item.meta?.toggle && (item.is_global || selectedIds?.includes(item.id))}
<div class="pb-1 pl-5 pr-1">
<ModelSettingToggle
label={$i18n.t('Start enabled in new chats')}
checked={defaultIds.includes(item.id)}
{disabled}
on:change={(e) => {
defaultIds = e.detail
? [...new Set([...(defaultIds ?? []), item.id])]
: (defaultIds ?? []).filter((id) => id !== item.id);
}}
/>
</div>
{/if}
{/each}
{/if}
</div>
<div
class="mt-1 flex items-center justify-between gap-3 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{#if selectedIds?.length}<button
type="button"
{disabled}
on:click={() => dispatch('clear')}
class="hover:text-gray-900 dark:hover:text-gray-100">{$i18n.t('Clear')}</button
>{/if}
<div class="ml-auto flex items-center gap-3">
{#if workspaceHref}<a
href={workspaceHref}
target="_blank"
rel="noreferrer"
class="hover:text-gray-900 dark:hover:text-gray-100">{$i18n.t('Manage')}</a
>{/if}
<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => dropdown.close()}>{$i18n.t('Done')}</button
>
</div>
</div>
</div>
</div>
</Dropdown>

View file

@ -31,24 +31,18 @@
};
</script>
<div class="my-3 flex items-center justify-between gap-3">
<div>
<div class="mb-1 text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Voice avatar')}
</div>
<div class="text-xs leading-5 font-normal text-gray-900 dark:text-gray-100">
{value ? $i18n.t('Custom avatar') : $i18n.t('Default orb')}
</div>
</div>
<button
type="button"
{disabled}
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
on:click={open}
<button
type="button"
{disabled}
on:click={open}
class="grid min-h-8 w-full grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1 text-left text-xs font-normal disabled:opacity-50 sm:grid-cols-[8rem_minmax(0,1fr)_auto]"
>
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Voice avatar')}</span>
<span class="truncate text-gray-900 dark:text-gray-100"
>{value ? $i18n.t('Custom avatar') : $i18n.t('Default orb')}</span
>
{$i18n.t('Configure')}
</button>
</div>
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('Configure')}</span>
</button>
<Modal bind:show size="sm">
<div class="p-5 text-gray-900 dark:text-gray-100">

View file

@ -44,8 +44,10 @@
import Switch from '../common/Switch.svelte';
import SkillMenu from './Skills/SkillMenu.svelte';
import SkillImport from './Skills/SkillImport.svelte';
import { cloneSkill, exportSkillBundle, skillError } from '$lib/apis/skills';
import ImportModal from '$lib/components/ImportModal.svelte';
import { cloneSkill, exportSkillBundle, loadSkillByUrl, skillError } from '$lib/apis/skills';
let showImport = false;
let showImportFromLink = false;
let bundleFiles: File[] = [];
let folderImportInput: HTMLInputElement;
import Pagination from '../common/Pagination.svelte';
@ -89,6 +91,14 @@
onClick: () => importInputElement?.click(),
visible: $user?.role === 'admin' || $user?.permissions?.workspace?.skills_import
},
{
id: 'skills-import-url',
label: $i18n.t('Import From Link'),
onClick: () => {
showImportFromLink = true;
},
visible: $user?.role === 'admin' || $user?.permissions?.workspace?.skills_import
},
{
id: 'skills-import-folder',
label: 'Import folder',
@ -296,16 +306,30 @@
</svelte:head>
{#if loaded}
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
<ImportModal
bind:show={showImportFromLink}
loadUrlHandler={(url) => loadSkillByUrl(localStorage.token, url)}
transformResult={(packages) => packages}
successMessage={$i18n.t('Skills loaded for preview')}
onImport={(packages) => {
bundleFiles = [
new File([JSON.stringify(packages)], 'skills.json', { type: 'application/json' })
];
showImport = true;
}}
/>
{#key bundleFiles}
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
{/key}
<input
bind:this={importInputElement}
type="file"

View file

@ -3804,5 +3804,21 @@
"YouTube": "",
"YouTube video player": "",
"Zoom in": "",
"Zoom out": ""
"Zoom out": "",
"Add actions in the Functions workspace.": "Add actions in the Functions workspace.",
"Work": "Work",
"Assistance": "Assistance",
"Search built-in tools": "Search built-in tools",
"{{COUNT}} of {{TOTAL}} enabled": "{{COUNT}} of {{TOTAL}} enabled",
"Start enabled in new chats": "Start enabled in new chats",
"{{COUNT}} disabled": "{{COUNT}} disabled",
"All allowed": "All allowed",
"Add filters in the Functions workspace.": "Add filters in the Functions workspace.",
"Search knowledge": "Search knowledge",
"Upload files or add sources in the Knowledge workspace.": "Upload files or add sources in the Knowledge workspace.",
"Add skills in the Skills workspace.": "Add skills in the Skills workspace.",
"Default terminal": "Default terminal",
"No terminal servers available": "No terminal servers available",
"Add tools in the Tools workspace or connect a tool server.": "Add tools in the Tools workspace or connect a tool server.",
"Used in native function calling mode. Workspace settings and user permissions still apply.": "Used in native function calling mode. Workspace settings and user permissions still apply."
}

View file

@ -5109,10 +5109,10 @@
},
"black": {
"name": "black",
"version": "26.10.0",
"file_name": "black-26.10.0-py3-none-any.whl",
"version": "26.10.1",
"file_name": "black-26.10.1-py3-none-any.whl",
"install_dir": "site",
"sha256": "b532e40012f21a8a0e8c5996ea8f5f8413dc89793361aa9e2f451826d0ebd711",
"sha256": "28842f9a8207cc1df6eb983a35a14c5a0dfcd603d214fe82d84bef552afd2e3a",
"package_type": "package",
"imports": [
"black"