前端模型选择器加载/展示用户私有模型,新增“添加API”入口,和删除功能

..
This commit is contained in:
Gaofeng 2025-11-28 21:05:57 +08:00
parent e03e1ed981
commit dabbf320db
10 changed files with 565 additions and 121 deletions

View file

@ -91,6 +91,7 @@ from open_webui.routers import (
evaluations,
tools,
users,
user_models,
utils,
scim,
)
@ -1297,7 +1298,9 @@ app.include_router(configs.router, prefix="/api/v1/configs", tags=["configs"])
app.include_router(auths.router, prefix="/api/v1/auths", tags=["auths"])
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(
user_models.router, prefix="/api/v1/user/models", tags=["user_models"]
)
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"])

View file

@ -38,6 +38,7 @@ from open_webui.routers.pipelines import (
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.user_model_credentials import UserModelCredentials
from open_webui.utils.plugin import (
@ -225,6 +226,23 @@ async def generate_chat_completion(
# === 4. 验证模型存在性 ===
model_id = form_data["model"]
# 私有模型直连:如果是用户私有模型,使用 credential_id 注入 direct 配置
if form_data.get("is_user_model") and form_data.get("model_item", {}).get("credential_id"):
cred = UserModelCredentials.get_credential_by_id_and_user_id(
form_data["model_item"]["credential_id"], user.id
)
if not cred:
raise Exception("User model credential not found")
request.state.direct = True
request.state.model = {
"id": cred.model_id,
"name": cred.name or cred.model_id,
"base_url": cred.base_url,
"api_key": cred.api_key,
}
models = {request.state.model["id"]: request.state.model}
model_id = cred.model_id
if model_id not in models:
raise Exception("Model not found")

130
src/lib/apis/userModels.ts Normal file
View file

@ -0,0 +1,130 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type UserModelCredentialForm = {
name?: string;
model_id: string;
base_url?: string;
api_key: string;
config?: object;
};
// WEBUI_API_BASE_URL 已包含 /api 或 /api/v1 前缀,这里仅追加 user/models
const BASE = `${WEBUI_API_BASE_URL}/user/models`;
export const listUserModels = async (token: string = '') => {
let error = null;
const res = await fetch(BASE, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createUserModel = async (token: string, body: UserModelCredentialForm) => {
let error = null;
const res = await fetch(BASE, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserModel = async (
token: string,
id: string,
body: UserModelCredentialForm
) => {
let error = null;
const res = await fetch(`${BASE}/${id}`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteUserModel = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${BASE}/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -13,34 +13,35 @@
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
import {
chatId,
chats,
config,
type Model,
models,
tags as allTags,
settings,
showSidebar,
WEBUI_NAME,
banners,
user,
socket,
showControls,
showCallOverlay,
currentChatPage,
temporaryChatEnabled,
mobile,
showOverview,
chatTitle,
showArtifacts,
tools,
toolServers,
functions,
selectedFolder,
pinnedChats,
showEmbeds
} from '$lib/stores';
import {
chatId,
chats,
config,
type Model,
models,
userModels,
tags as allTags,
settings,
showSidebar,
WEBUI_NAME,
banners,
user,
socket,
showControls,
showCallOverlay,
currentChatPage,
temporaryChatEnabled,
mobile,
showOverview,
chatTitle,
showArtifacts,
tools,
toolServers,
functions,
selectedFolder,
pinnedChats,
showEmbeds
} from '$lib/stores';
import {
convertMessagesToHistory,
copyToClipboard,
@ -1550,11 +1551,12 @@ let memoryLocked = false;
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId);
// === 1. 模型验证:确保选中的模型仍然存在 ===
// 过滤掉已被删除或不可用的模型,避免发送请求时出错
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
// === 1. 模型验证:确保选中的模型仍然存在 ===
// 过滤掉已被删除或不可用的模型,避免发送请求时出错
const _selectedModels = selectedModels.map((modelId) => {
const allIds = [...$models.map((m) => m.id), ...$userModels.map((m) => m.id)];
return allIds.includes(modelId) ? modelId : '';
});
// 如果模型列表发生变化,同步更新
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
@ -1773,9 +1775,9 @@ let memoryLocked = false;
// === 4. 为每个选中的模型创建响应消息占位符 ===
// 这样 UI 可以立即显示"正在输入..."状态
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
const combined = getCombinedModelById(modelId);
if (combined) {
const model = combined.model ?? combined.credential;
// 4.1 生成响应消息 ID 和空消息对象
let responseMessageId = uuidv4();
let responseMessage = {
@ -1784,8 +1786,14 @@ let memoryLocked = false;
childrenIds: [],
role: 'assistant',
content: '', // 初始为空,后续通过 WebSocket 流式填充
model: model.id,
modelName: model.name ?? model.id,
model:
combined.source === 'user' && combined.credential
? combined.credential.model_id
: model.id,
modelName:
combined.source === 'user' && combined.credential
? combined.credential.name ?? combined.credential.model_id
: model.name ?? model.id,
modelIdx: modelIdx ? modelIdx : _modelIdx, // 多模型对话时,区分不同模型的响应
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
@ -1829,22 +1837,27 @@ let memoryLocked = false;
await Promise.all(
selectedModelIds.map(async (modelId, _modelIdx) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
const combined = getCombinedModelById(modelId);
const model = combined?.model ?? combined?.credential;
if (model) {
// 7.1 检查模型视觉能力(如果消息包含图片)
const hasImages = createMessagesList(_history, parentId).some((message) =>
message.files?.some((file) => file.type === 'image')
if (combined && model) {
// 7.1 检查模型视觉能力(如果消息包含图片)
const hasImages = createMessagesList(_history, parentId).some((message) =>
message.files?.some((file) => file.type === 'image')
);
// 如果消息包含图片,但模型不支持视觉,提示错误(私有模型默认视为支持)
if (
combined.source !== 'user' &&
hasImages &&
!(model.info?.meta?.capabilities?.vision ?? true)
) {
toast.error(
$i18n.t('Model {{modelName}} is not vision capable', {
modelName: model.name ?? model.id
})
);
// 如果消息包含图片,但模型不支持视觉,提示错误
if (hasImages && !(model.info?.meta?.capabilities?.vision ?? true)) {
toast.error(
$i18n.t('Model {{modelName}} is not vision capable', {
modelName: model.name ?? model.id
})
);
}
}
// 7.2 获取响应消息 ID
let responseMessageId =
@ -1860,12 +1873,12 @@ let memoryLocked = false;
// - 构造请求 payloadmessages、files、tools、features 等)
// - 调用 generateOpenAIChatCompletion API
// - 处理流式响应(通过 WebSocket 实时更新消息内容)
await sendMessageSocket(
model,
messages && messages.length > 0
? messages // 使用自定义消息列表(例如重新生成时追加 follow-up
: createMessagesList(_history, responseMessageId), // 使用完整历史记录
_history,
await sendMessageSocket(
combined,
messages && messages.length > 0
? messages // 使用自定义消息列表(例如重新生成时追加 follow-up
: createMessagesList(_history, responseMessageId), // 使用完整历史记录
_history,
responseMessageId,
_chatId
);
@ -1883,8 +1896,8 @@ let memoryLocked = false;
chats.set(await getChatList(localStorage.token, $currentChatPage));
};
const getFeatures = () => {
let features = {};
const getFeatures = () => {
let features = {};
if ($config?.features)
features = {
@ -1921,17 +1934,27 @@ let memoryLocked = false;
}
// 如果用户手动切换了记忆开关,覆盖全局设置
if (memoryEnabled !== undefined && memoryEnabled !== ($settings?.memory ?? false)) {
features = { ...features, memory: memoryEnabled };
}
if (memoryEnabled !== undefined && memoryEnabled !== ($settings?.memory ?? false)) {
features = { ...features, memory: memoryEnabled };
}
return features;
};
return features;
};
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
const getCombinedModelById = (modelId) => {
const platform = $models.find((m) => m.id === modelId);
if (platform) return { source: 'platform', model: platform };
const priv = $userModels.find((m) => m.id === modelId);
if (priv) return { source: 'user', credential: priv };
return null;
};
const sendMessageSocket = async (combinedModel, _messages, _history, responseMessageId, _chatId) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
const model = combinedModel?.model ?? combinedModel?.credential ?? combinedModel;
const chatMessageFiles = _messages
.filter((message) => message.files)
.flatMap((message) => message.files);
@ -1972,6 +1995,9 @@ let memoryLocked = false;
});
}
const isUserModel = combinedModel?.source === 'user';
const credential = combinedModel?.credential;
const stream =
model?.info?.params?.stream_response ??
$settings?.params?.stream_response ??
@ -2039,7 +2065,7 @@ let memoryLocked = false;
localStorage.token,
{
stream: stream,
model: model.id,
model: isUserModel ? credential.model_id : model.id,
messages: messages,
params: {
...$settings?.params,
@ -2063,7 +2089,8 @@ let memoryLocked = false;
variables: {
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
},
model_item: $models.find((m) => m.id === model.id),
model_item: isUserModel ? { credential_id: credential.id } : $models.find((m) => m.id === model.id),
is_user_model: isUserModel,
session_id: $socket?.id,
chat_id: $chatId,

View file

@ -15,7 +15,15 @@
import { getChatById } from '$lib/apis/chats';
import { generateTags } from '$lib/apis';
import { config, models, settings, temporaryChatEnabled, TTSWorker, user } from '$lib/stores';
import {
config,
models,
settings,
temporaryChatEnabled,
TTSWorker,
user,
userModels
} from '$lib/stores';
import { synthesizeOpenAISpeech } from '$lib/apis/audio';
import { imageGenerations } from '$lib/apis/images';
import {
@ -147,8 +155,30 @@
let buttonsContainerElement: HTMLDivElement;
let showDeleteConfirm = false;
let model = null;
$: model = $models.find((m) => m.id === message.model);
let model = null;
let userModel = null;
let modelName = '';
$: {
const platformModel = $models.find((m) => m.id === message.model);
const credentialModel = (() => {
// 优先使用随消息携带的 credential_id 精确匹配
if (message?.model_item?.credential_id) {
return $userModels.find((m) => m.id === message.model_item.credential_id);
}
// 兼容历史消息:通过 model_id 反查
return $userModels.find((m) => m.model_id === message.model);
})();
userModel = credentialModel ? { ...credentialModel, id: credentialModel.model_id } : null;
model = platformModel ?? userModel;
modelName =
message.modelName ??
model?.name ??
(userModel?.name ?? userModel?.model_id) ??
message.model;
}
let edit = false;
let editedContent = '';
@ -619,10 +649,10 @@
<div class="flex-auto w-0 pl-1 relative">
<Name>
<Tooltip content={model?.name ?? message.model} placement="top-start">
<span class="line-clamp-1 text-black dark:text-white">
{model?.name ?? message.model}
</span>
<Tooltip content={modelName} placement="top-start">
<span class="line-clamp-1 text-black dark:text-white">
{modelName}
</span>
</Tooltip>
{#if message.timestamp}

View file

@ -1,11 +1,34 @@
<script lang="ts">
import { models, showSettings, settings, user, mobile, config } from '$lib/stores';
import {
models,
userModels,
showSettings,
settings,
user,
mobile,
config
} from '$lib/stores';
import { onMount, tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Selector from './ModelSelector/Selector.svelte';
import Tooltip from '../common/Tooltip.svelte';
import { updateUserSettings } from '$lib/apis/users';
import {
listUserModels,
createUserModel,
updateUserModel,
deleteUserModel
} from '$lib/apis/userModels';
let showUserModelModal = false;
let editingCredential = null;
let form = {
name: '',
model_id: '',
base_url: '',
api_key: ''
};
const i18n = getContext('i18n');
export let selectedModels = [''];
@ -38,17 +61,92 @@
await updateUserSettings(localStorage.token, { ui: $settings });
};
$: if (selectedModels.length > 0 && $models.length > 0) {
const _selectedModels = selectedModels.map((model) =>
$models.map((m) => m.id).includes(model) ? model : ''
);
$: if (selectedModels.length > 0 && ($models.length > 0 || $userModels.length > 0)) {
const allIds = [
...$models.map((m) => m.id),
...$userModels.map((m) => m.id)
];
const _selectedModels = selectedModels.map((model) => (allIds.includes(model) ? model : ''));
if (JSON.stringify(_selectedModels) !== JSON.stringify(selectedModels)) {
selectedModels = _selectedModels;
}
}
const loadUserModels = async () => {
const res = await listUserModels(localStorage.token).catch((err) => {
console.error(err);
return [];
});
userModels.set(
(res ?? []).map((item) => ({
...item,
id: `user:${item.id}`, // 前端区分私有模型 ID
source: 'user'
}))
);
};
onMount(async () => {
await loadUserModels();
});
const resetForm = () => {
form = {
name: '',
model_id: '',
base_url: '',
api_key: ''
};
editingCredential = null;
};
const submitUserModel = async () => {
if (!form.model_id || !form.api_key) {
toast.error($i18n.t('Model ID and API Key are required'));
return;
}
if (editingCredential) {
const res = await updateUserModel(localStorage.token, editingCredential, form).catch((err) => {
toast.error(`${err?.detail ?? err}`);
return null;
});
if (res) {
toast.success($i18n.t('Updated'));
await loadUserModels();
}
} else {
const res = await createUserModel(localStorage.token, form).catch((err) => {
toast.error(`${err?.detail ?? err}`);
return null;
});
if (res) {
toast.success($i18n.t('Added'));
await loadUserModels();
}
}
resetForm();
showUserModelModal = false;
};
const removeUserModel = async (cred) => {
console.log("removeUserModel");
console.log(cred);
const res = await deleteUserModel(localStorage.token, cred.id.replace('user:', '')).catch((err) => {
toast.error(`${err?.detail ?? err}`);
return null;
});
if (res) {
toast.success($i18n.t('Deleted'));
await loadUserModels();
}
};
</script>
<div class="flex flex-col w-full items-start">
{#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex w-full max-w-fit">
@ -57,12 +155,35 @@
<Selector
id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')}
items={$models.map((model) => ({
value: model.id,
label: model.name,
model: model
}))}
items={[
...$models.map((model) => ({
value: model.id,
label: model.name,
model: model,
source: 'platform'
})),
...$userModels.map((model) => ({
value: model.id,
label: model.name || model.model_id,
model: {
id: model.id,
name: model.name || model.model_id,
info: { meta: { description: model.base_url ?? '' } },
owned_by: 'openai',
source: 'user'
},
source: 'user',
_credential: model
}))
]}
{pinModelHandler}
addUserModel={() => {
resetForm();
showUserModelModal = true;
}}
on:deleteUserModel={(e) => {
removeUserModel(e.detail);
}}
bind:value={selectedModel}
/>
</div>
@ -70,39 +191,26 @@
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true)}
{#if selectedModelIdx === 0}
<!-- ai-friend 屏蔽添加模型按钮 -->
<div
class=" self-center mx-1 disabled:text-gray-600 disabled:hover:text-gray-600 -translate-y-[0.5px]"
>
<div class="self-center mx-1 disabled:text-gray-600 disabled:hover:text-gray-600 -translate-y-[0.5px]">
<Tooltip content={$i18n.t('Add Model')}>
{#if false}
<button
class=" "
{disabled}
on:click={() => {
selectedModels = [...selectedModels, ''];
}}
aria-label="Add Model"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3.5"
<button
class=" "
{disabled}
on:click={() => {
selectedModels = [...selectedModels, ''];
}}
aria-label="Add Model"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
</button>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
</button>
{/if}
</Tooltip>
</div>
{:else}
<div
class=" self-center mx-1 disabled:text-gray-600 disabled:hover:text-gray-600 -translate-y-[0.5px]"
>
<div class="self-center mx-1 disabled:text-gray-600 disabled:hover:text-gray-600 -translate-y-[0.5px]">
<Tooltip content={$i18n.t('Remove Model')}>
<button
{disabled}
@ -112,14 +220,7 @@
}}
aria-label="Remove Model"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
</svg>
</button>
@ -129,6 +230,70 @@
{/if}
</div>
{/each}
{#if showUserModelModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md p-4 space-y-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingCredential ? $i18n.t('Edit My API') : $i18n.t('Add My API')}
</div>
<div class="space-y-2 text-sm">
<div class="flex flex-col gap-1">
<label class="text-gray-600 dark:text-gray-400">{$i18n.t('Display Name')}</label>
<input
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 outline-none"
bind:value={form.name}
placeholder={$i18n.t('Optional')}
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-gray-600 dark:text-gray-400">{$i18n.t('Model ID')}</label>
<input
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 outline-none"
bind:value={form.model_id}
placeholder="gpt-4o / claude-3..."
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-gray-600 dark:text-gray-400">{$i18n.t('Base URL')}</label>
<input
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 outline-none"
bind:value={form.base_url}
placeholder="https://api.openai.com/v1"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-gray-600 dark:text-gray-400">API Key</label>
<input
type="password"
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 outline-none"
bind:value={form.api_key}
placeholder="sk-..."
/>
</div>
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-2 rounded-lg text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
on:click={() => {
resetForm();
showUserModelModal = false;
}}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3 py-2 rounded-lg text-sm bg-black text-white dark:bg-white dark:text-black"
on:click={submitUserModel}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{/if}
</div>
{#if showSetDefault}

View file

@ -16,6 +16,7 @@
import { toast } from 'svelte-sonner';
import Tag from '$lib/components/icons/Tag.svelte';
import Label from '$lib/components/icons/Label.svelte';
import { createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
@ -29,6 +30,8 @@
export let onClick: () => void = () => {};
const dispatch = createEventDispatcher();
const copyLinkHandler = async (model) => {
const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/?model=${encodeURIComponent(model.id)}`);
@ -86,11 +89,19 @@
</div>
<div class="flex items-center">
<Tooltip content={`${item.label} (${item.value})`} placement="top-start">
<Tooltip content={`${item.label}`} placement="top-start">
<div class="line-clamp-1">
{item.label}
</div>
</Tooltip>
{#if item?.source === 'user'}
<div
class="ml-2 inline-flex items-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-100 px-1.5 py-0.5 text-[11px]"
>
{$i18n.t('My API')}
</div>
{/if}
</div>
<div class=" shrink-0 flex items-center gap-2">
@ -249,10 +260,15 @@
<ModelItemMenu
bind:show={showMenu}
model={item.model}
{item}
{pinModelHandler}
copyLinkHandler={() => {
copyLinkHandler(item.model);
}}
onDelete={(cred) => {
const target = cred?._credential ?? cred;
dispatch('deleteUserModel', target);
}}
>
<button
aria-label={`${$i18n.t('More Options')}`}

View file

@ -14,6 +14,9 @@
export let show = false;
export let model;
export let item: any = {};
export let onDelete: Function = () => {}; // required: pass handler from parent
export let pinModelHandler: (modelId: string) => void = () => {};
export let copyLinkHandler: Function = () => {};
@ -92,5 +95,20 @@
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
</DropdownMenu.Item>
{#if item?.source === 'user'}
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-red-500"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
onDelete(item);
show = false;
}}
>
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -44,11 +44,14 @@
export let placeholder = $i18n.t('Select a model');
export let searchEnabled = true;
export let searchPlaceholder = $i18n.t('Search a model');
export let addUserModel: Function = () => {};
export let items: {
label: string;
value: string;
model: Model;
source?: string;
_credential?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}[] = [];
@ -541,6 +544,33 @@
</div>
<div class="px-2.5 max-h-64 overflow-y-auto group relative">
<!-- Add private model button -->
<button
class="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/60 flex items-center gap-2 transition mb-1"
on:click={() => {
addUserModel();
show = false;
}}
>
<div
class="size-7 rounded-full bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{$i18n.t('Add My API')}
</div>
</button>
{#each filteredItems as item, index}
<ModelItem
{selectedModelIdx}
@ -555,6 +585,12 @@
show = false;
}}
on:deleteUserModel={(e) => {
dispatch('deleteUserModel', e.detail);
}}
on:editUserModel={(e) => {
dispatch('editUserModel', e.detail);
}}
/>
{:else}
<div class="">

View file

@ -55,6 +55,7 @@ export const folders = writable([]);
export const selectedFolder = writable(null);
export const models: Writable<Model[]> = writable([]);
export const userModels: Writable<any[]> = writable([]);
export const prompts: Writable<null | Prompt[]> = writable(null);
export const knowledge: Writable<null | Document[]> = writable(null);