mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
perf: improve provider dialog (#10007)
This commit is contained in:
@@ -34,6 +34,14 @@ function setAxiosHeader(
|
||||
}
|
||||
|
||||
function attachAxiosHeaders(config: InternalAxiosRequestConfig) {
|
||||
// Dashboard credentials must not be attached to third-party requests.
|
||||
try {
|
||||
const requestUrl = new URL(axios.getUri(config), window.location.href);
|
||||
if (requestUrl.origin !== window.location.origin) return config;
|
||||
} catch {
|
||||
return config;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
setAxiosHeader(config.headers, AUTH_HEADER, `Bearer ${token}`);
|
||||
@@ -113,6 +121,19 @@ function installAxiosInterceptors(instance: AxiosInstance) {
|
||||
|
||||
export function fetchWithAuth(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const fetchImpl = originalFetch ?? window.fetch.bind(window);
|
||||
// The global fetch wrapper also handles public, cross-origin resources.
|
||||
try {
|
||||
const requestUrl = new URL(
|
||||
input instanceof Request ? input.url : input,
|
||||
window.location.href,
|
||||
);
|
||||
if (requestUrl.origin !== window.location.origin) {
|
||||
return fetchImpl(input, init);
|
||||
}
|
||||
} catch {
|
||||
return fetchImpl(input, init);
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const locale = getLocale();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="518" height="464" viewBox="128 178 518 464" fill="none">
|
||||
<title>Cat head with capsule eyes and reference-style short whiskers</title>
|
||||
<desc>A black cat head with two matching white capsule eyes with straight sides and semicircular ends and short whiskers, without pupils, on a transparent background.</desc>
|
||||
<path fill="#1D1D1B" d="M237.5 313.5C242.8 277.9 260.3 230.2 282.9 204.8C303.8 180.9 341.6 218.4 382.3 278.5C424.2 277 468.1 289.7 505.1 312.6C539.3 291.5 583.3 269.7 604.8 273C623.8 275.7 624.5 306.7 624.2 325.7C623.8 362.3 613.7 394.1 599.3 421.5C607.5 440 612.4 460.5 612.3 481.4C612 523.9 589.3 559 553.5 582.6C516.7 607 463.2 620.4 410.6 621C350.8 621.9 295.3 610.6 251.8 586.1C206.8 560.8 177.2 521.4 170.7 473C162.8 414.5 192.5 356.5 237.5 313.5Z"/>
|
||||
<g fill="#FFFFFF">
|
||||
<rect x="252" y="342" width="44" height="102" rx="22" fill="#FFFFFF" transform="rotate(18 274 393)"/>
|
||||
<rect x="403" y="387" width="44" height="102" rx="22" fill="#FFFFFF" transform="rotate(18 425 438)"/>
|
||||
</g>
|
||||
<g fill="none" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M146 405L180 416 M141 447L176 445" stroke="#1D1D1B"/>
|
||||
<path d="M548 493L573 500 M539 519L560 532" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 273 icons */
|
||||
/* Auto-generated MDI subset – 272 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -920,10 +920,6 @@
|
||||
content: "\F048F";
|
||||
}
|
||||
|
||||
.mdi-shape-outline::before {
|
||||
content: "\F0832";
|
||||
}
|
||||
|
||||
.mdi-shield-check::before {
|
||||
content: "\F0565";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -26,9 +26,12 @@
|
||||
<div class="provider-config-header">
|
||||
<div class="provider-config-headline">
|
||||
<div class="provider-config-title">{{ getSourceDisplayName(selectedProviderSource) }}</div>
|
||||
<div class="provider-config-subtitle">
|
||||
{{ selectedProviderSource.api_base || 'N/A' }}
|
||||
</div>
|
||||
<ProviderSourceSubtitle
|
||||
class="provider-config-subtitle"
|
||||
:api-base="selectedProviderSource.api_base"
|
||||
:sponsor="selectedSponsor"
|
||||
:tm="tm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="provider-config-actions">
|
||||
@@ -190,6 +193,7 @@ import { computed, ref } from 'vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
|
||||
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
|
||||
import ProviderSourceSubtitle from '@/components/provider/ProviderSourceSubtitle.vue'
|
||||
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
|
||||
import { useProviderModelConfigDialog } from '@/composables/useProviderModelConfigDialog'
|
||||
import { useProviderSources } from '@/composables/useProviderSources'
|
||||
@@ -215,6 +219,7 @@ function showMessage(message, color = 'success') {
|
||||
|
||||
const {
|
||||
selectedProviderSource,
|
||||
selectedSponsor,
|
||||
availableModels,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="open"
|
||||
max-width="820"
|
||||
scrollable
|
||||
:aria-label="tm('providerSources.selector.title')"
|
||||
@after-leave="search = ''"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
:disabled="sourceTypes.length === 0"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
>
|
||||
{{ tm('providerSources.add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="source-dialog" elevation="0">
|
||||
<v-card-title class="text-h3 pa-4 pb-0 pl-6 source-dialog__header">
|
||||
<span>{{ tm('providerSources.selector.title') }}</span>
|
||||
<div class="source-dialog__search">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
:placeholder="tm('providerSources.selector.search')"
|
||||
:aria-label="tm('providerSources.selector.search')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
>
|
||||
<template #clear="{ props: clearProps }">
|
||||
<v-btn
|
||||
v-bind="clearProps"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:aria-label="tm('providerSources.selector.clearSearch')"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="source-dialog__close"
|
||||
:aria-label="tm('dialogs.settings.close')"
|
||||
@click="open = false"
|
||||
/>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="source-dialog__body">
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="source-group"
|
||||
:class="`source-group--${group.id}`"
|
||||
:aria-label="tm(`providerSources.selector.${group.id}`)"
|
||||
>
|
||||
<div class="source-group__heading">
|
||||
<div class="source-group__label">
|
||||
<h3>{{ tm(`providerSources.selector.${group.id}`) }}</h3>
|
||||
<v-tooltip
|
||||
v-if="group.id === 'sponsors'"
|
||||
location="top"
|
||||
max-width="320"
|
||||
content-class="sponsor-info-tooltip"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
v-bind="tooltipProps"
|
||||
icon="mdi-help-circle-outline"
|
||||
variant="text"
|
||||
size="22"
|
||||
class="source-group__help"
|
||||
:aria-label="tm('providerSources.selector.sponsorInfoTitle')"
|
||||
/>
|
||||
</template>
|
||||
<div class="sponsor-tooltip">
|
||||
<div class="sponsor-tooltip__title">
|
||||
<Heart :size="16" class="sponsor-tooltip__heart" aria-hidden="true" />
|
||||
{{ tm('providerSources.selector.sponsorInfoTitle') }}
|
||||
</div>
|
||||
<div>{{ tm('providerSources.selector.sponsorInfoBody') }}</div>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<span class="source-group__count">{{ group.items.length }}</span>
|
||||
</div>
|
||||
<div class="source-grid">
|
||||
<div
|
||||
v-for="source in group.items"
|
||||
:key="source.value"
|
||||
class="source-card"
|
||||
:class="`source-card--${group.id}`"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="source-card__select"
|
||||
:aria-label="source.label"
|
||||
@click="selectSource(source.value)"
|
||||
/>
|
||||
<span class="source-card__icon">
|
||||
<v-img
|
||||
v-if="source.icon"
|
||||
:src="source.icon"
|
||||
:class="{ 'provider-icon--monochrome': source.isMonochrome }"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else class="source-card__initial" aria-hidden="true">{{ source.label.charAt(0) }}</span>
|
||||
</span>
|
||||
<span class="source-card__copy">
|
||||
<a
|
||||
v-if="source.isSponsor && source.website_url"
|
||||
class="source-card__name source-card__link"
|
||||
:href="source.website_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ source.label }}
|
||||
</a>
|
||||
<span v-else class="source-card__name">{{ source.label }}</span>
|
||||
<span v-if="source.subtitle" class="source-card__subtitle">{{ source.subtitle }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="groups.length === 0" class="source-dialog__empty" role="status">
|
||||
{{ tm('providerSources.selector.noResults') }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Heart } from '@lucide/vue'
|
||||
import { loadSponsorCatalog } from '@/utils/sponsorCatalog'
|
||||
|
||||
const props = defineProps({
|
||||
sourceTypes: { type: Array, default: () => [] },
|
||||
tm: { type: Function, required: true }
|
||||
})
|
||||
const emit = defineEmits(['select'])
|
||||
const open = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
watch(open, value => {
|
||||
if (value) void loadSponsorCatalog()
|
||||
})
|
||||
|
||||
const groups = computed(() => {
|
||||
const sponsorKeys = ['MiraRouter', 'SSYCloud(胜算云)']
|
||||
const preferredKeys = [
|
||||
'OpenAI Compatible', 'OpenAI Responses', 'Google Gemini', 'Anthropic',
|
||||
'DeepSeek', 'DeepSeek Responses', 'Moonshot', 'Kimi Coding Plan',
|
||||
'MiniMax', 'MiniMax Token Plan', 'Zhipu', 'Xiaomi', 'Xiaomi Token Plan',
|
||||
'xAI', 'LongCat'
|
||||
]
|
||||
const labels = {
|
||||
'Google Gemini': 'Gemini Compatible',
|
||||
Anthropic: 'Anthropic Compatible',
|
||||
Moonshot: 'Kimi',
|
||||
'SSYCloud(胜算云)': props.tm('providerSources.selector.ssycloud'),
|
||||
Gemini_OpenAI_API: 'Gemini OpenAI API'
|
||||
}
|
||||
const domains = {
|
||||
MiraRouter: 'mirarouter.com',
|
||||
'SSYCloud(胜算云)': 'shengsuanyun.com'
|
||||
}
|
||||
const sponsors = [
|
||||
...props.sourceTypes.filter(source => source.isSponsor && !sponsorKeys.includes(source.value)),
|
||||
...sponsorKeys.map(key => props.sourceTypes.find(source => source.value === key)).filter(Boolean)
|
||||
]
|
||||
const providers = props.sourceTypes.filter(source => !sponsors.includes(source))
|
||||
const query = (search.value || '').trim().toLowerCase()
|
||||
const sources = [
|
||||
...sponsors,
|
||||
...preferredKeys.map(key => providers.find(source => source.value === key)).filter(Boolean),
|
||||
...providers.filter(source => !preferredKeys.includes(source.value))
|
||||
].map(source => ({
|
||||
...source,
|
||||
label: labels[source.value] || source.label,
|
||||
subtitle: source.subtitle ?? domains[source.value] ?? '',
|
||||
isSponsor: source.isSponsor || sponsorKeys.includes(source.value)
|
||||
})).filter(source => `${source.value} ${source.label} ${source.subtitle}`.toLowerCase().includes(query))
|
||||
|
||||
return [
|
||||
{ id: 'sponsors', items: sources.filter(source => source.isSponsor) },
|
||||
{ id: 'providers', items: sources.filter(source => !source.isSponsor) }
|
||||
].filter(group => group.items.length > 0)
|
||||
})
|
||||
|
||||
function selectSource(value) {
|
||||
open.value = false
|
||||
emit('select', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.source-dialog {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 24px !important;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.source-dialog__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(200px, 300px) auto;
|
||||
margin-bottom: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
white-space: normal;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-dialog__search {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
font-weight: 400;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.025);
|
||||
}
|
||||
|
||||
.source-dialog__search:focus-within {
|
||||
border-color: rgba(var(--v-theme-primary), 0.75);
|
||||
}
|
||||
|
||||
.source-dialog__search :deep(.v-field) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.source-dialog__search :deep(.v-field__input) {
|
||||
min-height: 36px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.source-dialog__search :deep(.v-field__prepend-inner),
|
||||
.source-dialog__search :deep(.v-field__append-inner),
|
||||
.source-dialog__search :deep(.v-field__clearable) {
|
||||
padding-top: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-dialog__body {
|
||||
min-height: 0;
|
||||
height: 560px;
|
||||
padding: 0 24px 24px !important;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.source-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-group + .source-group {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.source-group__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 4px 14px;
|
||||
}
|
||||
|
||||
.source-group__heading h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.source-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.source-group__help {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
}
|
||||
|
||||
.source-group__help :deep(.v-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(.v-tooltip > .v-overlay__content.sponsor-info-tooltip) {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.sponsor-tooltip {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sponsor-tooltip__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sponsor-tooltip__heart {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-group__count {
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.source-group--sponsors .source-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
height: auto !important;
|
||||
min-height: 48px;
|
||||
padding: 12px !important;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.07);
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
box-shadow: none;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.source-card:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.045);
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.2);
|
||||
}
|
||||
|
||||
.source-card__select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-card__select:focus-visible,
|
||||
.source-card__link:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-primary));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.source-card__icon,
|
||||
.source-card__copy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.source-card__link {
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
pointer-events: auto;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.source-card__link:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.source-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.source-card__initial {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.source-card__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.source-card__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source-card__subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source-card--sponsors {
|
||||
min-height: 62px;
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.09);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.source-card--sponsors .source-card__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-dialog__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px 24px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.source-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.source-dialog__header {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.source-dialog__close {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.source-dialog__search {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.source-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.source-dialog__body {
|
||||
padding: 0 16px 20px !important;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="source-subtitle">
|
||||
<span>{{ apiBase || 'N/A' }}</span>
|
||||
<a v-if="sponsor?.website_url" :href="sponsor.website_url" target="_blank" rel="noopener noreferrer">
|
||||
{{ tm('providerSources.website') }}<v-icon icon="mdi-open-in-new" size="13" />
|
||||
</a>
|
||||
<a v-if="sponsor?.help_url" :href="sponsor.help_url" target="_blank" rel="noopener noreferrer">
|
||||
{{ tm('providerSources.setupGuide') }}<v-icon icon="mdi-open-in-new" size="13" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SponsorPreset } from '@/utils/sponsorCatalog'
|
||||
|
||||
defineProps<{
|
||||
apiBase?: string
|
||||
sponsor?: SponsorPreset
|
||||
tm: (key: string) => string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.source-subtitle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.source-subtitle > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source-subtitle a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.source-subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.source-subtitle a:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -69,41 +69,11 @@
|
||||
@click.stop="deleteSelectedSource"
|
||||
></v-btn>
|
||||
|
||||
<StyledMenu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
>
|
||||
{{ tm('providerSources.add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
class="styled-menu-item"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2 provider-source-avatar">
|
||||
<v-img
|
||||
v-if="sourceType.icon"
|
||||
:src="sourceType.icon"
|
||||
:class="{ 'provider-icon--monochrome': sourceType.isMonochrome }"
|
||||
alt="provider icon"
|
||||
cover
|
||||
></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
<ProviderSourceDialog
|
||||
:source-types="availableSourceTypes"
|
||||
:tm="tm"
|
||||
@select="emitAddSource"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
@@ -173,7 +143,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
import ProviderSourceDialog from '@/components/provider/ProviderSourceDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
displayedProviderSources: {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { providerApi } from '@/api/v1'
|
||||
import { getProviderIcon, isMonochromeProviderIcon } from '@/utils/providerUtils'
|
||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
import { sponsorCatalog, loadSponsorCatalog } from '@/utils/sponsorCatalog'
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
@@ -15,6 +17,9 @@ interface ProviderSourceType {
|
||||
label: string
|
||||
icon: string
|
||||
isMonochrome: boolean
|
||||
isSponsor?: boolean
|
||||
subtitle?: string
|
||||
website_url?: string
|
||||
}
|
||||
|
||||
interface ProviderIconSource {
|
||||
@@ -45,6 +50,7 @@ export function resolveDefaultTab(value?: string) {
|
||||
|
||||
export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
const { tm, showMessage } = options
|
||||
const { locale } = useI18n()
|
||||
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
@@ -92,13 +98,32 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
}
|
||||
|
||||
const types: ProviderSourceType[] = []
|
||||
const builtInSponsors = ['MiraRouter', 'SSYCloud(胜算云)']
|
||||
if (selectedProviderType.value === 'chat_completion' && sponsorCatalog.value) {
|
||||
for (const sponsor of sponsorCatalog.value.sponsors) {
|
||||
if (providerTemplates.value[sponsor.template]?.provider_type !== 'chat_completion') continue
|
||||
const translation = sponsor.i18n?.[locale.value]
|
||||
types.push({
|
||||
value: `sponsor:${sponsor.id}`,
|
||||
label: translation?.title || sponsor.title,
|
||||
icon: sponsor.logo,
|
||||
website_url: sponsor.website_url,
|
||||
subtitle: translation?.subtitle || sponsor.subtitle,
|
||||
isMonochrome: false,
|
||||
isSponsor: true
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
if (templateName === 'AIHubMix') continue
|
||||
if (sponsorCatalog.value && builtInSponsors.includes(templateName)) continue
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({
|
||||
value: templateName,
|
||||
label: templateName,
|
||||
icon: getProviderIcon(template.provider),
|
||||
isMonochrome: isMonochromeProviderIcon(template.provider)
|
||||
isMonochrome: isMonochromeProviderIcon(template.provider),
|
||||
isSponsor: builtInSponsors.includes(templateName)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -106,6 +131,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
return types
|
||||
})
|
||||
|
||||
const selectedSponsor = computed(() => {
|
||||
const source = selectedProviderSource.value
|
||||
if (!source?.api_base) return undefined
|
||||
return sponsorCatalog.value?.sponsors.find(sponsor =>
|
||||
sponsor.api_base.replace(/\/+$/, '') === source.api_base.replace(/\/+$/, '') &&
|
||||
providerTemplates.value[sponsor.template]?.type === source.type
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProviderSources = computed(() => {
|
||||
if (!providerSources.value) return []
|
||||
|
||||
@@ -455,7 +489,13 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
}
|
||||
|
||||
function addProviderSource(templateKey: string) {
|
||||
const template = providerTemplates.value[templateKey]
|
||||
const sponsor = templateKey.startsWith('sponsor:')
|
||||
? sponsorCatalog.value?.sponsors.find(item => `sponsor:${item.id}` === templateKey)
|
||||
: null
|
||||
const baseTemplate = providerTemplates.value[sponsor?.template || templateKey]
|
||||
const template = sponsor && baseTemplate
|
||||
? { ...baseTemplate, id: sponsor.id, api_base: sponsor.api_base }
|
||||
: baseTemplate
|
||||
if (!template) {
|
||||
showMessage('未找到对应的模板配置', 'error')
|
||||
return
|
||||
@@ -741,6 +781,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadSponsorCatalog()
|
||||
await loadProviderTemplate()
|
||||
})
|
||||
|
||||
@@ -770,6 +811,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
// computed
|
||||
providerTypes,
|
||||
availableSourceTypes,
|
||||
selectedSponsor,
|
||||
displayedProviderSources,
|
||||
sourceProviders,
|
||||
mergedModelEntries,
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
"title": "Provider Types"
|
||||
},
|
||||
"providerSources": {
|
||||
"website": "Website",
|
||||
"setupGuide": "Setup guide",
|
||||
"selector": {
|
||||
"ssycloud": "SSYCloud",
|
||||
"title": "New model provider",
|
||||
"sponsors": "Sponsors",
|
||||
"sponsorInfoTitle": "AstrBot sponsors",
|
||||
"sponsorInfoBody": "AstrBot is a free, open-source project supported by sponsorships. We may receive a small amount of revenue when you access model resources through our sponsors. You are welcome to support us by using these services.",
|
||||
"providers": "Providers",
|
||||
"search": "Search",
|
||||
"clearSearch": "Clear search",
|
||||
"noResults": "No matching providers"
|
||||
},
|
||||
"title": "Provider Sources",
|
||||
"add": "Add",
|
||||
"delete": "Delete provider source",
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
"title": "プロバイダータイプ"
|
||||
},
|
||||
"providerSources": {
|
||||
"website": "公式サイト",
|
||||
"setupGuide": "接続ガイド",
|
||||
"selector": {
|
||||
"ssycloud": "SSYCloud",
|
||||
"title": "モデルプロバイダーを追加",
|
||||
"sponsors": "スポンサー",
|
||||
"sponsorInfoTitle": "AstrBot のスポンサー",
|
||||
"sponsorInfoBody": "AstrBot は無料のオープンソースプロジェクトで、スポンサーの支援によって運営を維持しています。スポンサーが提供するサービスを通じてモデルを利用すると、私たちが少額の収益を得る場合があります。これらのサービスの利用を通じて、私たちの活動を応援していただけると幸いです。",
|
||||
"providers": "プロバイダー",
|
||||
"search": "検索",
|
||||
"clearSearch": "検索をクリア",
|
||||
"noResults": "一致するプロバイダーがありません"
|
||||
},
|
||||
"title": "プロバイダーソース",
|
||||
"add": "追加",
|
||||
"delete": "プロバイダーソースを削除",
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
"title": "Тип провайдера"
|
||||
},
|
||||
"providerSources": {
|
||||
"website": "Сайт",
|
||||
"setupGuide": "Руководство по подключению",
|
||||
"selector": {
|
||||
"ssycloud": "SSYCloud",
|
||||
"title": "Новый провайдер моделей",
|
||||
"sponsors": "Спонсоры",
|
||||
"sponsorInfoTitle": "Спонсоры AstrBot",
|
||||
"sponsorInfoBody": "AstrBot — бесплатный проект с открытым исходным кодом, работа которого поддерживается спонсорами. Когда вы подключаете модели через сервисы наших спонсоров, мы можем получать небольшой доход. Используя эти сервисы, вы можете поддержать нас.",
|
||||
"providers": "Провайдеры",
|
||||
"search": "Поиск",
|
||||
"clearSearch": "Очистить поиск",
|
||||
"noResults": "Подходящих провайдеров нет"
|
||||
},
|
||||
"title": "Источник провайдера",
|
||||
"add": "Добавить",
|
||||
"delete": "Удалить источник провайдера",
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
"title": "提供商类型"
|
||||
},
|
||||
"providerSources": {
|
||||
"website": "官网",
|
||||
"setupGuide": "接入帮助",
|
||||
"selector": {
|
||||
"ssycloud": "SSYCloud 胜算云",
|
||||
"title": "新建模型提供商",
|
||||
"sponsors": "赞助商",
|
||||
"sponsorInfoTitle": "AstrBot 赞助商",
|
||||
"sponsorInfoBody": "AstrBot 是免费开源项目,我们通过赞助支持维持项目运转。使用赞助商渠道接入模型资源时,我们可能获得少量收益。欢迎通过这些渠道支持我们。",
|
||||
"providers": "提供商",
|
||||
"search": "搜索",
|
||||
"clearSearch": "清除搜索",
|
||||
"noResults": "没有找到匹配的提供商"
|
||||
},
|
||||
"title": "提供商源",
|
||||
"add": "新增",
|
||||
"delete": "删除提供商源",
|
||||
|
||||
@@ -134,3 +134,8 @@ pre, code, .markdown pre, .markdown code, .release-notes pre, .release-notes cod
|
||||
.v-theme--PurpleThemeDark .provider-icon--monochrome {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
// Preserve the two-tone cat details when adapting the logo to dark surfaces.
|
||||
.v-theme--PurpleThemeDark img[src*="/mirarouter"] {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import mirarouterIcon from '@/assets/images/provider_logos/mirarouter.svg?no-inline';
|
||||
|
||||
/**
|
||||
* 提供商相关的工具函数
|
||||
*/
|
||||
@@ -9,6 +11,7 @@
|
||||
*/
|
||||
export function getProviderIcon(type) {
|
||||
const icons = {
|
||||
'mirarouter': mirarouterIcon,
|
||||
'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg',
|
||||
'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg',
|
||||
'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg',
|
||||
@@ -84,7 +87,8 @@ export function isMonochromeProviderIcon(type) {
|
||||
'mimo',
|
||||
'xiaomi',
|
||||
'xiaomi-token-plan',
|
||||
'openrouter'
|
||||
'openrouter',
|
||||
'groq'
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import { parseSponsorCatalog, SPONSOR_CACHE_TTL, SPONSOR_CATALOG_URL } from './sponsorCatalogSchema.mjs'
|
||||
|
||||
export interface SponsorPreset {
|
||||
id: string
|
||||
title: string
|
||||
logo: string
|
||||
subtitle: string
|
||||
order: number
|
||||
api_base: string
|
||||
website_url?: string
|
||||
help_url?: string
|
||||
template: string
|
||||
i18n?: Record<string, { title?: string; subtitle?: string }>
|
||||
}
|
||||
|
||||
interface Catalog {
|
||||
version: number
|
||||
updated_at: string
|
||||
sponsors: SponsorPreset[]
|
||||
}
|
||||
|
||||
export const sponsorCatalog = shallowRef<Catalog | null>(null)
|
||||
const cacheKey = 'astrbot:sponsor-catalog:v2'
|
||||
let loadedAt = 0
|
||||
let inFlight: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Load public sponsor presets with a six-hour cache and offline fallback.
|
||||
*
|
||||
* Returns:
|
||||
* A promise that resolves after using the cache or attempting a refresh.
|
||||
*/
|
||||
export function loadSponsorCatalog(): Promise<void> {
|
||||
if (inFlight) return inFlight
|
||||
if (loadedAt > 0 && Date.now() >= loadedAt && Date.now() - loadedAt < SPONSOR_CACHE_TTL) return Promise.resolve()
|
||||
inFlight = (async () => {
|
||||
const now = Date.now()
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null')
|
||||
if (cached && Number.isFinite(cached.fetchedAt) && cached.fetchedAt > 0 && cached.fetchedAt <= now) {
|
||||
sponsorCatalog.value = parseSponsorCatalog(cached.catalog) as unknown as Catalog
|
||||
if (now - cached.fetchedAt < SPONSOR_CACHE_TTL) {
|
||||
loadedAt = cached.fetchedAt
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unavailable storage or corrupt cache must not prevent provider setup.
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const response = await fetch(SPONSOR_CATALOG_URL, {
|
||||
signal: controller.signal,
|
||||
credentials: 'omit',
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
if (!response.ok) throw new Error(`Sponsor catalog returned ${response.status}`)
|
||||
const catalog = parseSponsorCatalog(await response.json()) as unknown as Catalog
|
||||
sponsorCatalog.value = catalog
|
||||
loadedAt = Date.now()
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ fetchedAt: loadedAt, catalog }))
|
||||
} catch {
|
||||
// The in-memory catalog remains usable when storage is full or disabled.
|
||||
}
|
||||
} catch {
|
||||
// Keep the last valid catalog, or use built-in presets on the first visit.
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
})().finally(() => { inFlight = null })
|
||||
return inFlight
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const SPONSOR_TEMPLATES = [
|
||||
'OpenAI Compatible', 'OpenAI Responses', 'Google Gemini', 'Anthropic',
|
||||
'MiraRouter', 'SSYCloud(胜算云)', 'AIHubMix'
|
||||
]
|
||||
|
||||
export const SPONSOR_CACHE_TTL = 6 * 60 * 60 * 1000
|
||||
export const SPONSOR_CATALOG_URL = 'https://sponsors.astrbot.app/providers.json'
|
||||
export const SPONSOR_LOCALES = {
|
||||
'zh-CN': '简体中文',
|
||||
'en-US': 'English',
|
||||
'ja-JP': '日本語',
|
||||
'ru-RU': 'Русский'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the public catalog before publishing, caching, or applying presets.
|
||||
*
|
||||
* Args:
|
||||
* input: Untrusted catalog JSON.
|
||||
* Returns:
|
||||
* A normalized catalog containing only supported public fields.
|
||||
* Raises:
|
||||
* TypeError: If a field, URL, identifier, or template is invalid.
|
||||
*/
|
||||
export function parseSponsorCatalog(input) {
|
||||
if (!input || input.version !== 1 || !Array.isArray(input.sponsors) || input.sponsors.length > 100) {
|
||||
throw new TypeError('Invalid sponsor catalog version or list')
|
||||
}
|
||||
if (typeof input.updated_at !== 'string' || !Number.isFinite(Date.parse(input.updated_at))) {
|
||||
throw new TypeError('Invalid catalog timestamp')
|
||||
}
|
||||
const ids = new Set()
|
||||
const sponsors = input.sponsors.map(item => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new TypeError('Invalid sponsor entry')
|
||||
}
|
||||
const fields = { id: 64, title: 80, logo: 2048, subtitle: 160, api_base: 2048, template: 80 }
|
||||
const sponsor = {}
|
||||
for (const [key, limit] of Object.entries(fields)) {
|
||||
if (typeof item[key] !== 'string' || item[key].length > limit) {
|
||||
throw new TypeError(`Invalid sponsor ${key}`)
|
||||
}
|
||||
sponsor[key] = item[key].trim()
|
||||
if (key !== 'subtitle' && !sponsor[key]) throw new TypeError(`Missing sponsor ${key}`)
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(sponsor.id) || ids.has(sponsor.id)) {
|
||||
throw new TypeError('Sponsor IDs must be unique lowercase identifiers')
|
||||
}
|
||||
ids.add(sponsor.id)
|
||||
if (!SPONSOR_TEMPLATES.includes(sponsor.template)) throw new TypeError('Unsupported provider template')
|
||||
for (const key of ['website_url', 'help_url']) {
|
||||
if (item[key] === undefined) continue
|
||||
if (typeof item[key] !== 'string' || item[key].length > 2048) {
|
||||
throw new TypeError(`Invalid sponsor ${key}`)
|
||||
}
|
||||
const value = item[key].trim()
|
||||
if (value) sponsor[key] = value
|
||||
}
|
||||
for (const key of ['logo', 'api_base', 'website_url', 'help_url']) {
|
||||
if (!sponsor[key]) continue
|
||||
let url
|
||||
try { url = new URL(sponsor[key]) } catch { throw new TypeError(`Invalid ${key} URL`) }
|
||||
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || (['logo', 'api_base'].includes(key) && url.hash)) {
|
||||
throw new TypeError(`${key} must be an HTTPS URL without credentials (logo and api_base also forbid fragments)`)
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(item.order) || item.order < 0 || item.order > 100000) {
|
||||
throw new TypeError('Sponsor order must be an integer from 0 to 100000')
|
||||
}
|
||||
sponsor.order = item.order
|
||||
if (item.i18n !== undefined) {
|
||||
if (!item.i18n || typeof item.i18n !== 'object' || Array.isArray(item.i18n)) {
|
||||
throw new TypeError('Invalid sponsor translations')
|
||||
}
|
||||
const translations = {}
|
||||
for (const [locale, translation] of Object.entries(item.i18n)) {
|
||||
if (!Object.hasOwn(SPONSOR_LOCALES, locale) || !translation || typeof translation !== 'object' || Array.isArray(translation)) {
|
||||
throw new TypeError('Invalid sponsor translation locale or fields')
|
||||
}
|
||||
const fields = {}
|
||||
for (const [key, limit] of [['title', 80], ['subtitle', 160]]) {
|
||||
if (translation[key] === undefined) continue
|
||||
if (typeof translation[key] !== 'string' || translation[key].length > limit) {
|
||||
throw new TypeError(`Invalid translated sponsor ${key}`)
|
||||
}
|
||||
const value = translation[key].trim()
|
||||
if (value) fields[key] = value
|
||||
}
|
||||
if (Object.keys(fields).length) translations[locale] = fields
|
||||
}
|
||||
if (Object.keys(translations).length) sponsor.i18n = translations
|
||||
}
|
||||
return sponsor
|
||||
})
|
||||
sponsors.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
|
||||
return { version: 1, updated_at: input.updated_at, sponsors }
|
||||
}
|
||||
@@ -67,9 +67,12 @@
|
||||
<div class="provider-config-header">
|
||||
<div class="provider-config-headline">
|
||||
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
|
||||
<div class="provider-config-subtitle">
|
||||
{{ selectedProviderSource.api_base || 'N/A' }}
|
||||
</div>
|
||||
<ProviderSourceSubtitle
|
||||
class="provider-config-subtitle"
|
||||
:api-base="selectedProviderSource.api_base"
|
||||
:sponsor="selectedSponsor"
|
||||
:tm="tm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="provider-config-actions">
|
||||
@@ -302,6 +305,7 @@ import { providerApi } from '@/api/v1'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
|
||||
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
|
||||
import ProviderSourceSubtitle from '@/components/provider/ProviderSourceSubtitle.vue'
|
||||
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
|
||||
import { useProviderModelConfigDialog } from '@/composables/useProviderModelConfigDialog'
|
||||
import { useProviderSources } from '@/composables/useProviderSources'
|
||||
@@ -329,6 +333,7 @@ const {
|
||||
providers,
|
||||
selectedProviderType,
|
||||
selectedProviderSource,
|
||||
selectedSponsor,
|
||||
availableModels,
|
||||
loadingSources,
|
||||
loadingModels,
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
export default {
|
||||
version: 1,
|
||||
updated_at: '2026-09-09T00:00:00.000Z',
|
||||
sponsors: [
|
||||
{
|
||||
id: 'mirarouter',
|
||||
title: 'MiraRouter',
|
||||
subtitle: 'mirarouter.com',
|
||||
order: 0,
|
||||
logo: 'https://sponsors.astrbot.app/provider-logos/mirarouter.svg',
|
||||
api_base: 'https://api.mirarouter.com/v1',
|
||||
template: 'MiraRouter'
|
||||
},
|
||||
{
|
||||
id: 'ssycloud',
|
||||
title: 'SSYCloud',
|
||||
i18n: { 'zh-CN': { title: 'SSYCloud 胜算云' } },
|
||||
subtitle: 'shengsuanyun.com',
|
||||
order: 1,
|
||||
logo: 'https://admin.shengsuanyun.com/assets/logo-BoujJhP-.png',
|
||||
api_base: 'https://router.shengsuanyun.com/api/v1',
|
||||
template: 'SSYCloud(胜算云)'
|
||||
},
|
||||
{
|
||||
id: 'aihubmix',
|
||||
title: 'AIHubMix',
|
||||
subtitle: 'aihubmix.com',
|
||||
order: 2,
|
||||
logo: 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
|
||||
api_base: 'https://aihubmix.com/v1',
|
||||
template: 'AIHubMix'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import defaults from './fixtures/sponsorCatalog.mjs'
|
||||
|
||||
test('logged-in dashboard requests keep credentials on their own origin', async (t) => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'localStorage'
|
||||
)
|
||||
const nativeFetch = globalThis.fetch
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch
|
||||
if (originalWindow)
|
||||
Object.defineProperty(globalThis, 'window', originalWindow)
|
||||
else delete globalThis.window
|
||||
if (originalStorage)
|
||||
Object.defineProperty(globalThis, 'localStorage', originalStorage)
|
||||
else delete globalThis.localStorage
|
||||
})
|
||||
const storage = new Map([
|
||||
['token', 'test-dashboard-token'],
|
||||
['astrbot-locale', 'zh-CN']
|
||||
])
|
||||
const requests = []
|
||||
const fetchStub = async (input, init) => {
|
||||
const headers = new Headers(
|
||||
init?.headers || (input instanceof Request ? input.headers : undefined)
|
||||
)
|
||||
requests.push({ input, init, headers })
|
||||
return Response.json(defaults)
|
||||
}
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
location: new URL('http://localhost:3000/#/providers'),
|
||||
fetch: fetchStub
|
||||
}
|
||||
})
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key)
|
||||
}
|
||||
})
|
||||
const { setupHttpClient, httpClient, apiV1Client } = await import(
|
||||
'../src/api/http.ts'
|
||||
)
|
||||
setupHttpClient()
|
||||
globalThis.fetch = window.fetch
|
||||
|
||||
await t.test(
|
||||
'sponsor loading uses the real auth wrapper without leaking a token and writes its cache',
|
||||
async () => {
|
||||
const { loadSponsorCatalog, sponsorCatalog } = await import(
|
||||
'../src/utils/sponsorCatalog.ts'
|
||||
)
|
||||
await loadSponsorCatalog()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(
|
||||
requests[0].input,
|
||||
'https://sponsors.astrbot.app/providers.json'
|
||||
)
|
||||
assert.equal(requests[0].headers.has('Authorization'), false)
|
||||
assert.equal(requests[0].headers.has('Accept-Language'), false)
|
||||
assert.equal(requests[0].headers.get('Accept'), 'application/json')
|
||||
assert.equal(requests[0].init.credentials, 'omit')
|
||||
assert.equal(sponsorCatalog.value.sponsors.length, 3)
|
||||
const cache = JSON.parse(storage.get('astrbot:sponsor-catalog:v2'))
|
||||
assert.equal(cache.catalog.sponsors.length, 3)
|
||||
assert.ok(cache.fetchedAt > 0)
|
||||
await loadSponsorCatalog()
|
||||
assert.equal(requests.length, 1)
|
||||
}
|
||||
)
|
||||
await t.test(
|
||||
'fetch URL, Request, protocol-relative and different-port destinations do not receive automatic auth',
|
||||
async () => {
|
||||
for (const input of [
|
||||
'https://example.com/api',
|
||||
'//example.com/api',
|
||||
'http://localhost:3001/api',
|
||||
new URL('https://example.com/api'),
|
||||
new Request('https://example.com/api')
|
||||
]) {
|
||||
await window.fetch(input)
|
||||
assert.equal(requests.at(-1).headers.has('Authorization'), false)
|
||||
assert.equal(requests.at(-1).headers.has('Accept-Language'), false)
|
||||
}
|
||||
await window.fetch('https://example.com/api', {
|
||||
headers: { Authorization: 'Bearer explicit-third-party-token' }
|
||||
})
|
||||
assert.equal(
|
||||
requests.at(-1).headers.get('Authorization'),
|
||||
'Bearer explicit-third-party-token'
|
||||
)
|
||||
}
|
||||
)
|
||||
await t.test(
|
||||
'same-origin fetch and streaming requests retain auth, locale, and explicit headers',
|
||||
async () => {
|
||||
for (const input of [
|
||||
'/api/v1/config',
|
||||
new URL('http://localhost:3000/api/v1/config'),
|
||||
new Request('http://localhost:3000/api/v1/chat/send')
|
||||
]) {
|
||||
await window.fetch(input)
|
||||
assert.equal(
|
||||
requests.at(-1).headers.get('Authorization'),
|
||||
'Bearer test-dashboard-token'
|
||||
)
|
||||
assert.equal(requests.at(-1).headers.get('Accept-Language'), 'zh-CN')
|
||||
}
|
||||
await window.fetch('/api/v1/chat/send', {
|
||||
headers: {
|
||||
Authorization: 'Bearer explicit-local-token',
|
||||
'Accept-Language': 'en-US'
|
||||
}
|
||||
})
|
||||
assert.equal(
|
||||
requests.at(-1).headers.get('Authorization'),
|
||||
'Bearer explicit-local-token'
|
||||
)
|
||||
assert.equal(requests.at(-1).headers.get('Accept-Language'), 'en-US')
|
||||
}
|
||||
)
|
||||
await t.test(
|
||||
'Axios resolves relative URLs and base URLs before attaching credentials',
|
||||
async () => {
|
||||
const adapter = async (config) => ({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
data: config.headers,
|
||||
config
|
||||
})
|
||||
for (const config of [
|
||||
{ url: 'https://example.com/catalog' },
|
||||
{ url: '//example.com/catalog' },
|
||||
{ url: '/catalog', baseURL: 'https://example.com' },
|
||||
{ url: 'http://localhost:3001/api' }
|
||||
]) {
|
||||
const response = await httpClient.request({ ...config, adapter })
|
||||
assert.equal(response.data.has('Authorization'), false)
|
||||
assert.equal(response.data.has('Accept-Language'), false)
|
||||
}
|
||||
for (const client of [httpClient, apiV1Client]) {
|
||||
const response = await client.get('/config', { adapter })
|
||||
assert.equal(
|
||||
response.data.get('Authorization'),
|
||||
'Bearer test-dashboard-token'
|
||||
)
|
||||
assert.equal(response.data.get('Accept-Language'), 'zh-CN')
|
||||
}
|
||||
const response = await httpClient.get('https://example.com/api', {
|
||||
adapter,
|
||||
headers: { Authorization: 'Bearer explicit-third-party-token' }
|
||||
})
|
||||
assert.equal(
|
||||
response.data.get('Authorization'),
|
||||
'Bearer explicit-third-party-token'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import builtIns from "./fixtures/sponsorCatalog.mjs";
|
||||
const defaults = structuredClone(builtIns);
|
||||
defaults.sponsors[0].website_url = "https://example.com/";
|
||||
defaults.sponsors[0].help_url = "https://docs.example.com/start#key";
|
||||
import { SPONSOR_CACHE_TTL } from "../src/utils/sponsorCatalogSchema.mjs";
|
||||
|
||||
test("dashboard six-hour cache", async (t) => {
|
||||
const now = Date.now();
|
||||
let cached = null;
|
||||
let fetched = 0;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
);
|
||||
t.after(() => {
|
||||
if (originalStorage)
|
||||
Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else delete globalThis.localStorage;
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
getItem: () => cached,
|
||||
setItem: (_, value) => {
|
||||
cached = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
t.mock.method(globalThis, "fetch", async (_, options) => {
|
||||
fetched++;
|
||||
assert.equal(options.credentials, "omit");
|
||||
return Response.json(defaults);
|
||||
});
|
||||
let sequence = 0;
|
||||
const freshModule = () =>
|
||||
import(`../src/utils/sponsorCatalog.ts?test=${sequence++}`);
|
||||
|
||||
await t.test(
|
||||
"fetches once, deduplicates and persists a valid catalog",
|
||||
async () => {
|
||||
const module = await freshModule();
|
||||
await Promise.all([
|
||||
module.loadSponsorCatalog(),
|
||||
module.loadSponsorCatalog(),
|
||||
]);
|
||||
assert.equal(
|
||||
JSON.parse(cached).catalog.sponsors[0].website_url,
|
||||
defaults.sponsors[0].website_url,
|
||||
);
|
||||
assert.equal(
|
||||
module.sponsorCatalog.value.sponsors[0].help_url,
|
||||
defaults.sponsors[0].help_url,
|
||||
);
|
||||
assert.equal(fetched, 1);
|
||||
assert.equal(JSON.parse(cached).fetchedAt, now);
|
||||
assert.equal(module.sponsorCatalog.value.sponsors.length, 3);
|
||||
assert.deepEqual(
|
||||
JSON.parse(cached).catalog.sponsors[1].i18n,
|
||||
defaults.sponsors[1].i18n,
|
||||
);
|
||||
assert.deepEqual(
|
||||
module.sponsorCatalog.value.sponsors[1].i18n,
|
||||
defaults.sponsors[1].i18n,
|
||||
);
|
||||
await module.loadSponsorCatalog();
|
||||
assert.equal(fetched, 1);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"fresh storage avoids a request until exactly six hours",
|
||||
async () => {
|
||||
cached = JSON.stringify({
|
||||
fetchedAt: now - SPONSOR_CACHE_TTL + 1,
|
||||
catalog: defaults,
|
||||
});
|
||||
await (await freshModule()).loadSponsorCatalog();
|
||||
assert.equal(fetched, 1);
|
||||
cached = JSON.stringify({
|
||||
fetchedAt: now - SPONSOR_CACHE_TTL,
|
||||
catalog: defaults,
|
||||
});
|
||||
await (await freshModule()).loadSponsorCatalog();
|
||||
assert.equal(fetched, 2);
|
||||
},
|
||||
);
|
||||
await t.test("corrupt and future-dated caches are replaced", async () => {
|
||||
for (const value of [
|
||||
"broken-json",
|
||||
JSON.stringify({ fetchedAt: now + 1, catalog: defaults }),
|
||||
]) {
|
||||
cached = value;
|
||||
await (await freshModule()).loadSponsorCatalog();
|
||||
assert.equal(JSON.parse(cached).fetchedAt, now);
|
||||
}
|
||||
assert.equal(fetched, 4);
|
||||
});
|
||||
await t.test(
|
||||
"offline refresh retains stale data and first visit uses built-ins",
|
||||
async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Offline");
|
||||
};
|
||||
cached = JSON.stringify({
|
||||
fetchedAt: now - SPONSOR_CACHE_TTL,
|
||||
catalog: defaults,
|
||||
});
|
||||
const stale = await freshModule();
|
||||
await stale.loadSponsorCatalog();
|
||||
assert.equal(stale.sponsorCatalog.value.sponsors.length, 3);
|
||||
cached = null;
|
||||
const empty = await freshModule();
|
||||
await empty.loadSponsorCatalog();
|
||||
assert.equal(empty.sponsorCatalog.value, null);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"invalid remote data never replaces a valid cached catalog",
|
||||
async () => {
|
||||
cached = JSON.stringify({
|
||||
fetchedAt: now - SPONSOR_CACHE_TTL,
|
||||
catalog: defaults,
|
||||
});
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({
|
||||
...defaults,
|
||||
sponsors: [
|
||||
{ ...defaults.sponsors[0], api_base: "javascript:alert(1)" },
|
||||
],
|
||||
});
|
||||
const module = await freshModule();
|
||||
await module.loadSponsorCatalog();
|
||||
assert.equal(
|
||||
module.sponsorCatalog.value.sponsors[0].api_base,
|
||||
defaults.sponsors[0].api_base,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"storage failures still allow loading and an empty remote list remains authoritative",
|
||||
async () => {
|
||||
globalThis.localStorage = {
|
||||
getItem() {
|
||||
throw new Error("Disabled");
|
||||
},
|
||||
setItem() {
|
||||
throw new Error("Full");
|
||||
},
|
||||
};
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({ ...defaults, sponsors: [] });
|
||||
const module = await freshModule();
|
||||
await module.loadSponsorCatalog();
|
||||
assert.deepEqual(module.sponsorCatalog.value.sponsors, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user