mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(models): restore workspace model selection (#592)
This commit is contained in:
parent
d5eb3507b6
commit
314b9ff82e
@ -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<{
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '云端模型',
|
||||
|
||||
56
mateclaw-ui/src/utils/__tests__/viewerModelProviders.test.ts
Normal file
56
mateclaw-ui/src/utils/__tests__/viewerModelProviders.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
48
mateclaw-ui/src/utils/viewerModelProviders.ts
Normal file
48
mateclaw-ui/src/utils/viewerModelProviders.ts
Normal 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]
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user