mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
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).
212 lines
7.5 KiB
TypeScript
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,
|
|
}
|
|
}
|