diff --git a/docs/architecture/baselines/renderer-application-boundaries-baseline.json b/docs/architecture/baselines/renderer-application-boundaries-baseline.json index 17006db5e4..2abd4374ca 100644 --- a/docs/architecture/baselines/renderer-application-boundaries-baseline.json +++ b/docs/architecture/baselines/renderer-application-boundaries-baseline.json @@ -152,7 +152,11 @@ "specifier": "@/components/settings/ModelConfigDialog.vue" }, { - "file": "src/renderer/settings/components/AddCustomProviderDialog.vue", + "file": "src/renderer/settings/components/AddProviderFlow.vue", + "specifier": "@/stores/modelStore" + }, + { + "file": "src/renderer/settings/components/AddProviderFlow.vue", "specifier": "@/stores/providerStore" }, { @@ -459,6 +463,18 @@ "file": "src/renderer/settings/components/ProviderApiConfig.vue", "specifier": "@/stores/modelCheck" }, + { + "file": "src/renderer/settings/components/ProviderCatalog.vue", + "specifier": "@/components/icons/ModelIcon.vue" + }, + { + "file": "src/renderer/settings/components/ProviderCatalog.vue", + "specifier": "@/stores/providerStore" + }, + { + "file": "src/renderer/settings/components/ProviderCatalog.vue", + "specifier": "@/stores/theme" + }, { "file": "src/renderer/settings/components/ProviderConfigImportDialog.vue", "specifier": "@/lib/utils" @@ -483,6 +499,10 @@ "file": "src/renderer/settings/components/ProviderModelList.vue", "specifier": "@/stores/uiSettingsStore" }, + { + "file": "src/renderer/settings/components/ProviderSettingsShell.vue", + "specifier": "@/stores/providerStore" + }, { "file": "src/renderer/settings/components/SettingsOverview.vue", "specifier": "@/stores/modelStore" @@ -532,5 +552,5 @@ "specifier": "@/i18n/bootstrap" } ], - "settingsToChatAppImportCount": 120 + "settingsToChatAppImportCount": 125 } diff --git a/src/main/app/settingsRoutes.ts b/src/main/app/settingsRoutes.ts index 148152eb9b..e763e3d2c5 100644 --- a/src/main/app/settingsRoutes.ts +++ b/src/main/app/settingsRoutes.ts @@ -65,6 +65,8 @@ export function createAppSettingsRoutes(deps: { read('artifact_think_collapse') read('providerOrder') read('providerTimestamps') + read('configuredProviders') + read('providerHealth') read('sidebar_group_mode') read('input_enabledMcpTools') return values diff --git a/src/main/provider/index.ts b/src/main/provider/index.ts index a2455655a5..773f2f9c03 100644 --- a/src/main/provider/index.ts +++ b/src/main/provider/index.ts @@ -922,6 +922,57 @@ export class ProviderRuntime return provider.getKeyStatus() } + /** + * Validates a draft provider configuration and loads its model catalog in one + * operation, without persisting the provider or toggling any enable flag. + * Used by the add-provider "Connect and load models" flow. + */ + async validateDraft(draft: LLM_PROVIDER): Promise<{ + isOk: boolean + errorMsg: string | null + models: MODEL_META[] + }> { + let instance: BaseLLMProvider | undefined + try { + // enable:false keeps the base-provider constructor from kicking off its + // background init fetch; check() and fetchModels() below are explicit. + instance = this.providerInstanceManager.createDraftInstance({ ...draft, enable: false }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return { isOk: false, errorMsg: errorMessage, models: [] } + } + if (!instance) { + return { isOk: false, errorMsg: `Unsupported provider type: ${draft.apiType}`, models: [] } + } + + try { + const checkResult = await instance.check() + if (!checkResult.isOk) { + return { ...checkResult, models: [] } + } + + const models = await instance.fetchModels({ suppressErrors: false }) + return { isOk: true, errorMsg: null, models } + } catch (error) { + // fetchModels with suppressErrors:false rethrows before any persisted + // write, so there is no partial catalog to clean up. Clearing here would + // erase an existing provider's saved models if draft.id matches it. + const errorMessage = error instanceof Error ? error.message : String(error) + return { isOk: false, errorMsg: errorMessage, models: [] } + } finally { + if ( + 'cleanup' in instance && + typeof (instance as { cleanup?: unknown }).cleanup === 'function' + ) { + try { + ;(instance as unknown as { cleanup: () => void }).cleanup() + } catch (error) { + console.error(`Failed to clean up draft provider instance ${draft.id}:`, error) + } + } + } + } + private getEnabledProviderIdsUsingProviderDb(): string[] { return this.providerInstanceManager .getProviders() diff --git a/src/main/provider/managers/providerInstanceManager.ts b/src/main/provider/managers/providerInstanceManager.ts index 006c657356..28a97fc4e7 100644 --- a/src/main/provider/managers/providerInstanceManager.ts +++ b/src/main/provider/managers/providerInstanceManager.ts @@ -315,6 +315,16 @@ export class ProviderInstanceManager { } } + /** + * Creates a transient instance for draft validation. The instance is not + * registered in the runtime maps, so validating a draft never touches the + * persisted provider list or an existing provider's live instance. + */ + createDraftInstance(draft: LLM_PROVIDER): BaseLLMProvider | undefined { + if (this.closed) throw new Error('[Provider] Runtime is closed') + return this.createProviderInstance(draft) + } + /** * Creates a provider instance while preserving backward compatibility. * Lookup order MUST remain id -> apiType so that legacy configs lacking ids continue to work. diff --git a/src/main/provider/routes.ts b/src/main/provider/routes.ts index 1f49cba051..1667c2597d 100644 --- a/src/main/provider/routes.ts +++ b/src/main/provider/routes.ts @@ -39,6 +39,7 @@ import { oauthXaiGrokLogoutRoute, oauthXaiGrokStartDeviceLoginRoute, providersAddRoute, + providersValidateDraftRoute, providersGetAcpProcessConfigOptionsRoute, providersGetEmbeddingDimensionsRoute, providersGetKeyStatusRoute, @@ -295,6 +296,22 @@ export function createProviderRoutes(deps: { return result } ], + [ + providersValidateDraftRoute.name, + async (rawInput) => { + const input = providersValidateDraftRoute.input.parse(rawInput) + const result = await providerRuntime.validateDraft(input.provider) + return providersValidateDraftRoute.output.parse({ + isOk: result.isOk, + errorMsg: result.errorMsg, + models: result.models.map((model) => ({ + id: model.id, + name: model.name, + ...(model.type ? { type: model.type } : {}) + })) + }) + } + ], [ providersRemoveRoute.name, async (rawInput, context) => { diff --git a/src/renderer/api/ProviderClient.ts b/src/renderer/api/ProviderClient.ts index 2388cf7cda..139a8a1ed0 100644 --- a/src/renderer/api/ProviderClient.ts +++ b/src/renderer/api/ProviderClient.ts @@ -30,6 +30,7 @@ import { providersSyncModelScopeMcpServersRoute, providersTestConnectionRoute, providersUpdateRoute, + providersValidateDraftRoute, providersUpdateRateLimitRoute, providersWarmupAcpProcessRoute } from '@shared/contracts/routes' @@ -85,6 +86,10 @@ export function createProviderClient(bridge: DeepchatBridge = getDeepchatBridge( return result.provider } + async function validateDraftProvider(provider: LLM_PROVIDER) { + return await bridge.invoke(providersValidateDraftRoute.name, { provider }) + } + async function removeProviderAtomic(providerId: string) { const result = await bridge.invoke(providersRemoveRoute.name, { providerId }) return result.removed @@ -293,6 +298,7 @@ export function createProviderClient(bridge: DeepchatBridge = getDeepchatBridge( setProviderById, updateProviderAtomic, addProviderAtomic, + validateDraftProvider, removeProviderAtomic, reorderProvidersAtomic, listModels, diff --git a/src/renderer/settings/components/AddCustomProviderDialog.vue b/src/renderer/settings/components/AddCustomProviderDialog.vue deleted file mode 100644 index 426259227c..0000000000 --- a/src/renderer/settings/components/AddCustomProviderDialog.vue +++ /dev/null @@ -1,281 +0,0 @@ - - - - - {{ t('settings.provider.dialog.addCustomProvider.title') }} - - {{ t('settings.provider.dialog.addCustomProvider.description') }} - - - - - - - {{ t('settings.provider.dialog.addCustomProvider.name') }} - - - - - - - - - - - - - {{ t('settings.provider.dialog.addCustomProvider.apiType') }} - - - - - - - - - - OpenAI - OpenAI Completions - Gemini - Anthropic - Ollama - Mistral AI - - - - - - - - - - - {{ t('settings.provider.dialog.addCustomProvider.apiKey') }} - - - - - - - - - - - - - {{ t('settings.provider.dialog.addCustomProvider.baseUrl') }} - - - - - - - {{ `${formData.baseUrl ?? ''}${apiEndpointSuffix}` }} - - - - - - - - - {{ t('settings.provider.dialog.addCustomProvider.enable') }} - - - - - - - {{ formData.enable ? t('common.enabled') : t('common.disabled') }} - - - - - - - - - - - - - diff --git a/src/renderer/settings/components/AddProviderFlow.vue b/src/renderer/settings/components/AddProviderFlow.vue new file mode 100644 index 0000000000..0b13b84b87 --- /dev/null +++ b/src/renderer/settings/components/AddProviderFlow.vue @@ -0,0 +1,273 @@ + + + + + + + + + + + {{ t('settings.provider.dialog.addCustomProvider.title') }} + + + {{ t('settings.provider.dialog.addCustomProvider.description') }} + + + + + + + + {{ t('settings.provider.dialog.addCustomProvider.name') }} + + + + + + + {{ t('settings.provider.dialog.addCustomProvider.apiType') }} + + + + + + + OpenAI + OpenAI Completions + Gemini + Anthropic + Ollama + Mistral AI + + + + + + + {{ t('settings.provider.dialog.addCustomProvider.baseUrl') }} + + + + {{ + t('settings.provider.urlFormat', { + defaultUrl: `${normalizedBaseUrl}${apiEndpointSuffix}` + }) + }} + + + + + + {{ t('settings.provider.dialog.addCustomProvider.apiKey') }} + + + + + + + + + + {{ connectButtonLabel }} + + + {{ t('common.cancel') }} + + + + + + + + + diff --git a/src/renderer/settings/components/BedrockProviderSettingsDetail.vue b/src/renderer/settings/components/BedrockProviderSettingsDetail.vue index a4f95c5eb6..9dbc10111f 100644 --- a/src/renderer/settings/components/BedrockProviderSettingsDetail.vue +++ b/src/renderer/settings/components/BedrockProviderSettingsDetail.vue @@ -1,9 +1,9 @@ @@ -228,7 +228,7 @@ const region = ref(props.provider.credential?.region || '') const profile = ref(props.provider.credential?.profile || '') const showAccessKeyId = ref(false) const showSecretAccessKey = ref(false) -const activeTab = ref<'connection' | 'models' | 'advanced'>('connection') +const providerHealth = computed(() => providerStore.getProviderHealth(props.provider.id)) const providerModels = ref([]) const customModels = computed(() => { const providerCustomModels = modelStore.customModels.find( diff --git a/src/renderer/settings/components/ModelProviderSettings.vue b/src/renderer/settings/components/ModelProviderSettings.vue index b0b506613a..456acbb8b1 100644 --- a/src/renderer/settings/components/ModelProviderSettings.vue +++ b/src/renderer/settings/components/ModelProviderSettings.vue @@ -58,17 +58,16 @@ /> - + + - {{ t('settings.provider.enabled') }} ({{ enabledProviders.length }}) + {{ t('settings.provider.sidebar.configured') }} ({{ configuredList.length }}) @@ -76,7 +75,10 @@ :data-provider-id="provider.id" :class="[ 'flex flex-row hover:bg-accent items-center gap-2 rounded-lg p-2 group', - route.params?.providerId === provider.id ? 'bg-accent text-accent-foreground' : '' + route.params?.providerId === provider.id + ? 'bg-accent text-accent-foreground' + : '', + provider.enable ? '' : 'opacity-60' ]" @click="handleProviderRowClick(provider.id)" > @@ -105,108 +107,101 @@ :dir="languageStore.dir" >{{ t(provider.name) }} - - + + {{ t('settings.provider.sidebar.disabledTag') }} + + + + + + + + + {{ healthTooltip(provider.id) }} + + + + + + + + + + + + + + + + + - - - - {{ t('settings.provider.disabled') }} ({{ disabledProviders.length }}) - - - - {{ t(isDisabledProvidersExpanded ? 'common.collapse' : 'common.expand') }} - - - - - - - - - - {{ t(provider.name) }} - - - - - - + + + {{ t('settings.provider.sidebar.empty') }} + - + + + + + {{ t('settings.provider.browseAll') }} + + {{ catalogProviders.length }} + {{ t('settings.provider.addCustomProvider') }} @@ -214,36 +209,59 @@ - - - + - + + + + + - !value && (providerPendingDelete = null)" + @confirm="confirmDeleteProvider" /> @@ -321,14 +339,28 @@ import { refDebounced } from '@vueuse/core' import ModelProviderSettingsDetail from './ModelProviderSettingsDetail.vue' import OllamaProviderSettingsDetail from './OllamaProviderSettingsDetail.vue' import BedrockProviderSettingsDetail from './BedrockProviderSettingsDetail.vue' +import ProviderCatalog from './ProviderCatalog.vue' import ModelIcon from '@/components/icons/ModelIcon.vue' import { Icon } from '@iconify/vue' -import AddCustomProviderDialog from './AddCustomProviderDialog.vue' +import AddProviderFlow from './AddProviderFlow.vue' import { useI18n } from 'vue-i18n' import type { AWS_BEDROCK_PROVIDER, LLM_PROVIDER } from '@shared/types/provider' -import { Switch } from '@shadcn/components/ui/switch' import { Input } from '@shadcn/components/ui/input' import { DcButton } from '@dc-ui/components/button' +import { DcConfirmDialog } from '@dc-ui/components/confirm-dialog' +import { DcDropdownActionItem } from '@dc-ui/components/dropdown-action-item' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@shadcn/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@shadcn/components/ui/tooltip' import { Skeleton } from '@shadcn/components/ui/skeleton' import draggable from 'vuedraggable' import { ScrollArea } from '@shadcn/components/ui/scroll-area' @@ -342,7 +374,7 @@ import { continueGuidedOnboardingFromSettings } from '../lib/guidedOnboardingSet const route = useRoute() const router = useRouter() -const { t } = useI18n() +const { t, locale } = useI18n() const windowClient = createWindowClient() const languageStore = useLanguageStore() const providerStore = useProviderStore() @@ -383,7 +415,8 @@ const startupWorkloadStore = (() => { return null } })() -const isAddProviderDialogOpen = ref(false) +const addFlowCounter = ref(0) +const providerPendingDelete = ref(null) const continueProviderGuide = async ( state: Awaited> | null | undefined @@ -397,7 +430,7 @@ const continueProviderGuide = async ( } const handleSelectProviderGuidePrimary = async () => { - const firstProviderId = visibleProviders.value[0]?.id + const firstProviderId = guideCandidateProviders.value[0]?.id if (firstProviderId && activeProvider.value?.id !== firstProviderId) { await setActiveProvider(firstProviderId) await nextTick() @@ -494,14 +527,12 @@ const handleProviderModelEnabled = async () => { const searchQueryBase = ref('') const searchQuery = refDebounced(searchQueryBase, 150) const showClearButton = computed(() => searchQueryBase.value.trim().length > 0) -const isDisabledProvidersExpanded = ref(false) const editingProviderId = ref(null) const editingName = ref('') const editInputRef = ref(null) -const startEditingName = (provider: LLM_PROVIDER, event: Event) => { - event.stopPropagation() +const startEditingName = (provider: LLM_PROVIDER) => { editingProviderId.value = provider.id editingName.value = provider.name nextTick(() => { @@ -546,11 +577,21 @@ const filterProviders = (providers: LLM_PROVIDER[]) => { return providers.filter((provider) => t(provider.name).toLowerCase().includes(query)) } -const visibleProviders = computed(() => +// Sidebar membership: configured providers only. The full catalog lives behind +// "Browse all providers" and never adds unconfigured rows to the daily sidebar. +const configuredList = computed(() => + filterProviders(providerStore.configuredProviders.filter((provider) => provider.id !== 'acp')) +) +const catalogProviders = computed(() => providerStore.sortedProviders.filter((provider) => provider.id !== 'acp') ) +// Guided onboarding needs a clickable provider row; before anything is +// configured those rows live in the catalog view instead of the sidebar. +const guideCandidateProviders = computed(() => + configuredList.value.length > 0 ? configuredList.value : catalogProviders.value +) const canAdvanceProviderSelection = computed(() => - Boolean(activeProvider.value ?? visibleProviders.value[0]) + Boolean(activeProvider.value ?? guideCandidateProviders.value[0]) ) const canAdvanceProviderApiKey = computed(() => Boolean(activeProvider.value?.apiKey?.trim())) const getCurrentProviderModels = () => { @@ -584,9 +625,58 @@ const showProviderSkeleton = computed( () => (!providerStore.initialized || startupWorkloadStore?.isTaskRunning('settings.providers.summary')) && - visibleProviders.value.length === 0 + providerStore.sortedProviders.length === 0 ) +// Compute each provider's health once per render instead of invoking the +// store lookup repeatedly in healthLabel/healthTooltip/healthDotClass. +const providerHealthMap = computed(() => { + const map: Record = {} + for (const provider of providerStore.configuredProviders) { + map[provider.id] = providerStore.getProviderHealth(provider.id) + } + return map +}) + +const getHealth = (providerId: string) => + providerHealthMap.value[providerId] ?? { status: 'not_checked' } + +const healthLabel = (providerId: string) => { + const health = getHealth(providerId) + switch (health.status) { + case 'verified': + return t('settings.provider.health.verified') + case 'checking': + return t('settings.provider.health.checking') + case 'needs_attention': + return t('settings.provider.health.needsAttention') + default: + return t('settings.provider.health.notChecked') + } +} + +const healthTooltip = (providerId: string) => { + const health = getHealth(providerId) + if (health.checkedAt) { + return `${healthLabel(providerId)} · ${new Date(health.checkedAt).toLocaleString(locale.value)}` + } + return healthLabel(providerId) +} + +const healthDotClass = (providerId: string) => { + const health = getHealth(providerId) + switch (health.status) { + case 'verified': + return 'bg-emerald-500' + case 'checking': + return 'bg-primary animate-pulse' + case 'needs_attention': + return 'bg-amber-500' + default: + return 'bg-muted-foreground/40' + } +} + let guideTargetSyncPending = false let providerDetailMutationObserver: MutationObserver | null = null @@ -642,7 +732,7 @@ const observeProviderDetailGuideTargets = () => { const syncGuideTargets = () => { const activeProviderId = typeof route.params.providerId === 'string' ? route.params.providerId : null - const firstProviderId = visibleProviders.value[0]?.id + const firstProviderId = guideCandidateProviders.value[0]?.id const detailRoot = providerDetailRef.value providerListGuideTargetRef.value = firstProviderId @@ -660,65 +750,44 @@ const syncGuideTargets = () => { `[data-testid^="provider-model-toggle-${activeProviderId}-"]` ) as HTMLElement | null)) : null) ?? - (detailRoot?.querySelector( - '[data-testid="provider-models-tab-trigger"]' - ) as HTMLElement | null) ?? - (document.querySelector('[data-testid="provider-models-tab-trigger"]') as HTMLElement | null) + (detailRoot?.querySelector('[data-testid="provider-models-section"]') as HTMLElement | null) ?? + (document.querySelector('[data-testid="provider-models-section"]') as HTMLElement | null) } -const allEnabledProviders = computed(() => visibleProviders.value.filter((p) => p.enable)) -const allDisabledProviders = computed(() => visibleProviders.value.filter((p) => !p.enable)) - -// 分别处理启用和禁用的 providers -const enabledProviders = computed({ - get: () => filterProviders(allEnabledProviders.value), +// Reordering the sidebar only rearranges configured providers; unconfigured +// catalog entries keep their relative order at the end of the persisted order. +const sidebarProviders = computed({ + get: () => configuredList.value, set: (newProviders) => { + const configuredIds = new Set(providerStore.configuredProviders.map((provider) => provider.id)) const isFiltered = searchQuery.value.trim().length > 0 + let reorderedConfigured: LLM_PROVIDER[] if (isFiltered) { const orderMap = new Map(newProviders.map((provider, index) => [provider.id, index])) - const reorderedEnabled = [...allEnabledProviders.value].sort((a, b) => { + reorderedConfigured = [...providerStore.configuredProviders].sort((a, b) => { const orderA = orderMap.get(a.id) ?? Infinity const orderB = orderMap.get(b.id) ?? Infinity return orderA - orderB }) - const allProviders = [...reorderedEnabled, ...allDisabledProviders.value] - providerStore.updateProvidersOrder(allProviders) } else { - const allProviders = [...newProviders, ...allDisabledProviders.value] - providerStore.updateProvidersOrder(allProviders) + reorderedConfigured = [ + ...newProviders, + ...providerStore.configuredProviders.filter( + (provider) => !newProviders.some((item) => item.id === provider.id) + ) + ] } - } -}) - -const disabledProviders = computed({ - get: () => filterProviders(allDisabledProviders.value), - set: (newProviders) => { - const isFiltered = searchQuery.value.trim().length > 0 - if (isFiltered) { - const orderMap = new Map(newProviders.map((provider, index) => [provider.id, index])) - const reorderedDisabled = [...allDisabledProviders.value].sort((a, b) => { - const orderA = orderMap.get(a.id) ?? Infinity - const orderB = orderMap.get(b.id) ?? Infinity - return orderA - orderB + const unconfigured = providerStore.sortedProviders.filter( + (provider) => !configuredIds.has(provider.id) + ) + void providerStore + .updateProvidersOrder([...reorderedConfigured, ...unconfigured]) + .catch((error) => { + console.error('Failed to reorder providers:', error) }) - const allProviders = [...allEnabledProviders.value, ...reorderedDisabled] - providerStore.updateProvidersOrder(allProviders) - } else { - const allProviders = [...allEnabledProviders.value, ...newProviders] - providerStore.updateProvidersOrder(allProviders) - } } }) -watch( - () => [searchQuery.value.trim(), disabledProviders.value.length] as const, - ([query, disabledMatchCount]) => { - if (query.length > 0 && disabledMatchCount > 0) { - isDisabledProvidersExpanded.value = true - } - } -) - const setActiveProvider = (providerId: string) => { return router.push({ name: 'settings-provider', @@ -728,6 +797,18 @@ const setActiveProvider = (providerId: string) => { }) } +const openCatalog = () => { + return router.push({ + name: 'settings-provider', + params: route.params, + query: { view: 'catalog' } + }) +} + +const handleCatalogSelect = async (providerId: string) => { + await handleProviderRowClick(providerId) +} + const handleProviderRowClick = async (providerId: string) => { await setActiveProvider(providerId) @@ -740,7 +821,7 @@ const handleProviderRowClick = async (providerId: string) => { return } - const firstProviderId = visibleProviders.value[0]?.id + const firstProviderId = guideCandidateProviders.value[0]?.id if (!firstProviderId || providerId !== firstProviderId) { return } @@ -763,7 +844,12 @@ const scrollToProvider = (providerId: string) => { const toggleProviderStatus = async (provider: LLM_PROVIDER) => { const willEnable = !provider.enable - await providerStore.updateProviderStatus(provider.id, willEnable) + try { + await providerStore.updateProviderStatus(provider.id, willEnable) + } catch (error) { + console.error('Failed to update provider status:', error) + return + } // 切换状态后,同时打开该服务商的详情页面 setActiveProvider(provider.id) @@ -774,6 +860,32 @@ const toggleProviderStatus = async (provider: LLM_PROVIDER) => { } } +const requestDeleteProvider = (provider: LLM_PROVIDER) => { + providerPendingDelete.value = provider +} + +const confirmDeleteProvider = async () => { + const provider = providerPendingDelete.value + if (!provider) { + return + } + providerPendingDelete.value = null + try { + await providerStore.removeProvider(provider.id) + } catch (error) { + console.error('Failed to delete provider:', error) + return + } + if (route.params.providerId === provider.id) { + const fallback = configuredList.value.find((item) => item.id !== provider.id) + if (fallback) { + await setActiveProvider(fallback.id) + } else { + await openCatalog() + } + } +} + const activeProvider = computed(() => { const provider = providerStore.providers.find((p) => p.id === route.params.providerId) if (provider?.id === 'acp') { @@ -783,8 +895,27 @@ const activeProvider = computed(() => { return provider }) -const openAddProviderDialog = () => { - isAddProviderDialogOpen.value = true +const showCatalog = computed(() => { + if (route.query.view === 'catalog') { + return true + } + // Use the unfiltered configured set so a no-match search does not replace + // the detail pane with the catalog view. + const hasConfigured = providerStore.configuredProviders.some((p) => p.id !== 'acp') + return !activeProvider.value && !hasConfigured +}) + +const showAddFlow = computed(() => route.query.view === 'add-custom') +// A fresh key per entry gives every add attempt its own draft id. +const addFlowKey = computed(() => `add-provider-${addFlowCounter.value}`) + +const openAddProviderFlow = () => { + addFlowCounter.value += 1 + return router.push({ + name: 'settings-provider', + params: route.params, + query: { view: 'add-custom' } + }) } const handleProviderAdded = (provider: LLM_PROVIDER) => { @@ -794,8 +925,8 @@ const handleProviderAdded = (provider: LLM_PROVIDER) => { onMounted(async () => { await providerStore.ensureInitialized() - if (!route.params.providerId && visibleProviders.value.length > 0) { - setActiveProvider(visibleProviders.value[0].id) + if (!route.params.providerId && !route.query.view && configuredList.value.length > 0) { + setActiveProvider(configuredList.value[0].id) } scheduleGuideTargetSync() @@ -822,7 +953,8 @@ watch( () => [ route.params.providerId, - visibleProviders.value.map((provider) => provider.id).join('|'), + guideCandidateProviders.value.map((provider) => provider.id).join('|'), + showCatalog.value, selectProviderGuide.showGuide.value, providerApiKeyGuide.showGuide.value, providerModelGuide.showGuide.value, @@ -854,32 +986,6 @@ watch( const handleDragEnd = () => { // 可以在这里添加额外的处理逻辑 } - -// 处理启用区域的拖拽移动事件 -const onMoveEnabled = (evt: any) => { - const draggedProvider = evt.draggedContext.element - const relatedProvider = evt.relatedContext?.element - if (!draggedProvider || !draggedProvider.enable) { - return false - } - if (relatedProvider && !relatedProvider.enable) { - return false - } - return true -} - -// 处理禁用区域的拖拽移动事件 -const onMoveDisabled = (evt: any) => { - const draggedProvider = evt.draggedContext.element - const relatedProvider = evt.relatedContext?.element - if (!draggedProvider || draggedProvider.enable) { - return false - } - if (relatedProvider && relatedProvider.enable) { - return false - } - return true -}
+ {{ t('settings.provider.dialog.addCustomProvider.description') }} +
+ {{ + t('settings.provider.urlFormat', { + defaultUrl: `${normalizedBaseUrl}${apiEndpointSuffix}` + }) + }} +
+ {{ t('settings.provider.sidebar.empty') }} +