This commit is contained in:
Timothy Jaeryang Baek 2026-10-05 14:35:15 +04:00
parent d4c561d9f2
commit 398c37c73c
13 changed files with 231 additions and 69 deletions

View file

@ -147,6 +147,7 @@ from open_webui.models.config import Config
from open_webui.models.functions import Functions
from open_webui.models.messages import Messages
from open_webui.models.models import Models, normalize_model_tags
from open_webui.models.groups import Groups, resolve_group_default_models
from open_webui.models.users import Users
from open_webui.routers import (
analytics,
@ -2277,6 +2278,24 @@ async def get_app_config(request: Request):
if data is not None and 'id' in data and await is_valid_token(data, request.app.state.redis):
user = await Users.get_user_by_id(data['id'])
group_defaults = None
if user is not None and user.role in ('admin', 'user'):
group_defaults, _ = resolve_group_default_models(
await Groups.get_groups_by_member_id(user.id, include_inherited=True)
)
if group_defaults:
try:
models = (await get_models(request, user=user))['data']
available = {
model['id']
for model in models
if not ((model.get('info') or {}).get('meta') or {}).get('hidden', False)
}
group_defaults = [model_id for model_id in group_defaults if model_id in available]
except Exception:
log.exception('Unable to resolve available group default models')
group_defaults = None
onboarding = False
if user is None:
onboarding = not await Users.has_users()
@ -2425,7 +2444,7 @@ async def get_app_config(request: Request):
},
**(
{
'default_models': config.get('ui.default_models'),
'default_models': ','.join(group_defaults) if group_defaults else config.get('ui.default_models'),
'default_pinned_models': config.get('ui.default_pinned_models'),
'default_prompt_suggestions': config.get('ui.prompt_suggestions'),
'default_prompt_suggestions_i18n': config.get('ui.prompt_suggestions_i18n'),

View file

@ -8,7 +8,7 @@ from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
from open_webui.internal.db import Base, JSONField, get_async_db_context, get_async_db
from open_webui.models.access_grants import AccessGrant
from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import (
JSON,
BigInteger,
@ -129,6 +129,21 @@ class GroupForm(BaseModel):
permissions: Optional[dict] = None
data: Optional[dict] = None
@field_validator('data')
@classmethod
def validate_default_models(cls, data):
if data is None or 'config' not in data:
return data
config = data['config']
if not isinstance(config, dict):
raise ValueError('Group config must be an object.')
if 'default_models' not in config or config['default_models'] is None:
return data
models = config['default_models']
if not isinstance(models, list) or any(not isinstance(model, str) or not model.strip() for model in models):
raise ValueError('Default models must be a list of non-empty model IDs.')
return {**data, 'config': {**config, 'default_models': list(dict.fromkeys(model.strip() for model in models))}}
class UserIdsForm(BaseModel):
user_ids: Optional[list[str]] = None
@ -149,6 +164,33 @@ class GroupHierarchyError(ValueError):
self.status_code = status_code
def group_default_models(group):
return ((group.data or {}).get('config') or {}).get('default_models') or None
def resolve_group_default_models(groups):
"""Resolve an ancestor-complete group list by depth, then creation time and ID."""
by_id = {group.id: group for group in groups}
depths = {}
for group in groups:
path = []
seen = set()
current = group
while current and current.id not in depths and current.id not in seen:
seen.add(current.id)
path.append(current.id)
current = by_id.get(current.parent_group_id)
depth = depths.get(current.id, -1) if current else -1
for group_id in reversed(path):
depth += 1
depths[group_id] = depth
configured = [group for group in groups if group_default_models(group)]
if not configured:
return None, None
winner = min(configured, key=lambda group: (-depths[group.id], group.created_at, group.id))
return group_default_models(winner), winner.id
def ancestor_groups(group_ids):
"""Identifier-only recursion: UNION also terminates on externally introduced cycles."""
chain = select(Group.id.label('group_id')).where(Group.id.in_(group_ids)).cte(recursive=True)
@ -504,13 +546,28 @@ class GroupTable:
if 'parent_group_id' in form_data.model_fields_set:
await validate_parent(session, id, form_data.parent_group_id)
values['parent_group_id'] = form_data.parent_group_id
if 'data' in values:
old_data = group.data or {}
new_data = values['data']
values['data'] = {
**old_data,
**new_data,
'config': {**(old_data.get('config') or {}), **(new_data.get('config') or {})},
}
defaults_changed = 'data' in values and (
(values['data'].get('config') or {}).get('default_models') or None
) != group_default_models(group)
parent_changed = values.get('parent_group_id', group.parent_group_id) != group.parent_group_id
if changes is not None:
changes.update(
old_parent_group_id=group.parent_group_id,
parent_group_id=values.get('parent_group_id', group.parent_group_id),
)
if parent_changed or ('permissions' in values and values['permissions'] != group.permissions):
if (
parent_changed
or defaults_changed
or ('permissions' in values and values['permissions'] != group.permissions)
):
members = group_user_memberships([id], True)
affected = list((await session.execute(select(members.c.user_id))).scalars())
for key, value in values.items():

View file

@ -12,6 +12,8 @@ from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import (
GroupForm,
group_default_models,
resolve_group_default_models,
GroupHierarchyError,
Group,
GroupMember,
@ -423,8 +425,19 @@ async def preview_group_access(
JSONCodec.loads(JSONCodec.dumps(inherited_permissions)), group.permissions or {}
)
default_models, source_group_id = resolve_group_default_models([group, *ancestors])
if default_models is None:
default_models = [
model.strip() for model in (await Config.get('ui.default_models') or '').split(',') if model.strip()
]
return {
'group': {'id': group.id, 'name': group.name},
'default_models': {
'local': group_default_models(group),
'effective': default_models,
'source_group_id': source_group_id,
},
'models': {
'items': [{'id': m.id, 'name': m.name} for m in active_models if m.id in accessible_model_ids],
'total': len(active_models),

View file

@ -1,5 +1,5 @@
<script context="module">
/** @typedef {{ id: string, name: string, parent_group_id?: string | null, path: string, ancestor_ids: string[] }} GroupListItem */
/** @typedef {{ id: string, name: string, parent_group_id?: string | null, path: string, ancestor_ids: string[], data?: { config?: { default_models?: string[] | null } } }} GroupListItem */
</script>
<script>

View file

@ -1,5 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getModelsConfig } from '$lib/apis/configs';
import { models } from '$lib/stores';
import ModelSelector from '$lib/components/chat/ModelSelector/Selector.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@ -31,6 +35,45 @@
group.path.toLowerCase().includes(parentSearch.toLowerCase()))
)
.sort((a, b) => a.path.localeCompare(b.path));
$: if (!Array.isArray(data?.config?.default_models)) {
data = { ...data, config: { ...data?.config, default_models: [] } };
}
$: modelItems = [
...$models.map((model) => ({ value: model.id, label: model.name || model.id, model })),
...(data?.config?.default_models ?? [])
.filter((id: string) => !$models.some((model) => model.id === id))
.map((id: string) => ({
value: id,
label: id,
model: { id, name: id, owned_by: 'openai' as const, external: false }
}))
];
let globalDefaultModels: string[] = [];
onMount(async () => {
try {
const modelConfig = await getModelsConfig(localStorage.token);
globalDefaultModels = modelConfig.DEFAULT_MODELS?.split(',').filter(Boolean) ?? [];
} catch (error) {
toast.error(String(error));
}
});
$: {
inheritedModelIds = globalDefaultModels;
inheritedModelSource = $i18n.t('Global defaults');
const seen = new Set<string>();
let parent = groups.find((group) => group.id === parent_group_id);
while (parent && !seen.has(parent.id)) {
seen.add(parent.id);
if (parent.data?.config?.default_models?.length) {
inheritedModelIds = parent.data.config.default_models;
inheritedModelSource = parent.path;
break;
}
parent = groups.find((group) => group.id === parent?.parent_group_id);
}
}
let inheritedModelIds: string[] = [];
let inheritedModelSource = '';
export let onDelete: Function = () => {};
</script>
@ -132,6 +175,53 @@
</div>
</div>
<div class="mb-3 space-y-1">
<div class="flex items-center justify-between gap-3">
<Tooltip
content={$i18n.t(
'Leave unset to inherit default models. Personal model selections take precedence.'
)}
>
<label for="model-selector-group-defaults-button" class="shrink-0 text-xs text-gray-500"
>{$i18n.t('Default models')}</label
>
</Tooltip>
<div class="flex min-w-0 max-w-[65%] items-center gap-2">
{#if data?.config?.default_models?.length}
<button
type="button"
class="shrink-0 whitespace-nowrap text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
data = { ...data, config: { ...data.config, default_models: [] } };
}}
>
{$i18n.t('Inherit')}
</button>
{/if}
<div class="min-w-0 flex-1">
<ModelSelector
id="group-defaults"
items={modelItems}
bind:values={data.config.default_models}
compareEnabled={true}
selectionOnly={true}
includeHidden={true}
placeholder={$i18n.t('Inherit')}
triggerClassName="text-sm"
align="end"
/>
</div>
</div>
</div>
{#if !data?.config?.default_models?.length}
<p class="text-xs text-gray-500">
{inheritedModelSource}{inheritedModelIds.length ? ': ' : ''}{inheritedModelIds
.map((id) => $models.find((model) => model.id === id)?.name || id)
.join(', ')}
</p>
{/if}
</div>
<!-- <div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>

View file

@ -53,6 +53,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
resolveDefaultModelIds,
convertMessagesToHistory,
copyToClipboard,
getMessageContentParts,
@ -196,28 +197,14 @@
let serverContextUsage = null;
let contextUsage = null;
const getAvailableModelIds = () =>
$models.filter((m) => !(m?.info?.meta?.hidden ?? false)).map((m) => m.id);
const getDefaultModelIds = () =>
$config?.default_models ? $config.default_models.split(',') : [];
const normalizeSelectedModels = (modelIds: string[] = []) => {
const availableModels = getAvailableModelIds();
const defaultModels = getDefaultModelIds();
let normalized = (modelIds ?? []).filter(
(modelId) => modelId && availableModels.includes(modelId)
const selected = resolveDefaultModelIds(
$models,
modelIds,
$settings?.models,
$config?.default_models?.split(',')
);
if (normalized.length === 0 && $settings?.models?.length) {
normalized = $settings.models.filter((modelId) => availableModels.includes(modelId));
}
if (normalized.length === 0 && defaultModels.length > 0) {
normalized = defaultModels.filter((modelId) => availableModels.includes(modelId));
}
if (normalized.length === 0) {
normalized = availableModels.length > 0 ? [availableModels[0]] : [''];
}
return normalized;
return selected.length ? selected : [''];
};
$: {
@ -2097,7 +2084,7 @@
.filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id);
const defaultModels = $config?.default_models ? $config?.default_models.split(',') : [];
const defaultModels = normalizeSelectedModels();
const openModelSelectorWithSearch = async (modelId: string) => {
const modelSelectorButton = document.getElementById('model-selector-model-button');
@ -2160,7 +2147,7 @@
}
// Unavailable & hidden models filtering
selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId));
selectedModels = normalizeSelectedModels(selectedModels);
}
// Ensure at least one model is selected

View file

@ -903,7 +903,7 @@
}}
/>
<svelte:window on:click|capture={handleWindowClick} on:keydown={handleKeydown} />
<svelte:window on:click|capture={handleWindowClick} on:keydown|capture={handleKeydown} />
<div class="relative flex w-full">
<button

View file

@ -21,7 +21,13 @@
dayjs.extend(duration);
dayjs.extend(relativeTime);
import { compressImage, copyToClipboard, convertHeicToJpeg, isHeicImage } from '$lib/utils';
import {
resolveDefaultModelIds,
compressImage,
copyToClipboard,
convertHeicToJpeg,
isHeicImage
} from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getFileById, uploadFile } from '$lib/apis/files';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
@ -930,28 +936,9 @@ ${content}
onMount(async () => {
await tick();
if ($settings?.models) {
selectedModelId = $settings?.models[0];
} else if ($config?.default_models) {
selectedModelId = $config?.default_models.split(',')[0];
} else {
selectedModelId = '';
}
if (selectedModelId) {
const model = $models
.filter((model) => model.id === selectedModelId && !(model?.info?.meta?.hidden ?? false))
.find((model) => model.id === selectedModelId);
if (!model) {
selectedModelId = '';
}
}
if (!selectedModelId) {
selectedModelId =
$models.filter((model) => !(model?.info?.meta?.hidden ?? false)).at(0)?.id || '';
}
selectedModelId =
resolveDefaultModelIds($models, $settings?.models, $config?.default_models?.split(','))[0] ??
'';
const dropzoneElement = document.getElementById('note-editor');

View file

@ -17,7 +17,7 @@
import { chatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import { splitStream, resolveDefaultModelIds } from '$lib/utils';
import Collapsible from '../common/Collapsible.svelte';
import Dropdown from '../common/Dropdown.svelte';
import DropdownMenu from '../common/DropdownMenu.svelte';
@ -307,13 +307,9 @@
await goto('/');
}
if ($settings?.models) {
selectedModelId = $settings?.models[0];
} else if ($config?.default_models) {
selectedModelId = $config?.default_models.split(',')[0];
} else {
selectedModelId = '';
}
selectedModelId =
resolveDefaultModelIds($models, $settings?.models, $config?.default_models?.split(','))[0] ??
'';
loaded = true;
});
</script>

View file

@ -8,7 +8,7 @@
import { WEBUI_NAME, config, user, models, settings, showSidebar } from '$lib/stores';
import { chatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import { splitStream, resolveDefaultModelIds } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
@ -108,13 +108,9 @@
await goto('/');
}
if ($settings?.models) {
selectedModelId = $settings?.models[0];
} else if ($config?.default_models) {
selectedModelId = $config?.default_models.split(',')[0];
} else {
selectedModelId = '';
}
selectedModelId =
resolveDefaultModelIds($models, $settings?.models, $config?.default_models?.split(','))[0] ??
'';
loaded = true;
});
</script>

View file

@ -3559,5 +3559,9 @@
"Move to top level": "Move to top level",
"Expand {{name}}": "Expand {{name}}",
"Collapse {{name}}": "Collapse {{name}}",
"Group moved successfully": "Group moved successfully"
"Group moved successfully": "Group moved successfully",
"Default models": "Default models",
"Global defaults": "Global defaults",
"Inherit": "Inherit",
"Leave unset to inherit default models. Personal model selections take precedence.": "Leave unset to inherit default models. Personal model selections take precedence."
}

View file

@ -33,6 +33,18 @@ import { decode } from 'html-entities';
// house falls. Let the quiet work here hold.
//////////////////////////
export const resolveDefaultModelIds = (
models: { id: string; info?: { meta?: { hidden?: boolean } } }[],
...preferences: (string[] | null | undefined)[]
): string[] => {
const available = models.filter((model) => !model.info?.meta?.hidden).map((model) => model.id);
for (const preference of preferences) {
const selected = [...new Set(preference ?? [])].filter((id) => available.includes(id));
if (selected.length) return selected;
}
return available.length ? [available[0]] : [];
};
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const formatNumber = (num: number): string => {

View file

@ -6,7 +6,7 @@
import { page } from '$app/stores';
import { fade } from 'svelte/transition';
import { getModels, getToolServersData, getVersionUpdates } from '$lib/apis';
import { getBackendConfig, getModels, getToolServersData, getVersionUpdates } from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { getSessionUser } from '$lib/apis/auths';
@ -177,6 +177,7 @@
try {
user.set(await getSessionUser(localStorage.token));
const results = await Promise.allSettled([
getBackendConfig().then((value) => config.set(value)),
setModels(),
setTools(),
setToolServers(),