mateclaw/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts
matevip a168f91215 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).
2026-04-28 15:01:14 +08:00

212 lines
7.5 KiB
TypeScript

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,
}
}