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
ebc6add67b
commit
efd94fde63
9 changed files with 173 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,7 @@ export interface ModelMeta {
|
|||
}
|
||||
|
||||
export interface ModelControl {
|
||||
display?: 'menu' | 'slider';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: string | null;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
104
src/lib/components/chat/MessageInput/ModelControlSlider.svelte
Normal file
104
src/lib/components/chat/MessageInput/ModelControlSlider.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue