Adding a model ID that was already on the list in the connection settings modal simply appended it again, so the same model could sit in the whitelist any number of times. The arena model modal had the same flaw, its dropdown kept offering models that were already selected. The connection modal now rejects a duplicate with a toast and trims the input first; surrounding whitespace renders invisibly in the list, so an untrimmed ID would slip past the duplicate check and still show up as a visually identical row. The arena modal instead filters already-added models out of the dropdown, matching the existing model selector in the admin settings, so a duplicate can no longer be picked at all. Both modals also drop duplicates when loading a stored list, so configs that already contain them are cleaned on their next save. Until such a config is re-saved, one residual effect of old data remains: a duplicated ID in an arena model's stored list keeps double weight in the random model draw. New duplicates can no longer be created through the UI. Fixes #28249
798 lines
22 KiB
Svelte
798 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { toast } from 'svelte-sonner';
|
|
import { getContext, onMount } from 'svelte';
|
|
const i18n = getContext('i18n');
|
|
|
|
import { verifyOpenAIConnection } from '$lib/apis/openai';
|
|
import { verifyOllamaConnection } from '$lib/apis/ollama';
|
|
|
|
import Modal from '$lib/components/common/Modal.svelte';
|
|
import Plus from '$lib/components/icons/Plus.svelte';
|
|
import Minus from '$lib/components/icons/Minus.svelte';
|
|
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
|
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
import Switch from '$lib/components/common/Switch.svelte';
|
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
import Tags from './common/Tags.svelte';
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import XMark from '$lib/components/icons/XMark.svelte';
|
|
import Textarea from './common/Textarea.svelte';
|
|
|
|
export let onSubmit: Function = () => {};
|
|
export let onDelete: Function = () => {};
|
|
|
|
export let show = false;
|
|
export let edit = false;
|
|
|
|
export let ollama = false;
|
|
export let direct = false;
|
|
|
|
export let connection = null;
|
|
|
|
let url = '';
|
|
let key = '';
|
|
let auth_type = 'bearer';
|
|
|
|
let connectionType = 'external';
|
|
let provider = '';
|
|
$: azure =
|
|
provider === 'azure' ||
|
|
((url.includes('azure.') || url.includes('cognitive.microsoft.com')) &&
|
|
!direct &&
|
|
provider === '' &&
|
|
!/\/openai\/v1(\/|$)/.test(url));
|
|
|
|
let prefixId = '';
|
|
let enable = true;
|
|
let apiVersion = '';
|
|
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
|
|
|
|
let headers = '';
|
|
let passthroughParams = '';
|
|
|
|
let tags = [];
|
|
|
|
let modelId = '';
|
|
let modelIds = [];
|
|
|
|
let loading = false;
|
|
let showDeleteConfirmDialog = false;
|
|
let showAdvanced = false;
|
|
|
|
const inputClass =
|
|
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
|
const selectClass =
|
|
'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
|
|
|
const parsePassthroughParams = (value: string) =>
|
|
value
|
|
.split(',')
|
|
.map((param) => param.trim())
|
|
.filter(Boolean);
|
|
|
|
const verifyOllamaHandler = async () => {
|
|
// remove trailing slash from url
|
|
url = url.replace(/\/$/, '');
|
|
|
|
const res = await verifyOllamaConnection(localStorage.token, {
|
|
url,
|
|
key
|
|
}).catch((error) => {
|
|
toast.error(`${error}`);
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Server connection verified'));
|
|
}
|
|
};
|
|
|
|
const verifyOpenAIHandler = async () => {
|
|
// remove trailing slash from url
|
|
url = url.replace(/\/$/, '');
|
|
|
|
let _headers = null;
|
|
|
|
if (headers) {
|
|
try {
|
|
_headers = JSON.parse(headers);
|
|
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
|
|
_headers = null;
|
|
throw new Error('Headers must be a valid JSON object');
|
|
}
|
|
headers = JSON.stringify(_headers, null, 2);
|
|
} catch (error) {
|
|
toast.error($i18n.t('Headers must be a valid JSON object'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const res = await verifyOpenAIConnection(
|
|
localStorage.token,
|
|
{
|
|
url,
|
|
key,
|
|
config: {
|
|
auth_type,
|
|
...(provider ? { provider } : {}),
|
|
...(azure ? { azure: true } : {}),
|
|
api_version: apiVersion,
|
|
passthrough_params: parsePassthroughParams(passthroughParams),
|
|
...(_headers ? { headers: _headers } : {})
|
|
}
|
|
},
|
|
direct
|
|
).catch((error) => {
|
|
toast.error(`${error}`);
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Server connection verified'));
|
|
}
|
|
};
|
|
|
|
const verifyHandler = () => {
|
|
if (ollama) {
|
|
verifyOllamaHandler();
|
|
} else {
|
|
verifyOpenAIHandler();
|
|
}
|
|
};
|
|
|
|
const addModelHandler = () => {
|
|
const newModelId = modelId.trim();
|
|
|
|
if (!newModelId) {
|
|
return;
|
|
}
|
|
|
|
if (modelIds.includes(newModelId)) {
|
|
toast.error($i18n.t('Model ID is already added'));
|
|
return;
|
|
}
|
|
|
|
modelIds = [...modelIds, newModelId];
|
|
modelId = '';
|
|
};
|
|
|
|
const submitHandler = async () => {
|
|
loading = true;
|
|
|
|
if (!ollama && !url) {
|
|
loading = false;
|
|
toast.error($i18n.t('URL is required'));
|
|
return;
|
|
}
|
|
|
|
if (azure) {
|
|
if (!apiVersion) {
|
|
loading = false;
|
|
showAdvanced = true;
|
|
|
|
toast.error($i18n.t('API Version is required'));
|
|
return;
|
|
}
|
|
|
|
if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
|
|
loading = false;
|
|
|
|
toast.error($i18n.t('Key is required'));
|
|
return;
|
|
}
|
|
|
|
if (modelIds.length === 0) {
|
|
loading = false;
|
|
showAdvanced = true;
|
|
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (headers) {
|
|
try {
|
|
const _headers = JSON.parse(headers);
|
|
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
|
|
throw new Error('Headers must be a valid JSON object');
|
|
}
|
|
headers = JSON.stringify(_headers, null, 2);
|
|
} catch (error) {
|
|
toast.error($i18n.t('Headers must be a valid JSON object'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// remove trailing slash from url
|
|
url = url.replace(/\/$/, '');
|
|
|
|
const connection = {
|
|
url,
|
|
key,
|
|
config: {
|
|
enable: enable,
|
|
tags: tags,
|
|
prefix_id: prefixId,
|
|
model_ids: modelIds,
|
|
connection_type: connectionType,
|
|
auth_type,
|
|
headers: headers ? JSON.parse(headers) : undefined,
|
|
passthrough_params: parsePassthroughParams(passthroughParams),
|
|
...(provider ? { provider } : {}),
|
|
...(!ollama && azure ? { azure: true } : {}),
|
|
...(azure ? { api_version: apiVersion } : {}),
|
|
...(apiType ? { api_type: apiType } : {})
|
|
}
|
|
};
|
|
|
|
await onSubmit(connection);
|
|
|
|
loading = false;
|
|
show = false;
|
|
|
|
url = '';
|
|
key = '';
|
|
auth_type = 'bearer';
|
|
prefixId = '';
|
|
passthroughParams = '';
|
|
showAdvanced = false;
|
|
tags = [];
|
|
modelIds = [];
|
|
};
|
|
|
|
const init = () => {
|
|
if (connection) {
|
|
url = connection.url;
|
|
key = connection.key;
|
|
|
|
auth_type = connection.config.auth_type ?? 'bearer';
|
|
headers = connection.config?.headers
|
|
? JSON.stringify(connection.config.headers, null, 2)
|
|
: '';
|
|
|
|
enable = connection.config?.enable ?? true;
|
|
tags = connection.config?.tags ?? [];
|
|
prefixId = connection.config?.prefix_id ?? '';
|
|
passthroughParams = Array.isArray(connection.config?.passthrough_params)
|
|
? connection.config.passthrough_params.join(', ')
|
|
: (connection.config?.passthrough_params ?? '');
|
|
modelIds = [...new Set(connection.config?.model_ids ?? [])];
|
|
|
|
if (ollama) {
|
|
connectionType = connection.config?.connection_type ?? 'local';
|
|
} else {
|
|
connectionType = connection.config?.connection_type ?? 'external';
|
|
provider = connection.config?.provider ?? (connection.config?.azure ? 'azure' : '');
|
|
apiVersion = connection.config?.api_version ?? '';
|
|
apiType = connection.config?.api_type ?? '';
|
|
}
|
|
}
|
|
};
|
|
|
|
$: if (show) {
|
|
init();
|
|
}
|
|
|
|
onMount(() => {
|
|
init();
|
|
});
|
|
</script>
|
|
|
|
<Modal size="sm" bind:show>
|
|
<div>
|
|
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-1.5">
|
|
<h1 class="text-lg font-medium self-center font-primary">
|
|
{#if edit}
|
|
{$i18n.t('Edit Connection')}
|
|
{:else}
|
|
{$i18n.t('Add Connection')}
|
|
{/if}
|
|
</h1>
|
|
<button
|
|
class="self-center"
|
|
aria-label={$i18n.t('Close modal')}
|
|
on:click={() => {
|
|
show = false;
|
|
}}
|
|
>
|
|
<XMark className={'size-5'} />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
|
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
|
<form
|
|
class="flex flex-col w-full"
|
|
on:submit={(e) => {
|
|
e.preventDefault();
|
|
submitHandler();
|
|
}}
|
|
>
|
|
<div class="px-1">
|
|
{#if !direct}
|
|
<div class="flex gap-2">
|
|
<div class="flex w-full justify-between items-center">
|
|
<div class=" text-xs text-gray-500">{$i18n.t('Connection Type')}</div>
|
|
|
|
<div class="">
|
|
<button
|
|
on:click={() => {
|
|
connectionType = connectionType === 'local' ? 'external' : 'local';
|
|
}}
|
|
type="button"
|
|
class=" text-xs text-gray-700 dark:text-gray-300"
|
|
>
|
|
{#if connectionType === 'local'}
|
|
{$i18n.t('Local')}
|
|
{:else}
|
|
{$i18n.t('External')}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex gap-2 mt-1.5">
|
|
<div class="flex flex-col w-full">
|
|
<label
|
|
for="url-input"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('URL')}</label
|
|
>
|
|
|
|
<div class="flex-1">
|
|
<input
|
|
id="url-input"
|
|
class={`w-full text-sm ${inputClass}`}
|
|
type="text"
|
|
bind:value={url}
|
|
placeholder={$i18n.t('API Base URL')}
|
|
autocomplete="off"
|
|
list={ollama ? undefined : 'suggestions'}
|
|
required
|
|
/>
|
|
|
|
{#if !ollama}
|
|
<datalist id="suggestions">
|
|
<option value="https://api.openai.com/v1" />
|
|
<option value="https://api.anthropic.com/v1" />
|
|
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
|
|
<option value="https://api.mistral.ai/v1" />
|
|
<option value="https://api.groq.com/openai/v1" />
|
|
<option value="https://openrouter.ai/api/v1" />
|
|
<option value="https://api.x.ai/v1" />
|
|
</datalist>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
|
|
<button
|
|
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
|
|
on:click={() => {
|
|
verifyHandler();
|
|
}}
|
|
type="button"
|
|
aria-label={$i18n.t('Verify Connection')}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
class="w-4 h-4"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
|
|
<div class="flex flex-col shrink-0 self-end">
|
|
<label class="sr-only" for="toggle-connection"
|
|
>{$i18n.t('Toggle whether current connection is active.')}</label
|
|
>
|
|
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
|
<Switch id="toggle-connection" bind:state={enable} />
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<label for="select-bearer-or-session" class={`text-xs text-gray-500`}
|
|
>{$i18n.t('Auth')}</label
|
|
>
|
|
|
|
<div class="flex gap-2">
|
|
<div class="flex-shrink-0 self-start">
|
|
<select
|
|
id="select-bearer-or-session"
|
|
class={`w-full text-sm ${selectClass}`}
|
|
bind:value={auth_type}
|
|
>
|
|
<option value="none">{$i18n.t('None')}</option>
|
|
<option value="bearer">{$i18n.t('Bearer')}</option>
|
|
|
|
{#if !ollama}
|
|
<option value="session">{$i18n.t('Session')}</option>
|
|
{#if !direct}
|
|
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
|
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
|
|
{/if}
|
|
{/if}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex flex-1 items-center">
|
|
{#if auth_type === 'bearer'}
|
|
<SensitiveInput
|
|
bind:value={key}
|
|
placeholder={$i18n.t('API Key')}
|
|
required={false}
|
|
/>
|
|
{:else if auth_type === 'none'}
|
|
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
|
{$i18n.t('No authentication')}
|
|
</div>
|
|
{:else if auth_type === 'session'}
|
|
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
|
{$i18n.t('Forwards system user session credentials to authenticate')}
|
|
</div>
|
|
{:else if auth_type === 'system_oauth'}
|
|
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
|
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
|
</div>
|
|
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
|
|
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
|
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if !ollama && !direct}
|
|
<div class="flex flex-row justify-between items-center w-full mt-1">
|
|
<label
|
|
for="api-type-toggle"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('API Type')}</label
|
|
>
|
|
|
|
<div>
|
|
<button
|
|
on:click={() => {
|
|
apiType = apiType === 'responses' ? '' : 'responses';
|
|
}}
|
|
type="button"
|
|
id="api-type-toggle"
|
|
class=" text-xs text-gray-700 dark:text-gray-300"
|
|
>
|
|
{#if apiType === 'responses'}
|
|
{$i18n.t('Responses')}
|
|
{:else}
|
|
{$i18n.t('Chat Completions')}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
|
on:click={() => (showAdvanced = !showAdvanced)}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
{$i18n.t('Advanced')}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showAdvanced}
|
|
{#if !direct}
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<label
|
|
for="headers-input"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('Headers')}</label
|
|
>
|
|
|
|
<div class="flex-1">
|
|
<Tooltip
|
|
content={$i18n.t(
|
|
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
|
)}
|
|
>
|
|
<Textarea
|
|
className="w-full text-sm outline-hidden"
|
|
bind:value={headers}
|
|
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
|
required={false}
|
|
minSize={30}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !ollama && !direct}
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<label
|
|
for="allowed-passthrough-params-input"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('Passthrough params')}</label
|
|
>
|
|
|
|
<div class="flex-1">
|
|
<Tooltip
|
|
content={$i18n.t(
|
|
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
|
|
)}
|
|
>
|
|
<input
|
|
class={`w-full text-sm ${inputClass}`}
|
|
type="text"
|
|
id="allowed-passthrough-params-input"
|
|
bind:value={passthroughParams}
|
|
placeholder={$i18n.t('thinking, output_config')}
|
|
autocomplete="off"
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<label
|
|
for="prefix-id-input"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('Prefix ID')}</label
|
|
>
|
|
|
|
<div class="flex-1">
|
|
<Tooltip
|
|
content={$i18n.t(
|
|
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
|
|
)}
|
|
>
|
|
<input
|
|
class={`w-full text-sm ${inputClass}`}
|
|
type="text"
|
|
id="prefix-id-input"
|
|
bind:value={prefixId}
|
|
placeholder={$i18n.t('Prefix ID')}
|
|
autocomplete="off"
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if !ollama && !direct}
|
|
<div class="flex flex-row justify-between items-center w-full mt-2">
|
|
<label
|
|
for="provider-select"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('Provider')}</label
|
|
>
|
|
|
|
<div>
|
|
<select
|
|
id="provider-select"
|
|
bind:value={provider}
|
|
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
|
>
|
|
<option value="">{$i18n.t('Default')}</option>
|
|
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
|
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
|
<option value="litellm">{$i18n.t('LiteLLM')}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if azure}
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<label
|
|
for="api-version-input"
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}>{$i18n.t('API Version')}</label
|
|
>
|
|
|
|
<div class="flex-1">
|
|
<input
|
|
id="api-version-input"
|
|
class={`w-full text-sm ${inputClass}`}
|
|
type="text"
|
|
bind:value={apiVersion}
|
|
placeholder={$i18n.t('API Version')}
|
|
autocomplete="off"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex flex-col w-full mt-2">
|
|
<div class="mb-1 flex justify-between">
|
|
<div
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}
|
|
>
|
|
{$i18n.t('Model IDs')}
|
|
</div>
|
|
</div>
|
|
|
|
{#if modelIds.length > 0}
|
|
<ul class="flex flex-col">
|
|
{#each modelIds as modelId, modelIdx}
|
|
<li class=" flex gap-2 w-full justify-between items-center">
|
|
<div class=" text-sm flex-1 py-1 rounded-lg">
|
|
{modelId}
|
|
</div>
|
|
<div class="shrink-0">
|
|
<button
|
|
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
|
|
MODELID: modelId
|
|
})}
|
|
type="button"
|
|
on:click={() => {
|
|
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
|
|
}}
|
|
>
|
|
<Minus strokeWidth="2" className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<div
|
|
class={`text-gray-500 text-xs text-center py-2 px-10
|
|
`}
|
|
>
|
|
{#if ollama}
|
|
{$i18n.t(
|
|
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
|
|
{
|
|
url: url
|
|
}
|
|
)}
|
|
{:else if azure}
|
|
{$i18n.t('Deployment names are required for Azure OpenAI')}
|
|
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
|
|
url: `${url}/openai/deployments`
|
|
})} -->
|
|
{:else}
|
|
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
|
|
url: url
|
|
})}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center">
|
|
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
|
<input
|
|
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
|
|
bind:value={modelId}
|
|
id="add-model-id-input"
|
|
placeholder={$i18n.t('Add a model ID')}
|
|
/>
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
aria-label={$i18n.t('Add')}
|
|
on:click={() => {
|
|
addModelHandler();
|
|
}}
|
|
>
|
|
<Plus className="size-3.5" strokeWidth="2" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2 mt-2">
|
|
<div class="flex flex-col w-full">
|
|
<div
|
|
class={`mb-0.5 text-xs text-gray-500
|
|
`}
|
|
>
|
|
{$i18n.t('Tags')}
|
|
</div>
|
|
|
|
<div class="flex-1 mt-0.5">
|
|
<Tags
|
|
bind:tags
|
|
on:add={(e) => {
|
|
tags = [
|
|
...tags,
|
|
{
|
|
name: e.detail
|
|
}
|
|
];
|
|
}}
|
|
on:delete={(e) => {
|
|
tags = tags.filter((tag) => tag.name !== e.detail);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex justify-between items-center pt-3 text-sm font-medium">
|
|
<div>
|
|
{#if edit}
|
|
<button
|
|
class="px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline transition"
|
|
type="button"
|
|
on:click={() => {
|
|
showDeleteConfirmDialog = true;
|
|
}}
|
|
>
|
|
{$i18n.t('Delete')}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<button
|
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
|
? ' cursor-not-allowed'
|
|
: ''}"
|
|
type="submit"
|
|
disabled={loading}
|
|
>
|
|
{$i18n.t('Save')}
|
|
|
|
{#if loading}
|
|
<span class="shrink-0">
|
|
<Spinner />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteConfirmDialog}
|
|
message={$i18n.t(
|
|
'Are you sure you want to delete this connection? This action cannot be undone.'
|
|
)}
|
|
confirmLabel={$i18n.t('Delete')}
|
|
on:confirm={() => {
|
|
onDelete();
|
|
show = false;
|
|
}}
|
|
/>
|