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. --> pre-opened via ?addProvider=1. -->
<template v-if="query.trim() === '' && groups.length === 0"> <template v-if="query.trim() === '' && groups.length === 0">
{{ emptyHint || $t('chat.noProvidersConfigured') }} {{ 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') }} {{ $t('chat.goConfigure') }}
</RouterLink> </RouterLink>
</template> </template>
@ -106,12 +111,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue' import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { ProviderInfo } from '@/types' import type { ProviderInfo } from '@/types'
const { t } = useI18n()
interface ModelItem { interface ModelItem {
value: string value: string
name: string name: string
@ -136,6 +138,8 @@ const props = defineProps<{
showAllStates?: boolean showAllStates?: boolean
/** Optional override for the empty-state text. */ /** Optional override for the empty-state text. */
emptyHint?: string emptyHint?: string
/** Whether this user may open the system-level provider configuration UI. */
canConfigure?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -377,6 +377,7 @@ export default {
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)', modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
// RFC-074 PR-2: empty-state inside the model dropdown // RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: 'No models available yet', noProvidersConfigured: 'No models available yet',
noModelsAvailableContactAdmin: 'No models are available. Contact a global administrator to configure one.',
goConfigure: 'Configure', goConfigure: 'Configure',
// Issue #81: liveness-aware popup state machine. // Issue #81: liveness-aware popup state machine.
prompt: { prompt: {
@ -925,6 +926,8 @@ export default {
model: { model: {
title: 'Model Management', title: 'Model Management',
desc: 'Configure model providers, credentials, and model lists', 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', addProvider: 'Add Provider',
localProviders: 'Local Models', localProviders: 'Local Models',
cloudProviders: 'Cloud Models', cloudProviders: 'Cloud Models',

View File

@ -377,6 +377,7 @@ export default {
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)', modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
// RFC-074 PR-2: empty-state inside the model dropdown // RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: '还没有可用的模型', noProvidersConfigured: '还没有可用的模型',
noModelsAvailableContactAdmin: '当前没有可用模型,请联系全局管理员配置',
goConfigure: '去配置', goConfigure: '去配置',
// Issue #81: liveness-aware popup state machine. // Issue #81: liveness-aware popup state machine.
prompt: { prompt: {
@ -787,6 +788,8 @@ export default {
model: { model: {
title: '模型管理', title: '模型管理',
desc: '配置模型提供商、凭证和模型列表', desc: '配置模型提供商、凭证和模型列表',
permissionTitle: '需要全局管理员权限',
permissionDesc: '模型提供商配置包含系统级凭证。你仍可在聊天中切换管理员已启用的模型;如需新增或修改模型,请联系全局管理员。',
addProvider: '新增提供商', addProvider: '新增提供商',
localProviders: '本地模型', localProviders: '本地模型',
cloudProviders: '云端模型', 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" :active-label="activeModelLabel"
:saving="modelSaving" :saving="modelSaving"
:show-all-states="true" :show-all-states="true"
:can-configure="canConfigureModels"
:empty-hint="modelSelectorEmptyHint"
@select="selectModel" @select="selectModel"
@navigate-fix="onModelSelectorFix" @navigate-fix="onModelSelectorFix"
/> />
@ -289,7 +291,7 @@ import { useChat } from '@/composables/chat/useChat'
import RunOverviewPanel from '@/components/chat/RunOverviewPanel.vue' import RunOverviewPanel from '@/components/chat/RunOverviewPanel.vue'
import { reconstructErrorInfo } from '@/types/chatError' import { reconstructErrorInfo } from '@/types/chatError'
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile' 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' import MessageList from '@/components/chat/MessageList.vue'
@ -309,6 +311,7 @@ import { useKatexRenderer } from '@/composables/useKatexRenderer'
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer' import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
import { useGoalStore } from '@/stores/useGoalStore' import { useGoalStore } from '@/stores/useGoalStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { buildViewerModelProviders } from '@/utils/viewerModelProviders'
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue' import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue' import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
@ -392,14 +395,12 @@ const recoverablePrompt = ref(false)
const recoverableDismissed = ref(false) const recoverableDismissed = ref(false)
const defaultModel = ref<ModelConfig | null>(null) const defaultModel = ref<ModelConfig | null>(null)
const providers = ref<ProviderInfo[]>([]) const providers = ref<ProviderInfo[]>([])
// True when /models 403s for a viewer-level user. Provider config (API keys, // True when a viewer uses the credential-free provider projection. Provider
// base URLs, liveness) is admin-only, so viewers chat without it; the prompt // config (API keys, base URLs, liveness) is admin-only, so viewers can switch
// flags fall back to "trust the active model" in that branch. // models but prompt flags must still trust the active model's runtime state.
const providersUnavailable = ref(false) const providersUnavailable = ref(false)
// Mirror of /models/enabled (viewer-accessible). Used to resolve the display // Mirror of /models/enabled (viewer-accessible). It supplies the model half of
// name of the active model when providers is empty for viewer-level users // the safe picker projection and resolves the active label during hydration.
// otherwise the model selector trigger would show its fallback even
// though there IS an active model.
const enabledModels = ref<ModelConfig[]>([]) const enabledModels = ref<ModelConfig[]>([])
// The model the CURRENT conversation uses. Per-conversation switching it // The model the CURRENT conversation uses. Per-conversation switching it
// never leaks into other conversations (see selectModel / applyConversationModel). // never leaks into other conversations (see selectModel / applyConversationModel).
@ -831,7 +832,7 @@ const currentRuntimeModel = computed(() => {
const all = provider ? [...(provider.models || []), ...(provider.extraModels || [])] : [] const all = provider ? [...(provider.models || []), ...(provider.extraModels || [])] : []
const hit = all.find((m) => m.id === modelName || m.name === modelName) const hit = all.find((m) => m.id === modelName || m.name === modelName)
if (hit) return `${hit.name || hit.id} (${hit.id})` 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( const em = enabledModels.value.find(
(m) => m.provider === providerId && (m.modelName === modelName || m.name === modelName) (m) => m.provider === providerId && (m.modelName === modelName || m.name === modelName)
) )
@ -879,10 +880,9 @@ const activeModelLabel = computed(() => {
if (!activeModelValue.value) return '' if (!activeModelValue.value) return ''
const match = eligibleModels.value.find(m => m.value === activeModelValue.value) const match = eligibleModels.value.find(m => m.value === activeModelValue.value)
if (match?.label) return match.label if (match?.label) return match.label
// Viewer-level users have an empty providers list (admin-only endpoint), so // Fall back to the viewer-readable /models/enabled list while the safe
// eligibleModels is empty even when there IS an active model. Fall back to // provider projection is still hydrating, so the trigger never flashes the
// the viewer-readable /models/enabled list to resolve a display name // "" placeholder for an already active model.
// otherwise the trigger button would read "" forever.
const providerId = activeModels.value?.activeLlm?.providerId const providerId = activeModels.value?.activeLlm?.providerId
const modelName = activeModels.value?.activeLlm?.model const modelName = activeModels.value?.activeLlm?.model
if (!providerId || !modelName) return '' if (!providerId || !modelName) return ''
@ -1298,8 +1298,12 @@ watch([selectedAgentId, currentConversationId], () => {
// avatar ring listens on goalStore.activeGoalByConv[cid]; without this // avatar ring listens on goalStore.activeGoalByConv[cid]; without this
// fetch the ring would only appear after an SSE event mutated the store. // fetch the ring would only appear after an SSE event mutated the store.
const goalStore = useGoalStore() const goalStore = useGoalStore()
const workspaceStoreForGoal = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStoreForGoal.currentWorkspaceId ?? '1') const currentWorkspaceId = computed(() => workspaceStore.currentWorkspaceId ?? '1')
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)
const modelSelectorEmptyHint = computed(() => canConfigureModels.value
? undefined
: t('chat.noModelsAvailableContactAdmin'))
watch(currentConversationId, async (cid) => { watch(currentConversationId, async (cid) => {
// Skip un-persisted conversations: a brand-new empty chat has no goal yet // 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 // 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() { async function loadModelState() {
// /default + /active + /enabled are viewer-accessible and required to chat. // /default + /active + /enabled are viewer-accessible and required to chat.
// /models (provider list) is admin-only because it returns API keys + base // /models (provider list) is admin-only because it returns API keys + base
// URLs; viewers degrade to "trust the active model, skip the liveness // URLs. Viewers join /models/options with /models/enabled for the selector,
// banner" and resolve the label via /enabled instead. // but still skip the liveness banner because neither safe response exposes
// runtime diagnostics.
try { 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.getDefault(),
modelApi.getActive(), modelApi.getActive(),
modelApi.listEnabled(), modelApi.listEnabled(),
providerOptionsRequest,
]) ])
defaultModel.value = defaultRes.data || null defaultModel.value = defaultRes.data || null
const ga = activeRes.data?.activeLlm const ga = activeRes.data?.activeLlm
@ -1495,6 +1504,17 @@ async function loadModelState() {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } } activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
} }
enabledModels.value = enabledRes.data || [] 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) { } catch (e) {
mcToast.error(t('chat.loadModelFailed')) mcToast.error(t('chat.loadModelFailed'))
blockingPrompt.value = true blockingPrompt.value = true

View File

@ -5,7 +5,7 @@
<h2 class="section-title">{{ t('settings.model.title') }}</h2> <h2 class="section-title">{{ t('settings.model.title') }}</h2>
<p class="section-desc">{{ t('settings.model.desc') }}</p> <p class="section-desc">{{ t('settings.model.desc') }}</p>
</div> </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. --> <!-- RFC-074 PR-2: primary entry to enable a built-in provider. -->
<button class="btn-primary" @click="openDrawer"> <button class="btn-primary" @click="openDrawer">
{{ t('settings.model.enableProviderCta') }} {{ t('settings.model.enableProviderCta') }}
@ -17,6 +17,13 @@
</div> </div>
</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 <!-- RFC-074 PR-1: skeleton placeholder so the page paints something
immediately on first load instead of blank-then-pop. --> immediately on first load instead of blank-then-pop. -->
<div v-if="loading" class="provider-group"> <div v-if="loading" class="provider-group">
@ -186,6 +193,7 @@
:expires-at="deviceCodeDialog.expiresAt" :expires-at="deviceCodeDialog.expiresAt"
@close="closeDeviceCodeDialog" @close="closeDeviceCodeDialog"
/> />
</template>
</div> </div>
</template> </template>
@ -195,6 +203,7 @@ import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
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'
@ -210,6 +219,8 @@ const AddProviderDrawer = defineAsyncComponent(() => import('./AddProviderDrawer
const DeviceCodeDialog = defineAsyncComponent(() => import('./modals/DeviceCodeDialog.vue')) const DeviceCodeDialog = defineAsyncComponent(() => import('./modals/DeviceCodeDialog.vue'))
const { t } = useI18n() const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)
const savedTip = ref('') const savedTip = ref('')
// Skeleton gate. Driven by onMounted only fine because /settings/models is // Skeleton gate. Driven by onMounted only fine because /settings/models is
// NOT a keepAlive route. If anyone re-adds keepAlive in router/index.ts, // 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' const AUTO_OPEN_KEY = 'rfc074-add-provider-auto-opened'
onMounted(async () => { onMounted(async () => {
if (!canConfigureModels.value) {
loading.value = false
return
}
try { try {
await Promise.all([loadProviders(), loadActiveModel()]) await Promise.all([loadProviders(), loadActiveModel()])
} finally { } 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 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 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); } .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); }