mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
62b94b522f
commit
a168f91215
23
mateclaw-ui/src/utils/modelProtocol.ts
Normal file
23
mateclaw-ui/src/utils/modelProtocol.ts
Normal 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'
|
||||
}
|
||||
19
mateclaw-ui/src/utils/safeJson.ts
Normal file
19
mateclaw-ui/src/utils/safeJson.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,14 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RFC-074 PR-1: skeleton placeholder so the page paints something
|
||||
immediately on first load instead of blank-then-pop. -->
|
||||
<div v-if="loading" class="provider-group">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
|
||||
<!-- 本地模型 -->
|
||||
<div v-if="localProviders.length" class="provider-group">
|
||||
<div v-if="!loading && localProviders.length" class="provider-group">
|
||||
<h3 class="group-title">
|
||||
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
@ -40,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 云端模型 -->
|
||||
<div v-if="cloudProviders.length" class="provider-group">
|
||||
<div v-if="!loading && cloudProviders.length" class="provider-group">
|
||||
<h3 class="group-title">
|
||||
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
|
||||
@ -120,18 +126,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
||||
import { useProviders } from './useProviders'
|
||||
import ProviderCard from './ProviderCard.vue'
|
||||
import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
|
||||
import ProviderConfigModal from './modals/ProviderConfigModal.vue'
|
||||
import ManageModelsModal from './modals/ManageModelsModal.vue'
|
||||
// RFC-074 PR-1: defer modal JS until the user actually opens one — same
|
||||
// pattern as ChannelEditModal in commit 9300559b. Drops ~30KB from the
|
||||
// initial Settings/Models route chunk.
|
||||
const ProviderConfigModal = defineAsyncComponent(() => import('./modals/ProviderConfigModal.vue'))
|
||||
const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
const savedTip = ref('')
|
||||
// Skeleton gate. Driven by onMounted only — fine because /settings/models is
|
||||
// NOT a keepAlive route. If anyone re-adds keepAlive in router/index.ts,
|
||||
// switch to onActivated (or reset loading there) so cached re-entries don't
|
||||
// skip the load + leave loading=false stale.
|
||||
const loading = ref(true)
|
||||
|
||||
const {
|
||||
providers,
|
||||
@ -188,7 +202,11 @@ const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||||
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProviders(), loadActiveModel()])
|
||||
try {
|
||||
await Promise.all([loadProviders(), loadActiveModel()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function onSaveProvider() {
|
||||
|
||||
@ -1,607 +1,48 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
|
||||
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
||||
import { useProviderList } from './composables/useProviderList'
|
||||
import { useProviderForm } from './composables/useProviderForm'
|
||||
import { useProviderDiscovery } from './composables/useProviderDiscovery'
|
||||
import { useProviderOAuth } from './composables/useProviderOAuth'
|
||||
import { useProviderPool } from './composables/useProviderPool'
|
||||
|
||||
/**
|
||||
* RFC-074 PR-1: composition facade. Each sub-composable owns one slice of
|
||||
* the model-settings surface; this entry point wires them together so
|
||||
* `index.vue` can keep its single `const { ... } = useProviders()` pattern.
|
||||
*
|
||||
* Cross-composable refs flow via dep-injection arguments (not module-level
|
||||
* state), so each composable stays independently testable.
|
||||
*
|
||||
* Owned slices:
|
||||
* - useProviderList — providers / activeModels / currentProvider, status pill, icons
|
||||
* - useProviderForm — provider create/edit modal + form state, save/delete
|
||||
* - useProviderDiscovery — manage-models modal, model discovery, connection / model tests
|
||||
* - useProviderOAuth — OAuth flows (openai-chatgpt + claude-code)
|
||||
* - useProviderPool — manual reprobe trigger (most pool surface inlined in RFC-073)
|
||||
*/
|
||||
export function useProviders() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const providers = ref<ProviderInfo[]>([])
|
||||
const activeModels = ref<ActiveModelsInfo | null>(null)
|
||||
// RFC-073: pool / cooldown / probe state now ships inline on each ProviderInfo
|
||||
// via `liveness`. Separate snapshot / indexed map removed — see ProviderCard.vue
|
||||
// for how the five states render.
|
||||
// PR-1e manual reprobe still tracks which provider is in flight.
|
||||
const reprobingId = ref<string | null>(null)
|
||||
const editingProvider = ref<ProviderInfo | null>(null)
|
||||
const currentProvider = ref<ProviderInfo | null>(null)
|
||||
const showProviderModal = ref(false)
|
||||
const showManageModelsModal = ref(false)
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
// Discovery & testing state
|
||||
const discovering = ref(false)
|
||||
const discoverResult = ref<DiscoverResult | null>(null)
|
||||
const selectedNewModelIds = ref<string[]>([])
|
||||
const applyingModels = ref(false)
|
||||
const connectionTestingId = ref<string | null>(null)
|
||||
const connectionResults = ref<Record<string, TestResult>>({})
|
||||
const testingModelId = ref<string | null>(null)
|
||||
const modelTestResults = ref<Record<string, TestResult>>({})
|
||||
|
||||
const providerForm = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
apiKeyPrefix: 'sk-',
|
||||
protocol: 'openai-compatible',
|
||||
chatModel: 'OpenAIChatModel',
|
||||
generateKwargsText: '{}',
|
||||
enableSearch: false,
|
||||
searchStrategy: '',
|
||||
// RFC-009 P3.5: position in the multi-model failover chain.
|
||||
// 0 = excluded; positive int = ascending try-order.
|
||||
fallbackPriority: 0,
|
||||
const list = useProviderList()
|
||||
const form = useProviderForm({
|
||||
loadProviders: list.loadProviders,
|
||||
loadActiveModel: list.loadActiveModel,
|
||||
})
|
||||
|
||||
const providerModelForm = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
const discovery = useProviderDiscovery({
|
||||
currentProvider: list.currentProvider,
|
||||
refreshCurrentProvider: list.refreshCurrentProvider,
|
||||
})
|
||||
|
||||
const protocolOptions = computed(() => ([
|
||||
{ value: 'openai-compatible', label: t('settings.model.protocolOpenAI') },
|
||||
{ value: 'anthropic-messages', label: t('settings.model.protocolAnthropic') },
|
||||
{ value: 'gemini-native', label: t('settings.model.protocolGemini') },
|
||||
{ value: 'dashscope-native', label: t('settings.model.protocolDashScope') },
|
||||
]))
|
||||
|
||||
// Data loading
|
||||
async function loadProviders() {
|
||||
const res: any = await modelApi.listProviders()
|
||||
providers.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadActiveModel() {
|
||||
const res: any = await modelApi.getActive()
|
||||
activeModels.value = res.data || null
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-073: synchronously re-probe one provider, then re-fetch the
|
||||
* provider list — `liveness` ships inline now so a single round-trip
|
||||
* refreshes everything the UI needs.
|
||||
*/
|
||||
async function reprobeProvider(provider: ProviderInfo) {
|
||||
reprobingId.value = provider.id
|
||||
try {
|
||||
const res: any = await providerPoolApi.reprobe(provider.id)
|
||||
const data = res.data || {}
|
||||
await loadProviders()
|
||||
if (data.success) {
|
||||
ElMessage.success(t('settings.model.poolReprobeOk'))
|
||||
} else {
|
||||
ElMessage.warning(t('settings.model.poolReprobeFail', { error: data.errorMessage || '—' }))
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
ElMessage.error(t('settings.model.poolReprobeFail', {
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}))
|
||||
} finally {
|
||||
reprobingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCurrentProvider(providerId: string) {
|
||||
await Promise.all([loadProviders(), loadActiveModel()])
|
||||
currentProvider.value = providers.value.find(provider => provider.id === providerId) || null
|
||||
}
|
||||
|
||||
// Provider CRUD
|
||||
function openCreateProviderModal() {
|
||||
editingProvider.value = null
|
||||
advancedOpen.value = false
|
||||
Object.assign(providerForm, {
|
||||
id: '',
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
apiKeyPrefix: 'sk-',
|
||||
protocol: 'openai-compatible',
|
||||
chatModel: 'OpenAIChatModel',
|
||||
generateKwargsText: '{}',
|
||||
enableSearch: false,
|
||||
searchStrategy: '',
|
||||
fallbackPriority: 0,
|
||||
})
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
function openProviderConfigModal(provider: ProviderInfo) {
|
||||
editingProvider.value = provider
|
||||
advancedOpen.value = true
|
||||
const kwargs = provider.generateKwargs || {}
|
||||
const protocol = provider.protocol || chatModelToProtocol(provider.chatModel)
|
||||
// DashScope 默认开启搜索:仅当 kwargs 中显式设为 false 时才关闭
|
||||
const isDashScope = protocol === 'dashscope-native'
|
||||
const searchDefault = isDashScope ? kwargs.enableSearch !== false : !!kwargs.enableSearch
|
||||
Object.assign(providerForm, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl || '',
|
||||
apiKey: '',
|
||||
apiKeyPrefix: provider.apiKeyPrefix || 'sk-',
|
||||
protocol,
|
||||
chatModel: provider.chatModel || 'OpenAIChatModel',
|
||||
generateKwargsText: JSON.stringify(kwargs, null, 2),
|
||||
enableSearch: searchDefault,
|
||||
searchStrategy: (kwargs.searchStrategy as string) || '',
|
||||
fallbackPriority: provider.fallbackPriority ?? 0,
|
||||
})
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
function closeProviderModal() {
|
||||
showProviderModal.value = false
|
||||
editingProvider.value = null
|
||||
advancedOpen.value = false
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
const kwargs = safeParseJson(providerForm.generateKwargsText)
|
||||
// 搜索设置写入 generateKwargs
|
||||
if (providerForm.enableSearch) {
|
||||
kwargs.enableSearch = true
|
||||
if (providerForm.searchStrategy) {
|
||||
kwargs.searchStrategy = providerForm.searchStrategy
|
||||
} else {
|
||||
delete kwargs.searchStrategy
|
||||
}
|
||||
} else {
|
||||
delete kwargs.enableSearch
|
||||
delete kwargs.searchStrategy
|
||||
}
|
||||
// RFC-009 P3.5: clamp to non-negative, coerce string input back to integer.
|
||||
const fallbackPriority = Math.max(0, Math.floor(Number(providerForm.fallbackPriority) || 0))
|
||||
if (editingProvider.value) {
|
||||
await modelApi.updateProviderConfig(editingProvider.value.id, {
|
||||
apiKey: providerForm.apiKey,
|
||||
baseUrl: providerForm.baseUrl,
|
||||
protocol: providerForm.protocol,
|
||||
chatModel: protocolToChatModel(providerForm.protocol),
|
||||
generateKwargs: kwargs,
|
||||
fallbackPriority,
|
||||
})
|
||||
} else {
|
||||
await modelApi.createCustomProvider({
|
||||
id: providerForm.id,
|
||||
name: providerForm.name,
|
||||
defaultBaseUrl: providerForm.baseUrl,
|
||||
apiKeyPrefix: providerForm.apiKeyPrefix,
|
||||
protocol: providerForm.protocol,
|
||||
chatModel: protocolToChatModel(providerForm.protocol),
|
||||
models: [],
|
||||
})
|
||||
if (providerForm.apiKey || providerForm.generateKwargsText || fallbackPriority > 0) {
|
||||
await modelApi.updateProviderConfig(providerForm.id, {
|
||||
apiKey: providerForm.apiKey,
|
||||
baseUrl: providerForm.baseUrl,
|
||||
protocol: providerForm.protocol,
|
||||
chatModel: protocolToChatModel(providerForm.protocol),
|
||||
generateKwargs: kwargs,
|
||||
fallbackPriority,
|
||||
})
|
||||
}
|
||||
}
|
||||
closeProviderModal()
|
||||
await Promise.all([loadProviders(), loadActiveModel()])
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: ProviderInfo) {
|
||||
if (!confirm(t('settings.model.deleteConfirm', { name: provider.name }))) {
|
||||
return false
|
||||
}
|
||||
await modelApi.deleteCustomProvider(provider.id)
|
||||
await loadProviders()
|
||||
return true
|
||||
}
|
||||
|
||||
// Model management
|
||||
function openManageModelsModal(provider: ProviderInfo) {
|
||||
currentProvider.value = provider
|
||||
providerModelForm.id = ''
|
||||
providerModelForm.name = ''
|
||||
showManageModelsModal.value = true
|
||||
}
|
||||
|
||||
function closeManageModelsModal() {
|
||||
showManageModelsModal.value = false
|
||||
currentProvider.value = null
|
||||
discoverResult.value = null
|
||||
selectedNewModelIds.value = []
|
||||
modelTestResults.value = {}
|
||||
testingModelId.value = null
|
||||
}
|
||||
|
||||
function isExtraModel(modelId: string) {
|
||||
return !!currentProvider.value?.extraModels?.some(model => model.id === modelId)
|
||||
}
|
||||
|
||||
async function addProviderModel() {
|
||||
if (!currentProvider.value || !providerModelForm.id) return
|
||||
await modelApi.addProviderModel(currentProvider.value.id, {
|
||||
id: providerModelForm.id,
|
||||
name: providerModelForm.name || providerModelForm.id,
|
||||
})
|
||||
await refreshCurrentProvider(currentProvider.value.id)
|
||||
providerModelForm.id = ''
|
||||
providerModelForm.name = ''
|
||||
}
|
||||
|
||||
async function removeProviderModel(model: ProviderModelInfo) {
|
||||
if (!currentProvider.value) return
|
||||
if (!confirm(t('settings.model.removeConfirm', { name: model.name }))) return
|
||||
await modelApi.removeProviderModel(currentProvider.value.id, model.id)
|
||||
await refreshCurrentProvider(currentProvider.value.id)
|
||||
}
|
||||
|
||||
// Active model
|
||||
function isProviderActive(provider: ProviderInfo) {
|
||||
return activeModels.value?.activeLlm?.providerId === provider.id
|
||||
}
|
||||
|
||||
function isActiveModel(model: ProviderModelInfo) {
|
||||
return activeModels.value?.activeLlm?.providerId === currentProvider.value?.id
|
||||
&& activeModels.value?.activeLlm?.model === model.id
|
||||
}
|
||||
|
||||
async function setActiveModel(model: ProviderModelInfo) {
|
||||
if (!currentProvider.value) return
|
||||
await modelApi.setActive({ providerId: currentProvider.value.id, model: model.id })
|
||||
await loadActiveModel()
|
||||
}
|
||||
|
||||
// Discovery & testing
|
||||
const allNewSelected = computed(() => {
|
||||
if (!discoverResult.value || discoverResult.value.newCount === 0) return false
|
||||
return selectedNewModelIds.value.length === discoverResult.value.newModels.length
|
||||
const oauth = useProviderOAuth({
|
||||
editingProvider: form.editingProvider,
|
||||
loadProviders: list.loadProviders,
|
||||
providers: list.providers,
|
||||
})
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (!discoverResult.value) return
|
||||
if (allNewSelected.value) {
|
||||
selectedNewModelIds.value = []
|
||||
} else {
|
||||
selectedNewModelIds.value = discoverResult.value.newModels.map(m => m.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscoverModels() {
|
||||
if (!currentProvider.value) return
|
||||
discovering.value = true
|
||||
discoverResult.value = null
|
||||
selectedNewModelIds.value = []
|
||||
try {
|
||||
const res: any = await modelApi.discoverModels(currentProvider.value.id)
|
||||
discoverResult.value = res.data
|
||||
if (res.data?.newCount > 0) {
|
||||
selectedNewModelIds.value = res.data.newModels.map((m: ProviderModelInfo) => m.id)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplyModels() {
|
||||
if (!currentProvider.value || selectedNewModelIds.value.length === 0) return
|
||||
applyingModels.value = true
|
||||
try {
|
||||
const res: any = await modelApi.applyDiscoveredModels(currentProvider.value.id, selectedNewModelIds.value)
|
||||
const added = res.data?.added ?? selectedNewModelIds.value.length
|
||||
discoverResult.value = null
|
||||
selectedNewModelIds.value = []
|
||||
await refreshCurrentProvider(currentProvider.value.id)
|
||||
return added
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : String(error))
|
||||
return 0
|
||||
} finally {
|
||||
applyingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection(provider: ProviderInfo) {
|
||||
connectionTestingId.value = provider.id
|
||||
delete connectionResults.value[provider.id]
|
||||
try {
|
||||
const res: any = await modelApi.testConnection(provider.id)
|
||||
connectionResults.value[provider.id] = res.data
|
||||
} catch (error) {
|
||||
connectionResults.value[provider.id] = {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
} finally {
|
||||
connectionTestingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestModel(model: ProviderModelInfo) {
|
||||
if (!currentProvider.value) return
|
||||
testingModelId.value = model.id
|
||||
delete modelTestResults.value[model.id]
|
||||
try {
|
||||
const res: any = await modelApi.testModel(currentProvider.value.id, model.id)
|
||||
modelTestResults.value[model.id] = res.data
|
||||
} catch (error) {
|
||||
modelTestResults.value[model.id] = {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
} finally {
|
||||
testingModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
const currentProviderForForm = computed(() => editingProvider.value ?? {
|
||||
id: providerForm.id,
|
||||
name: providerForm.name,
|
||||
const pool = useProviderPool({
|
||||
loadProviders: list.loadProviders,
|
||||
})
|
||||
|
||||
const providerBaseUrlPlaceholder = computed(() => {
|
||||
const id = currentProviderForForm.value?.id
|
||||
if (id === 'openai') return 'https://api.openai.com/v1'
|
||||
if (id === 'azure-openai') return 'https://<resource>.openai.azure.com/openai/v1'
|
||||
if (id === 'anthropic') return 'https://api.anthropic.com'
|
||||
if (id === 'ollama') return 'http://localhost:11434'
|
||||
if (id === 'lmstudio') return 'http://localhost:1234/v1'
|
||||
if (id === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
if (id === 'openrouter') return 'https://openrouter.ai/api/v1'
|
||||
if (id === 'zhipu-cn') return 'https://open.bigmodel.cn/api/paas/v4'
|
||||
if (id === 'zhipu-intl') return 'https://open.z.ai/api/paas/v4'
|
||||
if (id === 'volcengine') return 'https://ark.cn-beijing.volces.com/api/v3'
|
||||
return 'https://example.com/v1'
|
||||
})
|
||||
|
||||
const providerBaseUrlHint = computed(() => {
|
||||
const id = currentProviderForForm.value?.id
|
||||
if (id === 'openai') return t('settings.model.hints.openai')
|
||||
if (id === 'azure-openai') return t('settings.model.hints.azureOpenai')
|
||||
if (id === 'anthropic') return t('settings.model.hints.anthropic')
|
||||
if (id === 'ollama') return t('settings.model.hints.ollama')
|
||||
if (id === 'lmstudio') return t('settings.model.hints.lmstudio')
|
||||
if (id === 'gemini') return t('settings.model.hints.gemini')
|
||||
if (id === 'openrouter') return t('settings.model.hints.openrouter')
|
||||
if (id === 'zhipu-cn') return t('settings.model.hints.zhipu')
|
||||
if (id === 'zhipu-intl') return t('settings.model.hints.zhipuIntl')
|
||||
if (id === 'volcengine') return t('settings.model.hints.volcengine')
|
||||
return t('settings.model.hints.openaiCompatible')
|
||||
})
|
||||
|
||||
const providerApiKeyPlaceholder = computed(() => {
|
||||
return providerForm.apiKeyPrefix
|
||||
? `${t('settings.model.apiKeyInput')} (${providerForm.apiKeyPrefix}...)`
|
||||
: t('settings.model.apiKeyInput')
|
||||
})
|
||||
|
||||
// RFC-073: status pill is driven by liveness. Falls back to the legacy
|
||||
// configured/available booleans for older backends that don't yet send liveness.
|
||||
function providerStatus(provider: ProviderInfo) {
|
||||
switch (provider.liveness) {
|
||||
case 'LIVE':
|
||||
return { type: 'configured', label: t('settings.model.livenessLive') }
|
||||
case 'COOLDOWN':
|
||||
return { type: 'partial', label: t('settings.model.livenessCooldown') }
|
||||
case 'REMOVED':
|
||||
return { type: 'unavailable', label: t('settings.model.livenessRemoved') }
|
||||
case 'UNPROBED':
|
||||
return { type: 'partial', label: t('settings.model.livenessUnprobed') }
|
||||
case 'UNCONFIGURED':
|
||||
return { type: 'unavailable', label: t('settings.model.livenessUnconfigured') }
|
||||
}
|
||||
// Legacy backend (no liveness field)
|
||||
if (provider.available) return { type: 'configured', label: t('settings.model.configured') }
|
||||
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
|
||||
return { type: 'partial', label: t('settings.model.partial') }
|
||||
}
|
||||
return { type: 'unavailable', label: t('settings.model.unavailable') }
|
||||
}
|
||||
|
||||
const providerIconMap: Record<string, string> = {
|
||||
'dashscope': '/icons/providers/dashscope.png',
|
||||
'modelscope': '/icons/providers/modelscope.svg',
|
||||
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg',
|
||||
'openai': '/icons/providers/openai.svg',
|
||||
'azure-openai': '/icons/providers/azure-openai.svg',
|
||||
'minimax': '/icons/providers/minimax.png',
|
||||
'minimax-cn': '/icons/providers/minimax.png',
|
||||
'kimi-cn': '/icons/providers/kimi.svg',
|
||||
'kimi-intl': '/icons/providers/kimi.svg',
|
||||
'kimi-code': '/icons/providers/kimi.svg',
|
||||
'deepseek': '/icons/providers/deepseek.svg',
|
||||
'anthropic': '/icons/providers/anthropic.svg',
|
||||
'gemini': '/icons/providers/gemini.svg',
|
||||
'ollama': '/icons/providers/ollama.svg',
|
||||
'lmstudio': '/icons/providers/lmstudio.svg',
|
||||
'llamacpp': '/icons/providers/llamacpp.svg',
|
||||
'mlx': '/icons/providers/mlx.svg',
|
||||
'openrouter': '/icons/providers/openrouter.svg',
|
||||
'zhipu-cn': '/icons/providers/zhipu.svg',
|
||||
'zhipu-intl': '/icons/providers/zhipu.svg',
|
||||
'volcengine': '/icons/providers/volcengine.svg',
|
||||
'openai-chatgpt': '/icons/providers/openai.svg',
|
||||
'anthropic-claude-code': '/icons/providers/anthropic.svg',
|
||||
}
|
||||
|
||||
function getProviderIcon(providerId: string): string {
|
||||
return providerIconMap[providerId] || '/icons/providers/default.svg'
|
||||
}
|
||||
|
||||
// ==================== OAuth ====================
|
||||
|
||||
/** Refresh editingProvider after a load so the modal state stays in sync. */
|
||||
async function reloadProvidersAndSync() {
|
||||
await loadProviders()
|
||||
if (editingProvider.value) {
|
||||
const updated = providers.value.find(p => p.id === editingProvider.value!.id)
|
||||
if (updated) editingProvider.value = updated
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthLogin(providerId?: string) {
|
||||
// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code
|
||||
// install. There's no in-app authorize URL — the "Connect" button just
|
||||
// re-reads credentials from disk so a user who logged in via Claude Code
|
||||
// sees the connection appear without restarting the server.
|
||||
if (providerId === 'anthropic-claude-code') {
|
||||
try {
|
||||
const res: any = await claudeCodeOAuthApi.reload()
|
||||
if (res.data?.connected && !res.data?.expired) {
|
||||
ElMessage.success(t('settings.model.oauthLoginSuccess'))
|
||||
} else {
|
||||
ElMessage.warning(t('settings.model.claudeCodeOauthInstructions'))
|
||||
}
|
||||
await reloadProvidersAndSync()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || 'Claude Code OAuth detection failed')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await oauthApi.authorize()
|
||||
const { authorizeUrl } = res.data
|
||||
// 打开新窗口进行 OAuth 登录
|
||||
const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700')
|
||||
// 轮询检查 OAuth 状态
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes: any = await oauthApi.status()
|
||||
if (statusRes.data?.connected) {
|
||||
clearInterval(pollInterval)
|
||||
if (authWindow && !authWindow.closed) authWindow.close()
|
||||
ElMessage.success(t('settings.model.oauthLoginSuccess'))
|
||||
await reloadProvidersAndSync()
|
||||
}
|
||||
} catch { /* ignore polling errors */ }
|
||||
}, 2000)
|
||||
// 30 秒后停止轮询
|
||||
setTimeout(() => clearInterval(pollInterval), 30000)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || 'OAuth login failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthRevoke(providerId?: string) {
|
||||
// Claude Code OAuth credentials live on disk — MateClaw doesn't manage
|
||||
// them, so we don't expose a revoke that would clobber the user's
|
||||
// Claude Code login. Direct them to log out from the Claude Code app.
|
||||
if (providerId === 'anthropic-claude-code') {
|
||||
ElMessage.info(t('settings.model.claudeCodeOauthRevokeHint'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await oauthApi.revoke()
|
||||
ElMessage.success(t('settings.model.oauthRevokeSuccess'))
|
||||
await reloadProvidersAndSync()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || 'OAuth revoke failed')
|
||||
}
|
||||
}
|
||||
|
||||
function onIconError(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
providers,
|
||||
activeModels,
|
||||
editingProvider,
|
||||
currentProvider,
|
||||
showProviderModal,
|
||||
showManageModelsModal,
|
||||
advancedOpen,
|
||||
discovering,
|
||||
discoverResult,
|
||||
selectedNewModelIds,
|
||||
applyingModels,
|
||||
connectionTestingId,
|
||||
connectionResults,
|
||||
testingModelId,
|
||||
modelTestResults,
|
||||
providerForm,
|
||||
providerModelForm,
|
||||
protocolOptions,
|
||||
// Computed
|
||||
allNewSelected,
|
||||
providerBaseUrlPlaceholder,
|
||||
providerBaseUrlHint,
|
||||
providerApiKeyPlaceholder,
|
||||
// RFC-073: pool/cooldown/probe data is now baked into providers[].liveness.
|
||||
// The standalone snapshot/loadProviderPool are gone — saves a round trip.
|
||||
reprobingId,
|
||||
reprobeProvider,
|
||||
// Methods
|
||||
loadProviders,
|
||||
loadActiveModel,
|
||||
openCreateProviderModal,
|
||||
openProviderConfigModal,
|
||||
closeProviderModal,
|
||||
saveProvider,
|
||||
deleteProvider,
|
||||
openManageModelsModal,
|
||||
closeManageModelsModal,
|
||||
isExtraModel,
|
||||
addProviderModel,
|
||||
removeProviderModel,
|
||||
isProviderActive,
|
||||
isActiveModel,
|
||||
setActiveModel,
|
||||
toggleSelectAll,
|
||||
handleDiscoverModels,
|
||||
handleApplyModels,
|
||||
handleTestConnection,
|
||||
handleTestModel,
|
||||
providerStatus,
|
||||
getProviderIcon,
|
||||
onIconError,
|
||||
handleOAuthLogin,
|
||||
handleOAuthRevoke,
|
||||
...list,
|
||||
...form,
|
||||
...discovery,
|
||||
...oauth,
|
||||
...pool,
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helpers (not exported)
|
||||
function safeParseJson(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || '{}')
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Generate config must be a JSON object')
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
throw new Error('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
|
||||
function protocolToChatModel(protocol: string) {
|
||||
if (protocol === 'anthropic-messages') return 'AnthropicChatModel'
|
||||
if (protocol === 'gemini-native') return 'GeminiChatModel'
|
||||
if (protocol === 'dashscope-native') return 'DashScopeChatModel'
|
||||
return 'OpenAIChatModel'
|
||||
}
|
||||
|
||||
function chatModelToProtocol(chatModel?: string) {
|
||||
if (chatModel === 'AnthropicChatModel') return 'anthropic-messages'
|
||||
if (chatModel === 'GeminiChatModel') return 'gemini-native'
|
||||
if (chatModel === 'DashScopeChatModel') return 'dashscope-native'
|
||||
return 'openai-compatible'
|
||||
}
|
||||
|
||||
@ -140,11 +140,17 @@
|
||||
</button>
|
||||
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
|
||||
</div>
|
||||
<!-- RFC-074 PR-1 fix: include route.path in the key so two different
|
||||
keepAlive routes (e.g. /channels and /settings/models) don't collide
|
||||
on the same vnode slot. Without this, switching between two keep-alive
|
||||
routes leaves both component trees mounted because Vue sees identical
|
||||
keys and patches in place. The comment must live OUTSIDE <keep-alive>
|
||||
— KeepAlive treats comments as children and rejects "more than one". -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive>
|
||||
<component :is="Component" :key="workspaceRouteKey" v-if="route.meta?.keepAlive" />
|
||||
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="route.meta?.keepAlive" />
|
||||
</keep-alive>
|
||||
<component :is="Component" :key="workspaceRouteKey" v-if="!route.meta?.keepAlive" />
|
||||
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="!route.meta?.keepAlive" />
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user