feat(ui): dashboard model-config card + onboarding provider enablement

This commit is contained in:
matevip 2026-05-16 14:51:41 +08:00
parent 56a226a8c7
commit 8edf71cd64
6 changed files with 300 additions and 62 deletions

View File

@ -3173,7 +3173,9 @@ export default {
testConnection: 'Test Connection',
testSuccess: 'Connection Successful',
testFailed: 'Connection Failed',
saveAndContinue: 'Save & Continue',
saveAndContinue: 'Save & Enable',
testBeforeSave: 'Test the connection first — once it passes you can enable it',
saveFailed: 'Save failed, please try again',
verifyTitle: 'Verify Your Model',
verifyMessage: 'Hello! Tell me briefly about yourself.',
send: 'Send',
@ -3231,6 +3233,16 @@ export default {
thisWeek: 'This Week',
thisMonth: 'This Month',
},
models: {
title: 'Model Configuration',
subtitle: 'Enabled LLM providers and the active model.',
activeModel: 'Active model',
notSet: 'Not set',
configured: 'ready',
manage: 'Manage',
empty: 'No LLM providers enabled yet.',
emptyCta: 'Configure',
},
recentRuns: 'Recent Cron Job Runs',
noRuns: 'No runs yet',
runColumns: {

View File

@ -3265,7 +3265,9 @@ export default {
testConnection: '测试连接',
testSuccess: '连接成功',
testFailed: '连接失败',
saveAndContinue: '保存并继续',
saveAndContinue: '保存并启用',
testBeforeSave: '请先测试连接,通过后即可启用',
saveFailed: '保存失败,请重试',
verifyTitle: '验证你的模型',
verifyMessage: '你好!简单介绍一下你自己。',
send: '发送',
@ -3323,6 +3325,16 @@ export default {
thisWeek: '本周',
thisMonth: '本月',
},
models: {
title: '模型配置',
subtitle: '已启用的 LLM Provider 与当前生效模型。',
activeModel: '当前模型',
notSet: '未设置',
configured: '已就绪',
manage: '管理',
empty: '尚未启用任何模型 Provider。',
emptyCta: '去配置',
},
recentRuns: '最近定时任务执行',
noRuns: '暂无执行记录',
runColumns: {

View File

@ -0,0 +1,54 @@
// Provider brand icon resolution. Icon assets live in public/icons/providers/.
// Shared by the Models settings page and the dashboard model-config card so the
// id → icon mapping has a single source of truth.
const providerIconMap: Record<string, string> = {
'dashscope': '/icons/providers/dashscope.png',
// dashscope-compat shares the same Aliyun DashScope brand — same logo.
'dashscope-compat': '/icons/providers/dashscope.png',
'modelscope': '/icons/providers/modelscope.svg',
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg',
'aliyun-codingplan-intl': '/icons/providers/aliyun-codingplan.svg',
// bailian-team is an Aliyun product line — reuse the aliyun mark.
'bailian-team': '/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',
// Coding Plan subscription endpoints — same brand, reuse mark.
'zhipu-cn-codingplan': '/icons/providers/zhipu.svg',
'zhipu-intl-codingplan': '/icons/providers/zhipu.svg',
'volcengine': '/icons/providers/volcengine.svg',
// volcengine-plan = "Volcano Engine Coding Plan" — same brand, reuse mark.
'volcengine-plan': '/icons/providers/volcengine.svg',
'xiaomi-mimo': '/icons/providers/xiaomimimo.svg',
'hunyuan-3d': '/icons/providers/hunyuan-color.svg',
'opencode': '/icons/providers/opencode.svg',
'siliconflow-cn': '/icons/providers/siliconcloud.svg',
'siliconflow-intl': '/icons/providers/siliconcloud.svg',
'openai-chatgpt': '/icons/providers/openai.svg',
'anthropic-claude-code': '/icons/providers/anthropic.svg',
}
export function getProviderIcon(providerId: string): string {
return providerIconMap[providerId] || '/icons/providers/default.svg'
}
// Hide the <img> when the brand icon fails to load so the UI falls back cleanly.
export function onProviderIconError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}

View File

@ -55,6 +55,59 @@
</div>
</div>
<div class="models-section">
<div class="section-head">
<h2 class="section-title">{{ t('dashboard.models.title') }}</h2>
<p class="section-subtitle">{{ t('dashboard.models.subtitle') }}</p>
</div>
<div class="models-card mc-surface-card">
<div class="models-card__head">
<div class="active-model">
<span class="active-model__label">{{ t('dashboard.models.activeModel') }}</span>
<span v-if="activeModel" class="active-model__value">
<span class="active-model__dot"></span>
{{ activeProviderLabel }} · {{ activeModel.model }}
</span>
<span v-else class="active-model__value active-model__value--empty">
{{ t('dashboard.models.notSet') }}
</span>
</div>
<div class="models-card__actions">
<span v-if="modelProviders.length" class="models-count">
{{ readyProviderCount }}/{{ modelProviders.length }} {{ t('dashboard.models.configured') }}
</span>
<button class="models-manage" @click="goToModels">
{{ t('dashboard.models.manage') }}
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
<div v-if="modelProviders.length" class="provider-chips">
<button
v-for="p in modelProviders"
:key="p.id"
class="provider-chip"
:class="'provider-chip--' + providerChipStatus(p)"
:title="p.name"
@click="goToModels"
>
<span class="provider-chip__dot"></span>
<img
class="provider-chip__icon"
:src="getProviderIcon(p.id)"
:alt="p.name"
@error="onProviderIconError"
/>
<span class="provider-chip__name">{{ p.name }}</span>
</button>
</div>
<div v-else class="models-empty">
<span class="models-empty__text">{{ t('dashboard.models.empty') }}</span>
<button class="models-empty__btn" @click="goToModels">{{ t('dashboard.models.emptyCta') }}</button>
</div>
</div>
</div>
<div v-if="trendData.length" class="trend-section">
<div class="section-head">
<h2 class="section-title">{{ t('dashboard.trend.title', '7-Day Trend') }}</h2>
@ -133,10 +186,12 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
import { dashboardApi } from '@/api'
import { useRouter } from 'vue-router'
import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
import { dashboardApi, modelApi } from '@/api'
import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
@ -145,6 +200,7 @@ import { CanvasRenderer } from 'echarts/renderers'
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
const { t, locale } = useI18n()
const router = useRouter()
const overview = ref<Record<string, any>>({})
const recentRuns = ref<any[]>([])
@ -160,6 +216,42 @@ const todayStats = reactive({
errors: 0,
})
// Model configuration card
const modelProviders = ref<any[]>([])
const activeModel = ref<{ providerId: string; model: string } | null>(null)
const readyProviderCount = computed(
() => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length,
)
const activeProviderLabel = computed(() => {
if (!activeModel.value) return ''
const match = modelProviders.value.find((p) => p.id === activeModel.value!.providerId)
return match?.name || activeModel.value.providerId
})
// Maps a provider's liveness to a chip status, falling back to the legacy
// configured/available booleans for backends that don't report liveness.
function providerChipStatus(p: any): 'ready' | 'partial' | 'down' {
switch (p.liveness) {
case 'LIVE':
return 'ready'
case 'COOLDOWN':
case 'UNPROBED':
return 'partial'
case 'REMOVED':
case 'UNCONFIGURED':
return 'down'
}
if (p.available) return 'ready'
if (p.configured || (p.models?.length || 0) + (p.extraModels?.length || 0) > 0) return 'partial'
return 'down'
}
function goToModels() {
router.push('/settings/models')
}
onMounted(async () => {
try {
const [overviewRes, runsRes, trendRes] = await Promise.all([
@ -179,6 +271,19 @@ onMounted(async () => {
} catch {
// Dashboard data is non-critical
}
// Model configuration card loaded independently so a failure here never
// blanks the analytics above, and vice versa.
try {
const [provRes, activeRes] = await Promise.all([
modelApi.listProviders().catch(() => ({ data: [] })),
modelApi.getActive().catch(() => ({ data: null })),
])
modelProviders.value = (provRes as any).data || []
activeModel.value = (activeRes as any).data?.activeLlm || null
} catch {
// Non-critical
}
})
onUnmounted(() => {
@ -380,6 +485,75 @@ function calcDuration(run: any): string {
.stat-card--secondary .stat-icon { background: linear-gradient(135deg, rgba(148, 163, 184, 0.12), rgba(148, 163, 184, 0.06)); color: var(--mc-text-secondary); }
.stat-card--secondary .stat-value { font-size: 24px; }
/* Model Configuration card */
.models-section { margin-bottom: 22px; }
.models-card { padding: 18px; }
.models-card__head {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
}
.active-model { display: flex; align-items: center; gap: 10px; min-width: 0; }
.active-model__label {
font-size: 12px; color: var(--mc-text-tertiary);
text-transform: uppercase; letter-spacing: 0.08em;
}
.active-model__value {
display: flex; align-items: center; gap: 7px;
font-size: 14px; font-weight: 700; color: var(--mc-text-primary);
}
.active-model__value--empty { color: var(--mc-text-tertiary); font-weight: 600; }
.active-model__dot {
width: 7px; height: 7px; border-radius: 50%;
background: #10b981; flex-shrink: 0;
}
.models-card__actions { display: flex; align-items: center; gap: 14px; }
.models-count { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
.models-manage {
display: flex; align-items: center; gap: 3px;
font-size: 13px; font-weight: 600; color: var(--mc-primary);
background: none; border: none; cursor: pointer;
padding: 4px 6px; border-radius: 6px; transition: background 0.15s ease;
}
.models-manage:hover { background: rgba(217, 109, 70, 0.08); }
.provider-chips {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 16px; padding-top: 16px;
border-top: 1px solid var(--mc-border-light);
}
.provider-chip {
display: flex; align-items: center; gap: 6px;
padding: 5px 11px; border-radius: 999px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
font-size: 12px; font-weight: 600; color: var(--mc-text-secondary);
cursor: pointer; transition: border-color 0.15s ease, transform 0.15s ease;
}
.provider-chip:hover { border-color: var(--mc-primary); transform: translateY(-1px); }
.provider-chip__dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.provider-chip__icon {
width: 15px; height: 15px; border-radius: 4px;
object-fit: contain; flex-shrink: 0;
}
.provider-chip--ready .provider-chip__dot { background: #10b981; }
.provider-chip--partial .provider-chip__dot { background: #f59e0b; }
.provider-chip--down .provider-chip__dot { background: var(--mc-text-tertiary); }
.provider-chip--down { opacity: 0.7; }
.models-empty {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
margin-top: 16px; padding-top: 16px;
border-top: 1px solid var(--mc-border-light);
}
.models-empty__text { font-size: 13px; color: var(--mc-text-tertiary); }
.models-empty__btn {
padding: 6px 14px; border-radius: 8px;
background: var(--mc-primary); color: #fff;
border: none; font-size: 12px; font-weight: 600; cursor: pointer;
transition: opacity 0.15s ease;
}
.models-empty__btn:hover { opacity: 0.9; }
.trend-section { margin-bottom: 22px; }
.trend-chart { padding: 18px; }
.chart-container { width: 100%; height: 240px; }

View File

@ -30,6 +30,7 @@
:disabled="!selectedModel || applying"
@click="applyLocalModel"
>{{ applying ? t('common.loading') : t('onboarding.setDefault') }}</button>
<span v-if="applyError" class="test-failed">{{ applyError }}</span>
</template>
<!-- Cloud path: provider cards -->
@ -68,16 +69,18 @@
</div>
<button
class="btn-primary"
:disabled="!apiKey || saving"
:disabled="testResult !== 'success' || saving"
@click="saveCloudConfig"
>{{ saving ? t('common.loading') : t('onboarding.saveAndContinue') }}</button>
<p v-if="testResult !== 'success'" class="save-hint">{{ t('onboarding.testBeforeSave') }}</p>
<span v-if="saveError" class="test-failed">{{ saveError }}</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi } from '@/api/index'
@ -90,6 +93,7 @@ const loading = ref(false)
const models = ref<string[]>([])
const selectedModel = ref('')
const applying = ref(false)
const applyError = ref('')
// Cloud path state
const providers = [
@ -102,6 +106,14 @@ const apiKey = ref('')
const testing = ref(false)
const testResult = ref<'' | 'success' | 'failed'>('')
const saving = ref(false)
const saveError = ref('')
// A key or provider edit invalidates the previous connection test, so the
// user must re-test before the provider can be enabled.
watch([apiKey, selectedProvider], () => {
testResult.value = ''
saveError.value = ''
})
onMounted(async () => {
if (props.path === 'local') {
@ -124,12 +136,16 @@ onMounted(async () => {
async function applyLocalModel() {
if (!selectedModel.value) return
applying.value = true
applyError.value = ''
try {
await modelApi.applyDiscoveredModels('ollama', [selectedModel.value])
// Local providers ship disabled enable Ollama so it appears in the
// model dropdown and the chat runtime can route to it.
await modelApi.enableProvider('ollama')
await modelApi.setActive({ providerId: 'ollama', model: selectedModel.value })
emit('done')
} catch {
// Handle error silently, user can retry
} catch (err) {
applyError.value = err instanceof Error ? err.message : t('onboarding.saveFailed')
} finally {
applying.value = false
}
@ -139,6 +155,7 @@ async function testConnection() {
if (!selectedProvider.value || !apiKey.value) return
testing.value = true
testResult.value = ''
saveError.value = ''
try {
// Save key first so test can use it
await modelApi.updateProviderConfig(selectedProvider.value, { apiKey: apiKey.value })
@ -154,11 +171,23 @@ async function testConnection() {
async function saveCloudConfig() {
if (!selectedProvider.value || !apiKey.value) return
saving.value = true
saveError.value = ''
try {
await modelApi.updateProviderConfig(selectedProvider.value, { apiKey: apiKey.value })
const res: any = await modelApi.updateProviderConfig(selectedProvider.value, {
apiKey: apiKey.value,
})
// Cloud providers ship disabled enable this one so it surfaces in the
// model dropdown and the chat runtime can route to it.
await modelApi.enableProvider(selectedProvider.value)
// Promote one of its models to the active default so the verify step,
// and the rest of the app, has a usable model immediately.
const firstModel = (res?.data?.models || [])[0]
if (firstModel?.id) {
await modelApi.setActive({ providerId: selectedProvider.value, model: firstModel.id })
}
emit('done')
} catch {
// Handle error silently
} catch (err) {
saveError.value = err instanceof Error ? err.message : t('onboarding.saveFailed')
} finally {
saving.value = false
}
@ -309,6 +338,12 @@ async function saveCloudConfig() {
font-weight: 500;
}
.save-hint {
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 0;
}
.btn-primary {
padding: 10px 20px;
background: var(--mc-primary);

View File

@ -2,6 +2,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { modelApi } from '@/api'
import type { ActiveModelsInfo, ProviderInfo, ProviderModelInfo } from '@/types'
import { getProviderIcon, onProviderIconError as onIconError } from '@/utils/providerIcons'
/**
* RFC-074 PR-1: list / active-model / display helpers.
@ -70,56 +71,6 @@ export function useProviderList() {
return { type: 'unavailable', label: t('settings.model.unavailable') }
}
const providerIconMap: Record<string, string> = {
'dashscope': '/icons/providers/dashscope.png',
// dashscope-compat shares the same Aliyun DashScope brand — same logo.
'dashscope-compat': '/icons/providers/dashscope.png',
'modelscope': '/icons/providers/modelscope.svg',
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg',
'aliyun-codingplan-intl': '/icons/providers/aliyun-codingplan.svg',
// bailian-team is an Aliyun product line — reuse the aliyun mark.
'bailian-team': '/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',
// Coding Plan subscription endpoints — same brand, reuse mark.
'zhipu-cn-codingplan': '/icons/providers/zhipu.svg',
'zhipu-intl-codingplan': '/icons/providers/zhipu.svg',
'volcengine': '/icons/providers/volcengine.svg',
// volcengine-plan = "Volcano Engine Coding Plan" — same brand, reuse mark.
'volcengine-plan': '/icons/providers/volcengine.svg',
'xiaomi-mimo': '/icons/providers/xiaomimimo.svg',
'hunyuan-3d': '/icons/providers/hunyuan-color.svg',
'opencode': '/icons/providers/opencode.svg',
'siliconflow-cn': '/icons/providers/siliconcloud.svg',
'siliconflow-intl': '/icons/providers/siliconcloud.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,