open-webui/src/lib/components/workspace/Models/ModelEditor.svelte
Timothy Jaeryang Baek 810378c0b8
Some checks failed
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled
refac
2026-07-27 19:39:36 -04:00

1060 lines
32 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte';
import { models, tools, functions, user } from '$lib/stores';
import { WEBUI_BASE_URL, DEFAULT_CAPABILITIES } from '$lib/constants';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { getFunctions } from '$lib/apis/functions';
import { getModelsDefaults } from '$lib/apis/configs';
import { getBaseModelTags, getModelTags } from '$lib/apis/models';
import { getVoices } from '$lib/apis/audio';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector/Selector.svelte';
import Tags from '$lib/components/common/Tags.svelte';
import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
import ToolsSelector from '$lib/components/workspace/Models/ToolsSelector.svelte';
import SkillsSelector from '$lib/components/workspace/Models/SkillsSelector.svelte';
import FiltersSelector from '$lib/components/workspace/Models/FiltersSelector.svelte';
import ActionsSelector from '$lib/components/workspace/Models/ActionsSelector.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
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 PromptSuggestions from './PromptSuggestions.svelte';
import TerminalSelector from './TerminalSelector.svelte';
import TTSVoiceInput from './TTSVoiceInput.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
import { extractInputVariables } from '$lib/utils';
const i18n = getContext('i18n');
export let onSubmit: Function;
export let onBack: null | Function = null;
export let model = null;
export let edit = false;
export let preset = true;
let loading = false;
let success = false;
let filesInputElement;
let inputFiles;
let showAdvanced = false;
let showPreview = false;
let showAccessControlModal = false;
let loaded = false;
// ///////////
// model
// ///////////
let id = '';
let name = '';
let enableDescription = true;
$: if (!edit) {
if (name) {
id = name
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.toLowerCase();
}
}
let system = '';
let info = {
id: '',
base_model_id: null,
name: '',
meta: {
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
profile_image_url: `${WEBUI_BASE_URL}/static/favicon.png`,
description: '',
suggestion_prompts: null,
tags: []
},
params: {
system: ''
}
};
let params = {
system: ''
};
let knowledge = [];
let toolIds = [];
let skillIds = [];
let skillsList = [];
let filterIds = [];
let defaultFilterIds = [];
let capabilities = { ...DEFAULT_CAPABILITIES };
let defaultFeatureIds = [];
let builtinTools = {};
let actionIds = [];
let accessGrants = [];
let terminalId = '';
let tts = { voice: '' };
export let suggestionTags: { name: string }[] = [];
let voices: { id: string; name?: string }[] = [];
const chatVariableKeyRegex = /^[a-z][a-z0-9_]*$/;
const getChatVariablesPreview = (prompt: string) => {
const variables = extractInputVariables(prompt);
const warnings: string[] = [];
const seenDefinitions: Record<string, string> = {};
const typedRegex = /{{\s*chat\.variables\.([a-zA-Z0-9_.-]+)\s*\|\s*([^}]*)\s*}}/g;
const typedUserRegex = /{{\s*user\.variables\.([a-zA-Z0-9_.-]+)\s*\|\s*([^}]*)\s*}}/g;
for (const match of prompt.matchAll(typedRegex)) {
const key = match[1];
const definition = match[2].trim();
if (seenDefinitions[key] && seenDefinitions[key] !== definition) {
warnings.push(`${key} has conflicting duplicate definitions`);
}
seenDefinitions[key] = definition;
}
const fields = Object.entries(variables)
.filter(([name]) => name.startsWith('chat.variables.'))
.map(([name, field]) => ({ key: name.replace('chat.variables.', ''), ...(field as any) }));
const userFields = Object.entries(variables)
.filter(([name]) => name.startsWith('user.variables.'))
.map(([name]) => ({ key: name.replace('user.variables.', '') }));
for (const match of prompt.matchAll(typedUserRegex)) {
warnings.push(`${match[1]} uses metadata, but User Variables are configured by each user`);
}
for (const field of fields) {
const key = field.key;
if (!chatVariableKeyRegex.test(key)) {
warnings.push(`${key} must be lowercase snake case`);
continue;
}
if (
field.type === 'select' &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
warnings.push(`${key} select needs options=[...]`);
}
}
for (const field of userFields) {
if (!chatVariableKeyRegex.test(field.key)) {
warnings.push(`${field.key} must be lowercase snake case`);
}
}
return { fields, userFields, warnings };
};
$: chatVariablesPreview = getChatVariablesPreview(system ?? '');
const getBaseModelItems = (models: any[] = []) => {
const currentModelId = (model as any)?.id;
return models
.filter(
(baseModel) =>
(!currentModelId ||
baseModel.id !== currentModelId ||
(edit && baseModel.id === info.base_model_id)) &&
(!baseModel?.preset || (edit && baseModel.id === info.base_model_id)) &&
baseModel?.owned_by !== 'arena' &&
!(baseModel?.direct ?? false) &&
($user?.role === 'admin' ||
!(baseModel?.info?.meta?.hidden ?? false) ||
baseModel.id === info.base_model_id)
)
.map((baseModel) => ({
value: baseModel.id,
label: baseModel.name,
model: baseModel
}));
};
const loadSuggestionTags = async () => {
const res: string[] = await (preset ? getModelTags : getBaseModelTags)(
localStorage.token
).catch(() => []);
suggestionTags = res.map((tag) => ({ name: tag }));
};
const loadVoices = async () => {
const res = await getVoices(localStorage.token).catch(() => null);
voices = res?.voices ?? [];
};
const toModelKnowledgeReference = (item: any) => {
if (!item || typeof item !== 'object') {
return item;
}
return Object.fromEntries(
[
'id',
'name',
'type',
'description',
'context',
'legacy',
'collection_name',
'collection_names'
]
.filter((key) => item[key] !== undefined && item[key] !== null && item[key] !== '')
.map((key) => [key, item[key]])
);
};
const submitHandler = async () => {
loading = true;
info.id = id;
info.name = name;
if (id === '') {
toast.error($i18n.t('Model ID is required.'));
loading = false;
return;
}
if (name === '') {
toast.error($i18n.t('Model Name is required.'));
loading = false;
return;
}
if (preset && !info.base_model_id) {
toast.error($i18n.t('Base Model is required.'));
loading = false;
return;
}
if (knowledge.some((item) => item.status === 'uploading')) {
toast.error($i18n.t('Please wait until all files are uploaded.'));
loading = false;
return;
}
info.params = { ...info.params, ...params };
info.access_grants = accessGrants;
info.meta.capabilities = capabilities;
if (enableDescription) {
info.meta.description = info.meta.description.trim() === '' ? null : info.meta.description;
} else {
info.meta.description = null;
}
if (knowledge.length > 0) {
info.meta.knowledge = knowledge.map(toModelKnowledgeReference);
} else {
if (info.meta.knowledge) {
delete info.meta.knowledge;
}
}
if (toolIds.length > 0) {
info.meta.toolIds = toolIds;
} else {
if (info.meta.toolIds) {
delete info.meta.toolIds;
}
}
if (skillIds.length > 0) {
info.meta.skillIds = skillIds;
} else {
if (info.meta.skillIds) {
delete info.meta.skillIds;
}
}
if (filterIds.length > 0) {
info.meta.filterIds = filterIds;
} else {
if (info.meta.filterIds) {
delete info.meta.filterIds;
}
}
if (defaultFilterIds.length > 0) {
info.meta.defaultFilterIds = defaultFilterIds;
} else {
if (info.meta.defaultFilterIds) {
delete info.meta.defaultFilterIds;
}
}
if (actionIds.length > 0) {
info.meta.actionIds = actionIds;
} else {
if (info.meta.actionIds) {
delete info.meta.actionIds;
}
}
if (defaultFeatureIds.length > 0) {
info.meta.defaultFeatureIds = defaultFeatureIds;
} else {
if (info.meta.defaultFeatureIds) {
delete info.meta.defaultFeatureIds;
}
}
if (Object.keys(builtinTools).length > 0) {
info.meta.builtinTools = builtinTools;
} else {
if (info.meta.builtinTools) {
delete info.meta.builtinTools;
}
}
if (terminalId) {
info.meta.terminalId = terminalId;
} else {
if (info.meta.terminalId) {
delete info.meta.terminalId;
}
}
if (tts.voice !== '') {
if (!info.meta.tts) info.meta.tts = {};
info.meta.tts.voice = tts.voice;
} else {
if (info.meta.tts?.voice) {
delete info.meta.tts.voice;
if (Object.keys(info.meta.tts).length === 0) {
delete info.meta.tts;
}
}
}
info.params.system = system.trim() === '' ? null : system;
info.params.stop = params.stop
? (typeof params.stop === 'string' ? params.stop.split(',') : params.stop).filter((s) =>
s.trim()
)
: null;
Object.keys(info.params).forEach((key) => {
if (info.params[key] === '' || info.params[key] === null) {
delete info.params[key];
}
});
await onSubmit(info);
loading = false;
success = false;
};
onMount(async () => {
await tools.set((await getTools(localStorage.token).catch(() => null)) ?? []);
skillsList = (await getSkills(localStorage.token).catch(() => null)) ?? [];
if (!$functions) {
await functions.set(await getFunctions(localStorage.token));
}
if (suggestionTags.length === 0) {
await loadSuggestionTags();
}
if (voices.length === 0) {
await loadVoices();
}
// Fetch admin-configured default model metadata so the editor
// reflects the actual defaults rather than hardcoded values
const modelsConfig = await getModelsDefaults(localStorage.token).catch(() => null);
const defaultMeta = modelsConfig?.DEFAULT_MODEL_METADATA ?? {};
// Use admin defaults as base, falling back to hardcoded defaults
capabilities = { ...DEFAULT_CAPABILITIES, ...(defaultMeta.capabilities ?? {}) };
defaultFeatureIds = defaultMeta.defaultFeatureIds ?? [];
builtinTools = defaultMeta.builtinTools ?? {};
// Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container');
if (workspaceContainer) {
workspaceContainer.scrollTop = 0;
}
if (model) {
name = model.name;
await tick();
id = model.id;
enableDescription = model?.meta?.description !== null;
if (model.base_model_id) {
const base_model = $models
.filter(
(m) => (!m?.preset && !(m?.arena ?? false)) || (edit && m.id === model.base_model_id)
)
.find((m) => [model.base_model_id, `${model.base_model_id}:latest`].includes(m.id));
console.log('base_model', base_model);
if (base_model) {
model.base_model_id = base_model.id;
} else if (!edit) {
model.base_model_id = null;
}
}
system = model?.params?.system ?? '';
params = { ...params, ...model?.params };
params.stop = params?.stop
? (typeof params.stop === 'string' ? params.stop.split(',') : (params?.stop ?? [])).join(
','
)
: null;
knowledge = (model?.meta?.knowledge ?? []).map((item) => {
if (item?.collection_name && item?.type !== 'file') {
return {
id: item.collection_name,
name: item.name,
legacy: true
};
} else if (item?.collection_names) {
return {
name: item.name,
type: 'collection',
collection_names: item.collection_names,
legacy: true
};
} else {
return item;
}
});
toolIds = model?.meta?.toolIds ?? [];
skillIds = model?.meta?.skillIds ?? [];
filterIds = model?.meta?.filterIds ?? [];
defaultFilterIds = model?.meta?.defaultFilterIds ?? [];
actionIds = model?.meta?.actionIds ?? [];
// Per-model overrides take precedence over admin defaults
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? defaultFeatureIds;
builtinTools = model?.meta?.builtinTools ?? builtinTools;
terminalId = model?.meta?.terminalId ?? '';
tts = { voice: model?.meta?.tts?.voice ?? '' };
accessGrants = model?.access_grants ?? [];
info = {
...info,
...JSON.parse(
JSON.stringify(
model
? model
: {
id: model.id,
name: model.name
}
)
)
};
console.log(model);
}
loaded = true;
});
</script>
{#if loaded}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
accessRoles={preset ? ['read', 'write'] : ['read']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === 'admin'}
/>
<div class="flex h-full min-h-0 w-full flex-col">
{#if onBack}
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
onBack();
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
{/if}
<div class="min-h-0 w-full flex-1 overflow-y-auto pr-1 scrollbar-hover">
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
accept="image/*"
on:change={() => {
let reader = new FileReader();
reader.onload = (event) => {
let originalImageUrl = `${event.target?.result}`;
// For animated formats (gif, webp), skip resizing to preserve animation
const fileType = (inputFiles[0] as any)?.['type'];
if (fileType === 'image/gif' || fileType === 'image/webp') {
info.meta.profile_image_url = originalImageUrl;
inputFiles = null;
filesInputElement.value = '';
return;
}
const img = new Image();
img.src = originalImageUrl;
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate the aspect ratio of the image
const aspectRatio = img.width / img.height;
// Calculate the new width and height to fit within 100x100
let newWidth, newHeight;
if (aspectRatio > 1) {
newWidth = 250 * aspectRatio;
newHeight = 250;
} else {
newWidth = 250;
newHeight = 250 / aspectRatio;
}
// Set the canvas size
canvas.width = 250;
canvas.height = 250;
// Calculate the position to center the image
const offsetX = (250 - newWidth) / 2;
const offsetY = (250 - newHeight) / 2;
// Draw the image on the canvas
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Get the base64 representation of the compressed image
const compressedSrc = canvas.toDataURL('image/webp', 0.8);
// Display the compressed image
info.meta.profile_image_url = compressedSrc;
inputFiles = null;
filesInputElement.value = '';
};
};
if (
inputFiles &&
inputFiles.length > 0 &&
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/svg+xml'].includes(
(inputFiles[0] as any)?.['type']
)
) {
reader.readAsDataURL(inputFiles[0]);
} else {
console.log(`Unsupported File Type '${(inputFiles[0] as any)?.['type']}'.`);
inputFiles = null;
}
}}
/>
{#if !edit || (edit && model)}
<form
class="flex w-full flex-col gap-2.5 md:flex-row"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="w-full px-1">
<div class="flex w-full flex-col gap-3">
<div class="flex w-full min-w-0 items-center gap-3 py-0.5">
<div class="flex min-w-0 flex-1 items-center gap-3">
<!-- LICENSE covers this Open WebUI fallback logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<button
class="group relative flex size-12 shrink-0 items-center overflow-hidden rounded-xl md:size-14 {info
.meta.profile_image_url !== `${WEBUI_BASE_URL}/static/favicon.png`
? 'bg-transparent'
: 'bg-gray-50 dark:bg-gray-850'} ring-1 ring-gray-200/70 transition hover:ring-gray-300 dark:ring-white/10 dark:hover:ring-white/20"
type="button"
aria-label={$i18n.t('Upload profile image')}
on:click={() => {
filesInputElement.click();
}}
>
{#if info.meta.profile_image_url}
<img
src={info.meta.profile_image_url}
alt="model profile"
class="size-full object-cover"
/>
{:else}
<img
src="{WEBUI_BASE_URL}/static/favicon.png"
alt="model profile"
class="size-full object-cover"
/>
{/if}
<div
class="absolute bottom-0 right-0 z-10 opacity-0 transition group-hover:opacity-100"
>
<div class="m-1">
<div
class="rounded-full bg-gray-900 p-1 text-white shadow-sm transition dark:bg-white dark:text-black"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm10.5 5.707a.5.5 0 0 0-.146-.353l-1-1a.5.5 0 0 0-.708 0L9.354 9.646a.5.5 0 0 1-.708 0L6.354 7.354a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0-.146.353V12a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V9.707ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<div
class="absolute inset-0 bg-white opacity-0 transition group-hover:opacity-20 dark:bg-black"
></div>
</button>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<input
class="min-w-0 flex-1 bg-transparent text-base leading-tight text-gray-900 outline-hidden placeholder:text-gray-300 dark:text-white dark:placeholder:text-gray-700 md:text-lg"
placeholder={$i18n.t('Model Name')}
bind:value={name}
required
/>
<AccessButton
on:click={() => {
showAccessControlModal = true;
}}
/>
</div>
<input
class="block w-full bg-transparent py-0.5 text-xs text-gray-500 outline-hidden placeholder:text-gray-300 dark:text-gray-500 dark:placeholder:text-gray-700"
placeholder={$i18n.t('Model ID')}
bind:value={id}
disabled={edit}
required
/>
</div>
</div>
</div>
{#if preset}
<div>
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Base Model (From)')}
</div>
<ModelSelector
id="workspace-base-model"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
searchPlaceholder={$i18n.t('Search a model')}
items={getBaseModelItems($models)}
triggerClassName="text-xs"
selectionOnly
includeHidden={$user?.role === 'admin'}
bind:value={info.base_model_id}
/>
</div>
{/if}
<div>
<div class="mb-1 flex w-full items-center justify-between">
<div class="self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Description')}
</div>
<button
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
type="button"
aria-pressed={enableDescription ? 'true' : 'false'}
aria-label={enableDescription
? $i18n.t('Custom description enabled')
: $i18n.t('Default description enabled')}
on:click={() => {
enableDescription = !enableDescription;
}}
>
{#if !enableDescription}
<span>{$i18n.t('Default')}</span>
{:else}
<span>{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if enableDescription}
<Textarea
className="w-full resize-none overflow-y-hidden bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
placeholder={$i18n.t('Add a short description about what this model does')}
minSize={32}
bind:value={info.meta.description}
/>
{/if}
</div>
<div class="w-full max-w-full">
<Tags
tags={info?.meta?.tags ?? []}
{suggestionTags}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div>
</div>
<section class="mt-2.5">
<div class="mb-2 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Model Params')}
</div>
<div class="space-y-2.5">
<div>
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('System Prompt')}
</div>
<div>
<Textarea
className="min-h-12 w-full resize-none overflow-y-hidden bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
placeholder={$i18n.t(
'Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.'
)}
rows={2}
minSize={48}
bind:value={system}
/>
</div>
{#if chatVariablesPreview.fields.length > 0 || chatVariablesPreview.userFields.length > 0 || chatVariablesPreview.warnings.length > 0}
<div class="mt-2 border-t border-gray-100/60 pt-2 dark:border-gray-850/60">
<div class="mb-1.5 flex items-center justify-between gap-2">
<div class="text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Detected Variables')}
</div>
{#if chatVariablesPreview.fields.length + chatVariablesPreview.userFields.length > 0}
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{chatVariablesPreview.fields.length +
chatVariablesPreview.userFields.length}
</div>
{/if}
</div>
{#if chatVariablesPreview.fields.length > 0}
<div class="mb-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Chat Variables')}
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1.5 text-xs">
{#each chatVariablesPreview.fields as field}
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<span class="font-medium">{field.key}</span>
<span class="text-gray-400 dark:text-gray-600">{field.type}</span>
{#if field.required}
<span class="text-amber-600 dark:text-amber-400">required</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if chatVariablesPreview.userFields.length > 0}
<div class="mb-1 mt-2 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('User Variables')}
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1.5 text-xs">
{#each chatVariablesPreview.userFields as field}
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<span class="font-medium">{field.key}</span>
</div>
{/each}
</div>
{/if}
{#if chatVariablesPreview.warnings.length > 0}
<div
class="mt-2 flex flex-col gap-1 text-xs text-amber-600 dark:text-amber-400"
>
{#each chatVariablesPreview.warnings as warning}
<div>{warning}</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex h-7 w-full justify-between">
<div class="self-center text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Advanced Params')}
</div>
<button
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
showAdvanced = !showAdvanced;
}}
>
{#if showAdvanced}
<span>{$i18n.t('Hide')}</span>
{:else}
<span>{$i18n.t('Show')}</span>
{/if}
</button>
</div>
{#if showAdvanced}
<div class="my-2">
<AdvancedParams admin={true} custom={true} layout="grid" bind:params />
</div>
{/if}
</div>
</section>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<section class="my-2.5">
<div class="flex w-full items-center justify-between">
<div class="self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Prompts')}
</div>
<button
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
}}
>
{#if (info?.meta?.suggestion_prompts ?? null) === null}
<span>{$i18n.t('Default')}</span>
{:else}
<span>{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
{/if}
</section>
<div class="my-3">
<Knowledge bind:selectedItems={knowledge} />
</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/30 dark:border-gray-850/30" />
{#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>
{/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')}
/>
</div>
{/if}
{/if}
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
<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">
<TerminalSelector bind:terminalId />
</div>
{/if}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-normal text-gray-500">
{$i18n.t('TTS Voice')}
</div>
</div>
<TTSVoiceInput
bind:value={tts.voice}
{voices}
placeholder={$i18n.t('e.g. alloy, echo, shimmer')}
/>
</div>
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
<div class="my-2 flex justify-end">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-normal">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
<div class="my-2 text-gray-300 dark:text-gray-700 pb-20">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-normal">{$i18n.t('JSON Preview')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
showPreview = !showPreview;
}}
>
{#if showPreview}
<span class="ml-2 self-center">{$i18n.t('Hide')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div>
{#if showPreview}
<div>
<textarea
class="text-sm w-full bg-transparent outline-hidden resize-none"
rows="10"
value={JSON.stringify(info, null, 2)}
disabled
readonly
/>
</div>
{/if}
</div>
</div>
</form>
{/if}
</div>
</div>
{/if}