mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
539 lines
14 KiB
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>
|