mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
前端模型选择器加载/展示用户私有模型,新增“添加API”入口,和删除功能
..
This commit is contained in:
parent
e03e1ed981
commit
dabbf320db
10 changed files with 565 additions and 121 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
130
src/lib/apis/userModels.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
|||
// - 构造请求 payload(messages、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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue