Files
AstrBot/dashboard/src/components/chat/ProviderModelMenu.vue

539 lines
14 KiB
Vue

<template>
<v-menu
v-model="menuOpen"
:close-on-content-click="false"
:location="menuLocation"
offset="10"
transition="none"
@update:model-value="handleMenuToggle"
>
<template #activator="{ props: menuProps }">
<button
v-bind="menuProps"
class="provider-trigger"
:class="`provider-trigger--${variant}`"
type="button"
>
<span class="provider-trigger-copy">
<span class="provider-trigger-title">{{ triggerTitle }}</span>
<span v-if="triggerMeta" class="provider-trigger-meta">
{{ triggerMeta }}
</span>
</span>
<v-icon class="provider-trigger-chevron" size="18">
mdi-chevron-down
</v-icon>
</button>
</template>
<v-card class="provider-menu-card" elevation="0">
<div class="provider-menu-body">
<v-text-field
v-model="searchQuery"
placeholder="Search models"
hide-details
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
class="provider-search"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item
v-for="provider in filteredProviders"
:key="provider.id"
:active="selectedProviderId === provider.id"
rounded="lg"
class="provider-menu-item"
@click="selectProvider(provider)"
>
<v-list-item-title class="provider-item-title">
{{ provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="meta-icon-badge"
:class="{ 'meta-icon-badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="meta-context-badge"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
tm("models.metadata.context", {
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template #append>
<div class="provider-menu-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="testingProviderIds.includes(provider.id)"
:disabled="testingProviderIds.includes(provider.id)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ tm("models.testButton") }}</span>
</v-tooltip>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
</div>
</template>
</v-list-item>
</v-list>
<div v-if="loadingProviders" class="empty-hint">
Loading models...
</div>
<div v-else-if="filteredProviders.length === 0" class="empty-hint">
No available models
</div>
</div>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { providerApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import { useToast } from "@/utils/toast";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
api_base?: string;
enable?: boolean;
}
const props = withDefaults(
defineProps<{
variant?: "input" | "header";
}>(),
{
variant: "input",
},
);
const SELECTED_PROVIDER_KEY = "selectedProvider";
const SELECTED_PROVIDER_MODEL_KEY = "selectedProviderModel";
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref("");
const selectedModelName = ref("");
const searchQuery = ref("");
const menuOpen = ref(false);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
const testingProviderIds = ref<string[]>([]);
const modelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const { tm } = useModuleI18n("features/provider");
const { success: toastSuccess, error: toastError } = useToast();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
props.variant === "header" ? "bottom start" : "top",
);
const selectedProvider = computed(() =>
providerConfigs.value.find(
(provider) => provider.id === selectedProviderId.value,
),
);
const triggerTitle = computed(() => {
if (selectedProvider.value?.id) return selectedProvider.value.id;
if (selectedProviderId.value) return selectedProviderId.value;
return props.variant === "header" ? "Default model" : "Model";
});
const triggerMeta = computed(() => {
const model = selectedProvider.value?.model || selectedModelName.value;
if (!model || model === triggerTitle.value) return "";
return model;
});
const filteredProviders = computed(() => {
if (!searchQuery.value) {
return providerConfigs.value;
}
const query = searchQuery.value.toLowerCase();
return providerConfigs.value.filter(
(provider) =>
provider.id.toLowerCase().includes(query) ||
provider.model.toLowerCase().includes(query),
);
});
function loadFromStorage() {
const savedProvider = localStorage.getItem(SELECTED_PROVIDER_KEY);
const savedModel = localStorage.getItem(SELECTED_PROVIDER_MODEL_KEY);
if (savedProvider) {
selectedProviderId.value = savedProvider;
}
if (savedModel) {
selectedModelName.value = savedModel;
}
}
function saveToStorage(provider: ProviderConfig) {
localStorage.setItem(SELECTED_PROVIDER_KEY, provider.id);
localStorage.setItem(SELECTED_PROVIDER_MODEL_KEY, provider.model || "");
}
async function loadProviderConfigs(force = false) {
if (loadingProviders.value || (providersLoaded.value && !force)) return;
loadingProviders.value = true;
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
modelMetadata.value = (
response.data.model_metadata || {}
) as Record<string, ProviderModelMetadata>;
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
providersLoaded.value = true;
const selected = selectedProvider.value;
if (selected) {
selectedModelName.value = selected.model || "";
saveToStorage(selected);
}
}
} catch (error) {
console.error("Failed to load provider list:", error);
} finally {
loadingProviders.value = false;
}
}
function selectProvider(provider: ProviderConfig) {
selectedProviderId.value = provider.id;
selectedModelName.value = provider.model || "";
saveToStorage(provider);
menuOpen.value = false;
}
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, metadataForProvider(provider), tm);
}
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
async function testProvider(provider: ProviderConfig) {
if (testingProviderIds.value.includes(provider.id)) return;
testingProviderIds.value.push(provider.id);
try {
const startTime = performance.now();
const response = await providerApi.test(provider.id);
if (response.data.status === "ok" && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime));
toastSuccess(
tm("models.testSuccessWithLatency", {
id: provider.id,
latency,
}),
);
} else {
throw new Error(response.data.data.error || tm("models.testError"));
}
} catch (error: any) {
toastError(
error.response?.data?.message || error.message || tm("models.testError"),
);
} finally {
testingProviderIds.value = testingProviderIds.value.filter(
(id) => id !== provider.id,
);
}
}
function getCurrentSelection() {
return {
providerId: selectedProviderId.value,
modelName: selectedProvider.value?.model || selectedModelName.value || "",
};
}
function handleMenuToggle(isOpen: boolean) {
if (isOpen) {
loadProviderConfigs(true);
}
}
onMounted(() => {
loadFromStorage();
loadProviderConfigs();
});
defineExpose({
getCurrentSelection,
});
</script>
<style scoped>
.provider-trigger {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 6px;
border: 0;
background: transparent;
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
font: inherit;
letter-spacing: 0;
text-align: left;
}
.provider-trigger-copy {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.provider-trigger-title,
.provider-trigger-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-trigger--header {
height: 24px;
margin-top: 2px;
padding: 0;
}
.provider-trigger--header .provider-trigger-title {
font-size: 17px;
font-weight: 620;
line-height: 24px;
}
.provider-trigger--header .provider-trigger-meta {
color: rgba(var(--v-theme-on-surface), 0.55);
font-size: 13px;
font-weight: 500;
line-height: 18px;
}
.provider-trigger--input {
height: 40px;
max-width: min(280px, 42vw);
padding: 0 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
border-radius: 999px;
}
.provider-trigger--input:hover {
border-color: rgba(var(--v-theme-on-surface), 0.34);
background: rgba(var(--v-theme-on-surface), 0.04);
}
.provider-trigger--input .provider-trigger-title {
font-size: 14px;
font-weight: 500;
}
.provider-trigger--input .provider-trigger-meta {
display: none;
}
.provider-trigger-chevron {
flex: 0 0 auto;
opacity: 0.64;
}
.provider-menu-card {
width: min(420px, calc(100vw - 24px));
overflow: hidden;
border: 0;
border-radius: 14px !important;
background: rgb(var(--v-theme-surface));
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08) !important;
}
.provider-menu-body {
padding: 10px;
}
.provider-search {
margin-bottom: 8px;
}
.provider-search :deep(.v-field) {
border-radius: 10px;
box-shadow: none;
}
.provider-search :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.16);
}
.provider-menu-list {
max-height: min(360px, 58vh);
overflow-y: auto;
padding: 0;
}
.provider-menu-item {
min-height: 54px !important;
margin-bottom: 2px;
border-radius: 10px !important;
}
.provider-menu-item:hover {
background: rgba(var(--v-theme-on-surface), 0.05);
}
.provider-menu-item.v-list-item--active {
background: #f2f2f2;
color: rgb(var(--v-theme-on-surface));
}
.provider-item-title {
font-size: 14px;
font-weight: 600;
line-height: 18px;
}
.provider-subtitle {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.model-name {
min-width: 0;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-icons {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.meta-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.meta-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.meta-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-menu-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.provider-selected-icon {
color: rgba(var(--v-theme-on-surface), 0.72);
}
.empty-hint {
padding: 16px;
color: rgba(var(--v-theme-on-surface), 0.5);
font-size: 12px;
text-align: center;
}
:global(.v-theme--dark) .provider-menu-item.v-list-item--active {
background: rgba(255, 255, 255, 0.1);
}
@media (max-width: 768px) {
.provider-trigger--header .provider-trigger-title {
font-size: 16px;
}
.provider-trigger--header .provider-trigger-meta {
display: none;
}
.provider-trigger--input {
height: 38px;
max-width: 48vw;
}
}
</style>