diff --git a/cmd/gateway.go b/cmd/gateway.go index cb72601b..423c1bfc 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -481,7 +481,7 @@ func runGateway() { // Register all RPC methods server.SetLogTee(logTee) - pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants) + pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs) // Wire post-turn processor for team task dispatch (WS chat.send + HTTP API paths). if postTurn != nil { diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 4a74cf46..2da98dea 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -13,7 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { +func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { router := server.Router() // Phase 1: Core methods @@ -36,7 +36,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore configMethods.Register(router) // Phase 2: Skills (uses SkillStore interface — PG or File) - methods.NewSkillsMethods(skillStore).Register(router) + methods.NewSkillsMethods(skillStore, skillTenantCfgStore).Register(router) // Phase 2: Cron (store created externally, shared with gateway) methods.NewCronMethods(cronStore, msgBus, cfg).Register(router) diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index 5ff87570..502d04a2 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -3,6 +3,7 @@ package methods import ( "context" "encoding/json" + "log/slog" "github.com/google/uuid" @@ -20,11 +21,12 @@ type skillOwnerGetter interface { // SkillsMethods handles skills.list, skills.get, skills.update. type SkillsMethods struct { - store store.SkillStore + store store.SkillStore + tenantCfgStore store.SkillTenantConfigStore } -func NewSkillsMethods(s store.SkillStore) *SkillsMethods { - return &SkillsMethods{store: s} +func NewSkillsMethods(s store.SkillStore, tenantCfg store.SkillTenantConfigStore) *SkillsMethods { + return &SkillsMethods{store: s, tenantCfgStore: tenantCfg} } func (m *SkillsMethods) Register(router *gateway.MethodRouter) { @@ -68,6 +70,28 @@ func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, result = append(result, entry) } + // Merge per-tenant overrides when tenant-scoped + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil && m.tenantCfgStore != nil { + overrides, err := m.tenantCfgStore.ListAll(ctx, tid) + if err != nil { + slog.Warn("skill tenant config list failed", "tenant", tid, "error", err) + } + if err == nil && len(overrides) > 0 { + for i, entry := range result { + idStr, _ := entry["id"].(string) + if idStr == "" { + continue + } + if skID, err := uuid.Parse(idStr); err == nil { + if enabled, ok := overrides[skID]; ok { + result[i]["tenant_enabled"] = enabled + } + } + } + } + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "skills": result, })) diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index cc9e360b..93c62ece 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -73,6 +73,13 @@ "disable": "Disable skill", "disabled": "Disabled" }, + "tenant": { + "enabled": "Enabled", + "disabled": "Disabled", + "default": "Default", + "overrideHint": "Per-tenant override for this skill", + "resetDefault": "Reset to default" + }, "deps": { "rescan": "Rescan Deps", "rescanning": "Scanning...", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 055d9043..9b8897ea 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -73,6 +73,13 @@ "disable": "Tắt skill", "disabled": "Đã tắt" }, + "tenant": { + "enabled": "Đã bật", + "disabled": "Đã tắt", + "default": "Mặc định", + "overrideHint": "Ghi đè riêng cho tenant này", + "resetDefault": "Đặt lại mặc định" + }, "deps": { "rescan": "Rescan Deps", "rescanning": "Scanning...", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index 9b7d212e..55e7ec92 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -73,6 +73,13 @@ "disable": "禁用Skill", "disabled": "已禁用" }, + "tenant": { + "enabled": "已启用", + "disabled": "已禁用", + "default": "默认", + "overrideHint": "此租户的技能覆盖设置", + "resetDefault": "恢复默认" + }, "deps": { "rescan": "Rescan Deps", "rescanning": "Scanning...", 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 049acbf9..73374585 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tools-page.tsx @@ -249,7 +249,7 @@ function ToolRow({ const configHint = getConfigHint(tool); const editable = hasEditableSettings(tool); const deprecated = isDeprecated(tool); - const hasTenantScope = !!tenantId && tenantId !== "00000000-0000-0000-0000-000000000000"; + const hasTenantScope = !!tenantId && tenantId !== "0193a5b0-7000-7000-8000-000000000001"; const hasOverride = tool.tenant_enabled !== null && tool.tenant_enabled !== undefined; return ( diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index ce60f111..9faf7094 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -174,9 +174,38 @@ export function useSkills() { [http, invalidate], ); + const setTenantConfig = useCallback( + async (id: string, enabled: boolean) => { + try { + await http.put(`/v1/skills/${id}/tenant-config`, { enabled }); + await invalidate(); + toast.success(i18next.t("skills:toast.updated")); + } catch (err) { + toast.error(i18next.t("skills:toast.updateFailed"), userFriendlyError(err)); + throw err; + } + }, + [http, invalidate], + ); + + const deleteTenantConfig = useCallback( + async (id: string) => { + try { + await http.delete(`/v1/skills/${id}/tenant-config`); + await invalidate(); + toast.success(i18next.t("skills:toast.updated")); + } catch (err) { + toast.error(i18next.t("skills:toast.updateFailed"), userFriendlyError(err)); + throw err; + } + }, + [http, invalidate], + ); + return { skills, loading, refresh: invalidate, getSkill, uploadSkill, updateSkill, deleteSkill, getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installDeps, installSingleDep, toggleSkill, + setTenantConfig, deleteTenantConfig, }; } diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index 825e2b3c..2da2d28c 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch } from "lucide-react"; +import { Zap, Pencil, RefreshCw, Upload, Trash2, ScanSearch, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { SearchInput } from "@/components/shared/search-input"; @@ -20,6 +21,9 @@ import { useRuntimes } from "./hooks/use-runtimes"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; import { usePagination } from "@/hooks/use-pagination"; +import { useTenants } from "@/hooks/use-tenants"; + +const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001"; const visibilityColor: Record = { public: "default", @@ -34,8 +38,11 @@ export function SkillsPage() { const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill, getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installSingleDep, toggleSkill, + setTenantConfig, deleteTenantConfig, } = useSkills(); const { runtimes } = useRuntimes(); + const { currentTenantId } = useTenants(); + const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID; const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && skills.length === 0); const [tab, setTab] = useState("core"); @@ -286,13 +293,28 @@ export function SkillsPage() {
{skill.id && ( <> - handleToggle(skill, checked)} - title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")} - /> + {hasTenantScope ? ( + { + setToggling(id); + try { await setTenantConfig(id, enabled); } finally { setToggling(null); } + }} + onDeleteTenantConfig={async (id) => { + setToggling(id); + try { await deleteTenantConfig(id); } finally { setToggling(null); } + }} + /> + ) : ( + handleToggle(skill, checked)} + title={skill.enabled !== false ? t("toggle.disable") : t("toggle.enable")} + /> + )} + + +

{t("tenant.resetDefault")}

+
+ + + )} +
+ ); +} diff --git a/ui/web/src/types/skill.ts b/ui/web/src/types/skill.ts index 725e47cc..41571592 100644 --- a/ui/web/src/types/skill.ts +++ b/ui/web/src/types/skill.ts @@ -10,6 +10,7 @@ export interface SkillInfo { is_system?: boolean; status?: string; enabled?: boolean; + tenant_enabled?: boolean | null; author?: string; missing_deps?: string[]; }