mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-07 02:58:21 +00:00
refac
This commit is contained in:
parent
d4c561d9f2
commit
398c37c73c
13 changed files with 231 additions and 69 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue