mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
Adds explicit user-intent gating to the provider catalog. Fresh installs
get an empty dropdown by default — built-in cloud + local providers
(OpenAI, Anthropic, Ollama, LM Studio, MLX, llama.cpp, etc.) live in a
new 'Add Provider' drawer until the user opts them in. Existing installs
upgrade conservatively: V55 promotes any provider with evidence of use
(real api_key, OAuth token, recent chat usage, or current default model).
Backend
- V55 migration (H2 + MySQL): adds enabled BOOLEAN DEFAULT FALSE on
mate_model_provider, plus 4 promote-to-true UPDATE rules. Also
CREATE INDEX idx_message_runtime_provider_time so the 30-day usage
lookup doesn't full-scan mate_message on heavy users.
- ModelProviderEntity, ProviderInfoDTO: enabled field.
- ModelProviderService:
* listProviders() now filters WHERE enabled = TRUE — chat path,
ModelSelector, Settings/Models main grid see only opted-in rows.
* listCatalog() new — full catalog (enabled + disabled) for the drawer.
* setEnabled(id, enabled) flips the flag, publishes
ModelConfigChangedEvent (re-probe via the existing listener), and
on disable auto-promotes a replacement default model when the
disabled provider owned the current default. Returns EnableResult
so the frontend can fire a toast.
* createCustomProvider sets enabled=true (user just made the row).
- ProviderInitProbe.listConfiguredProviders also filters enabled=true —
no point probing rows the user can't see.
- ModelConfigController: GET /catalog, POST /{id}/enable, POST /{id}/disable.
- Plugin-registered ChatModels are unaffected — they live in
pluginChatModels (in-memory map), don't go through DB listProviders,
so the enabled filter doesn't strand them.
Frontend
- New types: ProviderInfo.enabled, EnableResult.
- New API: catalog / enableProvider / disableProvider.
- New composable useProviderEnablement: catalog ref, drawerOpen,
togglingId, loadCatalog, openDrawer / closeDrawer, enableProvider,
disableProvider (fires defaultSwitchedToast on auto-switch).
- AddProviderDrawer.vue: lazy-loaded, reuses DoctorDrawer's Teleport +
overlay + slide-in panel pattern. Two groups (cloud / local),
unenabled rows surface to the top of each group, enabled rows show
an 'Enabled' badge instead of a button. Mobile: full-screen sheet
that slides up from below.
- ProviderCard: new 'Disable' button with soft-danger styling on
enabled providers — soft-hide that keeps the config; user can
re-enable from the drawer.
- Settings/Models index.vue:
* Two top CTAs: 'Enable Provider' (drawer) and 'Custom' (existing
custom-create modal) — distinct workflows, both surfaced.
* Empty state with prominent 'Enable Provider' CTA when zero
enabled providers — paired with onMounted auto-open of the
drawer (sessionStorage guard so closing it doesn't bring it
back on the next route visit in the same session).
* Deep-link: ?addProvider=1 query forces the drawer open and
strips itself after, so a back/forward doesn't re-fire the open.
- ModelSelector: when groups.length === 0 and not searching, show
'No providers configured -> Configure' CTA linking to
/settings/models?addProvider=1 — the natural flow when a fresh
user opens chat before configuring anything.
- i18n: 13 new keys per locale (zh-CN + en-US) plus common.close.
Migration safety
- Conservative default policy: only rows with concrete evidence of
use are auto-enabled; everything else stays hidden. Upgrade users
may notice unused built-ins disappearing from their dropdown —
that's the intended cleanup.
- mate_message index added so the 30-day usage rule doesn't full-scan
on large installations; FlywayRepairConfig handles redeploy idempotency.
Tests
- ModelProviderServiceEnableTest covers all 7 enable/disable branches:
flag flip + event publish, no-op on already-{enabled,disabled},
default-switch when disabled provider owned current default,
no-switch when default belongs elsewhere, no-replacement returns
unchanged, getDefaultModel exception path, candidates with no
models are skipped.
- ProviderInitProbeTest: helper provider() now sets enabled=true so
the new probe filter doesn't strand existing fixtures.
- vip.mate.llm.** suite: 125 tests green. vue-tsc 0 errors. Browser
page renders with both new buttons + drawer.
483 lines
12 KiB
Vue
483 lines
12 KiB
Vue
<template>
|
||
<div class="model-selector-wrap" ref="triggerRef">
|
||
<button
|
||
class="model-select-trigger"
|
||
:disabled="saving"
|
||
@click="toggle"
|
||
>
|
||
<span class="model-select-trigger__name">{{ activeLabel || $t('chat.configModel') }}</span>
|
||
<svg class="model-select-trigger__arrow" :class="{ open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||
</button>
|
||
|
||
<Teleport to="body">
|
||
<Transition name="fade">
|
||
<div v-if="open" class="model-dropdown-backdrop" @click="open = false"></div>
|
||
</Transition>
|
||
|
||
<Transition name="model-dropdown">
|
||
<div v-if="open" ref="dropdownRef" class="model-dropdown" :style="dropdownStyle">
|
||
<!-- 搜索 -->
|
||
<div v-if="totalCount > 5" class="model-search">
|
||
<svg class="model-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input
|
||
ref="searchRef"
|
||
v-model="query"
|
||
class="model-search__input"
|
||
:placeholder="$t('chat.searchModel')"
|
||
@keydown.esc.stop="open = false"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 分组列表 -->
|
||
<div class="model-groups" ref="listRef">
|
||
<template v-for="group in filteredGroups" :key="group.provider.id">
|
||
<div class="model-group-header">
|
||
<span class="model-group-header__name">{{ group.provider.name }}</span>
|
||
<span v-if="group.provider.isLocal" class="model-group-header__badge model-group-header__badge--local">Local</span>
|
||
<!-- RFC-073: liveness dot. UNPROBED = grey (still booting),
|
||
COOLDOWN = amber (transient backoff). LIVE has no dot. -->
|
||
<span
|
||
v-if="group.provider.liveness === 'UNPROBED'"
|
||
class="model-group-header__dot model-group-header__dot--unprobed"
|
||
:title="$t('chat.modelLivenessUnprobed')"
|
||
></span>
|
||
<span
|
||
v-else-if="group.provider.liveness === 'COOLDOWN'"
|
||
class="model-group-header__dot model-group-header__dot--cooldown"
|
||
:title="$t('chat.modelLivenessCooldown', { seconds: cooldownSeconds(group.provider) })"
|
||
></span>
|
||
</div>
|
||
<div
|
||
v-for="item in group.models"
|
||
:key="item.value"
|
||
class="model-dropdown-item"
|
||
:class="{ active: item.value === activeValue, dimmed: group.provider.liveness === 'COOLDOWN' || group.provider.liveness === 'UNPROBED' }"
|
||
@click="handleSelect(item.value)"
|
||
>
|
||
<span class="model-dropdown-item__name">{{ item.name }}</span>
|
||
<svg v-if="item.value === activeValue" class="model-dropdown-item__check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 无结果 -->
|
||
<div v-if="filteredGroups.length === 0" class="model-empty">
|
||
<!-- RFC-074 PR-2: when there are zero usable models AND the user
|
||
isn't searching, this is the "no providers configured" empty
|
||
state. Push them into Settings/Models with the drawer
|
||
pre-opened via ?addProvider=1. -->
|
||
<template v-if="query.trim() === '' && groups.length === 0">
|
||
{{ $t('chat.noProvidersConfigured') }}
|
||
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
|
||
{{ $t('chat.goConfigure') }}
|
||
</RouterLink>
|
||
</template>
|
||
<template v-else>
|
||
{{ $t('chat.noMatchModel') }}
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { RouterLink } from 'vue-router'
|
||
import type { ProviderInfo } from '@/types'
|
||
|
||
const { t } = useI18n()
|
||
|
||
interface ModelItem {
|
||
value: string
|
||
name: string
|
||
id: string
|
||
}
|
||
|
||
interface ModelGroup {
|
||
provider: ProviderInfo
|
||
models: ModelItem[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
providers: ProviderInfo[]
|
||
activeValue: string
|
||
activeLabel: string
|
||
saving?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
select: [value: string]
|
||
}>()
|
||
|
||
const open = ref(false)
|
||
const query = ref('')
|
||
const searchRef = ref<HTMLInputElement>()
|
||
const triggerRef = ref<HTMLElement>()
|
||
const dropdownRef = ref<HTMLElement>()
|
||
const listRef = ref<HTMLElement>()
|
||
|
||
// 下拉框定位(基于 trigger 按钮的位置)
|
||
const dropdownStyle = ref<CSSProperties>({})
|
||
|
||
function updatePosition() {
|
||
const el = triggerRef.value
|
||
if (!el) return
|
||
const rect = el.getBoundingClientRect()
|
||
dropdownStyle.value = {
|
||
position: 'fixed',
|
||
top: `${rect.bottom + 6}px`,
|
||
right: `${window.innerWidth - rect.right}px`,
|
||
}
|
||
}
|
||
|
||
function toggle() {
|
||
if (open.value) {
|
||
open.value = false
|
||
} else {
|
||
updatePosition()
|
||
open.value = true
|
||
}
|
||
}
|
||
|
||
// 按 provider 分组,云端在前,本地在后
|
||
// RFC-073: 仅过滤 UNCONFIGURED / REMOVED;UNPROBED + COOLDOWN 仍显示但视觉上区分。
|
||
function isHidden(p: ProviderInfo): boolean {
|
||
// 旧后端不返回 liveness 时退回 available 行为,避免渐进升级期间 UI 全空。
|
||
if (!p.liveness) return !p.available
|
||
return p.liveness === 'UNCONFIGURED' || p.liveness === 'REMOVED'
|
||
}
|
||
|
||
const groups = computed<ModelGroup[]>(() => {
|
||
const cloud: ModelGroup[] = []
|
||
const local: ModelGroup[] = []
|
||
|
||
for (const provider of props.providers) {
|
||
if (isHidden(provider)) continue
|
||
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
||
if (allModels.length === 0) continue
|
||
|
||
const group: ModelGroup = {
|
||
provider,
|
||
models: allModels.map(m => ({
|
||
value: `${provider.id}::${m.id}`,
|
||
name: m.name || m.id,
|
||
id: m.id,
|
||
})),
|
||
}
|
||
|
||
if (provider.isLocal) {
|
||
local.push(group)
|
||
} else {
|
||
cloud.push(group)
|
||
}
|
||
}
|
||
|
||
return [...cloud, ...local]
|
||
})
|
||
|
||
function cooldownSeconds(provider: ProviderInfo): number {
|
||
return Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
|
||
}
|
||
|
||
const totalCount = computed(() =>
|
||
groups.value.reduce((n, g) => n + g.models.length, 0)
|
||
)
|
||
|
||
// 搜索过滤
|
||
const filteredGroups = computed<ModelGroup[]>(() => {
|
||
const q = query.value.trim().toLowerCase()
|
||
if (!q) return groups.value
|
||
|
||
const result: ModelGroup[] = []
|
||
for (const group of groups.value) {
|
||
if (group.provider.name.toLowerCase().includes(q)) {
|
||
result.push(group)
|
||
continue
|
||
}
|
||
const matched = group.models.filter(
|
||
m => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
||
)
|
||
if (matched.length > 0) {
|
||
result.push({ ...group, models: matched })
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
function handleSelect(value: string) {
|
||
open.value = false
|
||
query.value = ''
|
||
emit('select', value)
|
||
}
|
||
|
||
// 打开时聚焦搜索框 + 滚动到当前选中项
|
||
watch(open, async (isOpen) => {
|
||
if (isOpen) {
|
||
query.value = ''
|
||
await nextTick()
|
||
searchRef.value?.focus()
|
||
await nextTick()
|
||
const activeEl = listRef.value?.querySelector('.model-dropdown-item.active')
|
||
if (activeEl) {
|
||
activeEl.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.model-selector-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.model-select-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 34px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 12px;
|
||
background: var(--mc-panel-raised);
|
||
color: var(--mc-text-primary);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
max-width: 280px;
|
||
}
|
||
|
||
.model-select-trigger:hover {
|
||
border-color: var(--mc-primary);
|
||
}
|
||
|
||
.model-select-trigger:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.model-select-trigger__name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.model-select-trigger__arrow {
|
||
flex-shrink: 0;
|
||
color: var(--mc-text-tertiary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.model-select-trigger__arrow.open {
|
||
transform: rotate(180deg);
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* Teleport 到 body 的元素不能用 scoped */
|
||
|
||
.model-dropdown-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 4000;
|
||
}
|
||
|
||
.model-dropdown {
|
||
z-index: 4001;
|
||
min-width: 280px;
|
||
max-width: 360px;
|
||
background: var(--mc-bg-elevated);
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 14px;
|
||
padding: 6px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||
max-height: 420px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* ---- Search ---- */
|
||
|
||
.model-search {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
margin-bottom: 4px;
|
||
border-bottom: 1px solid var(--mc-border);
|
||
}
|
||
|
||
.model-search__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-text-tertiary);
|
||
}
|
||
|
||
.model-search__input {
|
||
flex: 1;
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
color: var(--mc-text-primary);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.model-search__input::placeholder {
|
||
color: var(--mc-text-quaternary);
|
||
}
|
||
|
||
/* ---- Groups ---- */
|
||
|
||
.model-groups {
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.model-group-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 10px 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--mc-text-tertiary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
user-select: none;
|
||
}
|
||
|
||
.model-group-header__badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 16px;
|
||
padding: 0 5px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.model-group-header__badge--local {
|
||
background: var(--mc-success-bg, rgba(52, 199, 89, 0.12));
|
||
color: var(--mc-success, #34c759);
|
||
}
|
||
|
||
/* RFC-073 liveness dot — sits next to the provider name */
|
||
.model-group-header__dot {
|
||
display: inline-block;
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
cursor: help;
|
||
}
|
||
.model-group-header__dot--unprobed {
|
||
background: var(--mc-text-quaternary, #c0c4cc);
|
||
animation: model-dot-pulse 1.6s ease-in-out infinite;
|
||
}
|
||
.model-group-header__dot--cooldown {
|
||
background: #f59e0b;
|
||
}
|
||
@keyframes model-dot-pulse {
|
||
0%, 100% { opacity: 0.4; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
/* ---- Items ---- */
|
||
|
||
.model-dropdown-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.model-dropdown-item:hover {
|
||
background: var(--mc-bg-sunken);
|
||
}
|
||
|
||
.model-dropdown-item.active {
|
||
background: var(--mc-primary-bg);
|
||
}
|
||
|
||
/* RFC-073: cooldown / unprobed models render dimmed but still selectable. */
|
||
.model-dropdown-item.dimmed {
|
||
opacity: 0.55;
|
||
}
|
||
.model-dropdown-item.dimmed:hover {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.model-dropdown-item__name {
|
||
font-size: 13px;
|
||
color: var(--mc-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.model-dropdown-item__check {
|
||
flex-shrink: 0;
|
||
color: var(--mc-primary);
|
||
}
|
||
|
||
/* ---- Empty ---- */
|
||
|
||
.model-empty {
|
||
padding: 20px 10px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--mc-text-quaternary);
|
||
}
|
||
.model-empty__cta {
|
||
display: inline-block;
|
||
margin-top: 8px;
|
||
padding: 6px 12px;
|
||
border-radius: 8px;
|
||
background: var(--mc-primary);
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
}
|
||
.model-empty__cta:hover { background: var(--mc-primary-hover, var(--mc-primary)); }
|
||
|
||
/* ---- Transition ---- */
|
||
|
||
.model-dropdown-enter-active {
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
}
|
||
.model-dropdown-leave-active {
|
||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||
}
|
||
.model-dropdown-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(-6px);
|
||
}
|
||
.model-dropdown-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
/* ---- Dark mode ---- */
|
||
|
||
.dark .model-dropdown {
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
/* ---- Mobile ---- */
|
||
|
||
@media (max-width: 768px) {
|
||
.model-dropdown {
|
||
min-width: 240px;
|
||
max-width: calc(100vw - 40px);
|
||
}
|
||
}
|
||
</style>
|