From a168f91215fd26b007daf15cfaf2edc964a2e6fb Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 15:01:14 +0800 Subject: [PATCH] refactor(ui): split useProviders into 5 single-responsibility composables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 slot used :key='workspaceRouteKey' (a workspace-scoped string), shared between two 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 , 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). --- mateclaw-ui/src/utils/modelProtocol.ts | 23 + mateclaw-ui/src/utils/safeJson.ts | 19 + .../Models/EmbeddingModelsSection.vue | 18 +- .../composables/useProviderDiscovery.ts | 184 +++++ .../Models/composables/useProviderForm.ts | 211 ++++++ .../Models/composables/useProviderList.ts | 126 ++++ .../Models/composables/useProviderOAuth.ts | 93 +++ .../Models/composables/useProviderPool.ts | 50 ++ .../src/views/Settings/Models/index.vue | 30 +- .../src/views/Settings/Models/useProviders.ts | 635 ++---------------- mateclaw-ui/src/views/layout/MainLayout.vue | 10 +- 11 files changed, 793 insertions(+), 606 deletions(-) create mode 100644 mateclaw-ui/src/utils/modelProtocol.ts create mode 100644 mateclaw-ui/src/utils/safeJson.ts create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderPool.ts diff --git a/mateclaw-ui/src/utils/modelProtocol.ts b/mateclaw-ui/src/utils/modelProtocol.ts new file mode 100644 index 00000000..f63ac117 --- /dev/null +++ b/mateclaw-ui/src/utils/modelProtocol.ts @@ -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' +} diff --git a/mateclaw-ui/src/utils/safeJson.ts b/mateclaw-ui/src/utils/safeJson.ts new file mode 100644 index 00000000..b4e34f6f --- /dev/null +++ b/mateclaw-ui/src/utils/safeJson.ts @@ -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 { + 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 + } catch { + throw new Error('Invalid JSON format') + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue b/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue index 9118081f..1605bfca 100644 --- a/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue +++ b/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue @@ -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; diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts new file mode 100644 index 00000000..9490e8cf --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts @@ -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 + refreshCurrentProvider: (providerId: string) => Promise +} + +/** + * 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(null) + const selectedNewModelIds = ref([]) + const applyingModels = ref(false) + + const connectionTestingId = ref(null) + const connectionResults = ref>({}) + const testingModelId = ref(null) + const modelTestResults = ref>({}) + + 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, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts new file mode 100644 index 00000000..c3f0c242 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts @@ -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 + loadActiveModel: () => Promise +} + +/** + * 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(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://.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, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts new file mode 100644 index 00000000..7b2374b9 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts @@ -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([]) + const activeModels = ref(null) + /** The provider currently shown in the manage-models modal (cross-composable focus point). */ + const currentProvider = ref(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 = { + '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, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts new file mode 100644 index 00000000..adb6ef32 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts @@ -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 +} + +interface ListDeps { + loadProviders: () => Promise + /** Read-only access to the current providers list — used to re-resolve editingProvider after refresh. */ + providers: Ref +} + +/** + * 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, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderPool.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderPool.ts new file mode 100644 index 00000000..f8d05004 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderPool.ts @@ -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 +} + +/** + * 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(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, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index 9c49232c..db645cb2 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -10,8 +10,14 @@ + +
+ +
+ -
+

@@ -40,7 +46,7 @@

-
+

@@ -120,18 +126,26 @@ MateClaw

+ - + - +