mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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. -->
|
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<{
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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: '云端模型',
|
||||||
|
|||||||
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"
|
: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
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user