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