fix(models): restore workspace model selection (#592)

This commit is contained in:
mateaix 2026-08-10 20:52:37 +08:00
parent d5eb3507b6
commit 314b9ff82e
7 changed files with 173 additions and 23 deletions

View File

@ -89,7 +89,12 @@
pre-opened via ?addProvider=1. -->
<template v-if="query.trim() === '' && groups.length === 0">
{{ emptyHint || $t('chat.noProvidersConfigured') }}
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
<RouterLink
v-if="canConfigure !== false"
class="model-empty__cta"
to="/settings/models?addProvider=1"
@click="open = false"
>
{{ $t('chat.goConfigure') }}
</RouterLink>
</template>
@ -106,12 +111,9 @@
<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
@ -136,6 +138,8 @@ const props = defineProps<{
showAllStates?: boolean
/** Optional override for the empty-state text. */
emptyHint?: string
/** Whether this user may open the system-level provider configuration UI. */
canConfigure?: boolean
}>()
const emit = defineEmits<{

View File

@ -377,6 +377,7 @@ export default {
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: 'No models available yet',
noModelsAvailableContactAdmin: 'No models are available. Contact a global administrator to configure one.',
goConfigure: 'Configure',
// Issue #81: liveness-aware popup state machine.
prompt: {
@ -925,6 +926,8 @@ export default {
model: {
title: 'Model Management',
desc: 'Configure model providers, credentials, and model lists',
permissionTitle: 'Global administrator access required',
permissionDesc: 'Provider configuration contains system-level credentials. You can still switch among enabled models in Chat; contact a global administrator to add or change models.',
addProvider: 'Add Provider',
localProviders: 'Local Models',
cloudProviders: 'Cloud Models',

View File

@ -377,6 +377,7 @@ export default {
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: '还没有可用的模型',
noModelsAvailableContactAdmin: '当前没有可用模型,请联系全局管理员配置',
goConfigure: '去配置',
// Issue #81: liveness-aware popup state machine.
prompt: {
@ -787,6 +788,8 @@ export default {
model: {
title: '模型管理',
desc: '配置模型提供商、凭证和模型列表',
permissionTitle: '需要全局管理员权限',
permissionDesc: '模型提供商配置包含系统级凭证。你仍可在聊天中切换管理员已启用的模型;如需新增或修改模型,请联系全局管理员。',
addProvider: '新增提供商',
localProviders: '本地模型',
cloudProviders: '云端模型',

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { ModelConfig } from '@/types'
import { buildViewerModelProviders } from '@/utils/viewerModelProviders'
function model(overrides: Partial<ModelConfig> = {}): ModelConfig {
return {
id: '1',
name: 'GPT Test',
provider: 'openai',
modelName: 'gpt-test',
enabled: true,
isDefault: false,
...overrides,
}
}
describe('buildViewerModelProviders', () => {
it('joins safe provider options with enabled models for the chat picker', () => {
const providers = buildViewerModelProviders(
[{ id: 'openai', name: 'OpenAI' }],
[model()],
)
expect(providers).toHaveLength(1)
expect(providers[0]).toMatchObject({
id: 'openai',
name: 'OpenAI',
available: true,
configured: true,
models: [{ id: 'gpt-test', name: 'GPT Test' }],
})
})
it('drops disabled models and providers with no selectable model', () => {
const providers = buildViewerModelProviders(
[
{ id: 'openai', name: 'OpenAI' },
{ id: 'empty', name: 'Empty Provider' },
],
[model({ enabled: false })],
)
expect(providers).toEqual([])
})
it('does not copy connection settings into the provider projection', () => {
const [provider] = buildViewerModelProviders(
[{ id: 'openai', name: 'OpenAI' }],
[model()],
)
expect(provider).not.toHaveProperty('apiKey')
expect(provider).not.toHaveProperty('baseUrl')
expect(provider).not.toHaveProperty('generateKwargs')
})
})

View File

@ -0,0 +1,48 @@
import type { ModelConfig, ProviderInfo, ProviderModelInfo } from '@/types'
export interface ProviderOption {
id: string
name: string
}
/**
* Build the credential-free provider shape consumed by the chat model picker.
*
* Viewer-level users cannot read GET /models because that response includes
* connection settings. They can, however, read the provider option projection
* and the enabled model list. Joining those responses gives the picker all it
* needs without exposing credentials or inventing liveness diagnostics.
*/
export function buildViewerModelProviders(
options: ProviderOption[],
enabledModels: ModelConfig[],
): ProviderInfo[] {
const modelsByProvider = new Map<string, ProviderModelInfo[]>()
for (const model of enabledModels) {
if (!model.enabled || !model.provider || !model.modelName) continue
const models = modelsByProvider.get(model.provider) || []
models.push({ id: model.modelName, name: model.name || model.modelName })
modelsByProvider.set(model.provider, models)
}
return options.flatMap((option) => {
const models = modelsByProvider.get(option.id) || []
if (models.length === 0) return []
return [{
id: option.id,
name: option.name || option.id,
models,
extraModels: [],
isCustom: false,
isLocal: false,
supportModelDiscovery: false,
supportConnectionCheck: false,
freezeUrl: true,
requireApiKey: false,
configured: true,
available: true,
enabled: true,
} satisfies ProviderInfo]
})
}

View File

@ -78,6 +78,8 @@
:active-label="activeModelLabel"
:saving="modelSaving"
:show-all-states="true"
:can-configure="canConfigureModels"
:empty-hint="modelSelectorEmptyHint"
@select="selectModel"
@navigate-fix="onModelSelectorFix"
/>
@ -289,7 +291,7 @@ import { useChat } from '@/composables/chat/useChat'
import RunOverviewPanel from '@/components/chat/RunOverviewPanel.vue'
import { reconstructErrorInfo } from '@/types/chatError'
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo, ChatAttachment, MessageContentPart, Message, ToolCallMeta, StreamPhase } from '@/types'
import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo, ChatAttachment, MessageContentPart, Message, ToolCallMeta } from '@/types'
//
import MessageList from '@/components/chat/MessageList.vue'
@ -309,6 +311,7 @@ import { useKatexRenderer } from '@/composables/useKatexRenderer'
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
import { useGoalStore } from '@/stores/useGoalStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { buildViewerModelProviders } from '@/utils/viewerModelProviders'
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
@ -392,14 +395,12 @@ const recoverablePrompt = ref(false)
const recoverableDismissed = ref(false)
const defaultModel = ref<ModelConfig | null>(null)
const providers = ref<ProviderInfo[]>([])
// True when /models 403s for a viewer-level user. Provider config (API keys,
// base URLs, liveness) is admin-only, so viewers chat without it; the prompt
// flags fall back to "trust the active model" in that branch.
// True when a viewer uses the credential-free provider projection. Provider
// config (API keys, base URLs, liveness) is admin-only, so viewers can switch
// models but prompt flags must still trust the active model's runtime state.
const providersUnavailable = ref(false)
// Mirror of /models/enabled (viewer-accessible). Used to resolve the display
// name of the active model when providers is empty for viewer-level users
// otherwise the model selector trigger would show its fallback even
// though there IS an active model.
// Mirror of /models/enabled (viewer-accessible). It supplies the model half of
// the safe picker projection and resolves the active label during hydration.
const enabledModels = ref<ModelConfig[]>([])
// The model the CURRENT conversation uses. Per-conversation switching it
// never leaks into other conversations (see selectModel / applyConversationModel).
@ -831,7 +832,7 @@ const currentRuntimeModel = computed(() => {
const all = provider ? [...(provider.models || []), ...(provider.extraModels || [])] : []
const hit = all.find((m) => m.id === modelName || m.name === modelName)
if (hit) return `${hit.name || hit.id} (${hit.id})`
// Viewer-level users get an empty providers list resolve via /models/enabled.
// During initial hydration the safe provider projection may not be ready yet.
const em = enabledModels.value.find(
(m) => m.provider === providerId && (m.modelName === modelName || m.name === modelName)
)
@ -879,10 +880,9 @@ const activeModelLabel = computed(() => {
if (!activeModelValue.value) return ''
const match = eligibleModels.value.find(m => m.value === activeModelValue.value)
if (match?.label) return match.label
// Viewer-level users have an empty providers list (admin-only endpoint), so
// eligibleModels is empty even when there IS an active model. Fall back to
// the viewer-readable /models/enabled list to resolve a display name
// otherwise the trigger button would read "" forever.
// Fall back to the viewer-readable /models/enabled list while the safe
// provider projection is still hydrating, so the trigger never flashes the
// "" placeholder for an already active model.
const providerId = activeModels.value?.activeLlm?.providerId
const modelName = activeModels.value?.activeLlm?.model
if (!providerId || !modelName) return ''
@ -1298,8 +1298,12 @@ watch([selectedAgentId, currentConversationId], () => {
// avatar ring listens on goalStore.activeGoalByConv[cid]; without this
// fetch the ring would only appear after an SSE event mutated the store.
const goalStore = useGoalStore()
const workspaceStoreForGoal = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStoreForGoal.currentWorkspaceId ?? '1')
const workspaceStore = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStore.currentWorkspaceId ?? '1')
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)
const modelSelectorEmptyHint = computed(() => canConfigureModels.value
? undefined
: t('chat.noModelsAvailableContactAdmin'))
watch(currentConversationId, async (cid) => {
// Skip un-persisted conversations: a brand-new empty chat has no goal yet
// and the lookup would only 403 (Not the owner). The ring is hydrated by the
@ -1475,13 +1479,18 @@ async function loadAgents() {
async function loadModelState() {
// /default + /active + /enabled are viewer-accessible and required to chat.
// /models (provider list) is admin-only because it returns API keys + base
// URLs; viewers degrade to "trust the active model, skip the liveness
// banner" and resolve the label via /enabled instead.
// URLs. Viewers join /models/options with /models/enabled for the selector,
// but still skip the liveness banner because neither safe response exposes
// runtime diagnostics.
try {
const [defaultRes, activeRes, enabledRes]: any = await Promise.all([
const providerOptionsRequest = workspaceStore.isGlobalAdmin
? Promise.resolve({ data: [] })
: modelApi.listProviderOptions()
const [defaultRes, activeRes, enabledRes, providerOptionsRes]: any = await Promise.all([
modelApi.getDefault(),
modelApi.getActive(),
modelApi.listEnabled(),
providerOptionsRequest,
])
defaultModel.value = defaultRes.data || null
const ga = activeRes.data?.activeLlm
@ -1495,6 +1504,17 @@ async function loadModelState() {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
}
enabledModels.value = enabledRes.data || []
if (!workspaceStore.isGlobalAdmin) {
providers.value = buildViewerModelProviders(
providerOptionsRes.data || [],
enabledModels.value,
)
// The safe projection intentionally has no liveness diagnostics. Trust
// the active model and let the runtime report a real call failure.
providersUnavailable.value = true
recomputePromptFlags()
return
}
} catch (e) {
mcToast.error(t('chat.loadModelFailed'))
blockingPrompt.value = true

View File

@ -5,7 +5,7 @@
<h2 class="section-title">{{ t('settings.model.title') }}</h2>
<p class="section-desc">{{ t('settings.model.desc') }}</p>
</div>
<div class="section-header__actions">
<div v-if="canConfigureModels" class="section-header__actions">
<!-- RFC-074 PR-2: primary entry to enable a built-in provider. -->
<button class="btn-primary" @click="openDrawer">
{{ t('settings.model.enableProviderCta') }}
@ -17,6 +17,13 @@
</div>
</div>
<div v-if="!canConfigureModels" class="provider-empty provider-empty--permission">
<h3>{{ t('settings.model.permissionTitle') }}</h3>
<p>{{ t('settings.model.permissionDesc') }}</p>
</div>
<template v-else>
<!-- 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">
@ -186,6 +193,7 @@
:expires-at="deviceCodeDialog.expiresAt"
@close="closeDeviceCodeDialog"
/>
</template>
</div>
</template>
@ -195,6 +203,7 @@ import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders'
import ProviderCard from './ProviderCard.vue'
@ -210,6 +219,8 @@ const AddProviderDrawer = defineAsyncComponent(() => import('./AddProviderDrawer
const DeviceCodeDialog = defineAsyncComponent(() => import('./modals/DeviceCodeDialog.vue'))
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)
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,
@ -296,6 +307,10 @@ const router = useRouter()
const AUTO_OPEN_KEY = 'rfc074-add-provider-auto-opened'
onMounted(async () => {
if (!canConfigureModels.value) {
loading.value = false
return
}
try {
await Promise.all([loadProviders(), loadActiveModel()])
} finally {
@ -489,6 +504,7 @@ function showSavedTip(message: string) {
}
.provider-empty h3 { margin: 0 0 8px; font-size: 16px; color: var(--mc-text-primary); }
.provider-empty p { margin: 0 0 18px; font-size: 13px; color: var(--mc-text-tertiary); }
.provider-empty--permission p { margin-bottom: 0; }
.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); }