From 26df2c94d01589a7940507ae5dfec42564e1496a Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 15 May 2026 10:19:03 +0800 Subject: [PATCH] fix(rbac): let viewers read active model so they can actually chat --- .../llm/controller/ModelConfigController.java | 13 +++++-- mateclaw-ui/src/views/ChatConsole.vue | 37 +++++++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index b79f1a3e..0cdd0886 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -64,23 +64,28 @@ public class ModelConfigController { return R.ok(modelProviderService.setEnabled(providerId, false)); } + // Viewers need to know which models are available + which one is active so + // the chat runtime can render correctly. None of these endpoints return + // API keys or base URLs — those live on ProviderInfoDTO (GET /models) + // which stays admin-only. + @Operation(summary = "获取启用模型列表") @GetMapping("/enabled") - @RequireWorkspaceRole("admin") + @RequireWorkspaceRole("viewer") public R> listEnabled() { return R.ok(modelConfigService.listEnabledModels()); } @Operation(summary = "获取默认模型") @GetMapping("/default") - @RequireWorkspaceRole("admin") + @RequireWorkspaceRole("viewer") public R getDefaultModel() { return R.ok(modelConfigService.getDefaultModel()); } @Operation(summary = "获取当前激活模型") @GetMapping("/active") - @RequireWorkspaceRole("admin") + @RequireWorkspaceRole("viewer") public R getActiveModel() { ModelConfigEntity model = modelConfigService.getDefaultModel(); ActiveModelsInfo info = new ActiveModelsInfo(); @@ -230,7 +235,7 @@ public class ModelConfigController { @Operation(summary = "按类型筛选模型(chat / embedding),可选 modality 过滤") @GetMapping("/by-type") - @RequireWorkspaceRole("admin") + @RequireWorkspaceRole("member") public R> listByType( @RequestParam(defaultValue = "chat") String modelType, @RequestParam(required = false) String modality) { diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index dcad686d..6c97f984 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -443,6 +443,10 @@ const recoverablePrompt = ref(false) const recoverableDismissed = ref(false) const defaultModel = ref(null) const providers = ref([]) +// 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. +const providersUnavailable = ref(false) const activeModels = ref(null) const pendingAttachments = ref([]) const uploadingAttachment = ref(false) @@ -1180,21 +1184,36 @@ async function loadAgents() { } async function loadModelState() { + // /default + /active 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". try { - const [defaultRes, providersRes, activeRes]: any = await Promise.all([ + const [defaultRes, activeRes]: any = await Promise.all([ modelApi.getDefault(), - modelApi.listProviders(), modelApi.getActive(), ]) defaultModel.value = defaultRes.data || null - providers.value = providersRes.data || [] activeModels.value = activeRes.data || null - recomputePromptFlags() } catch (e) { ElMessage.error(t('chat.loadModelFailed')) blockingPrompt.value = true recoverablePrompt.value = false + return } + try { + const providersRes: any = await modelApi.listProviders() + providers.value = providersRes.data || [] + providersUnavailable.value = false + } catch (e: any) { + if (e?.response?.status === 403) { + providers.value = [] + providersUnavailable.value = true + } else { + // Non-403 failure is still a real problem worth surfacing. + ElMessage.error(t('chat.loadModelFailed')) + } + } + recomputePromptFlags() } /** @@ -1213,6 +1232,16 @@ function recomputePromptFlags() { recoverableDismissed.value = false return } + // Viewer-level users cannot read the provider list (admin-only because it + // returns API keys), so we can't compute liveness. Trust the active model + // and let the agent runtime surface any per-call failure instead of + // blocking the entire chat surface. + if (providersUnavailable.value) { + blockingPrompt.value = false + recoverablePrompt.value = false + recoverableDismissed.value = false + return + } const ap = providers.value.find(p => p.id === active.providerId) || null const apHasModels = ap ? ((ap.models?.length || 0) + (ap.extraModels?.length || 0)) > 0