This commit is contained in:
Timothy Jaeryang Baek 2026-10-06 21:45:10 +04:00
parent d3913657df
commit 106aae70e9
16 changed files with 711 additions and 85 deletions

View file

@ -263,6 +263,7 @@ from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.utils.payload import apply_model_controls
from open_webui.utils.oauth import (
OAuthClientInformationFull,
OAuthClientManager,
@ -1192,7 +1193,19 @@ async def chat_completion(
default_model_params,
model_info.params.model_dump() if model_info and model_info.params else {},
)
model_info_params.pop('model_controls', None)
request_params = {key: value for key, value in (form_data.get('params') or {}).items() if value is not None}
model_controls = request_params.pop('model_controls', {})
if not isinstance(model_controls, dict):
raise HTTPException(400, 'Model control selections must be keyed by model.')
model_controls = {} if form_data.get('automation_id') else model_controls
if any(model_controls.values()) and user.role != 'admin':
permissions = await Config.get('user.permissions')
for permission in ('chat.controls', 'chat.params'):
if not await has_permission(user.id, permission, permissions):
raise HTTPException(403, 'You cannot change model parameters.')
if missing_base_model and model_controls.get(model_id):
raise HTTPException(400, 'Model control selections cannot be applied to the fallback model.')
if model_info_params or request_params:
form_data['params'] = merge_model_params(model_info_params, request_params)
@ -1313,6 +1326,7 @@ async def chat_completion(
or 'native'
),
'tool_approval_mode': tool_approval_mode,
'model_controls': model_controls,
},
}
@ -1670,6 +1684,15 @@ async def chat_completion(
error_detail = None
try:
try:
if not metadata.get('direct'):
target = (
model_info if form_data['model'] == model_id
else await Models.get_model_by_id(form_data['model'])
)
controls = target.params.model_dump().get('model_controls', {}) if target else {}
form_data['params'] = apply_model_controls(
copy.deepcopy(form_data.get('params') or {}), controls, model_controls.get(form_data['model'], {})
)
ctx = None
# Saved chats load the message after approved tool calls run, so their results are kept
if metadata.get('assistant_message_id') and not is_saved_chat_id(metadata.get('chat_id')):

View file

@ -1,9 +1,10 @@
from __future__ import annotations
import logging
import re
import time
from copy import deepcopy
from typing import Any
from typing import Annotated, Any
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
@ -11,7 +12,7 @@ from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_image_url
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationInfo, field_validator, model_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -65,11 +66,38 @@ def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
# --- Models DB Schema ---
ModelControlKey = Annotated[str, Field(pattern=re.compile(r'^(?!(?:constructor|prototype)\Z)[a-zA-Z][a-zA-Z0-9_-]*\Z'))]
class ModelControlOption(BaseModel):
model_config = ConfigDict(allow_inf_nan=False)
label: str = Field(pattern=r'\S')
params: dict[str, JsonValue]
class ModelControl(BaseModel):
label: str = Field(pattern=r'\S')
description: str | None = Field(default=None, exclude_if=lambda value: value is None)
default: str | None = Field(default=None, exclude_if=lambda value: value is None)
options: dict[ModelControlKey, ModelControlOption] = Field(min_length=1)
@model_validator(mode='after')
def check_default(self):
if self.default is not None and self.default not in self.options:
raise ValueError('Default must name an approved option.')
return self
class ModelParams(BaseModel):
"""Parameters for model inference (temperature, top_p, etc.)."""
model_config = ConfigDict(extra='allow')
model_controls: dict[ModelControlKey, ModelControl] = Field(
default_factory=dict, exclude_if=lambda value: not value
)
class ModelMeta(BaseModel):
"""Metadata for a workspace model entry (profile, description, tags, capabilities)."""

View file

@ -47,6 +47,7 @@ from open_webui.utils.access_control import filter_allowed_access_grants, has_ac
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_variables import get_chat_variables_schema
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.models import get_all_models
from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image
from pydantic import BaseModel, Field
@ -57,6 +58,32 @@ log = logging.getLogger(__name__)
router = APIRouter()
def model_response(model, user):
if model is None:
return None
data = model.model_dump()
if user.role != 'admin':
data.get('params', {}).pop('model_controls', None)
return data
async def _check_model_controls(form, previous, user, request):
old = previous.params.model_dump().get('model_controls', {}) if previous else {}
if 'model_controls' not in form.params.model_fields_set:
if old:
form.params.model_controls = previous.params.model_controls
return
controls = form.params.model_dump().get('model_controls', {})
if user.role != 'admin' and JSONCodec.dumps(controls) != JSONCodec.dumps(old):
raise HTTPException(403, 'Only admins can change model controls.')
if controls:
if not request.app.state.MODELS:
await get_all_models(request, user=user)
base = request.app.state.MODELS.get(form.base_model_id or form.id, {})
if 'pipe' in base or base.get('direct') or base.get('owned_by') == 'arena':
raise HTTPException(400, 'Model controls require a server-managed provider model.')
def add_chat_variables_schema(model_dict: dict) -> dict:
system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None
schema = get_chat_variables_schema(system)
@ -217,7 +244,7 @@ async def get_models(
# Strip profile_image_url from meta — images are served via /model/profile/image.
items = []
for model in result.items:
data = add_chat_variables_schema(model.model_dump())
data = add_chat_variables_schema(model_response(model, user))
if data.get('meta'):
data['meta'].pop('profile_image_url', None)
write_access = (
@ -352,6 +379,7 @@ async def create_new_model(
'sharing.public_models',
)
await _check_model_controls(form_data, None, user, request)
model = await Models.insert_new_model(form_data, user.id, db=db)
if not model:
raise HTTPException(
@ -366,7 +394,7 @@ async def create_new_model(
subject_id=model.id,
data={'name': model.name},
)
return model
return model_response(model, user)
############################
@ -406,7 +434,7 @@ async def export_models(
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
exported = []
for model in models:
data = model.model_dump()
data = model_response(model, user)
url = model.meta.background_image_url
if url:
try:
@ -602,6 +630,7 @@ async def import_models(
)
imported_model = new_model
await _check_model_controls(imported_model, existing_model, user, request)
uploaded = None
try:
encoded = model_data.pop('background_image_data', None)
@ -692,6 +721,7 @@ async def sync_models(
existing = {model.id: model for model in await Models.get_models_by_ids([m.id for m in form_data.models], db=db)}
for model in form_data.models:
previous = existing.get(model.id)
await _check_model_controls(model, previous, user, request)
if previous and 'background_image_url' not in model.meta.model_fields_set:
model.meta.background_image_url = previous.meta.background_image_url
await _verify_background_image(
@ -741,7 +771,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
permission='read',
db=db,
):
model_dict = model.model_dump()
model_dict = model_response(model, user)
model_dict = add_chat_variables_schema(model_dict)
# Strip params (system prompt and other admin-curated config)
# for read-only callers — matches the params strip already
@ -909,7 +939,7 @@ async def toggle_model_by_id(
subject_type='model',
data={'name': model.name},
)
return model
return model_response(model, user)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -1012,6 +1042,7 @@ async def update_model_by_id(
'sharing.public_models',
)
await _check_model_controls(form_data, model, user, request)
model = await Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db)
if model:
await publish_event(
@ -1021,7 +1052,7 @@ async def update_model_by_id(
subject_id=model.id,
data={'name': model.name},
)
return model
return model_response(model, user)
############################
@ -1103,7 +1134,7 @@ async def update_model_access_by_id(
actor=user,
subject_id=form_data.id,
)
return model
return model_response(model, user)
############################

View file

@ -32,6 +32,23 @@ log = logging.getLogger(__name__)
BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base'
def public_model_params(params):
controls = params.get('model_controls', {})
return (
{
'model_controls': {
key: {
**{field: control[field] for field in ('label', 'description', 'default') if field in control},
'options': {key: {'label': option['label']} for key, option in control['options'].items()},
}
for key, control in controls.items()
}
}
if controls
else {}
)
async def fetch_ollama_models(request: Request, user: UserModel = None):
raw_ollama_models = await ollama.get_all_models(request, user=user)
return [
@ -209,8 +226,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
action_ids.extend(model['info']['meta'].get('actionIds', []))
filter_ids.extend(model['info']['meta'].get('filterIds', []))
if 'params' in model['info']:
del model['info']['params']
model['info']['params'] = public_model_params(model['info'].get('params', {}))
model['action_ids'] = action_ids
model['filter_ids'] = filter_ids
@ -253,9 +269,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
info.setdefault('meta', {})['chat_variables_schema'] = schema
elif isinstance(info.get('meta'), dict):
info['meta'].pop('chat_variables_schema', None)
if 'params' in info:
# Remove params to avoid exposing sensitive info
del info['params']
info['params'] = public_model_params(info.get('params', {}))
model['info'] = info

View file

@ -1,6 +1,9 @@
import logging
from copy import deepcopy
from typing import Callable, Optional
from fastapi import HTTPException
from open_webui.utils.chat_variables import render_chat_variables, render_user_variables
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import (
@ -14,6 +17,22 @@ from open_webui.utils.task import prompt_template, prompt_variables_template
log = logging.getLogger(__name__)
def apply_model_controls(params: dict, controls: dict, selections: dict) -> dict:
"""Expand approved choices into ordinary Custom Params before normal request processing."""
if not isinstance(selections, dict):
raise HTTPException(400, 'Model control selections must be an object.')
for key, choice in selections.items():
if key not in controls or not isinstance(choice, str) or choice not in controls[key]['options']:
raise HTTPException(400, f'Model control {key}: the selected option is no longer available.')
for key, control in controls.items():
choice = selections.get(key, control.get('default'))
if choice is not None:
params['custom_params'] = deep_update(
deepcopy(params.get('custom_params') or {}), deepcopy(control['options'][choice]['params'])
)
return params
async def resolve_system_prompt(
system: Optional[str],
metadata: Optional[dict] = None,
@ -96,6 +115,7 @@ def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None
'system': str,
'note_id': str,
'tool_approval_mode': str,
'model_controls': dict,
}
for key in list(params.keys()):
@ -151,6 +171,7 @@ def remove_open_webui_params(params: dict) -> dict:
'system': str,
'note_id': str,
'tool_approval_mode': str,
'model_controls': dict,
}
for key in list(params.keys()):

View file

@ -1790,4 +1790,13 @@ export interface ModelMeta {
background_image_url?: string | null;
}
export interface ModelParams {}
export interface ModelControl {
label: string;
description?: string;
default?: string | null;
options: Record<string, { label: string; params?: Record<string, any> }>;
}
export interface ModelParams {
model_controls?: Record<string, ModelControl>;
}

View file

@ -1268,6 +1268,7 @@
</div>
{:else}
<ModelEditor
admin
edit
model={models.find((m) => m.id === selectedModelId)}
preset={false}

View file

@ -0,0 +1,294 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
// @ts-expect-error The existing SortableJS dependency does not ship declarations.
import Sortable from 'sortablejs';
import type { ModelControl } from '$lib/apis';
import CustomParams from '$lib/components/chat/Settings/Advanced/CustomParams.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import NativeSelect from '$lib/components/common/NativeSelect.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
const i18n: any = getContext('i18n');
export let controls: Record<string, ModelControl> = {};
let show = false;
let editingKey = '';
let label = '';
let description = '';
let defaultOption = '';
let options: { id: string; key?: string; label: string; params: Record<string, any> }[] = [];
let nameInput: HTMLInputElement;
const actionClass =
'flex size-7 shrink-0 items-center justify-center rounded-lg text-gray-500 transition hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200';
const inputClass =
'w-full min-w-0 bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700';
const keyFor = (label: string, items: object) => {
let base = label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '');
if (!/^[a-z]/.test(base) || ['constructor', 'prototype'].includes(base))
base = `option_${base}`;
let key = base;
let index = 2;
while (key in items) key = `${base}_${index++}`;
return key;
};
const focus = (node: HTMLInputElement) => node.focus();
const addOption = () => {
options = [...options, { id: crypto.randomUUID(), label: '', params: {} }];
};
const edit = async (key = '') => {
editingKey = key;
const control = controls?.[key];
label = control?.label ?? '';
description = control?.description ?? '';
defaultOption = control?.default ?? '';
options = Object.entries(control?.options ?? {}).map(([key, option]) => ({
id: key,
key,
label: option.label,
params: structuredClone(option.params ?? {})
}));
if (!options.length) addOption();
show = true;
await tick();
nameInput?.focus();
};
const apply = () => {
const saved: ModelControl['options'] = {};
// Reserve existing keys so a new option cannot overwrite a renamed option.
const keys = Object.fromEntries(
options.filter((option) => option.key).map((option) => [option.key!, true])
);
let selected: string | null = null;
for (const option of options) {
const key = option.key ?? keyFor(option.label, keys);
keys[key] = true;
saved[key] = { label: option.label.trim(), params: option.params };
if (option.id === defaultOption) selected = key;
}
controls = {
...controls,
[editingKey || keyFor(label, controls ?? {})]: {
label: label.trim(),
...(description.trim() ? { description: description.trim() } : {}),
default: selected,
options: saved
}
};
show = false;
};
const move = <T,>(items: T[], from: number, to: number): T[] => {
if (to < 0 || to >= items.length) return items;
const result = [...items];
result.splice(to, 0, result.splice(from, 1)[0]);
return result;
};
const moveControl = (from: number, to: number) => {
controls = Object.fromEntries(move(Object.entries(controls ?? {}), from, to));
};
const moveOption = (from: number, to: number) => {
options = move(options, from, to);
};
const keyboardMove = (
event: KeyboardEvent,
index: number,
reorder: (from: number, to: number) => void
) => {
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
reorder(index, index + (event.key === 'ArrowUp' ? -1 : 1));
};
const sortable = (node: HTMLElement, reorder: (from: number, to: number) => void) => {
let nextSibling: ChildNode | null;
const instance = new Sortable(node, {
animation: 150,
handle: '.sort-handle',
onStart: ({ item }: { item: HTMLElement }) => (nextSibling = item.nextSibling),
onEnd: ({
item,
from,
oldIndex,
newIndex
}: {
item: HTMLElement;
from: HTMLElement;
oldIndex: number;
newIndex: number;
}) => {
// Let Svelte reconcile the keyed rows in their original DOM order.
from.insertBefore(item, nextSibling);
reorder(oldIndex, newIndex);
}
});
return { destroy: () => instance.destroy() };
};
</script>
<div>
<div class="flex h-7 w-full items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Model controls')}</span>
<button
type="button"
class="rounded p-1 text-gray-500 transition hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"
aria-label={$i18n.t('Add control')}
title={$i18n.t('Add control')}
on:click={() => edit()}><Plus className="size-3" /></button
>
</div>
<p class="mb-1 text-xs leading-4 text-gray-400 dark:text-gray-600">
{$i18n.t('Let people choose approved parameter presets in chat.')}
</p>
<div use:sortable={moveControl}>
{#each Object.entries(controls ?? {}) as [key, control], index (key)}
<div class="flex items-center gap-1 py-0.5">
<button
type="button"
class={`${actionClass} sort-handle cursor-grab`}
aria-label={`${$i18n.t('Reorder')} ${control.label}`}
title={$i18n.t('Drag or use the up and down arrow keys to reorder')}
on:keydown={(event) => keyboardMove(event, index, moveControl)}
><EllipsisVertical className="size-4" /></button
>
<button
type="button"
class="flex min-w-0 flex-1 items-center justify-between gap-3 py-1 text-left text-[0.8125rem] text-gray-700 dark:text-gray-300"
aria-label={`${$i18n.t('Edit')} ${control.label}`}
on:click={() => edit(key)}
>
<span class="truncate">{control.label}</span>
<span class="flex min-w-0 items-center gap-3 text-xs">
<span class="truncate text-gray-400 dark:text-gray-600"
>{$i18n.t('Default option')}: {control.options[control.default ?? '']?.label ??
$i18n.t('None')}</span
>
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('Edit')}</span>
</span>
</button>
<button
type="button"
class="ml-2 flex shrink-0 items-center justify-center rounded text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
aria-label={`${$i18n.t('Remove')} ${control.label}`}
on:click={() => {
delete controls[key];
controls = { ...controls };
}}><XMark className="size-3" /></button
>
</div>
{/each}
</div>
</div>
<Modal size="sm" bind:show>
<div class="px-5 pb-5 pt-4 dark:text-gray-200">
<div class="mb-3 flex items-center justify-between gap-3">
<div class="font-primary text-lg font-medium">
{$i18n.t(editingKey ? 'Edit control' : 'Add control')}
</div>
<button
type="button"
class={actionClass}
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}><XMark className="size-5" /></button
>
</div>
<form class="space-y-2.5" on:submit|preventDefault|stopPropagation={apply}>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,8rem)] gap-4">
<label class="block min-w-0 text-xs leading-4">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Name')}</span>
<input
bind:this={nameInput}
class={inputClass}
aria-label={$i18n.t('Control name')}
placeholder={$i18n.t('e.g. Thinking')}
bind:value={label}
required
/>
</label>
<label class="block min-w-0 text-xs leading-4">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Default option')}</span>
<NativeSelect
value={defaultOption}
options={[
{ value: '', label: $i18n.t('None') },
...options
.filter((option) => option.label.trim())
.map((option) => ({ value: option.id, label: option.label }))
]}
className={`${inputClass} block`}
on:change={(event) => (defaultOption = event.detail)}
/>
</label>
</div>
<label class="block text-xs leading-4">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Description')}</span>
<input class={inputClass} placeholder={$i18n.t('Optional')} bind:value={description} />
</label>
<div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Options')}</span>
<button
type="button"
class="rounded p-1 text-gray-500 transition hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"
aria-label={$i18n.t('Add option')}
title={$i18n.t('Add option')}
on:click={addOption}><Plus className="size-3" /></button
>
</div>
<div class="divide-y divide-gray-100/60 dark:divide-gray-850/60" use:sortable={moveOption}>
{#each options as option, index (option.id)}
<div class="py-1.5">
<div class="flex items-center gap-1">
<button
type="button"
class={`${actionClass} sort-handle cursor-grab`}
aria-label={`${$i18n.t('Reorder')} ${option.label || $i18n.t('option')}`}
title={$i18n.t('Drag or use the up and down arrow keys to reorder')}
on:keydown={(event) => keyboardMove(event, index, moveOption)}
><EllipsisVertical className="size-4" /></button
>
<input
class={inputClass}
aria-label={$i18n.t('Option name')}
use:focus
placeholder={$i18n.t('Option name')}
bind:value={option.label}
required
/>
<button
type="button"
class="flex shrink-0 items-center justify-center rounded text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
aria-label={`${$i18n.t('Remove')} ${option.label || $i18n.t('option')}`}
on:click={() => {
options = options.filter((item) => item.id !== option.id);
if (defaultOption === option.id) defaultOption = '';
}}><XMark className="size-3" /></button
>
</div>
<div class="pl-8"><CustomParams compact bind:value={option.params} /></div>
</div>
{/each}
</div>
</div>
<div class="flex justify-end gap-1.5 pt-1 text-sm font-medium">
<button
type="button"
class="rounded-full px-3.5 py-1.5 transition hover:bg-gray-100 dark:hover:bg-gray-850"
on:click={() => (show = false)}>{$i18n.t('Cancel')}</button
>
<button
type="submit"
disabled={!label.trim() ||
!options.length ||
options.some((option) => !option.label.trim())}
class="rounded-full bg-black px-3.5 py-1.5 text-white transition hover:bg-gray-950 disabled:opacity-40 dark:bg-white dark:text-black dark:hover:bg-gray-100"
>{$i18n.t('Apply')}</button
>
</div>
</form>
</div>
</Modal>

View file

@ -4504,6 +4504,7 @@
class=" pb-2 {dragged ? 'z-0' : 'z-10'}"
>
<MessageInput
bind:params
bind:this={messageInput}
{history}
{taskIds}
@ -4596,6 +4597,7 @@
{/if}
<div id={embedded ? messageInputDropzoneId : undefined} class="pb-2 z-10">
<MessageInput
bind:params
bind:this={messageInput}
{history}
{taskIds}
@ -4657,6 +4659,7 @@
{:else}
<div class="flex items-center h-full">
<Placeholder
bind:params
bind:selectedModelIdx
{history}
bind:selectedModels

View file

@ -81,6 +81,7 @@
import InputMenu from './MessageInput/InputMenu.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import ModelSelector from './ModelSelector.svelte';
import ModelControls from './MessageInput/ModelControls.svelte';
import ToolServersModal from './ToolServersModal.svelte';
import SkillsModal from './SkillsModal.svelte';
@ -141,6 +142,7 @@
export let statusHandler: Function = () => {};
export let forkHandler: Function = () => {};
export let chatId = '';
export let params = {};
export let contextUsage = null;
export let contextCompactionEnabled = false;
export let embedded = false;
@ -2616,6 +2618,7 @@
triggerClassName="items-center gap-1.5 rounded-lg pl-2 pr-1.5 py-1 text-[0.8125rem] font-normal text-gray-600 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
/>
</div>
<ModelControls selectedModels={selectedModelIds} bind:params />
{#if isActive && prompt === '' && files.length === 0}
<div class=" flex items-center">

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { models, user, mobile, settings, type Model } from '$lib/stores';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import DropdownSub from '$lib/components/common/DropdownSub.svelte';
import type { ModelControl } from '$lib/apis';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Knobs from '$lib/components/icons/Knobs.svelte';
import { resolveLocalizedModelName } from '$lib/utils/localizedContent';
const i18n: any = getContext('i18n');
export let selectedModels: string[] = [];
export let params: Record<string, any> = {};
let active: {
model: Model;
key: string;
control: ModelControl;
trigger: HTMLButtonElement;
} | null = null;
const back = async () => {
const trigger = active?.trigger;
active = null;
await tick();
if (trigger) document.getElementById(trigger.id)?.focus();
};
$: available = selectedModels
.map((id) => $models.find((model) => model.id === id))
.filter(
(model): model is Model =>
!!model &&
!('direct' in model && model.direct) &&
!('pipe' in model && model.pipe) &&
Object.keys(model.info?.params?.model_controls ?? {}).length > 0
);
$: permitted =
$user?.role === 'admin' ||
(($user?.permissions?.chat?.controls ?? true) && ($user?.permissions?.chat?.params ?? true));
$: rowClass = `focus-ring flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100 ${$settings?.highContrastMode ? 'hover:bg-gray-200! dark:hover:bg-gray-800!' : 'hover:bg-gray-50/40! dark:hover:bg-gray-800/40!'}`;
$: selectedClass = $settings?.highContrastMode
? 'bg-gray-200 dark:bg-gray-800'
: 'bg-gray-50/70 dark:bg-gray-800/60';
const select = (modelId: string, controlId: string, value: string) => {
const selections = { ...params.model_controls?.[modelId] };
if (value) selections[controlId] = value;
else delete selections[controlId];
params = { ...params, model_controls: { ...params.model_controls, [modelId]: selections } };
};
</script>
{#snippet summary(model: Model, key: string, control: ModelControl)}
<span class="min-w-0 flex-1 truncate text-left">{control.label}</span>
<span class="max-w-[55%] truncate text-gray-500 dark:text-gray-400">
{control.options[params.model_controls?.[model.id]?.[key] ?? control.default ?? '']?.label ??
$i18n.t('Default')}
</span>
<ChevronRight className="size-3.5 shrink-0 text-gray-400" />
{/snippet}
{#snippet choices(model: Model, key: string, control: ModelControl)}
{#if control.description}
<p class="px-2 py-1.5 text-xs leading-4 text-gray-500 dark:text-gray-400">
{control.description}
</p>
{/if}
<button
type="button"
role="menuitemradio"
aria-checked={!params.model_controls?.[model.id]?.[key]}
class={`${rowClass} ${!params.model_controls?.[model.id]?.[key] ? selectedClass : ''}`}
on:click={() => select(model.id, key, '')}
>
<span class="min-w-0 flex-1 truncate text-left">
{$i18n.t('Default')}{#if control.options[control.default ?? '']?.label}{' · '}{control
.options[control.default ?? ''].label}{/if}
</span>
{#if !params.model_controls?.[model.id]?.[key]}<Check className="size-3! shrink-0" />{/if}
</button>
{#each Object.entries(control.options) as [value, option] (value)}
<button
type="button"
role="menuitemradio"
aria-checked={params.model_controls?.[model.id]?.[key] === value}
class={`${rowClass} ${params.model_controls?.[model.id]?.[key] === value ? selectedClass : ''}`}
on:click={() => select(model.id, key, value)}
>
<span class="min-w-0 flex-1 truncate text-left">{option.label}</span>
{#if params.model_controls?.[model.id]?.[key] === value}<Check
className="size-3! shrink-0"
/>{/if}
</button>
{/each}
{/snippet}
{#if permitted && available.length}
<Dropdown align="end" visualViewportAware onOpenChange={() => (active = null)}>
<Tooltip content={$i18n.t('Model controls')} placement="top">
<button
type="button"
aria-label={$i18n.t('Model controls')}
class="mr-2 flex shrink-0 items-center justify-center text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<Knobs className="size-4" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-60 max-w-[calc(100vw-2rem)]">
{#if $mobile && active}
<button type="button" aria-label={$i18n.t('Back')} on:click={back}>
<ChevronLeft className="size-3.5" />
<span>{active.control.label}</span>
</button>
{@render choices(active.model, active.key, active.control)}
{:else}
{#each available as model (model.id)}
{#if selectedModels.length > 1}
<div class="truncate px-2 pt-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-500">
{resolveLocalizedModelName(model, $i18n.language)}
</div>
{/if}
{#each Object.entries(model.info?.params?.model_controls ?? {}) as [key, control] (key)}
{#if $mobile}
<button
type="button"
aria-label={control.label}
id={`model-control-${model.id}-${key}`}
class={rowClass}
on:click={(event) =>
(active = { model, key, control, trigger: event.currentTarget })}
>
{@render summary(model, key, control)}
</button>
{:else}
<DropdownSub
maxWidth={240}
contentClass="w-60 max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-thin"
>
<button
slot="trigger"
type="button"
aria-label={control.label}
aria-haspopup="menu"
class={rowClass}
>
{@render summary(model, key, control)}
</button>
{@render choices(model, key, control)}
</DropdownSub>
{/if}
{/each}
{/each}
{/if}
</DropdownMenu>
</div>
</Dropdown>
{/if}

View file

@ -47,6 +47,7 @@
export let history;
export let prompt = '';
export let params = {};
export let files = [];
export let messageInput = null;
@ -241,6 +242,7 @@
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
{#if !($selectedFolder && folderReadOnly)}
<MessageInput
bind:params
bind:this={messageInput}
{history}
bind:selectedModels

View file

@ -2,7 +2,7 @@
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import CustomParams from './CustomParams.svelte';
import type { i18n as i18nType } from 'i18next';
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
@ -1490,72 +1490,7 @@
</div>
{#if custom && admin}
<div class="flex flex-col justify-center">
{#each Object.keys(params?.custom_params ?? {}) as key}
<div class=" py-0.5 w-full justify-between mb-1">
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
<input
type="text"
class=" text-xs w-full bg-transparent outline-none"
aria-label={$i18n.t('Custom Parameter Name')}
placeholder={$i18n.t('Custom Parameter Name')}
value={key}
on:change={(e) => {
const newKey = e.currentTarget.value.trim();
if (newKey && newKey !== key) {
params.custom_params[newKey] = params.custom_params[key];
delete params.custom_params[key];
params = {
...params,
custom_params: { ...params.custom_params }
};
}
}}
/>
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
delete params.custom_params[key];
params = {
...params,
custom_params: { ...params.custom_params }
};
}}
>
{$i18n.t('Remove')}
</button>
</div>
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
bind:value={params.custom_params[key]}
type="text"
class="text-sm w-full bg-transparent outline-hidden outline-none"
aria-label={$i18n.t('Custom Parameter Value')}
placeholder={$i18n.t('Custom Parameter Value')}
/>
</div>
</div>
</div>
{/each}
<button
class=" flex gap-2 items-center w-full text-center justify-center mt-1 mb-5"
type="button"
on:click={() => {
params.custom_params = (params?.custom_params ?? {}) || {};
params.custom_params['custom_param_name'] = 'custom_param_value';
}}
>
<div>
<Plus />
</div>
<div>{$i18n.t('Add Custom Parameter')}</div>
</button>
</div>
<CustomParams bind:value={params.custom_params} />
{/if}
{/if}
</div>

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { getContext } from 'svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n: any = getContext('i18n');
export let compact = false;
export let value: Record<string, any> = {};
const focusNew = (node: HTMLInputElement, empty: boolean) => {
if (empty) node.focus();
};
</script>
<div class="flex flex-col justify-center">
<div class="min-w-0 flex-1">
{#each Object.keys(value ?? {}) as key}
<div
class={compact
? 'mb-1 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3 py-1'
: 'mb-1 grid grid-cols-[1fr_auto] items-center gap-y-0.5 py-0.5'}
>
<label class="min-w-0">
{#if compact}<span class="block text-xs text-gray-400 dark:text-gray-600"
>{$i18n.t('Parameter')}</span
>{/if}
<input
type="text"
class={compact
? 'w-full min-w-0 bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700'
: 'min-w-0 w-full bg-transparent text-xs text-gray-400 dark:text-gray-600 outline-none'}
aria-label={$i18n.t('Custom Parameter Name')}
placeholder={compact ? $i18n.t('e.g. temperature') : $i18n.t('Custom Parameter Name')}
required={compact}
pattern={compact ? '.*\\S.*' : undefined}
use:focusNew={compact && key === ''}
title={key}
value={key}
on:change={(event) => {
const name = event.currentTarget.value.trim();
if (name && name !== key) {
value[name] = value[key];
delete value[key];
value = { ...value };
}
}}
/>
</label>
<label class={compact ? 'min-w-0' : 'order-last col-span-2'}>
{#if compact}<span class="block text-xs text-gray-400 dark:text-gray-600"
>{$i18n.t('Value')}</span
>{/if}
<input
type="text"
class={compact
? 'w-full min-w-0 bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700'
: 'w-full min-w-0 bg-transparent text-[0.8125rem] text-gray-700 dark:text-gray-300 outline-none'}
aria-label={$i18n.t('Custom Parameter Value')}
placeholder={compact ? $i18n.t('e.g. 0.7') : $i18n.t('Custom Parameter Value')}
value={typeof value[key] === 'object' ? JSON.stringify(value[key]) : value[key]}
on:input={(event) => (value = { ...value, [key]: event.currentTarget.value })}
/>
</label>
<button
type="button"
class={compact
? 'mt-4 flex size-5 items-center justify-center rounded text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300'
: 'flex shrink-0 rounded-sm p-1 px-3 text-xs outline-hidden transition'}
aria-label={`${$i18n.t('Remove')} ${key || $i18n.t('parameter')}`}
on:click={() => {
delete value[key];
value = { ...value };
}}
>
{#if compact}<XMark className="size-3" />{:else}{$i18n.t('Remove')}{/if}
</button>
</div>
{/each}
</div>
<button
type="button"
class={compact
? 'flex w-fit items-center gap-1.5 whitespace-nowrap py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-40'
: 'mt-1 mb-5 flex w-full items-center justify-center gap-2 text-center'}
aria-label={$i18n.t('Add Custom Parameter')}
title={$i18n.t('Add Custom Parameter')}
disabled={compact && Object.hasOwn(value ?? {}, '')}
on:click={() => {
value = value ?? {};
let key = compact ? '' : 'custom_param_name';
let index = 2;
while (key in value) key = `custom_param_name_${index++}`;
value = { ...value, [key]: '' };
}}
>
<Plus className={compact ? 'size-3' : 'size-4'} /><span
>{$i18n.t(compact ? 'Add parameter' : 'Add Custom Parameter')}</span
>
</button>
</div>

View file

@ -15,7 +15,7 @@
bind:value
{required}
on:change={() => {
dispatch('change');
dispatch('change', value);
}}
>
{#if placeholder}

View file

@ -15,6 +15,7 @@
import { uploadFile, deleteFileById } from '$lib/apis/files';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import ModelControls from '$lib/components/admin/Settings/Models/ModelControls.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector/Selector.svelte';
import Tags from '$lib/components/common/Tags.svelte';
import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
@ -47,6 +48,7 @@
export let model = null;
export let edit = false;
export let admin = false;
export let preset = true;
@ -111,7 +113,7 @@
}
};
let params = {
let params: Record<string, any> = {
system: ''
};
@ -1155,6 +1157,9 @@
<AdvancedParams admin={true} custom={true} layout="grid" bind:params />
</div>
{/if}
{#if admin}
<ModelControls bind:controls={params.model_controls} />
{/if}
</div>
</section>
@ -1330,7 +1335,7 @@
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={async () => {
const copied = await copyToClipboard(JSON.stringify(info, null, 2));
const copied = await copyToClipboard(JSON.stringify(modelInfo, null, 2));
if (copied) {
toast.success($i18n.t('Copied to clipboard'));
}