open-webui/src/lib/components/chat/ModelSelector/Selector.svelte

664 lines
19 KiB
Svelte
Raw Normal View History

<script lang="ts">
2024-05-03 03:33:04 +08:00
import { DropdownMenu } from 'bits-ui';
2024-06-16 00:43:10 +08:00
import { marked } from 'marked';
import Fuse from 'fuse.js';
2025-06-09 04:33:41 +08:00
import dayjs from '$lib/dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
2025-06-26 06:44:45 +08:00
import Spinner from '$lib/components/common/Spinner.svelte';
import { flyAndScale } from '$lib/utils/transitions';
2024-03-26 16:51:24 +08:00
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
2025-06-09 04:33:41 +08:00
import { goto } from '$app/navigation';
2025-05-23 23:45:29 +08:00
import { deleteModel, getOllamaVersion, pullModel, unloadModel } from '$lib/apis/ollama';
2025-02-12 17:17:30 +08:00
import {
user,
MODEL_DOWNLOAD_POOL,
models,
mobile,
temporaryChatEnabled,
2025-02-12 17:22:53 +08:00
settings,
config
2025-02-12 17:17:30 +08:00
} from '$lib/stores';
import { toast } from 'svelte-sonner';
2024-05-24 17:17:48 +08:00
import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
import { getModels } from '$lib/apis';
2025-06-09 04:33:41 +08:00
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
2025-06-09 04:33:41 +08:00
import ModelItem from './ModelItem.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
2024-10-19 16:30:49 +08:00
export let id = '';
export let value = '';
2025-08-14 08:15:16 +08:00
export let placeholder = $i18n.t('Select a model');
export let searchEnabled = true;
export let searchPlaceholder = $i18n.t('Search a model');
export let showTemporaryChatControl = false;
export let items: {
label: string;
value: string;
2024-08-20 08:44:09 +08:00
model: Model;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
2024-08-20 08:44:09 +08:00
}[] = [];
2024-07-25 19:08:47 +08:00
export let className = 'w-[32rem]';
2024-10-22 06:24:59 +08:00
export let triggerClassName = 'text-lg';
2024-05-03 03:33:04 +08:00
2025-06-09 05:24:11 +08:00
export let pinModelHandler: (modelId: string) => void = () => {};
2025-03-04 13:47:27 +08:00
let tagsContainerElement;
2024-05-03 04:25:44 +08:00
let show = false;
2025-03-04 13:47:27 +08:00
let tags = [];
2024-05-03 03:33:04 +08:00
let selectedModel = '';
$: selectedModel = items.find((item) => item.value === value) ?? '';
let searchValue = '';
2025-03-04 13:47:27 +08:00
let selectedTag = '';
let selectedConnectionType = '';
2025-03-04 13:47:27 +08:00
let ollamaVersion = null;
2025-03-12 04:49:14 +08:00
let selectedModelIdx = 0;
const fuse = new Fuse(
2024-11-16 17:24:34 +08:00
items.map((item) => {
const _item = {
...item,
modelName: item.model?.name,
2025-03-12 04:37:30 +08:00
tags: (item.model?.tags ?? []).map((tag) => tag.name).join(' '),
2024-11-16 17:24:34 +08:00
desc: item.model?.info?.meta?.description
};
return _item;
}),
{
2024-08-15 21:44:38 +08:00
keys: ['value', 'tags', 'modelName'],
2024-11-17 16:00:03 +08:00
threshold: 0.4
}
2024-05-31 17:11:25 +08:00
);
2025-04-11 01:18:56 +08:00
$: filteredItems = (
searchValue
? fuse
.search(searchValue)
.map((e) => {
return e.item;
})
.filter((item) => {
if (selectedTag === '') {
return true;
}
return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
})
.filter((item) => {
if (selectedConnectionType === '') {
return true;
2025-05-17 05:47:48 +08:00
} else if (selectedConnectionType === 'local') {
return item.model?.connection_type === 'local';
} else if (selectedConnectionType === 'external') {
return item.model?.connection_type === 'external';
2025-04-11 01:18:56 +08:00
} else if (selectedConnectionType === 'direct') {
return item.model?.direct;
}
})
: items
.filter((item) => {
if (selectedTag === '') {
return true;
}
return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
})
.filter((item) => {
if (selectedConnectionType === '') {
return true;
2025-05-17 05:47:48 +08:00
} else if (selectedConnectionType === 'local') {
return item.model?.connection_type === 'local';
} else if (selectedConnectionType === 'external') {
return item.model?.connection_type === 'external';
2025-04-11 01:18:56 +08:00
} else if (selectedConnectionType === 'direct') {
return item.model?.direct;
}
})
).filter((item) => !(item.model?.info?.meta?.hidden ?? false));
2025-03-12 04:49:14 +08:00
$: if (selectedTag || selectedConnectionType) {
resetView();
} else {
resetView();
}
const resetView = async () => {
await tick();
const selectedInFiltered = filteredItems.findIndex((item) => item.value === value);
if (selectedInFiltered >= 0) {
// The selected model is visible in the current filter
selectedModelIdx = selectedInFiltered;
} else {
// The selected model is not visible, default to first item in filtered list
selectedModelIdx = 0;
}
await tick();
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
};
const pullModelHandler = async () => {
2024-04-15 04:29:27 +08:00
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
console.log($MODEL_DOWNLOAD_POOL);
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
toast.error(
$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
modelTag: sanitizedModelTag
})
);
return;
}
if (Object.keys($MODEL_DOWNLOAD_POOL).length === 3) {
toast.error(
$i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
);
return;
}
const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
(error) => {
2025-01-21 14:41:32 +08:00
toast.error(`${error}`);
return null;
}
);
if (res) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[sanitizedModelTag]: {
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
abortController: controller,
reader,
done: false
}
});
while (true) {
try {
const { value, done } = await reader.read();
if (done) break;
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
let data = JSON.parse(line);
console.log(data);
if (data.error) {
throw data.error;
}
if (data.detail) {
throw data.detail;
}
if (data.status) {
if (data.digest) {
let downloadProgress = 0;
if (data.completed) {
downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
} else {
downloadProgress = 100;
}
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[sanitizedModelTag]: {
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
pullProgress: downloadProgress,
digest: data.digest
}
});
} else {
toast.success(data.status);
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[sanitizedModelTag]: {
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
done: data.status === 'success'
}
});
}
}
}
}
} catch (error) {
console.log(error);
if (typeof error !== 'string') {
error = error.message;
}
2025-01-21 14:41:32 +08:00
toast.error(`${error}`);
// opts.callback({ success: false, error, modelName: opts.modelName });
break;
}
}
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
toast.success(
$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
modelName: sanitizedModelTag
})
);
2024-03-25 14:11:55 +08:00
2025-02-12 17:22:53 +08:00
models.set(
await getModels(
localStorage.token,
2025-02-12 17:32:49 +08:00
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
2025-02-12 17:22:53 +08:00
)
);
} else {
2024-05-07 17:44:35 +08:00
toast.error($i18n.t('Download canceled'));
}
delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL
});
}
};
onMount(async () => {
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
2025-03-04 13:47:27 +08:00
if (items) {
2025-04-11 01:18:56 +08:00
tags = items
.filter((item) => !(item.model?.info?.meta?.hidden ?? false))
.flatMap((item) => item.model?.tags ?? [])
.map((tag) => tag.name);
2025-03-05 10:08:43 +08:00
// Remove duplicates and sort
tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
2025-03-04 13:47:27 +08:00
}
});
const cancelModelPullHandler = async (model: string) => {
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
if (abortController) {
abortController.abort();
}
if (reader) {
await reader.cancel();
delete $MODEL_DOWNLOAD_POOL[model];
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL
});
await deleteModel(localStorage.token, model);
2025-08-14 08:24:05 +08:00
toast.success($i18n.t("{{model}} download has been canceled", { model: model }));
}
};
2025-05-23 23:45:29 +08:00
const unloadModelHandler = async (model: string) => {
const res = await unloadModel(localStorage.token, model).catch((error) => {
toast.error($i18n.t('Error unloading model: {{error}}', { error }));
});
if (res) {
toast.success($i18n.t('Model unloaded successfully'));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
</script>
2024-05-03 03:33:04 +08:00
<DropdownMenu.Root
2024-05-03 04:25:44 +08:00
bind:open={show}
2024-03-26 16:51:24 +08:00
onOpenChange={async () => {
searchValue = '';
2025-03-12 04:49:14 +08:00
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
2025-03-12 04:37:30 +08:00
2025-03-12 04:49:14 +08:00
resetView();
}}
2024-06-19 08:16:33 +08:00
closeFocus={false}
>
2024-10-19 16:30:49 +08:00
<DropdownMenu.Trigger
class="relative w-full font-primary {($settings?.highContrastMode ?? false)
? ''
: 'outline-hidden focus:outline-hidden'}"
2024-10-19 16:30:49 +08:00
aria-label={placeholder}
id="model-selector-{id}-button"
>
<div
2025-07-02 19:14:48 +08:00
class="flex w-full text-left px-0.5 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
false)
? 'dark:placeholder-gray-100 placeholder-gray-800'
: 'placeholder-gray-400'}"
2025-05-23 23:13:18 +08:00
on:mouseenter={async () => {
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}}
2024-05-03 03:33:04 +08:00
>
{#if selectedModel}
{selectedModel.label}
{:else}
{placeholder}
{/if}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</div>
2024-05-03 03:33:04 +08:00
</DropdownMenu.Trigger>
2024-05-15 06:26:53 +08:00
2024-05-03 03:33:04 +08:00
<DropdownMenu.Content
2024-05-19 22:32:19 +08:00
class=" z-40 {$mobile
? `w-full`
2025-02-16 11:27:25 +08:00
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition={flyAndScale}
2024-05-15 06:26:53 +08:00
side={$mobile ? 'bottom' : 'bottom-start'}
2024-10-19 16:44:45 +08:00
sideOffset={3}
>
<slot>
{#if searchEnabled}
2025-03-04 14:44:03 +08:00
<div class="flex items-center gap-2.5 px-5 mt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
<input
2024-03-26 16:51:24 +08:00
id="model-search-input"
bind:value={searchValue}
2025-02-16 11:27:25 +08:00
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
2024-05-03 04:25:44 +08:00
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
2025-08-09 00:58:52 +08:00
e.stopPropagation();
selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
} else if (e.code === 'ArrowUp') {
2025-08-09 00:58:52 +08:00
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
} else {
// if the user types something, reset to the top selection.
selectedModelIdx = 0;
}
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
}}
/>
</div>
{/if}
2025-06-10 18:13:52 +08:00
<div class="px-3">
2025-04-01 08:58:48 +08:00
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
2025-06-10 18:13:52 +08:00
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
2025-03-04 13:47:27 +08:00
<div
class="flex gap-1 w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5"
2025-03-04 13:47:27 +08:00
bind:this={tagsContainerElement}
>
2025-06-16 16:02:34 +08:00
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
2025-04-11 01:18:56 +08:00
<button
class="min-w-fit outline-none p-1.5 {selectedTag === '' &&
selectedConnectionType === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
2025-04-11 01:18:56 +08:00
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{/if}
2025-03-04 13:47:27 +08:00
2025-06-16 15:46:02 +08:00
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
2025-05-17 05:47:48 +08:00
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
2025-05-17 05:47:48 +08:00
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
2025-06-16 15:46:02 +08:00
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
2025-05-17 05:47:48 +08:00
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
2025-05-17 05:47:48 +08:00
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
2025-03-09 19:18:28 +08:00
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
2025-03-09 00:23:20 +08:00
2025-03-04 13:47:27 +08:00
{#each tags as tag}
<button
class="min-w-fit outline-none p-1.5 {selectedTag === tag
? ''
2025-03-04 14:24:19 +08:00
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
2025-03-04 13:47:27 +08:00
on:click={() => {
selectedConnectionType = '';
2025-03-04 13:47:27 +08:00
selectedTag = tag;
}}
>
{tag}
</button>
{/each}
</div>
</div>
{/if}
2025-06-10 18:13:52 +08:00
</div>
2025-03-04 13:47:27 +08:00
2025-06-10 18:13:52 +08:00
<div class="px-3 max-h-64 overflow-y-auto group relative">
2025-04-11 01:18:56 +08:00
{#each filteredItems as item, index}
2025-06-09 04:33:41 +08:00
<ModelItem
{selectedModelIdx}
{item}
{index}
{value}
2025-06-09 05:24:11 +08:00
{pinModelHandler}
2025-06-09 04:33:41 +08:00
{unloadModelHandler}
onClick={() => {
2025-04-01 08:58:48 +08:00
value = item.value;
selectedModelIdx = index;
show = false;
}}
2025-06-09 04:33:41 +08:00
/>
{:else}
2025-04-14 16:40:29 +08:00
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
{/each}
2025-04-01 11:32:12 +08:00
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
2024-11-04 17:57:01 +08:00
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
2024-03-25 14:11:55 +08:00
>
2024-11-04 17:57:01 +08:00
<button
2025-02-16 11:27:25 +08:00
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
2024-11-04 17:57:01 +08:00
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</div>
</button>
</Tooltip>
2024-03-25 14:11:55 +08:00
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
2025-02-16 11:27:25 +08:00
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-lg cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5">
2025-06-26 06:44:45 +08:00
<Spinner />
</div>
<div class="flex flex-col self-start">
2024-10-19 16:34:03 +08:00
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
2025-02-16 11:27:25 +08:00
<div class="shrink-0">
2024-10-19 16:34:03 +08:00
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
{$MODEL_DOWNLOAD_POOL[model].digest}
</div>
{/if}
</div>
</div>
2024-10-19 16:34:03 +08:00
<div class="mr-2 ml-1 translate-y-0.5">
2024-05-07 17:34:15 +08:00
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
on:click={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
2024-05-03 09:50:20 +08:00
{#if showTemporaryChatControl}
2025-05-23 23:13:18 +08:00
<div class="flex items-center mx-2 mt-1 mb-2">
<DropdownMenu.Item
2025-02-16 11:27:25 +08:00
class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
2024-08-16 21:10:21 +08:00
on:click={async () => {
temporaryChatEnabled.set(!$temporaryChatEnabled);
2024-08-16 21:10:21 +08:00
await goto('/');
2024-08-16 21:15:06 +08:00
const newChatButton = document.getElementById('new-chat-button');
setTimeout(() => {
newChatButton?.click();
}, 0);
2024-08-22 00:55:12 +08:00
// add 'temporary-chat=true' to the URL
if ($temporaryChatEnabled) {
history.replaceState(null, '', '?temporary-chat=true');
} else {
history.replaceState(null, '', location.pathname);
}
show = false;
}}
>
<div class="flex gap-2.5 items-center">
<ChatBubbleOval className="size-4" strokeWidth="2.5" />
{$i18n.t(`Temporary Chat`)}
</div>
<div>
<Switch state={$temporaryChatEnabled} />
</div>
</DropdownMenu.Item>
</div>
2025-04-15 13:34:38 +08:00
{:else}
2025-04-14 16:40:29 +08:00
<div class="mb-3"></div>
{/if}
2024-05-03 09:50:20 +08:00
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
2024-05-03 03:33:04 +08:00
</DropdownMenu.Content>
</DropdownMenu.Root>