Przeglądaj źródła

enh: reintroduce model order/default models

Timothy Jaeryang Baek 5 miesięcy temu
rodzic
commit
5fac25a002

+ 2 - 0
backend/open_webui/apps/webui/main.py

@@ -31,6 +31,7 @@ from open_webui.config import (
     DEFAULT_MODELS,
     DEFAULT_PROMPT_SUGGESTIONS,
     DEFAULT_USER_ROLE,
+    MODEL_ORDER_LIST,
     ENABLE_COMMUNITY_SHARING,
     ENABLE_LOGIN_FORM,
     ENABLE_MESSAGE_RATING,
@@ -120,6 +121,7 @@ app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
 app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
 app.state.config.WEBHOOK_URL = WEBHOOK_URL
 app.state.config.BANNERS = WEBUI_BANNERS
+app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
 
 app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
 app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING

+ 28 - 17
backend/open_webui/apps/webui/routers/configs.py

@@ -34,8 +34,32 @@ async def export_config(user=Depends(get_admin_user)):
     return get_config()
 
 
-class SetDefaultModelsForm(BaseModel):
-    models: str
+############################
+# SetDefaultModels
+############################
+class ModelsConfigForm(BaseModel):
+    DEFAULT_MODELS: str
+    MODEL_ORDER_LIST: list[str]
+
+
+@router.get("/models", response_model=ModelsConfigForm)
+async def get_models_config(request: Request, user=Depends(get_admin_user)):
+    return {
+        "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
+        "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
+    }
+
+
+@router.post("/models", response_model=ModelsConfigForm)
+async def set_models_config(
+    request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)
+):
+    request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
+    request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
+    return {
+        "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
+        "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
+    }
 
 
 class PromptSuggestion(BaseModel):
@@ -47,21 +71,8 @@ class SetDefaultSuggestionsForm(BaseModel):
     suggestions: list[PromptSuggestion]
 
 
-############################
-# SetDefaultModels
-############################
-
-
-@router.post("/default/models", response_model=str)
-async def set_global_default_models(
-    request: Request, form_data: SetDefaultModelsForm, user=Depends(get_admin_user)
-):
-    request.app.state.config.DEFAULT_MODELS = form_data.models
-    return request.app.state.config.DEFAULT_MODELS
-
-
-@router.post("/default/suggestions", response_model=list[PromptSuggestion])
-async def set_global_default_suggestions(
+@router.post("/suggestions", response_model=list[PromptSuggestion])
+async def set_default_suggestions(
     request: Request,
     form_data: SetDefaultSuggestionsForm,
     user=Depends(get_admin_user),

+ 6 - 0
backend/open_webui/config.py

@@ -740,6 +740,12 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
     ],
 )
 
+MODEL_ORDER_LIST = PersistentConfig(
+    "MODEL_ORDER_LIST",
+    "ui.model_order_list",
+    [],
+)
+
 DEFAULT_USER_ROLE = PersistentConfig(
     "DEFAULT_USER_ROLE",
     "ui.default_user_role",

+ 8 - 0
backend/open_webui/main.py

@@ -1194,6 +1194,14 @@ async def get_models(user=Depends(get_verified_user)):
         if "pipeline" not in model or model["pipeline"].get("type", None) != "filter"
     ]
 
+    model_order_list = webui_app.state.config.MODEL_ORDER_LIST
+    if model_order_list:
+        model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)}
+        # Sort models by order list priority, with fallback for those not in the list
+        models.sort(
+            key=lambda x: (model_order_dict.get(x["id"], float("inf")), x["name"])
+        )
+
     # Filter out models that the user does not have access to
     if user.role == "user":
         filtered_models = []

+ 34 - 4
src/lib/apis/configs/index.ts

@@ -58,17 +58,46 @@ export const exportConfig = async (token: string) => {
 	return res;
 };
 
-export const setDefaultModels = async (token: string, models: string) => {
+
+export const getModelsConfig = async (token: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+
+export const setModelsConfig = async (token: string, config: object) => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/models`, {
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models`, {
 		method: 'POST',
 		headers: {
 			'Content-Type': 'application/json',
 			Authorization: `Bearer ${token}`
 		},
 		body: JSON.stringify({
-			models: models
+			...config
 		})
 	})
 		.then(async (res) => {
@@ -88,10 +117,11 @@ export const setDefaultModels = async (token: string, models: string) => {
 	return res;
 };
 
+
 export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/default/suggestions`, {
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/suggestions`, {
 		method: 'POST',
 		headers: {
 			'Content-Type': 'application/json',

+ 0 - 20
src/lib/apis/index.ts

@@ -25,26 +25,6 @@ export const getModels = async (token: string = '', base: boolean = false) => {
 	}
 
 	let models = res?.data ?? [];
-	models = models
-		.filter((models) => models)
-		// Sort the models
-		.sort((a, b) => {
-			// Compare case-insensitively by name for models without position property
-			const lowerA = a.name.toLowerCase();
-			const lowerB = b.name.toLowerCase();
-
-			if (lowerA < lowerB) return -1;
-			if (lowerA > lowerB) return 1;
-
-			// If same case-insensitively, sort by original strings,
-			// lowercase will come before uppercase due to ASCII values
-			if (a.name < b.name) return -1;
-			if (a.name > b.name) return 1;
-
-			return 0; // They are equal
-		});
-
-	console.log(models);
 	return models;
 };
 

+ 2 - 2
src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte

@@ -375,7 +375,7 @@
 					<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
 						{#if edit}
 							<button
-								class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
+								class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
 								type="button"
 								on:click={() => {
 									dispatch('delete', model);
@@ -387,7 +387,7 @@
 						{/if}
 
 						<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 flex-row space-x-1 items-center {loading
+							class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
 								? ' cursor-not-allowed'
 								: ''}"
 							type="submit"

+ 8 - 18
src/lib/components/admin/Settings/Models.svelte

@@ -24,6 +24,8 @@
 	import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
 	import { toast } from 'svelte-sonner';
 	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import Cog6 from '$lib/components/icons/Cog6.svelte';
+	import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
 
 	let importFiles;
 	let modelsImportInputElement: HTMLInputElement;
@@ -35,7 +37,8 @@
 
 	let filteredModels = [];
 	let selectedModelId = null;
-	let showResetModal = false;
+
+	let showConfigModal = false;
 
 	$: if (models) {
 		filteredModels = models
@@ -135,18 +138,7 @@
 	});
 </script>
 
-<ConfirmDialog
-	title={$i18n.t('Delete All Models')}
-	message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
-	bind:show={showResetModal}
-	onConfirm={async () => {
-		const res = deleteAllModels(localStorage.token);
-		if (res) {
-			toast.success($i18n.t('All models deleted successfully'));
-			init();
-		}
-	}}
-/>
+<ConfigureModelsModal bind:show={showConfigModal} {init} />
 
 {#if models !== null}
 	{#if selectedModelId === null}
@@ -161,17 +153,15 @@
 				</div>
 
 				<div>
-					<Tooltip content={$i18n.t('This will delete all models including custom models')}>
+					<Tooltip content={$i18n.t('Configure')}>
 						<button
 							class=" px-2.5 py-1 rounded-full flex gap-1 items-center"
 							type="button"
 							on:click={() => {
-								showResetModal = true;
+								showConfigModal = true;
 							}}
 						>
-							<div class="text-xs flex-shrink-0">
-								{$i18n.t('Reset')}
-							</div>
+							<Cog6 />
 						</button>
 					</Tooltip>
 				</div>

+ 258 - 0
src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte

@@ -0,0 +1,258 @@
+<script>
+	import { toast } from 'svelte-sonner';
+
+	import { createEventDispatcher, getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+	const dispatch = createEventDispatcher();
+
+	import { models } from '$lib/stores';
+	import { deleteAllModels } from '$lib/apis/models';
+
+	import Modal from '$lib/components/common/Modal.svelte';
+	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import ModelList from './ModelList.svelte';
+	import { getModelsConfig, setModelsConfig } from '$lib/apis/configs';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import Minus from '$lib/components/icons/Minus.svelte';
+	import Plus from '$lib/components/icons/Plus.svelte';
+
+	export let show = false;
+	export let init = () => {};
+
+	let config = null;
+
+	let selectedModelId = '';
+	let defaultModelIds = [];
+	let modelIds = [];
+
+	let loading = false;
+	let showResetModal = false;
+
+	const submitHandler = async () => {
+		loading = true;
+
+		const res = await setModelsConfig(localStorage.token, {
+			DEFAULT_MODELS: defaultModelIds.join(','),
+			MODEL_ORDER_LIST: modelIds
+		});
+
+		if (res) {
+			toast.success($i18n.t('Models configuration saved successfully'));
+			init();
+			show = false;
+		} else {
+			toast.error($i18n.t('Failed to save models configuration'));
+		}
+
+		loading = false;
+	};
+
+	onMount(async () => {
+		config = await getModelsConfig(localStorage.token);
+
+		const modelOrderList = config.MODEL_ORDER_LIST || [];
+		const allModelIds = $models.map((model) => model.id);
+
+		// Create a Set for quick lookup of ordered IDs
+		const orderedSet = new Set(modelOrderList);
+
+		modelIds = [
+			// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
+			...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
+			// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
+			...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
+		];
+	});
+</script>
+
+<ConfirmDialog
+	title={$i18n.t('Delete All Models')}
+	message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
+	bind:show={showResetModal}
+	onConfirm={async () => {
+		const res = deleteAllModels(localStorage.token);
+		if (res) {
+			toast.success($i18n.t('All models deleted successfully'));
+			init();
+		}
+	}}
+/>
+
+<Modal size="sm" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
+			<div class=" text-lg font-medium self-center font-primary">
+				{$i18n.t('Configure Models')}
+			</div>
+			<button
+				class="self-center"
+				on:click={() => {
+					show = false;
+				}}
+			>
+				<svg
+					xmlns="http://www.w3.org/2000/svg"
+					viewBox="0 0 20 20"
+					fill="currentColor"
+					class="w-5 h-5"
+				>
+					<path
+						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+					/>
+				</svg>
+			</button>
+		</div>
+
+		<div class="flex flex-col md:flex-row w-full px-5 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">
+				{#if config}
+					<form
+						class="flex flex-col w-full"
+						on:submit|preventDefault={() => {
+							submitHandler();
+						}}
+					>
+						<div>
+							<div class="flex flex-col w-full">
+								<div class="mb-1 flex justify-between">
+									<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
+								</div>
+
+								<ModelList bind:modelIds />
+							</div>
+						</div>
+
+						<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
+
+						<div>
+							<div class="flex flex-col w-full">
+								<div class="mb-1 flex justify-between">
+									<div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div>
+								</div>
+
+								{#if defaultModelIds.length > 0}
+									<div class="flex flex-col">
+										{#each defaultModelIds as modelId, modelIdx}
+											<div class=" flex gap-2 w-full justify-between items-center">
+												<div class=" text-sm flex-1 py-1 rounded-lg">
+													{$models.find((model) => model.id === modelId)?.name}
+												</div>
+												<div class="flex-shrink-0">
+													<button
+														type="button"
+														on:click={() => {
+															defaultModelIds = defaultModelIds.filter(
+																(_, idx) => idx !== modelIdx
+															);
+														}}
+													>
+														<Minus strokeWidth="2" className="size-3.5" />
+													</button>
+												</div>
+											</div>
+										{/each}
+									</div>
+								{:else}
+									<div class="text-gray-500 text-xs text-center py-2">
+										{$i18n.t('No models selected')}
+									</div>
+								{/if}
+
+								<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
+
+								<div class="flex items-center">
+									<select
+										class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
+											? ''
+											: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
+										bind:value={selectedModelId}
+									>
+										<option value="">{$i18n.t('Select a model')}</option>
+										{#each $models as model}
+											<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
+												>{model.name}</option
+											>
+										{/each}
+									</select>
+
+									<div>
+										<button
+											type="button"
+											on:click={() => {
+												if (defaultModelIds.includes(selectedModelId)) {
+													return;
+												}
+
+												defaultModelIds = [...defaultModelIds, selectedModelId];
+												selectedModelId = '';
+											}}
+										>
+											<Plus className="size-3.5" strokeWidth="2" />
+										</button>
+									</div>
+								</div>
+							</div>
+						</div>
+
+						<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
+							<Tooltip content={$i18n.t('This will delete all models including custom models')}>
+								<button
+									class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
+									type="button"
+									on:click={() => {
+										showResetModal = true;
+									}}
+								>
+									{$i18n.t('Delete All Models')}
+								</button>
+							</Tooltip>
+
+							<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 flex-row space-x-1 items-center {loading
+									? ' cursor-not-allowed'
+									: ''}"
+								type="submit"
+								disabled={loading}
+							>
+								{$i18n.t('Save')}
+
+								{#if loading}
+									<div class="ml-2 self-center">
+										<svg
+											class=" w-4 h-4"
+											viewBox="0 0 24 24"
+											fill="currentColor"
+											xmlns="http://www.w3.org/2000/svg"
+											><style>
+												.spinner_ajPY {
+													transform-origin: center;
+													animation: spinner_AtaB 0.75s infinite linear;
+												}
+												@keyframes spinner_AtaB {
+													100% {
+														transform: rotate(360deg);
+													}
+												}
+											</style><path
+												d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+												opacity=".25"
+											/><path
+												d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+												class="spinner_ajPY"
+											/></svg
+										>
+									</div>
+								{/if}
+							</button>
+						</div>
+					</form>
+				{:else}
+					<div>
+						<Spinner />
+					</div>
+				{/if}
+			</div>
+		</div>
+	</div>
+</Modal>

+ 58 - 0
src/lib/components/admin/Settings/Models/ModelList.svelte

@@ -0,0 +1,58 @@
+<script lang="ts">
+	import Sortable from 'sortablejs';
+
+	import { createEventDispatcher, getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { models } from '$lib/stores';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
+
+	export let modelIds = [];
+
+	let sortable = null;
+	let modelListElement = null;
+
+	const positionChangeHandler = () => {
+		const modelList = Array.from(modelListElement.children).map((child) =>
+			child.id.replace('model-item-', '')
+		);
+
+		modelIds = modelList;
+	};
+
+	onMount(() => {
+		sortable = Sortable.create(modelListElement, {
+			animation: 150,
+			onUpdate: async (event) => {
+				positionChangeHandler();
+			}
+		});
+	});
+</script>
+
+{#if modelIds.length > 0}
+	<div class="flex flex-col -translate-x-1" bind:this={modelListElement}>
+		{#each modelIds as modelId, modelIdx (modelId)}
+			<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
+				<Tooltip content={modelId} placement="top-start">
+					<div class="flex items-center gap-1">
+						<EllipsisVertical className="size-4 cursor-move" />
+
+						<div class=" text-sm flex-1 py-1 rounded-lg">
+							{#if $models.find((model) => model.id === modelId)}
+								{$models.find((model) => model.id === modelId).name}
+							{:else}
+								{modelId}
+							{/if}
+						</div>
+					</div>
+				</Tooltip>
+			</div>
+		{/each}
+	</div>
+{:else}
+	<div class="text-gray-500 text-xs text-center py-2">
+		{$i18n.t('No models found')}
+	</div>
+{/if}

+ 0 - 2
src/lib/components/chat/ModelSelector.svelte

@@ -5,9 +5,7 @@
 	import Selector from './ModelSelector/Selector.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
 
-	import { setDefaultModels } from '$lib/apis/configs';
 	import { updateUserSettings } from '$lib/apis/users';
-
 	const i18n = getContext('i18n');
 
 	export let selectedModels = [''];

+ 3 - 3
src/lib/components/common/Modal.svelte

@@ -6,7 +6,7 @@
 
 	export let show = true;
 	export let size = 'md';
-	export let className = 'bg-gray-50 dark:bg-gray-900  rounded-2xl';
+	export let className = 'bg-gray-50 dark:bg-gray-900 rounded-2xl';
 
 	let modalElement = null;
 	let mounted = false;
@@ -65,7 +65,7 @@
 	<!-- svelte-ignore a11y-no-static-element-interactions -->
 	<div
 		bind:this={modalElement}
-		class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-[9999] overflow-hidden overscroll-contain"
+		class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] p-3 flex justify-center z-[9999] overflow-y-auto overscroll-contain"
 		in:fade={{ duration: 10 }}
 		on:mousedown={() => {
 			show = false;
@@ -74,7 +74,7 @@
 		<div
 			class=" m-auto max-w-full {sizeToWidth(size)} {size !== 'full'
 				? 'mx-2'
-				: ''} shadow-3xl max-h-[100dvh] overflow-y-auto scrollbar-hidden {className}"
+				: ''} shadow-3xl min-h-fit scrollbar-hidden {className}"
 			in:flyAndScale
 			on:mousedown={(e) => {
 				e.stopPropagation();