Files
open-webui/src/lib/components/common/Valves.svelte
T
Classic298 7e96c53a20 feat: multiselect valve input type with static or dynamic options (#26884)
Adds a multiselect input type for Valves and UserValves so plugin authors can let users pick multiple values from static or runtime-resolved options instead of maintaining comma-separated text fields with hardcoded allowed-value lists in the description.

ENABLED_ITEMS: list[str] = Field(
    default=["foo"],
    json_schema_extra={"input": {"type": "multiselect", "options": "get_item_options"}},
)

@classmethod
def get_item_options(cls):
    return [{"value": "foo", "label": "Foo"}, {"value": "bar", "label": "Bar"}]

Options accept the same shapes as the existing select input: either a static list (strings or {value, label} dicts) or a classmethod name resolved at request time (including __user__ context for UserValves). No backend changes are needed because resolve_valves_schema_options already resolves options independently of the input type.

The new MultiSelect component follows the existing Select portal dropdown pattern and renders checkbox rows that stay open while toggling, with the selected labels shown in the trigger. Values bind as a real string array end to end: the array-to-comma-string conversions in the chat controls valves panel and the valves modal are skipped for multiselect fields, so the stored valve is a native list[str] validated by Pydantic.

Requested in #26848.
2026-07-27 01:11:38 -04:00

234 lines
8.3 KiB
Svelte

<script>
import { getContext, createEventDispatcher } from 'svelte';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import Switch from './Switch.svelte';
import SensitiveInput from './SensitiveInput.svelte';
import NativeSelect from './NativeSelect.svelte';
import MultiSelect from './MultiSelect.svelte';
import MapSelector from './Valves/MapSelector.svelte';
export let valvesSpec = null;
export let valves = {};
</script>
{#if valvesSpec && Object.keys(valvesSpec?.properties ?? {}).length}
{#each Object.keys(valvesSpec.properties) as property}
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-normal">
{valvesSpec.properties[property].title}
{#if (valvesSpec?.required ?? []).includes(property)}
<span class=" text-gray-500">*required</span>
{/if}
</div>
<button
class="px-2 py-1 text-xs flex rounded-lg transition hover:bg-gray-50/70 dark:hover:bg-gray-850/50"
type="button"
on:click={() => {
const propertySpec = valvesSpec.properties[property] ?? {};
if ((valves[property] ?? null) === null) {
// Initialize to custom value
if ((propertySpec?.type ?? null) === 'array') {
const defaultArray = propertySpec?.default ?? [];
if (propertySpec?.input?.type === 'multiselect') {
valves[property] = Array.isArray(defaultArray) ? [...defaultArray] : [];
} else {
valves[property] = Array.isArray(defaultArray) ? defaultArray.join(', ') : '';
}
} else {
valves[property] = propertySpec?.default ?? '';
}
} else {
valves[property] = null;
}
dispatch('change');
}}
>
{#if (valves[property] ?? null) === null}
<span class="ml-2 self-center">
{#if (valvesSpec?.required ?? []).includes(property)}
{$i18n.t('None')}
{:else}
{$i18n.t('Default')}
{/if}
</span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
{#if (valves[property] ?? null) !== null}
<!-- {valves[property]} -->
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1">
{#if valvesSpec.properties[property]?.enum ?? null}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
on:change={() => {
dispatch('change');
}}
>
{#each valvesSpec.properties[property].enum as option}
<option value={option} selected={option === valves[property]}>
{option}
</option>
{/each}
</select>
{:else if (valvesSpec.properties[property]?.type ?? null) === 'boolean'}
<div class="flex justify-between items-center">
<div class="text-xs text-gray-500">
{valves[property] ? $i18n.t('Enabled') : $i18n.t('Disabled')}
</div>
<div class=" pr-2">
<Switch
bind:state={valves[property]}
on:change={() => {
dispatch('change');
}}
/>
</div>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'multiselect' && valvesSpec.properties[property]?.input?.options}
<MultiSelect
className="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
options={valvesSpec.properties[property].input.options}
placeholder={$i18n.t('Select options')}
on:change={() => {
dispatch('change');
}}
/>
{:else if (valvesSpec.properties[property]?.type ?? null) !== 'string'}
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
type="text"
placeholder={valvesSpec.properties[property].title}
bind:value={valves[property]}
autocomplete="off"
required
on:change={() => {
dispatch('change');
}}
/>
{:else if valvesSpec.properties[property]?.input ?? null}
{#if valvesSpec.properties[property]?.input?.type === 'password'}
<div
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 border border-gray-100/30 dark:border-gray-850/30"
>
<SensitiveInput
id="valve-{property}"
placeholder={valvesSpec.properties[property]?.description ?? ''}
bind:value={valves[property]}
required={(valvesSpec?.required ?? []).includes(property)}
on:change={() => {
dispatch('change');
}}
/>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'select' && valvesSpec.properties[property]?.input?.options}
<NativeSelect
className="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
options={valvesSpec.properties[property].input.options}
placeholder={valvesSpec.properties[property]?.description ??
$i18n.t('Select an option')}
on:change={() => {
dispatch('change');
}}
/>
{:else if valvesSpec.properties[property]?.input?.type === 'color'}
<div class="flex items-center space-x-2">
<div class="relative size-6">
<input
type="color"
class="size-6 rounded cursor-pointer border border-gray-200 dark:border-gray-700"
value={valves[property] ?? '#000000'}
on:input={(e) => {
// Convert the color value to uppercase immediately
valves[property] = e.target.value.toUpperCase();
dispatch('change');
}}
/>
</div>
<input
type="text"
class="flex-1 rounded-lg py-2 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
placeholder={$i18n.t('Enter hex color (e.g. #FF0000)')}
bind:value={valves[property]}
autocomplete="off"
disabled
on:change={() => {
dispatch('change');
}}
/>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'map'}
<!-- EXPERIMENTAL INPUT TYPE, DO NOT USE IN PRODUCTION -->
<div class="flex flex-col items-center gap-1">
<MapSelector
setViewLocation={((valves[property] ?? '').includes(',') ?? false)
? valves[property].split(',')
: null}
onClick={(value) => {
valves[property] = value;
dispatch('change');
}}
/>
{#if valves[property]}
<input
type="text"
class=" w-full rounded-lg py-1 text-left text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
placeholder={$i18n.t('Enter coordinates (e.g. 51.505, -0.09)')}
bind:value={valves[property]}
autocomplete="off"
on:change={() => {
dispatch('change');
}}
/>
{/if}
</div>
{/if}
{:else}
<textarea
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
placeholder={valvesSpec.properties[property].title}
bind:value={valves[property]}
autocomplete="off"
required
on:change={() => {
dispatch('change');
}}
></textarea>
{/if}
</div>
</div>
{/if}
{#if (valvesSpec.properties[property]?.description ?? null) !== null}
<div class="markdown-prose-xs max-w-full text-gray-500 dark:text-gray-400">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html DOMPurify.sanitize(
marked.parse(valvesSpec.properties[property].description ?? '', { async: false })
)}
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-xs">{$i18n.t('No valves')}</div>
{/if}