diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index 9b9525be..cff29a66 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -85,6 +85,10 @@ "tenantDisabled": "Disabled for this tenant", "tenantDefault": "Using default", "resetToDefault": "Reset to default", + "tenantOverrideBadge": "Tenant override", + "tenantOverrideHint": "Editing this tenant's override. Changes apply only to this tenant.", + "tenantOverrideNewHint": "Creating a new override for this tenant. Pre-filled with global defaults.", + "resetToGlobalDefault": "Reset to global default", "unconfiguredWarning": "{{count}} tools need provider configuration for agents to use them at full capacity.", "unconfiguredAction": "Configure", "categories": { diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index 59b491dc..a6bb3f2d 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -85,6 +85,10 @@ "tenantDisabled": "Tắt cho tenant này", "tenantDefault": "Dùng mặc định", "resetToDefault": "Đặt lại mặc định", + "tenantOverrideBadge": "Ghi đè tenant", + "tenantOverrideHint": "Đang chỉnh ghi đè của tenant này. Thay đổi chỉ áp dụng cho tenant này.", + "tenantOverrideNewHint": "Tạo ghi đè mới cho tenant này. Đã điền sẵn giá trị mặc định toàn cục.", + "resetToGlobalDefault": "Đặt lại về mặc định toàn cục", "unconfiguredWarning": "{{count}} công cụ cần cấu hình provider để agent sử dụng đầy đủ năng lực.", "unconfiguredAction": "Cấu hình", "categories": { diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 384d0684..06c2003b 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -22,6 +22,10 @@ "tenantDisabled": "已为此租户禁用", "tenantDefault": "使用默认", "resetToDefault": "恢复默认", + "tenantOverrideBadge": "租户覆盖", + "tenantOverrideHint": "正在编辑该租户的覆盖配置,更改仅对该租户生效。", + "tenantOverrideNewHint": "为该租户创建新的覆盖配置,已预填全局默认值。", + "resetToGlobalDefault": "恢复为全局默认", "unconfiguredWarning": "{{count}} 个工具需要配置 Provider 才能让 Agent 发挥全部能力。", "unconfiguredAction": "配置", "description": "管理系统内置工具。全局启用/禁用或配置设置。", diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 962bd055..3b318971 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -25,39 +25,96 @@ interface Props { open: boolean; onOpenChange: (open: boolean) => void; onSave: (name: string, settings: Record) => Promise; + /** + * When true the dialog is editing a tenant override. Initial form values + * are drawn from `tool.tenant_settings ?? tool.settings` (fall back to the + * global default when no override exists yet) and the parent page is + * expected to route the save through the tenant-config endpoint. When + * false the dialog edits the global `tool.settings` directly — parent + * must enforce master-scope (the backend rejects non-master writes). + */ + tenantScope?: boolean; + /** + * Optional reset handler. When provided, dialogs rendered in tenant scope + * with an existing override show a "Reset to default" button that calls + * this with the tool name — parent maps it to PUT tenant-config with + * settings:null, clearing the override while preserving tenant_enabled. + */ + onResetToDefault?: (name: string) => Promise; } -export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) { +export function BuiltinToolSettingsDialog({ + tool, + open, + onOpenChange, + onSave, + tenantScope = false, + onResetToDefault, +}: Props) { + const { t } = useTranslation("tools"); const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false; const isKG = tool?.name === KG_TOOL; const isWebFetch = tool?.name === WEB_FETCH_TOOL; const wide = isMedia || isKG || isWebFetch; + // Tenant-scope overlay: prefer the tenant override when present; fall back + // to the global default so the form opens pre-populated with something + // sensible when the admin is creating a new override. + const initialSettings: Record = + (tenantScope ? (tool?.tenant_settings ?? tool?.settings) : tool?.settings) ?? {}; + const hasTenantOverride = tenantScope && tool?.tenant_settings != null; + + const modeBadge = tenantScope ? ( +
+ + {t("builtin.tenantOverrideBadge")} + + {hasTenantOverride ? t("builtin.tenantOverrideHint") : t("builtin.tenantOverrideNewHint")} +
+ ) : null; + return ( + {modeBadge} {isWebFetch && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> ) : isMedia && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> ) : isKG && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> ) : ( - + )} + {hasTenantOverride && onResetToDefault && tool ? ( +
+ +
+ ) : null}
); @@ -66,10 +123,12 @@ export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: function JsonSettingsForm({ tool, + initialSettings, onOpenChange, onSave, }: { tool: BuiltinToolData | null; + initialSettings: Record; onOpenChange: (open: boolean) => void; onSave: (name: string, settings: Record) => Promise; }) { @@ -81,11 +140,15 @@ function JsonSettingsForm({ useEffect(() => { if (tool) { - setJson(JSON.stringify(tool.settings ?? {}, null, 2)); + // initialSettings is pre-resolved by the parent to account for tenant + // scope (tenant_settings ?? settings). Re-hydrate every time the tool + // or the resolved initial changes — handles switch-between-tools and + // tenant-override-cleared flows. + setJson(JSON.stringify(initialSettings, null, 2)); setError(""); setValidJson(true); } - }, [tool]); + }, [tool, initialSettings]); const handleJsonChange = (text: string) => { setJson(text); diff --git a/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx b/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx index 39055458..7839d019 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx @@ -19,6 +19,13 @@ const CATEGORY_ORDER = [ "sessions", "messaging", "scheduling", "subagents", "skills", "delegation", "teams", ]; +// Hardcoded master tenant UUID; mirrors backend store.MasterTenantID. When +// the current tenant is NOT the master, UI routes settings writes through +// the tenant-config endpoint. Backend enforces the same rule defensively — +// this is purely a UX guard so tenant admins never see a 403 from the +// global endpoint. +const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001"; + /** Media tool that is enabled but has no provider chain configured */ function needsProviderConfig(tool: BuiltinToolData): boolean { if (!MEDIA_TOOLS.has(tool.name) || !tool.enabled) return false; @@ -30,8 +37,18 @@ function needsProviderConfig(tool: BuiltinToolData): boolean { export function BuiltinToolsPage() { const { t } = useTranslation("tools"); - const { tools, loading, refresh, updateTool, setTenantConfig, deleteTenantConfig } = useBuiltinTools(); + const { + tools, + loading, + refresh, + updateTool, + setTenantConfig, + deleteTenantConfig, + setTenantSettings, + clearTenantSettings, + } = useBuiltinTools(); const { currentTenantId } = useTenants(); + const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID; const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && tools.length === 0); const [search, setSearch] = useState(""); @@ -64,8 +81,22 @@ export function BuiltinToolsPage() { await updateTool(tool.name, { enabled: !tool.enabled }); }; + // Route settings writes through the correct endpoint based on scope: + // - Master scope → PUT /v1/tools/builtin/{name} (global default) + // - Tenant scope → PUT /v1/tools/builtin/{name}/tenant-config (override) + // Backend enforces master-scope defensively (Phase 0b), so even if this + // UI check drifted, a tenant admin would hit 403 instead of corrupting + // the global defaults. const handleSaveSettings = async (name: string, settings: Record) => { - await updateTool(name, { settings }); + if (hasTenantScope) { + await setTenantSettings(name, settings); + } else { + await updateTool(name, { settings }); + } + }; + + const handleResetTenantSettings = async (name: string) => { + await clearTenantSettings(name); }; return ( @@ -161,6 +192,8 @@ export function BuiltinToolsPage() { if (!open) setSettingsTool(null); }} onSave={handleSaveSettings} + tenantScope={hasTenantScope} + onResetToDefault={hasTenantScope ? handleResetTenantSettings : undefined} /> ); diff --git a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts index 673de3f1..0ef0b91d 100644 --- a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts +++ b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts @@ -14,6 +14,10 @@ export interface BuiltinToolData { enabled: boolean; tenant_enabled: boolean | null; settings: Record; + // Tenant override for settings JSON. Null when no tenant override + // exists (row in builtin_tool_tenant_configs has settings column NULL + // or row absent). Present only when request is tenant-scoped. + tenant_settings: Record | null; requires: string[]; metadata: Record; created_at: string; @@ -71,7 +75,33 @@ export function useBuiltinTools() { throw err; } }, - [http, invalidate], + [http, queryClient, invalidate], + ); + + // setTenantSettings writes the JSONB `settings` column of + // builtin_tool_tenant_configs for the current tenant. Passing `null` + // clears the override (backend maps literal `null` → SQL NULL) while + // preserving the `enabled` column on the same row. + const setTenantSettings = useCallback( + async (name: string, settings: Record | null) => { + try { + queryClient.setQueryData(queryKeys.builtinTools.all, (old) => + old?.map((t) => (t.name === name ? { ...t, tenant_settings: settings } : t)), + ); + await http.put(`/v1/tools/builtin/${name}/tenant-config`, { settings }); + await invalidate(); + toast.success(i18next.t("tools:builtin.settingsDialog.toast.saved")); + } catch (err) { + toast.error(i18next.t("tools:builtin.settingsDialog.toast.failed"), userFriendlyError(err)); + throw err; + } + }, + [http, queryClient, invalidate], + ); + + const clearTenantSettings = useCallback( + (name: string) => setTenantSettings(name, null), + [setTenantSettings], ); const deleteTenantConfig = useCallback( @@ -88,5 +118,14 @@ export function useBuiltinTools() { [http, invalidate], ); - return { tools, loading, refresh: invalidate, updateTool, setTenantConfig, deleteTenantConfig }; + return { + tools, + loading, + refresh: invalidate, + updateTool, + setTenantConfig, + deleteTenantConfig, + setTenantSettings, + clearTenantSettings, + }; }