mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
Reshapes the Settings/Models frontend to match the channel-module split
convention (commit 22894ac4 'perf(channels): split Channels.vue...'),
zero behavior change. Paves the way for a follow-up that adds an enabled
column + AddProviderDrawer without bloating useProviders back to monolith.
Frontend split
- useProviders.ts goes from 615-line monolith to a 48-line facade that
composes five single-responsibility slices:
* useProviderList — providers / activeModels / currentProvider,
loaders, status pill, icons
* useProviderForm — create/edit modal + form, save/delete
* useProviderDiscovery — manage-models modal, discovery, connection
and per-model tests
* useProviderOAuth — openai-chatgpt + claude-code OAuth flows
* useProviderPool — manual reprobe (most pool surface inlined to
ProviderInfo.liveness in the prior liveness change)
Cross-composable refs flow via dep-injection arguments — no module-
level state, no circular deps. Each composable stays independently
testable.
- Pure helpers extracted to src/utils:
* safeJson.ts — strict JSON-object parser
* modelProtocol.ts — protocol <-> ChatModel class translation
- Modals (ProviderConfigModal, ManageModelsModal) loaded via
defineAsyncComponent so the route's first paint doesn't drag along
~30KB of form/auth UI.
- el-skeleton placeholder during the initial Promise.all so the page
paints something instead of blank-then-pop.
Layout regression fix
- MainLayout's <keep-alive> slot used :key='workspaceRouteKey' (a
workspace-scoped string), shared between two <component v-if> blocks.
Adding a second keepAlive route would have caused two components to
mount side by side, because Vue saw identical keys and patched in
place across the v-if boundary. Switched the key to
${workspaceRouteKey}:${route.path} so different routes get distinct
vnode identities while workspace switching still busts the cache.
Discovered while implementing the split — the multi-line HTML
comment also had to live OUTSIDE <keep-alive>, since KeepAlive
treats comments as children and rejects 'more than one'.
Embedding section title fix (drive-by)
- EmbeddingModelsSection.vue's scoped style didn't redeclare
.group-title's flex layout, so the icon stacked above the title
text instead of sitting inline. Added the missing flex rules
locally — now matches the local-models / cloud-models group headers.
Verification
- vue-tsc 0 errors.
- Browser end-to-end: 27 cards render correctly, modals open via lazy
load, /channels <-> /settings/models switch four times in a row with
exactly one page title visible at each step (no stacking).
319 lines
10 KiB
Vue
319 lines
10 KiB
Vue
<template>
|
||
<div class="settings-section model-section">
|
||
<div class="section-header">
|
||
<div>
|
||
<h2 class="section-title">{{ t('settings.model.title') }}</h2>
|
||
<p class="section-desc">{{ t('settings.model.desc') }}</p>
|
||
</div>
|
||
<button class="btn-primary" @click="openCreateProviderModal">
|
||
{{ t('settings.model.addProvider') }}
|
||
</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="!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"/>
|
||
</svg>
|
||
{{ t('settings.model.localProviders') }}
|
||
</h3>
|
||
<div class="provider-grid">
|
||
<div v-for="provider in localProviders" :key="provider.id" class="provider-card">
|
||
<ProviderCard
|
||
:provider="provider"
|
||
:connection-testing-id="connectionTestingId"
|
||
:connection-results="connectionResults"
|
||
:reprobing="reprobingId === provider.id"
|
||
:is-provider-active="isProviderActive"
|
||
:provider-status="providerStatus"
|
||
:get-provider-icon="getProviderIcon"
|
||
:on-icon-error="onIconError"
|
||
@manage-models="openManageModelsModal"
|
||
@provider-settings="openProviderConfigModal"
|
||
@test-connection="handleTestConnection"
|
||
@delete-provider="onDeleteProvider"
|
||
@reprobe="reprobeProvider"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 云端模型 -->
|
||
<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"/>
|
||
</svg>
|
||
{{ t('settings.model.cloudProviders') }}
|
||
</h3>
|
||
<div class="provider-grid">
|
||
<div v-for="provider in cloudProviders" :key="provider.id" class="provider-card">
|
||
<ProviderCard
|
||
:provider="provider"
|
||
:connection-testing-id="connectionTestingId"
|
||
:connection-results="connectionResults"
|
||
:reprobing="reprobingId === provider.id"
|
||
:is-provider-active="isProviderActive"
|
||
:provider-status="providerStatus"
|
||
:get-provider-icon="getProviderIcon"
|
||
:on-icon-error="onIconError"
|
||
@manage-models="openManageModelsModal"
|
||
@provider-settings="openProviderConfigModal"
|
||
@test-connection="handleTestConnection"
|
||
@delete-provider="onDeleteProvider"
|
||
@reprobe="reprobeProvider"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Embedding 模型(RFC Embedding UI) -->
|
||
<EmbeddingModelsSection />
|
||
|
||
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
|
||
|
||
<!-- Provider Config Modal -->
|
||
<ProviderConfigModal
|
||
:show="showProviderModal"
|
||
:editing-provider="editingProvider"
|
||
:form="providerForm"
|
||
:advanced-open="advancedOpen"
|
||
:protocol-options="protocolOptions"
|
||
:base-url-placeholder="providerBaseUrlPlaceholder"
|
||
:base-url-hint="providerBaseUrlHint"
|
||
:api-key-placeholder="providerApiKeyPlaceholder"
|
||
@close="closeProviderModal"
|
||
@save="onSaveProvider"
|
||
@toggle-advanced="advancedOpen = !advancedOpen"
|
||
@oauth-login="handleOAuthLogin"
|
||
@oauth-revoke="handleOAuthRevoke"
|
||
/>
|
||
|
||
<!-- Manage Models Modal -->
|
||
<ManageModelsModal
|
||
:show="showManageModelsModal"
|
||
:provider="currentProvider"
|
||
:model-form="providerModelForm"
|
||
:discovering="discovering"
|
||
:discover-result="discoverResult"
|
||
:selected-new-model-ids="selectedNewModelIds"
|
||
:applying-models="applyingModels"
|
||
:all-new-selected="allNewSelected"
|
||
:testing-model-id="testingModelId"
|
||
:model-test-results="modelTestResults"
|
||
:is-extra-model="isExtraModel"
|
||
:is-active-model="isActiveModel"
|
||
:get-provider-icon="getProviderIcon"
|
||
:on-icon-error="onIconError"
|
||
@close="closeManageModelsModal"
|
||
@discover="handleDiscoverModels"
|
||
@toggle-select-all="toggleSelectAll"
|
||
@toggle-model="onToggleModel"
|
||
@apply-models="onApplyModels"
|
||
@test-model="handleTestModel"
|
||
@set-active="onSetActiveModel"
|
||
@remove-model="onRemoveProviderModel"
|
||
@add-model="onAddProviderModel"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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'
|
||
// 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,
|
||
editingProvider,
|
||
currentProvider,
|
||
showProviderModal,
|
||
showManageModelsModal,
|
||
advancedOpen,
|
||
discovering,
|
||
discoverResult,
|
||
selectedNewModelIds,
|
||
applyingModels,
|
||
connectionTestingId,
|
||
connectionResults,
|
||
testingModelId,
|
||
modelTestResults,
|
||
providerForm,
|
||
providerModelForm,
|
||
protocolOptions,
|
||
allNewSelected,
|
||
providerBaseUrlPlaceholder,
|
||
providerBaseUrlHint,
|
||
providerApiKeyPlaceholder,
|
||
reprobingId,
|
||
reprobeProvider,
|
||
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,
|
||
} = useProviders()
|
||
|
||
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await Promise.all([loadProviders(), loadActiveModel()])
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
async function onSaveProvider() {
|
||
try {
|
||
await saveProvider()
|
||
showSavedTip(t('settings.model.providerSaved'))
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : t('settings.messages.saveFailed'))
|
||
}
|
||
}
|
||
|
||
async function onDeleteProvider(provider: ProviderInfo) {
|
||
const deleted = await deleteProvider(provider)
|
||
if (deleted) showSavedTip(t('settings.model.providerDeleted'))
|
||
}
|
||
|
||
async function onAddProviderModel() {
|
||
try {
|
||
await addProviderModel()
|
||
showSavedTip(t('settings.model.modelAdded'))
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.modelAddFailed'))
|
||
}
|
||
}
|
||
|
||
async function onRemoveProviderModel(model: ProviderModelInfo) {
|
||
try {
|
||
await removeProviderModel(model)
|
||
showSavedTip(t('settings.model.modelRemoved'))
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.modelRemoveFailed'))
|
||
}
|
||
}
|
||
|
||
async function onSetActiveModel(model: ProviderModelInfo) {
|
||
try {
|
||
await setActiveModel(model)
|
||
showSavedTip(t('settings.model.activeChanged'))
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.activeChangeFailed'))
|
||
}
|
||
}
|
||
|
||
async function onApplyModels() {
|
||
const added = await handleApplyModels()
|
||
if (added) showSavedTip(t('settings.model.discovery.addedCount', { count: added }))
|
||
}
|
||
|
||
function onToggleModel(modelId: string) {
|
||
const idx = selectedNewModelIds.value.indexOf(modelId)
|
||
if (idx >= 0) {
|
||
selectedNewModelIds.value.splice(idx, 1)
|
||
} else {
|
||
selectedNewModelIds.value.push(modelId)
|
||
}
|
||
}
|
||
|
||
function showSavedTip(message: string) {
|
||
savedTip.value = message
|
||
window.setTimeout(() => { savedTip.value = '' }, 2500)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.settings-section { width: 100%; }
|
||
.settings-section.model-section { max-width: none; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||
.section-title { margin: 0 0 6px; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
|
||
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
|
||
|
||
.provider-group {
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.provider-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.provider-card {
|
||
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04);
|
||
}
|
||
.btn-primary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; }
|
||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||
|
||
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); }
|
||
|
||
@media (max-width: 900px) {
|
||
.section-header { flex-direction: column; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.provider-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|