mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-11 03:38:02 +00:00
refac
This commit is contained in:
parent
0401b7522f
commit
8d0ff76f2b
26 changed files with 972 additions and 1142 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [])}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 = [])}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 = [])}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = [])}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue