This commit is contained in:
Timothy Jaeryang Baek 2026-10-07 00:54:53 +04:00
parent ebc6add67b
commit efd94fde63
9 changed files with 173 additions and 5 deletions

View file

@ -4,7 +4,7 @@ import logging
import re
import time
from copy import deepcopy
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
@ -77,6 +77,7 @@ class ModelControlOption(BaseModel):
class ModelControl(BaseModel):
display: Literal['menu', 'slider'] = Field(default='menu', exclude_if=lambda value: value == 'menu')
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)
@ -84,6 +85,8 @@ class ModelControl(BaseModel):
@model_validator(mode='after')
def check_default(self):
if self.display == 'slider' and len(self.options) < 2:
raise ValueError('A slider needs at least two options.')
if self.default is not None and self.default not in self.options:
raise ValueError('Default must name an approved option.')
return self

View file

@ -38,7 +38,7 @@ def public_model_params(params):
{
'model_controls': {
key: {
**{field: control[field] for field in ('label', 'description', 'default') if field in control},
**{field: control[field] for field in ('label', 'description', 'default', 'display') if field in control},
'options': {key: {'label': option['label']} for key, option in control['options'].items()},
}
for key, control in controls.items()

View file

@ -1792,6 +1792,7 @@ export interface ModelMeta {
}
export interface ModelControl {
display?: 'menu' | 'slider';
label: string;
description?: string;
default?: string | null;

View file

@ -16,6 +16,7 @@
let editingKey = '';
let label = '';
let description = '';
let display: 'menu' | 'slider' = 'menu';
let defaultOption = '';
let options: { id: string; key?: string; label: string; params: Record<string, any> }[] = [];
let nameInput: HTMLInputElement;
@ -46,6 +47,7 @@
const control = controls?.[key];
label = control?.label ?? '';
description = control?.description ?? '';
display = control?.display ?? 'menu';
defaultOption = control?.default ?? '';
options = Object.entries(control?.options ?? {}).map(([key, option]) => ({
id: key,
@ -75,6 +77,7 @@
...controls,
[editingKey || keyFor(label, controls ?? {})]: {
label: label.trim(),
...(display === 'slider' ? { display } : {}),
...(description.trim() ? { description: description.trim() } : {}),
default: selected,
options: saved
@ -228,6 +231,23 @@
<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 class="flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Display')}</span>
<div class="flex gap-1">
{#each ['menu', 'slider'] as mode}
<button
type="button"
aria-pressed={display === mode}
class="rounded-lg px-2 py-1 text-xs transition {display === mode
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
on:click={() => (display = mode as 'menu' | 'slider')}
>
{$i18n.t(mode === 'slider' ? 'Slider' : 'Menu')}
</button>
{/each}
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Options')}</span>
@ -274,6 +294,11 @@
{/each}
</div>
</div>
{#if display === 'slider' && options.length < 2}
<p class="text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Add at least two options for a slider.')}
</p>
{/if}
<div class="flex justify-end gap-1.5 pt-1 text-sm font-medium">
<button
type="button"
@ -284,6 +309,7 @@
type="submit"
disabled={!label.trim() ||
!options.length ||
(display === 'slider' && options.length < 2) ||
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

View file

@ -4675,6 +4675,7 @@
class=" pb-2 {dragged ? 'z-0' : 'z-10'}"
>
<MessageInput
callActive={!!(bridge?.connected || bridge?.connecting)}
bind:this={messageInput}
{history}
{taskIds}
@ -4767,6 +4768,7 @@
{/if}
<div id={embedded ? messageInputDropzoneId : undefined} class="pb-2 z-10">
<MessageInput
callActive={!!(bridge?.connected || bridge?.connecting)}
bind:this={messageInput}
{history}
{taskIds}
@ -4828,6 +4830,7 @@
{:else}
<div class="flex items-center h-full">
<Placeholder
callActive={!!(bridge?.connected || bridge?.connecting)}
bind:selectedModelIdx
{history}
bind:selectedModels

View file

@ -145,6 +145,7 @@
export let contextUsage = null;
export let contextCompactionEnabled = false;
export let embedded = false;
export let callActive = false;
export let autoScroll = false;
export let generating = false;
@ -2690,11 +2691,17 @@
{#if !embedded && prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center">
<!-- {$i18n.t('Call')} -->
<Tooltip content={$i18n.t('Voice mode')}>
<Tooltip content={$i18n.t(callActive ? 'Call in progress' : 'Voice mode')}>
<button
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-[0.3125rem] self-center"
class:call-active={callActive}
type="button"
on:click={async () => {
if (callActive) {
showCallOverlay.set(true);
showControls.set(true);
return;
}
if (selectedModels.length > 1) {
toast.error($i18n.t('Select only one model to call'));
@ -2750,7 +2757,7 @@
);
}
}}
aria-label={$i18n.t('Voice mode')}
aria-label={$i18n.t(callActive ? 'Return to call' : 'Voice mode')}
>
<Voice className="size-5" strokeWidth="2.5" />
</button>
@ -2809,3 +2816,17 @@
</div>
</div>
{/if}
<style>
.call-active {
box-shadow:
0 0 0 2px rgb(0 0 0 / 0.2),
0 0 12px rgb(0 0 0 / 0.15);
}
:global(.dark) .call-active {
box-shadow:
0 0 0 2px rgb(255 255 255 / 0.35),
0 0 12px rgb(255 255 255 / 0.25);
}
</style>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import type { ModelControl } from '$lib/apis';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import QuestionMarkCircle from '$lib/components/icons/QuestionMarkCircle.svelte';
const i18n: any = getContext('i18n');
const dispatch = createEventDispatcher<{ change: string }>();
export let control: ModelControl;
export let value = '';
export let disabled = false;
let editing = false;
$: options = Object.entries(control.options);
$: index = Math.max(
0,
options.findIndex(([key]) => key === (value || control.default))
);
$: progress = (index / Math.max(1, options.length - 1)) * 100;
$: current = editing ? options[index]?.[0] : value || control.default;
$: label = control.options[current ?? '']?.label ?? $i18n.t('Default');
</script>
<div class="space-y-2 px-2 py-2 text-xs">
<div class="flex items-center gap-2">
<span class="text-gray-700 dark:text-gray-100">{control.label}</span>
<span class="min-w-0 flex-1 truncate text-gray-500 dark:text-gray-400">{label}</span>
{#if control.description}
<Tooltip content={control.description}>
<button
type="button"
aria-label={control.description}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<QuestionMarkCircle className="size-3.5" />
</button>
</Tooltip>
{/if}
</div>
<div class="space-y-1">
<div
class="flex justify-between gap-2 text-[0.6875rem] text-gray-500 dark:text-gray-400"
aria-hidden="true"
>
<span class="truncate">{options[0]?.[1].label}</span>
<span class="truncate text-right">{options.at(-1)?.[1].label}</span>
</div>
<div class="relative flex h-5 items-center">
<div class="pointer-events-none absolute inset-x-2.5 flex justify-between" aria-hidden="true">
{#each options as [key] (key)}
<span class="size-0.5 rounded-full bg-gray-400 dark:bg-gray-500"></span>
{/each}
</div>
<input
type="range"
min="0"
max={options.length - 1}
step="1"
bind:value={index}
{disabled}
aria-label={control.label}
aria-valuetext={label}
class="control-slider h-5 w-full cursor-pointer appearance-none rounded-md bg-gray-100 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-400 disabled:cursor-wait [--slider-fill:var(--color-gray-300)] dark:bg-gray-800 dark:[--slider-fill:var(--color-gray-600)]"
style:background-image={`linear-gradient(to right, var(--slider-fill) ${progress}%, transparent ${progress}%)`}
on:input={() => (editing = true)}
on:change={() => {
dispatch('change', options[index][0]);
editing = false;
}}
on:pointerup={() => {
if (!current) dispatch('change', options[index][0]);
}}
/>
</div>
</div>
<button
type="button"
disabled={disabled || !value}
on:click={() => dispatch('change', '')}
class="text-[0.6875rem] text-gray-500 transition enabled:hover:text-gray-900 dark:text-gray-400 dark:enabled:hover:text-gray-100"
>
{$i18n.t('Default')}{#if control.options[control.default ?? '']?.label}{' · '}{control.options[
control.default ?? ''
].label}{/if}
</button>
</div>
<style>
.control-slider::-webkit-slider-thumb {
appearance: none;
width: 1rem;
height: 1.25rem;
border-radius: 0.375rem;
background: white;
box-shadow: 0 1px 3px #0003;
}
.control-slider::-moz-range-thumb {
width: 1rem;
height: 1.25rem;
border: 0;
border-radius: 0.375rem;
background: white;
box-shadow: 0 1px 3px #0003;
}
</style>

View file

@ -4,6 +4,7 @@
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 ModelControlSlider from './ModelControlSlider.svelte';
import type { ModelControl } from '$lib/apis';
import { updateUserSettings } from '$lib/apis/users';
import { toast } from 'svelte-sonner';
@ -143,7 +144,14 @@
</div>
{/if}
{#each Object.entries(model.info?.params?.model_controls ?? {}) as [key, control] (key)}
{#if $mobile}
{#if control.display === 'slider'}
<ModelControlSlider
{control}
value={modelControls[model.id]?.[key] ?? ''}
disabled={saving}
on:change={(event) => select(model.id, key, event.detail)}
/>
{:else if $mobile}
<button
type="button"
aria-label={control.label}

View file

@ -40,6 +40,7 @@
export let stopResponse: Function;
export let autoScroll = false;
export let callActive = false;
export let atSelectedModel: Model | undefined;
export let selectedModels: [''];
@ -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
{callActive}
bind:this={messageInput}
{history}
bind:selectedModels