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 { .embedding-section {
margin-top: 24px; 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 { .group-hint {
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
margin-left: 12px; margin-left: 4px;
} }
.loading-state, .empty-state { .loading-state, .empty-state {
padding: 32px; 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> </button>
</div> </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"> <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"> <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"/> <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>
<!-- 云端模型 --> <!-- 云端模型 -->
<div v-if="cloudProviders.length" class="provider-group"> <div v-if="!loading && cloudProviders.length" class="provider-group">
<h3 class="group-title"> <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"> <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"/> <path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
@ -120,18 +126,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { ProviderInfo, ProviderModelInfo } from '@/types' import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders' import { useProviders } from './useProviders'
import ProviderCard from './ProviderCard.vue' import ProviderCard from './ProviderCard.vue'
import EmbeddingModelsSection from './EmbeddingModelsSection.vue' import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
import ProviderConfigModal from './modals/ProviderConfigModal.vue' // RFC-074 PR-1: defer modal JS until the user actually opens one same
import ManageModelsModal from './modals/ManageModelsModal.vue' // 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 { t } = useI18n()
const savedTip = ref('') 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 { const {
providers, providers,
@ -188,7 +202,11 @@ const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal)) const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
onMounted(async () => { onMounted(async () => {
try {
await Promise.all([loadProviders(), loadActiveModel()]) await Promise.all([loadProviders(), loadActiveModel()])
} finally {
loading.value = false
}
}) })
async function onSaveProvider() { async function onSaveProvider() {

View File

@ -1,607 +1,48 @@
import { computed, reactive, ref } from 'vue' import { useProviderList } from './composables/useProviderList'
import { useI18n } from 'vue-i18n' import { useProviderForm } from './composables/useProviderForm'
import { ElMessage } from 'element-plus' import { useProviderDiscovery } from './composables/useProviderDiscovery'
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api' import { useProviderOAuth } from './composables/useProviderOAuth'
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types' import { useProviderPool } from './composables/useProviderPool'
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 providerModelForm = reactive({
id: '',
name: '',
})
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 * RFC-074 PR-1: composition facade. Each sub-composable owns one slice of
* provider list `liveness` ships inline now so a single round-trip * the model-settings surface; this entry point wires them together so
* refreshes everything the UI needs. * `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)
*/ */
async function reprobeProvider(provider: ProviderInfo) { export function useProviders() {
reprobingId.value = provider.id const list = useProviderList()
try { const form = useProviderForm({
const res: any = await providerPoolApi.reprobe(provider.id) loadProviders: list.loadProviders,
const data = res.data || {} loadActiveModel: list.loadActiveModel,
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 const discovery = useProviderDiscovery({
} currentProvider: list.currentProvider,
refreshCurrentProvider: list.refreshCurrentProvider,
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 const oauth = useProviderOAuth({
} editingProvider: form.editingProvider,
loadProviders: list.loadProviders,
function closeProviderModal() { providers: list.providers,
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 { const pool = useProviderPool({
await modelApi.createCustomProvider({ loadProviders: list.loadProviders,
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
})
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 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 { return {
// State ...list,
providers, ...form,
activeModels, ...discovery,
editingProvider, ...oauth,
currentProvider, ...pool,
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,
} }
} }
// 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> </button>
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span> <span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
</div> </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 }"> <router-view v-slot="{ Component, route }">
<keep-alive> <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> </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> </router-view>
</main> </main>