refactor(ui): split useProviders into 5 single-responsibility composables

Reshapes the Settings/Models frontend to match the channel-module split
convention (commit 22894ac4 'perf(channels): split Channels.vue...'),
zero behavior change. Paves the way for a follow-up that adds an enabled
column + AddProviderDrawer without bloating useProviders back to monolith.

Frontend split
- useProviders.ts goes from 615-line monolith to a 48-line facade that
  composes five single-responsibility slices:
    * useProviderList — providers / activeModels / currentProvider,
      loaders, status pill, icons
    * useProviderForm — create/edit modal + form, save/delete
    * useProviderDiscovery — manage-models modal, discovery, connection
      and per-model tests
    * useProviderOAuth — openai-chatgpt + claude-code OAuth flows
    * useProviderPool — manual reprobe (most pool surface inlined to
      ProviderInfo.liveness in the prior liveness change)
  Cross-composable refs flow via dep-injection arguments — no module-
  level state, no circular deps. Each composable stays independently
  testable.
- Pure helpers extracted to src/utils:
    * safeJson.ts — strict JSON-object parser
    * modelProtocol.ts — protocol <-> ChatModel class translation
- Modals (ProviderConfigModal, ManageModelsModal) loaded via
  defineAsyncComponent so the route's first paint doesn't drag along
  ~30KB of form/auth UI.
- el-skeleton placeholder during the initial Promise.all so the page
  paints something instead of blank-then-pop.

Layout regression fix
- MainLayout's <keep-alive> slot used :key='workspaceRouteKey' (a
  workspace-scoped string), shared between two <component v-if> blocks.
  Adding a second keepAlive route would have caused two components to
  mount side by side, because Vue saw identical keys and patched in
  place across the v-if boundary. Switched the key to
  ${workspaceRouteKey}:${route.path} so different routes get distinct
  vnode identities while workspace switching still busts the cache.
  Discovered while implementing the split — the multi-line HTML
  comment also had to live OUTSIDE <keep-alive>, since KeepAlive
  treats comments as children and rejects 'more than one'.

Embedding section title fix (drive-by)
- EmbeddingModelsSection.vue's scoped style didn't redeclare
  .group-title's flex layout, so the icon stacked above the title
  text instead of sitting inline. Added the missing flex rules
  locally — now matches the local-models / cloud-models group headers.

Verification
- vue-tsc 0 errors.
- Browser end-to-end: 27 cards render correctly, modals open via lazy
  load, /channels <-> /settings/models switch four times in a row with
  exactly one page title visible at each step (no stacking).
This commit is contained in:
matevip 2026-04-28 15:01:14 +08:00
parent 62b94b522f
commit a168f91215
11 changed files with 793 additions and 606 deletions

View File

@ -0,0 +1,23 @@
/**
* Two-way translation between the high-level "protocol" the user picks in the
* provider form (openai-compatible / anthropic-messages / gemini-native /
* dashscope-native) and the Spring AI ChatModel class name persisted on the
* provider row.
*
* Default for unknown values is OpenAI-compatible that's the protocol most
* third-party providers (Kimi, DeepSeek, Zhipu, OpenRouter, OpenCode, etc.)
* speak, so falling back there is safer than throwing.
*/
export function protocolToChatModel(protocol: string): string {
if (protocol === 'anthropic-messages') return 'AnthropicChatModel'
if (protocol === 'gemini-native') return 'GeminiChatModel'
if (protocol === 'dashscope-native') return 'DashScopeChatModel'
return 'OpenAIChatModel'
}
export function chatModelToProtocol(chatModel?: string): string {
if (chatModel === 'AnthropicChatModel') return 'anthropic-messages'
if (chatModel === 'GeminiChatModel') return 'gemini-native'
if (chatModel === 'DashScopeChatModel') return 'dashscope-native'
return 'openai-compatible'
}

View File

@ -0,0 +1,19 @@
/**
* Strict JSON object parser used by config / settings forms where the user
* types raw JSON. Rejects arrays and primitives so downstream `Object.assign`
* / spread paths are always safe.
*
* Throws an Error with a stable message on either parse failure or non-object
* payload callers typically display it via toast.
*/
export function safeParseJson(value: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value || '{}')
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Generate config must be a JSON object')
}
return parsed as Record<string, unknown>
} catch {
throw new Error('Invalid JSON format')
}
}

View File

@ -133,11 +133,27 @@ defineExpose({ refresh: loadAll })
.embedding-section {
margin-top: 24px;
}
/* Mirror the flex layout used by .group-title in index.vue scoped styles
* don't cross component boundaries, so without this the icon stacks above
* the title instead of sitting inline. */
.group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
}
.group-title__icon {
flex-shrink: 0;
color: var(--mc-text-secondary);
}
.group-hint {
font-size: 12px;
font-weight: 400;
color: var(--mc-text-tertiary);
margin-left: 12px;
margin-left: 4px;
}
.loading-state, .empty-state {
padding: 32px;

View File

@ -0,0 +1,184 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { modelApi } from '@/api'
import type { DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
interface ListDeps {
currentProvider: Ref<ProviderInfo | null>
refreshCurrentProvider: (providerId: string) => Promise<void>
}
/**
* RFC-074 PR-1: manage-models modal + per-provider model discovery and
* connection / model testing. All testing state (connectionResults,
* modelTestResults) lives here so the cards can render results without the
* list composable knowing about probes.
*/
export function useProviderDiscovery(deps: ListDeps) {
const { t } = useI18n()
const showManageModelsModal = ref(false)
const providerModelForm = reactive({
id: '',
name: '',
})
const discovering = ref(false)
const discoverResult = ref<DiscoverResult | null>(null)
const selectedNewModelIds = ref<string[]>([])
const applyingModels = ref(false)
const connectionTestingId = ref<string | null>(null)
const connectionResults = ref<Record<string, TestResult>>({})
const testingModelId = ref<string | null>(null)
const modelTestResults = ref<Record<string, TestResult>>({})
const allNewSelected = computed(() => {
if (!discoverResult.value || discoverResult.value.newCount === 0) return false
return selectedNewModelIds.value.length === discoverResult.value.newModels.length
})
function openManageModelsModal(provider: ProviderInfo) {
deps.currentProvider.value = provider
providerModelForm.id = ''
providerModelForm.name = ''
showManageModelsModal.value = true
}
function closeManageModelsModal() {
showManageModelsModal.value = false
deps.currentProvider.value = null
discoverResult.value = null
selectedNewModelIds.value = []
modelTestResults.value = {}
testingModelId.value = null
}
function isExtraModel(modelId: string) {
return !!deps.currentProvider.value?.extraModels?.some(model => model.id === modelId)
}
async function addProviderModel() {
if (!deps.currentProvider.value || !providerModelForm.id) return
await modelApi.addProviderModel(deps.currentProvider.value.id, {
id: providerModelForm.id,
name: providerModelForm.name || providerModelForm.id,
})
await deps.refreshCurrentProvider(deps.currentProvider.value.id)
providerModelForm.id = ''
providerModelForm.name = ''
}
async function removeProviderModel(model: ProviderModelInfo) {
if (!deps.currentProvider.value) return
if (!confirm(t('settings.model.removeConfirm', { name: model.name }))) return
await modelApi.removeProviderModel(deps.currentProvider.value.id, model.id)
await deps.refreshCurrentProvider(deps.currentProvider.value.id)
}
function toggleSelectAll() {
if (!discoverResult.value) return
if (allNewSelected.value) {
selectedNewModelIds.value = []
} else {
selectedNewModelIds.value = discoverResult.value.newModels.map(m => m.id)
}
}
async function handleDiscoverModels() {
if (!deps.currentProvider.value) return
discovering.value = true
discoverResult.value = null
selectedNewModelIds.value = []
try {
const res: any = await modelApi.discoverModels(deps.currentProvider.value.id)
discoverResult.value = res.data
if (res.data?.newCount > 0) {
selectedNewModelIds.value = res.data.newModels.map((m: ProviderModelInfo) => m.id)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : String(error))
} finally {
discovering.value = false
}
}
async function handleApplyModels() {
if (!deps.currentProvider.value || selectedNewModelIds.value.length === 0) return 0
applyingModels.value = true
try {
const res: any = await modelApi.applyDiscoveredModels(
deps.currentProvider.value.id, selectedNewModelIds.value)
const added = res.data?.added ?? selectedNewModelIds.value.length
discoverResult.value = null
selectedNewModelIds.value = []
await deps.refreshCurrentProvider(deps.currentProvider.value.id)
return added
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : String(error))
return 0
} finally {
applyingModels.value = false
}
}
async function handleTestConnection(provider: ProviderInfo) {
connectionTestingId.value = provider.id
delete connectionResults.value[provider.id]
try {
const res: any = await modelApi.testConnection(provider.id)
connectionResults.value[provider.id] = res.data
} catch (error) {
connectionResults.value[provider.id] = {
success: false,
latencyMs: 0,
errorMessage: error instanceof Error ? error.message : String(error),
}
} finally {
connectionTestingId.value = null
}
}
async function handleTestModel(model: ProviderModelInfo) {
if (!deps.currentProvider.value) return
testingModelId.value = model.id
delete modelTestResults.value[model.id]
try {
const res: any = await modelApi.testModel(deps.currentProvider.value.id, model.id)
modelTestResults.value[model.id] = res.data
} catch (error) {
modelTestResults.value[model.id] = {
success: false,
latencyMs: 0,
errorMessage: error instanceof Error ? error.message : String(error),
}
} finally {
testingModelId.value = null
}
}
return {
showManageModelsModal,
providerModelForm,
discovering,
discoverResult,
selectedNewModelIds,
applyingModels,
connectionTestingId,
connectionResults,
testingModelId,
modelTestResults,
allNewSelected,
openManageModelsModal,
closeManageModelsModal,
isExtraModel,
addProviderModel,
removeProviderModel,
toggleSelectAll,
handleDiscoverModels,
handleApplyModels,
handleTestConnection,
handleTestModel,
}
}

View File

@ -0,0 +1,211 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi } from '@/api'
import type { ProviderInfo } from '@/types'
import { safeParseJson } from '@/utils/safeJson'
import { chatModelToProtocol, protocolToChatModel } from '@/utils/modelProtocol'
interface ListDeps {
loadProviders: () => Promise<void>
loadActiveModel: () => Promise<void>
}
/**
* RFC-074 PR-1: provider create / edit / save / delete + the form reactive
* model and its derived placeholders. Refresh after mutation goes back to
* useProviderList via injected callbacks so this composable stays UI-only.
*/
export function useProviderForm(deps: ListDeps) {
const { t } = useI18n()
const editingProvider = ref<ProviderInfo | null>(null)
const showProviderModal = ref(false)
const advancedOpen = ref(false)
const providerForm = reactive({
id: '',
name: '',
baseUrl: '',
apiKey: '',
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
// RFC-009 P3.5: position in the multi-model failover chain.
// 0 = excluded; positive int = ascending try-order.
fallbackPriority: 0,
})
const protocolOptions = computed(() => ([
{ value: 'openai-compatible', label: t('settings.model.protocolOpenAI') },
{ value: 'anthropic-messages', label: t('settings.model.protocolAnthropic') },
{ value: 'gemini-native', label: t('settings.model.protocolGemini') },
{ value: 'dashscope-native', label: t('settings.model.protocolDashScope') },
]))
const currentProviderForForm = computed(() => editingProvider.value ?? {
id: providerForm.id,
name: providerForm.name,
})
const providerBaseUrlPlaceholder = computed(() => {
const id = currentProviderForForm.value?.id
if (id === 'openai') return 'https://api.openai.com/v1'
if (id === 'azure-openai') return 'https://<resource>.openai.azure.com/openai/v1'
if (id === 'anthropic') return 'https://api.anthropic.com'
if (id === 'ollama') return 'http://localhost:11434'
if (id === 'lmstudio') return 'http://localhost:1234/v1'
if (id === 'gemini') return 'https://generativelanguage.googleapis.com'
if (id === 'openrouter') return 'https://openrouter.ai/api/v1'
if (id === 'zhipu-cn') return 'https://open.bigmodel.cn/api/paas/v4'
if (id === 'zhipu-intl') return 'https://open.z.ai/api/paas/v4'
if (id === 'volcengine') return 'https://ark.cn-beijing.volces.com/api/v3'
return 'https://example.com/v1'
})
const providerBaseUrlHint = computed(() => {
const id = currentProviderForForm.value?.id
if (id === 'openai') return t('settings.model.hints.openai')
if (id === 'azure-openai') return t('settings.model.hints.azureOpenai')
if (id === 'anthropic') return t('settings.model.hints.anthropic')
if (id === 'ollama') return t('settings.model.hints.ollama')
if (id === 'lmstudio') return t('settings.model.hints.lmstudio')
if (id === 'gemini') return t('settings.model.hints.gemini')
if (id === 'openrouter') return t('settings.model.hints.openrouter')
if (id === 'zhipu-cn') return t('settings.model.hints.zhipu')
if (id === 'zhipu-intl') return t('settings.model.hints.zhipuIntl')
if (id === 'volcengine') return t('settings.model.hints.volcengine')
return t('settings.model.hints.openaiCompatible')
})
const providerApiKeyPlaceholder = computed(() => {
return providerForm.apiKeyPrefix
? `${t('settings.model.apiKeyInput')} (${providerForm.apiKeyPrefix}...)`
: t('settings.model.apiKeyInput')
})
function openCreateProviderModal() {
editingProvider.value = null
advancedOpen.value = false
Object.assign(providerForm, {
id: '',
name: '',
baseUrl: '',
apiKey: '',
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
fallbackPriority: 0,
})
showProviderModal.value = true
}
function openProviderConfigModal(provider: ProviderInfo) {
editingProvider.value = provider
advancedOpen.value = true
const kwargs = provider.generateKwargs || {}
const protocol = provider.protocol || chatModelToProtocol(provider.chatModel)
// DashScope opens search by default — only off when kwargs explicitly set false.
const isDashScope = protocol === 'dashscope-native'
const searchDefault = isDashScope ? kwargs.enableSearch !== false : !!kwargs.enableSearch
Object.assign(providerForm, {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl || '',
apiKey: '',
apiKeyPrefix: provider.apiKeyPrefix || 'sk-',
protocol,
chatModel: provider.chatModel || 'OpenAIChatModel',
generateKwargsText: JSON.stringify(kwargs, null, 2),
enableSearch: searchDefault,
searchStrategy: (kwargs.searchStrategy as string) || '',
fallbackPriority: provider.fallbackPriority ?? 0,
})
showProviderModal.value = true
}
function closeProviderModal() {
showProviderModal.value = false
editingProvider.value = null
advancedOpen.value = false
}
async function saveProvider() {
const kwargs = safeParseJson(providerForm.generateKwargsText)
if (providerForm.enableSearch) {
kwargs.enableSearch = true
if (providerForm.searchStrategy) {
kwargs.searchStrategy = providerForm.searchStrategy
} else {
delete kwargs.searchStrategy
}
} else {
delete kwargs.enableSearch
delete kwargs.searchStrategy
}
// RFC-009 P3.5: clamp to non-negative, coerce string input back to integer.
const fallbackPriority = Math.max(0, Math.floor(Number(providerForm.fallbackPriority) || 0))
if (editingProvider.value) {
await modelApi.updateProviderConfig(editingProvider.value.id, {
apiKey: providerForm.apiKey,
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
generateKwargs: kwargs,
fallbackPriority,
})
} else {
await modelApi.createCustomProvider({
id: providerForm.id,
name: providerForm.name,
defaultBaseUrl: providerForm.baseUrl,
apiKeyPrefix: providerForm.apiKeyPrefix,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
models: [],
})
if (providerForm.apiKey || providerForm.generateKwargsText || fallbackPriority > 0) {
await modelApi.updateProviderConfig(providerForm.id, {
apiKey: providerForm.apiKey,
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
generateKwargs: kwargs,
fallbackPriority,
})
}
}
closeProviderModal()
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
}
async function deleteProvider(provider: ProviderInfo) {
if (!confirm(t('settings.model.deleteConfirm', { name: provider.name }))) {
return false
}
await modelApi.deleteCustomProvider(provider.id)
await deps.loadProviders()
return true
}
return {
editingProvider,
showProviderModal,
advancedOpen,
providerForm,
protocolOptions,
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
openCreateProviderModal,
openProviderConfigModal,
closeProviderModal,
saveProvider,
deleteProvider,
}
}

View File

@ -0,0 +1,126 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi } from '@/api'
import type { ActiveModelsInfo, ProviderInfo, ProviderModelInfo } from '@/types'
/**
* RFC-074 PR-1: list / active-model / display helpers.
* Owns the "what providers exist + which one is currently active + which one
* the user is focused on" slice of state. Other composables receive these refs
* via dep injection rather than duplicating data fetches.
*/
export function useProviderList() {
const { t } = useI18n()
const providers = ref<ProviderInfo[]>([])
const activeModels = ref<ActiveModelsInfo | null>(null)
/** The provider currently shown in the manage-models modal (cross-composable focus point). */
const currentProvider = ref<ProviderInfo | null>(null)
async function loadProviders() {
const res: any = await modelApi.listProviders()
providers.value = res.data || []
}
async function loadActiveModel() {
const res: any = await modelApi.getActive()
activeModels.value = res.data || null
}
/** Re-fetch providers + active model, then re-resolve currentProvider so the modal stays in sync. */
async function refreshCurrentProvider(providerId: string) {
await Promise.all([loadProviders(), loadActiveModel()])
currentProvider.value = providers.value.find(provider => provider.id === providerId) || null
}
function isProviderActive(provider: ProviderInfo) {
return activeModels.value?.activeLlm?.providerId === provider.id
}
function isActiveModel(model: ProviderModelInfo) {
return activeModels.value?.activeLlm?.providerId === currentProvider.value?.id
&& activeModels.value?.activeLlm?.model === model.id
}
async function setActiveModel(model: ProviderModelInfo) {
if (!currentProvider.value) return
await modelApi.setActive({ providerId: currentProvider.value.id, model: model.id })
await loadActiveModel()
}
// RFC-073: status pill is driven by liveness. Falls back to legacy
// configured/available booleans for older backends without liveness.
function providerStatus(provider: ProviderInfo) {
switch (provider.liveness) {
case 'LIVE':
return { type: 'configured', label: t('settings.model.livenessLive') }
case 'COOLDOWN':
return { type: 'partial', label: t('settings.model.livenessCooldown') }
case 'REMOVED':
return { type: 'unavailable', label: t('settings.model.livenessRemoved') }
case 'UNPROBED':
return { type: 'partial', label: t('settings.model.livenessUnprobed') }
case 'UNCONFIGURED':
return { type: 'unavailable', label: t('settings.model.livenessUnconfigured') }
}
if (provider.available) return { type: 'configured', label: t('settings.model.configured') }
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
return { type: 'partial', label: t('settings.model.partial') }
}
return { type: 'unavailable', label: t('settings.model.unavailable') }
}
const providerIconMap: Record<string, string> = {
'dashscope': '/icons/providers/dashscope.png',
'modelscope': '/icons/providers/modelscope.svg',
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg',
'openai': '/icons/providers/openai.svg',
'azure-openai': '/icons/providers/azure-openai.svg',
'minimax': '/icons/providers/minimax.png',
'minimax-cn': '/icons/providers/minimax.png',
'kimi-cn': '/icons/providers/kimi.svg',
'kimi-intl': '/icons/providers/kimi.svg',
'kimi-code': '/icons/providers/kimi.svg',
'deepseek': '/icons/providers/deepseek.svg',
'anthropic': '/icons/providers/anthropic.svg',
'gemini': '/icons/providers/gemini.svg',
'ollama': '/icons/providers/ollama.svg',
'lmstudio': '/icons/providers/lmstudio.svg',
'llamacpp': '/icons/providers/llamacpp.svg',
'mlx': '/icons/providers/mlx.svg',
'openrouter': '/icons/providers/openrouter.svg',
'zhipu-cn': '/icons/providers/zhipu.svg',
'zhipu-intl': '/icons/providers/zhipu.svg',
'volcengine': '/icons/providers/volcengine.svg',
'openai-chatgpt': '/icons/providers/openai.svg',
'anthropic-claude-code': '/icons/providers/anthropic.svg',
}
function getProviderIcon(providerId: string): string {
return providerIconMap[providerId] || '/icons/providers/default.svg'
}
function onIconError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
return {
providers,
currentProvider,
loadProviders,
loadActiveModel,
isProviderActive,
isActiveModel,
setActiveModel,
providerStatus,
getProviderIcon,
onIconError,
// Cross-composable wiring — facade hands these to other composables (e.g.
// Discovery uses refreshCurrentProvider, OAuth would read activeModels via
// List's helpers). Not part of the public surface index.vue destructures;
// they're returned here only so the facade has somewhere to grab them from.
activeModels,
refreshCurrentProvider,
}
}

View File

@ -0,0 +1,93 @@
import { type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { claudeCodeOAuthApi, oauthApi } from '@/api'
import type { ProviderInfo } from '@/types'
interface FormDeps {
/** Editing-modal context — rebound after a load so the modal sees fresh OAuth state. */
editingProvider: Ref<ProviderInfo | null>
}
interface ListDeps {
loadProviders: () => Promise<void>
/** Read-only access to the current providers list — used to re-resolve editingProvider after refresh. */
providers: Ref<ProviderInfo[]>
}
/**
* RFC-074 PR-1: OAuth flows. Two distinct shapes:
* - openai-chatgpt: pop a real authorize window, poll status, refresh on success.
* - anthropic-claude-code (RFC-062): credentials live on disk under the user's
* Claude Code install the "Connect" button just re-reads from disk.
*/
export function useProviderOAuth(deps: FormDeps & ListDeps) {
const { t } = useI18n()
/** After a load that may have changed OAuth state, keep the editing modal in sync. */
async function reloadProvidersAndSync() {
await deps.loadProviders()
if (deps.editingProvider.value) {
const updated = deps.providers.value.find(p => p.id === deps.editingProvider.value!.id)
if (updated) deps.editingProvider.value = updated
}
}
async function handleOAuthLogin(providerId?: string) {
if (providerId === 'anthropic-claude-code') {
try {
const res: any = await claudeCodeOAuthApi.reload()
if (res.data?.connected && !res.data?.expired) {
ElMessage.success(t('settings.model.oauthLoginSuccess'))
} else {
ElMessage.warning(t('settings.model.claudeCodeOauthInstructions'))
}
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'Claude Code OAuth detection failed')
}
return
}
try {
const res: any = await oauthApi.authorize()
const { authorizeUrl } = res.data
const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700')
const pollInterval = setInterval(async () => {
try {
const statusRes: any = await oauthApi.status()
if (statusRes.data?.connected) {
clearInterval(pollInterval)
if (authWindow && !authWindow.closed) authWindow.close()
ElMessage.success(t('settings.model.oauthLoginSuccess'))
await reloadProvidersAndSync()
}
} catch { /* ignore polling errors */ }
}, 2000)
setTimeout(() => clearInterval(pollInterval), 30000)
} catch (e: any) {
ElMessage.error(e.msg || 'OAuth login failed')
}
}
async function handleOAuthRevoke(providerId?: string) {
// Claude Code OAuth credentials live on disk and are owned by the Claude
// Code app, not MateClaw — direct the user to log out there instead of
// clobbering their machine-level login.
if (providerId === 'anthropic-claude-code') {
ElMessage.info(t('settings.model.claudeCodeOauthRevokeHint'))
return
}
try {
await oauthApi.revoke()
ElMessage.success(t('settings.model.oauthRevokeSuccess'))
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'OAuth revoke failed')
}
}
return {
handleOAuthLogin,
handleOAuthRevoke,
}
}

View File

@ -0,0 +1,50 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { providerPoolApi } from '@/api'
import type { ProviderInfo } from '@/types'
interface ListDeps {
loadProviders: () => Promise<void>
}
/**
* RFC-074 PR-1: manual reprobe trigger. Most pool surface was inlined into
* ProviderInfo.liveness in RFC-073, leaving this composable thin but the
* "in-flight" tracking and toast-on-result behavior still belong here, not
* in the list composable.
*/
export function useProviderPool(deps: ListDeps) {
const { t } = useI18n()
/** Provider id currently being manually reprobed (drives the spinner on the card). */
const reprobingId = ref<string | null>(null)
async function reprobeProvider(provider: ProviderInfo) {
reprobingId.value = provider.id
try {
const res: any = await providerPoolApi.reprobe(provider.id)
const data = res.data || {}
// Re-fetch the list — liveness ships inline now so this single round-trip
// refreshes everything the UI needs (badge + status pill + dropdown filter).
await deps.loadProviders()
if (data.success) {
ElMessage.success(t('settings.model.poolReprobeOk'))
} else {
ElMessage.warning(t('settings.model.poolReprobeFail', { error: data.errorMessage || '—' }))
}
return data
} catch (err) {
ElMessage.error(t('settings.model.poolReprobeFail', {
error: err instanceof Error ? err.message : String(err)
}))
} finally {
reprobingId.value = null
}
}
return {
reprobingId,
reprobeProvider,
}
}

View File

@ -10,8 +10,14 @@
</button>
</div>
<!-- RFC-074 PR-1: skeleton placeholder so the page paints something
immediately on first load instead of blank-then-pop. -->
<div v-if="loading" class="provider-group">
<el-skeleton :rows="4" animated />
</div>
<!-- 本地模型 -->
<div v-if="localProviders.length" class="provider-group">
<div v-if="!loading && localProviders.length" class="provider-group">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
@ -40,7 +46,7 @@
</div>
<!-- 云端模型 -->
<div v-if="cloudProviders.length" class="provider-group">
<div v-if="!loading && cloudProviders.length" class="provider-group">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
@ -120,18 +126,26 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders'
import ProviderCard from './ProviderCard.vue'
import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
import ProviderConfigModal from './modals/ProviderConfigModal.vue'
import ManageModelsModal from './modals/ManageModelsModal.vue'
// RFC-074 PR-1: defer modal JS until the user actually opens one same
// pattern as ChannelEditModal in commit 9300559b. Drops ~30KB from the
// initial Settings/Models route chunk.
const ProviderConfigModal = defineAsyncComponent(() => import('./modals/ProviderConfigModal.vue'))
const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.vue'))
const { t } = useI18n()
const savedTip = ref('')
// Skeleton gate. Driven by onMounted only fine because /settings/models is
// NOT a keepAlive route. If anyone re-adds keepAlive in router/index.ts,
// switch to onActivated (or reset loading there) so cached re-entries don't
// skip the load + leave loading=false stale.
const loading = ref(true)
const {
providers,
@ -188,7 +202,11 @@ const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
onMounted(async () => {
await Promise.all([loadProviders(), loadActiveModel()])
try {
await Promise.all([loadProviders(), loadActiveModel()])
} finally {
loading.value = false
}
})
async function onSaveProvider() {

View File

@ -1,607 +1,48 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
import { useProviderList } from './composables/useProviderList'
import { useProviderForm } from './composables/useProviderForm'
import { useProviderDiscovery } from './composables/useProviderDiscovery'
import { useProviderOAuth } from './composables/useProviderOAuth'
import { useProviderPool } from './composables/useProviderPool'
/**
* RFC-074 PR-1: composition facade. Each sub-composable owns one slice of
* the model-settings surface; this entry point wires them together so
* `index.vue` can keep its single `const { ... } = useProviders()` pattern.
*
* Cross-composable refs flow via dep-injection arguments (not module-level
* state), so each composable stays independently testable.
*
* Owned slices:
* - useProviderList providers / activeModels / currentProvider, status pill, icons
* - useProviderForm provider create/edit modal + form state, save/delete
* - useProviderDiscovery manage-models modal, model discovery, connection / model tests
* - useProviderOAuth OAuth flows (openai-chatgpt + claude-code)
* - useProviderPool manual reprobe trigger (most pool surface inlined in RFC-073)
*/
export function useProviders() {
const { t } = useI18n()
const providers = ref<ProviderInfo[]>([])
const activeModels = ref<ActiveModelsInfo | null>(null)
// RFC-073: pool / cooldown / probe state now ships inline on each ProviderInfo
// via `liveness`. Separate snapshot / indexed map removed — see ProviderCard.vue
// for how the five states render.
// PR-1e manual reprobe still tracks which provider is in flight.
const reprobingId = ref<string | null>(null)
const editingProvider = ref<ProviderInfo | null>(null)
const currentProvider = ref<ProviderInfo | null>(null)
const showProviderModal = ref(false)
const showManageModelsModal = ref(false)
const advancedOpen = ref(false)
// Discovery & testing state
const discovering = ref(false)
const discoverResult = ref<DiscoverResult | null>(null)
const selectedNewModelIds = ref<string[]>([])
const applyingModels = ref(false)
const connectionTestingId = ref<string | null>(null)
const connectionResults = ref<Record<string, TestResult>>({})
const testingModelId = ref<string | null>(null)
const modelTestResults = ref<Record<string, TestResult>>({})
const providerForm = reactive({
id: '',
name: '',
baseUrl: '',
apiKey: '',
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
// RFC-009 P3.5: position in the multi-model failover chain.
// 0 = excluded; positive int = ascending try-order.
fallbackPriority: 0,
const list = useProviderList()
const form = useProviderForm({
loadProviders: list.loadProviders,
loadActiveModel: list.loadActiveModel,
})
const providerModelForm = reactive({
id: '',
name: '',
const discovery = useProviderDiscovery({
currentProvider: list.currentProvider,
refreshCurrentProvider: list.refreshCurrentProvider,
})
const protocolOptions = computed(() => ([
{ value: 'openai-compatible', label: t('settings.model.protocolOpenAI') },
{ value: 'anthropic-messages', label: t('settings.model.protocolAnthropic') },
{ value: 'gemini-native', label: t('settings.model.protocolGemini') },
{ value: 'dashscope-native', label: t('settings.model.protocolDashScope') },
]))
// Data loading
async function loadProviders() {
const res: any = await modelApi.listProviders()
providers.value = res.data || []
}
async function loadActiveModel() {
const res: any = await modelApi.getActive()
activeModels.value = res.data || null
}
/**
* RFC-073: synchronously re-probe one provider, then re-fetch the
* provider list `liveness` ships inline now so a single round-trip
* refreshes everything the UI needs.
*/
async function reprobeProvider(provider: ProviderInfo) {
reprobingId.value = provider.id
try {
const res: any = await providerPoolApi.reprobe(provider.id)
const data = res.data || {}
await loadProviders()
if (data.success) {
ElMessage.success(t('settings.model.poolReprobeOk'))
} else {
ElMessage.warning(t('settings.model.poolReprobeFail', { error: data.errorMessage || '—' }))
}
return data
} catch (err) {
ElMessage.error(t('settings.model.poolReprobeFail', {
error: err instanceof Error ? err.message : String(err)
}))
} finally {
reprobingId.value = null
}
}
async function refreshCurrentProvider(providerId: string) {
await Promise.all([loadProviders(), loadActiveModel()])
currentProvider.value = providers.value.find(provider => provider.id === providerId) || null
}
// Provider CRUD
function openCreateProviderModal() {
editingProvider.value = null
advancedOpen.value = false
Object.assign(providerForm, {
id: '',
name: '',
baseUrl: '',
apiKey: '',
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
fallbackPriority: 0,
})
showProviderModal.value = true
}
function openProviderConfigModal(provider: ProviderInfo) {
editingProvider.value = provider
advancedOpen.value = true
const kwargs = provider.generateKwargs || {}
const protocol = provider.protocol || chatModelToProtocol(provider.chatModel)
// DashScope 默认开启搜索:仅当 kwargs 中显式设为 false 时才关闭
const isDashScope = protocol === 'dashscope-native'
const searchDefault = isDashScope ? kwargs.enableSearch !== false : !!kwargs.enableSearch
Object.assign(providerForm, {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl || '',
apiKey: '',
apiKeyPrefix: provider.apiKeyPrefix || 'sk-',
protocol,
chatModel: provider.chatModel || 'OpenAIChatModel',
generateKwargsText: JSON.stringify(kwargs, null, 2),
enableSearch: searchDefault,
searchStrategy: (kwargs.searchStrategy as string) || '',
fallbackPriority: provider.fallbackPriority ?? 0,
})
showProviderModal.value = true
}
function closeProviderModal() {
showProviderModal.value = false
editingProvider.value = null
advancedOpen.value = false
}
async function saveProvider() {
const kwargs = safeParseJson(providerForm.generateKwargsText)
// 搜索设置写入 generateKwargs
if (providerForm.enableSearch) {
kwargs.enableSearch = true
if (providerForm.searchStrategy) {
kwargs.searchStrategy = providerForm.searchStrategy
} else {
delete kwargs.searchStrategy
}
} else {
delete kwargs.enableSearch
delete kwargs.searchStrategy
}
// RFC-009 P3.5: clamp to non-negative, coerce string input back to integer.
const fallbackPriority = Math.max(0, Math.floor(Number(providerForm.fallbackPriority) || 0))
if (editingProvider.value) {
await modelApi.updateProviderConfig(editingProvider.value.id, {
apiKey: providerForm.apiKey,
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
generateKwargs: kwargs,
fallbackPriority,
})
} else {
await modelApi.createCustomProvider({
id: providerForm.id,
name: providerForm.name,
defaultBaseUrl: providerForm.baseUrl,
apiKeyPrefix: providerForm.apiKeyPrefix,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
models: [],
})
if (providerForm.apiKey || providerForm.generateKwargsText || fallbackPriority > 0) {
await modelApi.updateProviderConfig(providerForm.id, {
apiKey: providerForm.apiKey,
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
generateKwargs: kwargs,
fallbackPriority,
})
}
}
closeProviderModal()
await Promise.all([loadProviders(), loadActiveModel()])
}
async function deleteProvider(provider: ProviderInfo) {
if (!confirm(t('settings.model.deleteConfirm', { name: provider.name }))) {
return false
}
await modelApi.deleteCustomProvider(provider.id)
await loadProviders()
return true
}
// Model management
function openManageModelsModal(provider: ProviderInfo) {
currentProvider.value = provider
providerModelForm.id = ''
providerModelForm.name = ''
showManageModelsModal.value = true
}
function closeManageModelsModal() {
showManageModelsModal.value = false
currentProvider.value = null
discoverResult.value = null
selectedNewModelIds.value = []
modelTestResults.value = {}
testingModelId.value = null
}
function isExtraModel(modelId: string) {
return !!currentProvider.value?.extraModels?.some(model => model.id === modelId)
}
async function addProviderModel() {
if (!currentProvider.value || !providerModelForm.id) return
await modelApi.addProviderModel(currentProvider.value.id, {
id: providerModelForm.id,
name: providerModelForm.name || providerModelForm.id,
})
await refreshCurrentProvider(currentProvider.value.id)
providerModelForm.id = ''
providerModelForm.name = ''
}
async function removeProviderModel(model: ProviderModelInfo) {
if (!currentProvider.value) return
if (!confirm(t('settings.model.removeConfirm', { name: model.name }))) return
await modelApi.removeProviderModel(currentProvider.value.id, model.id)
await refreshCurrentProvider(currentProvider.value.id)
}
// Active model
function isProviderActive(provider: ProviderInfo) {
return activeModels.value?.activeLlm?.providerId === provider.id
}
function isActiveModel(model: ProviderModelInfo) {
return activeModels.value?.activeLlm?.providerId === currentProvider.value?.id
&& activeModels.value?.activeLlm?.model === model.id
}
async function setActiveModel(model: ProviderModelInfo) {
if (!currentProvider.value) return
await modelApi.setActive({ providerId: currentProvider.value.id, model: model.id })
await loadActiveModel()
}
// Discovery & testing
const allNewSelected = computed(() => {
if (!discoverResult.value || discoverResult.value.newCount === 0) return false
return selectedNewModelIds.value.length === discoverResult.value.newModels.length
const oauth = useProviderOAuth({
editingProvider: form.editingProvider,
loadProviders: list.loadProviders,
providers: list.providers,
})
function toggleSelectAll() {
if (!discoverResult.value) return
if (allNewSelected.value) {
selectedNewModelIds.value = []
} else {
selectedNewModelIds.value = discoverResult.value.newModels.map(m => m.id)
}
}
async function handleDiscoverModels() {
if (!currentProvider.value) return
discovering.value = true
discoverResult.value = null
selectedNewModelIds.value = []
try {
const res: any = await modelApi.discoverModels(currentProvider.value.id)
discoverResult.value = res.data
if (res.data?.newCount > 0) {
selectedNewModelIds.value = res.data.newModels.map((m: ProviderModelInfo) => m.id)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : String(error))
} finally {
discovering.value = false
}
}
async function handleApplyModels() {
if (!currentProvider.value || selectedNewModelIds.value.length === 0) return
applyingModels.value = true
try {
const res: any = await modelApi.applyDiscoveredModels(currentProvider.value.id, selectedNewModelIds.value)
const added = res.data?.added ?? selectedNewModelIds.value.length
discoverResult.value = null
selectedNewModelIds.value = []
await refreshCurrentProvider(currentProvider.value.id)
return added
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : String(error))
return 0
} finally {
applyingModels.value = false
}
}
async function handleTestConnection(provider: ProviderInfo) {
connectionTestingId.value = provider.id
delete connectionResults.value[provider.id]
try {
const res: any = await modelApi.testConnection(provider.id)
connectionResults.value[provider.id] = res.data
} catch (error) {
connectionResults.value[provider.id] = {
success: false,
latencyMs: 0,
errorMessage: error instanceof Error ? error.message : String(error),
}
} finally {
connectionTestingId.value = null
}
}
async function handleTestModel(model: ProviderModelInfo) {
if (!currentProvider.value) return
testingModelId.value = model.id
delete modelTestResults.value[model.id]
try {
const res: any = await modelApi.testModel(currentProvider.value.id, model.id)
modelTestResults.value[model.id] = res.data
} catch (error) {
modelTestResults.value[model.id] = {
success: false,
latencyMs: 0,
errorMessage: error instanceof Error ? error.message : String(error),
}
} finally {
testingModelId.value = null
}
}
// Computed helpers
const currentProviderForForm = computed(() => editingProvider.value ?? {
id: providerForm.id,
name: providerForm.name,
const pool = useProviderPool({
loadProviders: list.loadProviders,
})
const providerBaseUrlPlaceholder = computed(() => {
const id = currentProviderForForm.value?.id
if (id === 'openai') return 'https://api.openai.com/v1'
if (id === 'azure-openai') return 'https://<resource>.openai.azure.com/openai/v1'
if (id === 'anthropic') return 'https://api.anthropic.com'
if (id === 'ollama') return 'http://localhost:11434'
if (id === 'lmstudio') return 'http://localhost:1234/v1'
if (id === 'gemini') return 'https://generativelanguage.googleapis.com'
if (id === 'openrouter') return 'https://openrouter.ai/api/v1'
if (id === 'zhipu-cn') return 'https://open.bigmodel.cn/api/paas/v4'
if (id === 'zhipu-intl') return 'https://open.z.ai/api/paas/v4'
if (id === 'volcengine') return 'https://ark.cn-beijing.volces.com/api/v3'
return 'https://example.com/v1'
})
const providerBaseUrlHint = computed(() => {
const id = currentProviderForForm.value?.id
if (id === 'openai') return t('settings.model.hints.openai')
if (id === 'azure-openai') return t('settings.model.hints.azureOpenai')
if (id === 'anthropic') return t('settings.model.hints.anthropic')
if (id === 'ollama') return t('settings.model.hints.ollama')
if (id === 'lmstudio') return t('settings.model.hints.lmstudio')
if (id === 'gemini') return t('settings.model.hints.gemini')
if (id === 'openrouter') return t('settings.model.hints.openrouter')
if (id === 'zhipu-cn') return t('settings.model.hints.zhipu')
if (id === 'zhipu-intl') return t('settings.model.hints.zhipuIntl')
if (id === 'volcengine') return t('settings.model.hints.volcengine')
return t('settings.model.hints.openaiCompatible')
})
const providerApiKeyPlaceholder = computed(() => {
return providerForm.apiKeyPrefix
? `${t('settings.model.apiKeyInput')} (${providerForm.apiKeyPrefix}...)`
: t('settings.model.apiKeyInput')
})
// RFC-073: status pill is driven by liveness. Falls back to the legacy
// configured/available booleans for older backends that don't yet send liveness.
function providerStatus(provider: ProviderInfo) {
switch (provider.liveness) {
case 'LIVE':
return { type: 'configured', label: t('settings.model.livenessLive') }
case 'COOLDOWN':
return { type: 'partial', label: t('settings.model.livenessCooldown') }
case 'REMOVED':
return { type: 'unavailable', label: t('settings.model.livenessRemoved') }
case 'UNPROBED':
return { type: 'partial', label: t('settings.model.livenessUnprobed') }
case 'UNCONFIGURED':
return { type: 'unavailable', label: t('settings.model.livenessUnconfigured') }
}
// Legacy backend (no liveness field)
if (provider.available) return { type: 'configured', label: t('settings.model.configured') }
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
return { type: 'partial', label: t('settings.model.partial') }
}
return { type: 'unavailable', label: t('settings.model.unavailable') }
}
const providerIconMap: Record<string, string> = {
'dashscope': '/icons/providers/dashscope.png',
'modelscope': '/icons/providers/modelscope.svg',
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg',
'openai': '/icons/providers/openai.svg',
'azure-openai': '/icons/providers/azure-openai.svg',
'minimax': '/icons/providers/minimax.png',
'minimax-cn': '/icons/providers/minimax.png',
'kimi-cn': '/icons/providers/kimi.svg',
'kimi-intl': '/icons/providers/kimi.svg',
'kimi-code': '/icons/providers/kimi.svg',
'deepseek': '/icons/providers/deepseek.svg',
'anthropic': '/icons/providers/anthropic.svg',
'gemini': '/icons/providers/gemini.svg',
'ollama': '/icons/providers/ollama.svg',
'lmstudio': '/icons/providers/lmstudio.svg',
'llamacpp': '/icons/providers/llamacpp.svg',
'mlx': '/icons/providers/mlx.svg',
'openrouter': '/icons/providers/openrouter.svg',
'zhipu-cn': '/icons/providers/zhipu.svg',
'zhipu-intl': '/icons/providers/zhipu.svg',
'volcengine': '/icons/providers/volcengine.svg',
'openai-chatgpt': '/icons/providers/openai.svg',
'anthropic-claude-code': '/icons/providers/anthropic.svg',
}
function getProviderIcon(providerId: string): string {
return providerIconMap[providerId] || '/icons/providers/default.svg'
}
// ==================== OAuth ====================
/** Refresh editingProvider after a load so the modal state stays in sync. */
async function reloadProvidersAndSync() {
await loadProviders()
if (editingProvider.value) {
const updated = providers.value.find(p => p.id === editingProvider.value!.id)
if (updated) editingProvider.value = updated
}
}
async function handleOAuthLogin(providerId?: string) {
// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code
// install. There's no in-app authorize URL — the "Connect" button just
// re-reads credentials from disk so a user who logged in via Claude Code
// sees the connection appear without restarting the server.
if (providerId === 'anthropic-claude-code') {
try {
const res: any = await claudeCodeOAuthApi.reload()
if (res.data?.connected && !res.data?.expired) {
ElMessage.success(t('settings.model.oauthLoginSuccess'))
} else {
ElMessage.warning(t('settings.model.claudeCodeOauthInstructions'))
}
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'Claude Code OAuth detection failed')
}
return
}
try {
const res: any = await oauthApi.authorize()
const { authorizeUrl } = res.data
// 打开新窗口进行 OAuth 登录
const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700')
// 轮询检查 OAuth 状态
const pollInterval = setInterval(async () => {
try {
const statusRes: any = await oauthApi.status()
if (statusRes.data?.connected) {
clearInterval(pollInterval)
if (authWindow && !authWindow.closed) authWindow.close()
ElMessage.success(t('settings.model.oauthLoginSuccess'))
await reloadProvidersAndSync()
}
} catch { /* ignore polling errors */ }
}, 2000)
// 30 秒后停止轮询
setTimeout(() => clearInterval(pollInterval), 30000)
} catch (e: any) {
ElMessage.error(e.msg || 'OAuth login failed')
}
}
async function handleOAuthRevoke(providerId?: string) {
// Claude Code OAuth credentials live on disk — MateClaw doesn't manage
// them, so we don't expose a revoke that would clobber the user's
// Claude Code login. Direct them to log out from the Claude Code app.
if (providerId === 'anthropic-claude-code') {
ElMessage.info(t('settings.model.claudeCodeOauthRevokeHint'))
return
}
try {
await oauthApi.revoke()
ElMessage.success(t('settings.model.oauthRevokeSuccess'))
await reloadProvidersAndSync()
} catch (e: any) {
ElMessage.error(e.msg || 'OAuth revoke failed')
}
}
function onIconError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
return {
// State
providers,
activeModels,
editingProvider,
currentProvider,
showProviderModal,
showManageModelsModal,
advancedOpen,
discovering,
discoverResult,
selectedNewModelIds,
applyingModels,
connectionTestingId,
connectionResults,
testingModelId,
modelTestResults,
providerForm,
providerModelForm,
protocolOptions,
// Computed
allNewSelected,
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
// RFC-073: pool/cooldown/probe data is now baked into providers[].liveness.
// The standalone snapshot/loadProviderPool are gone — saves a round trip.
reprobingId,
reprobeProvider,
// Methods
loadProviders,
loadActiveModel,
openCreateProviderModal,
openProviderConfigModal,
closeProviderModal,
saveProvider,
deleteProvider,
openManageModelsModal,
closeManageModelsModal,
isExtraModel,
addProviderModel,
removeProviderModel,
isProviderActive,
isActiveModel,
setActiveModel,
toggleSelectAll,
handleDiscoverModels,
handleApplyModels,
handleTestConnection,
handleTestModel,
providerStatus,
getProviderIcon,
onIconError,
handleOAuthLogin,
handleOAuthRevoke,
...list,
...form,
...discovery,
...oauth,
...pool,
}
}
// Internal helpers (not exported)
function safeParseJson(value: string) {
try {
const parsed = JSON.parse(value || '{}')
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Generate config must be a JSON object')
}
return parsed
} catch {
throw new Error('Invalid JSON format')
}
}
function protocolToChatModel(protocol: string) {
if (protocol === 'anthropic-messages') return 'AnthropicChatModel'
if (protocol === 'gemini-native') return 'GeminiChatModel'
if (protocol === 'dashscope-native') return 'DashScopeChatModel'
return 'OpenAIChatModel'
}
function chatModelToProtocol(chatModel?: string) {
if (chatModel === 'AnthropicChatModel') return 'anthropic-messages'
if (chatModel === 'GeminiChatModel') return 'gemini-native'
if (chatModel === 'DashScopeChatModel') return 'dashscope-native'
return 'openai-compatible'
}

View File

@ -140,11 +140,17 @@
</button>
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
</div>
<!-- RFC-074 PR-1 fix: include route.path in the key so two different
keepAlive routes (e.g. /channels and /settings/models) don't collide
on the same vnode slot. Without this, switching between two keep-alive
routes leaves both component trees mounted because Vue sees identical
keys and patches in place. The comment must live OUTSIDE <keep-alive>
KeepAlive treats comments as children and rejects "more than one". -->
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="workspaceRouteKey" v-if="route.meta?.keepAlive" />
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="route.meta?.keepAlive" />
</keep-alive>
<component :is="Component" :key="workspaceRouteKey" v-if="!route.meta?.keepAlive" />
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="!route.meta?.keepAlive" />
</router-view>
</main>