mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
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.
This commit is contained in:
@@ -62,6 +62,9 @@
|
||||
// Convert array to string
|
||||
for (const property in valvesSpec.properties) {
|
||||
if (valvesSpec.properties[property]?.type === 'array') {
|
||||
if (valvesSpec.properties[property]?.input?.type === 'multiselect') {
|
||||
continue;
|
||||
}
|
||||
valves[property] = (valves[property] ?? []).join(',');
|
||||
}
|
||||
}
|
||||
@@ -75,6 +78,9 @@
|
||||
// Convert string to array
|
||||
for (const property in valvesSpec.properties) {
|
||||
if (valvesSpec.properties[property]?.type === 'array') {
|
||||
if (valvesSpec.properties[property]?.input?.type === 'multiselect') {
|
||||
continue;
|
||||
}
|
||||
valves[property] = (valves[property] ?? '').split(',').map((v) => v.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { tick, createEventDispatcher } from 'svelte';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let value: string[] = [];
|
||||
export let options: ({ label?: string; value: string } | string)[] = [];
|
||||
export let placeholder = '';
|
||||
export let className = '';
|
||||
|
||||
let open = false;
|
||||
let triggerEl;
|
||||
let contentEl;
|
||||
|
||||
$: items = options.map((option) =>
|
||||
typeof option === 'object' && option !== null
|
||||
? { value: option.value, label: option.label ?? option.value }
|
||||
: { value: option, label: option }
|
||||
);
|
||||
|
||||
$: selectedValues = Array.isArray(value) ? value : [];
|
||||
$: selectedLabels = items
|
||||
.filter((item) => selectedValues.includes(item.value))
|
||||
.map((item) => item.label);
|
||||
|
||||
const toggleItem = (itemValue) => {
|
||||
const current = Array.isArray(value) ? value : [];
|
||||
value = current.includes(itemValue)
|
||||
? current.filter((v) => v !== itemValue)
|
||||
: [...current, itemValue];
|
||||
dispatch('change');
|
||||
};
|
||||
|
||||
/** Svelte action: moves the node to document.body (portal) */
|
||||
function portal(node) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function positionContent() {
|
||||
if (!triggerEl || !contentEl) return;
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
|
||||
contentEl.style.position = 'fixed';
|
||||
contentEl.style.zIndex = '9999';
|
||||
contentEl.style.top = `${rect.bottom + 4}px`;
|
||||
contentEl.style.left = `${rect.left}px`;
|
||||
contentEl.style.minWidth = `${rect.width}px`;
|
||||
}
|
||||
|
||||
async function toggleOpen() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
await tick();
|
||||
positionContent();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowClick(event) {
|
||||
if (!open) return;
|
||||
if (triggerEl?.contains(event.target)) return;
|
||||
if (contentEl?.contains(event.target)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:click={handleWindowClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:scroll|capture={positionContent}
|
||||
on:resize={positionContent}
|
||||
/>
|
||||
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
class={className}
|
||||
aria-label={placeholder}
|
||||
type="button"
|
||||
on:click={toggleOpen}
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="truncate text-left {selectedLabels.length ? '' : 'text-gray-500'}">
|
||||
{selectedLabels.length ? selectedLabels.join(', ') : placeholder}
|
||||
</span>
|
||||
|
||||
<ChevronDown className="size-3 shrink-0" strokeWidth="2.5" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div use:portal bind:this={contentEl} transition:flyAndScale>
|
||||
<div
|
||||
class="rounded-2xl min-w-[170px] max-h-72 overflow-y-auto p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
>
|
||||
{#each items as item}
|
||||
<button
|
||||
class="flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => toggleItem(item.value)}
|
||||
>
|
||||
<div
|
||||
class="size-3.5 shrink-0 rounded-sm flex items-center justify-center outline -outline-offset-1 outline-[1.5px] {selectedValues.includes(
|
||||
item.value
|
||||
)
|
||||
? 'bg-black outline-black text-white'
|
||||
: 'outline-gray-200 dark:outline-gray-600'}"
|
||||
>
|
||||
{#if selectedValues.includes(item.value)}
|
||||
<svg
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="m5 12 4.7 4.5 9.3-9"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="truncate {selectedValues.includes(item.value)
|
||||
? ''
|
||||
: 'text-gray-500 dark:text-gray-400'}"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -9,6 +9,7 @@
|
||||
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;
|
||||
@@ -37,7 +38,11 @@
|
||||
// Initialize to custom value
|
||||
if ((propertySpec?.type ?? null) === 'array') {
|
||||
const defaultArray = propertySpec?.default ?? [];
|
||||
valves[property] = Array.isArray(defaultArray) ? defaultArray.join(', ') : '';
|
||||
if (propertySpec?.input?.type === 'multiselect') {
|
||||
valves[property] = Array.isArray(defaultArray) ? [...defaultArray] : [];
|
||||
} else {
|
||||
valves[property] = Array.isArray(defaultArray) ? defaultArray.join(', ') : '';
|
||||
}
|
||||
} else {
|
||||
valves[property] = propertySpec?.default ?? '';
|
||||
}
|
||||
@@ -95,6 +100,16 @@
|
||||
/>
|
||||
</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"
|
||||
|
||||
@@ -129,6 +129,9 @@
|
||||
if (valvesSpec) {
|
||||
for (const property in valvesSpec.properties) {
|
||||
if (valvesSpec.properties[property]?.type === 'array') {
|
||||
if (valvesSpec.properties[property]?.input?.type === 'multiselect') {
|
||||
continue;
|
||||
}
|
||||
if (valves[property] != null) {
|
||||
valves[property] = (Array.isArray(valves[property]) ? valves[property] : []).join(
|
||||
','
|
||||
|
||||
Reference in New Issue
Block a user