diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index ab1fdea70..fea751ef3 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -39,6 +39,8 @@ func Models() []any { &model.AdminAuditEvent{}, &model.UserDailyActivity{}, &model.SystemSetting{}, + &model.PluginPlatformState{}, + &model.UserPluginState{}, &model.ArkPrivateAssetBinding{}, &model.UserOSSSetting{}, &model.UserDailyUploadUsage{}, diff --git a/backend/internal/handler/comfy_bridge.go b/backend/internal/handler/comfy_bridge.go index a4447610b..2091700e1 100644 --- a/backend/internal/handler/comfy_bridge.go +++ b/backend/internal/handler/comfy_bridge.go @@ -38,7 +38,7 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil { failService(c, err) return } @@ -61,7 +61,7 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil { failService(c, err) return } @@ -78,10 +78,6 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { - failService(c, err) - return - } if err := svc.RevokeComfyBridge(user.ID, c.Param("id")); err != nil { failService(c, err) return @@ -97,10 +93,6 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { - failService(c, err) - return - } wait, err := comfyBridgeWait(c.Query("wait")) if err != nil { fail(c, http.StatusBadRequest, err) @@ -122,10 +114,6 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { - failService(c, err) - return - } if c.Request.ContentLength > maxComfyBridgeRequestBytes { fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB")) return @@ -163,10 +151,6 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { - failService(c, err) - return - } var req struct { Capabilities map[string]any `json:"capabilities,omitempty"` } diff --git a/backend/internal/handler/plugin.go b/backend/internal/handler/plugin.go index 950dbc435..4409b10a8 100644 --- a/backend/internal/handler/plugin.go +++ b/backend/internal/handler/plugin.go @@ -16,11 +16,56 @@ import ( func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { statusRoutes := r.Group("/plugins") statusRoutes.GET("/status", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { + user, err := currentUser(c, svc) + if err != nil { failService(c, err) return } - ok(c, gin.H{"statuses": svc.WorkflowPluginStatuses()}) + states, err := svc.PluginStatesForUser(user) + if err != nil { + failService(c, err) + return + } + statuses, err := svc.WorkflowPluginStatusesForUser(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"statuses": statuses, "states": states}) + }) + adminRoutes := r.Group("/admin/plugins") + adminRoutes.GET("", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + states, err := svc.AdminPluginStates(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"plugins": svc.Plugins(), "states": states}) + }) + adminRoutes.PUT("/:id/availability", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request struct { + Available bool `json:"available"` + } + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + state, err := svc.SetPluginPlatformAvailability(user, c.Param("id"), request.Available) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"state": state}) }) pluginRoutes := r.Group("/plugins") pluginRoutes.Use(requirePluginCenterAccess(svc)) @@ -37,7 +82,12 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - ok(c, gin.H{"plugins": plugins}) + states, err := svc.PluginStatesForUser(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"plugins": plugins, "states": states}) }) pluginRoutes.GET("/catalog", func(c *gin.Context) { if _, err := currentUser(c, svc); err != nil { @@ -77,7 +127,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { fail(c, http.StatusBadRequest, err) return } - plugin, err := svc.InstallPlugin(data, fileName) + plugin, err := svc.InstallPluginForAdmin(user, data, fileName) if err != nil { failService(c, err) return @@ -85,10 +135,27 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"plugin": plugin}) }) pluginRoutes.GET("/:id/package", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { + user, err := currentUser(c, svc) + if err != nil { failService(c, err) return } + visiblePlugins, err := svc.PluginsForUser(user) + if err != nil { + failService(c, err) + return + } + visible := false + for _, plugin := range visiblePlugins { + if plugin.Manifest.ID == c.Param("id") { + visible = true + break + } + } + if !visible { + failService(c, service.Forbidden("无权访问该插件包")) + return + } data, fileName, err := svc.PluginPackage(c.Param("id")) if err != nil { failService(c, err) @@ -103,6 +170,26 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { }) pluginRoutes.POST("/:id/enable", pluginToggle(svc, true)) pluginRoutes.POST("/:id/disable", pluginToggle(svc, false)) + pluginRoutes.PUT("/:id/activation", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + state, err := svc.SetUserPluginEnabled(user, c.Param("id"), request.Enabled) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"state": state}) + }) pluginRoutes.DELETE("/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -113,7 +200,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.UninstallPlugin(c.Param("id")); err != nil { + if err := svc.UninstallPluginForAdmin(user, c.Param("id")); err != nil { failService(c, err) return } @@ -121,8 +208,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { }) pluginRoutes.GET("/eagle/library", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } library, err := svc.EagleLibrary(c.Query("baseUrl")) @@ -134,8 +220,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"library": library}) }) pluginRoutes.GET("/eagle/items", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "60")) @@ -148,8 +233,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"items": items}) }) pluginRoutes.GET("/eagle/items/:itemId/file", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } file, err := svc.OpenEagleItemFile(c.Query("baseUrl"), c.Param("itemId")) @@ -164,8 +248,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil) }) pluginRoutes.GET("/eagle/items/:itemId/thumbnail", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } file, err := svc.OpenEagleItemThumbnail(c.Query("baseUrl"), c.Param("itemId")) @@ -179,8 +262,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil) }) pluginRoutes.POST("/eagle/items", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 160<<20) @@ -197,8 +279,7 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"item": item}) }) pluginRoutes.POST("/eagle/folders", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) + if _, allowed := requireEnabledPlugin(c, svc, service.PluginEagleAssetConnector); !allowed { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) @@ -250,11 +331,23 @@ func pluginToggle(svc *service.Service, enabled bool) gin.HandlerFunc { failService(c, err) return } - plugin, err := svc.SetPluginEnabled(c.Param("id"), enabled) + state, err := svc.SetPluginPlatformAvailability(user, c.Param("id"), enabled) if err != nil { failService(c, err) return } - ok(c, gin.H{"plugin": plugin}) + ok(c, gin.H{"state": state}) } } + +func requireEnabledPlugin(c *gin.Context, svc *service.Service, pluginID string) (*model.User, bool) { + user, err := currentUser(c, svc) + if err == nil { + err = svc.RequirePluginForUser(user.ID, pluginID) + } + if err != nil { + failService(c, err) + return nil, false + } + return user, true +} diff --git a/backend/internal/handler/runninghub.go b/backend/internal/handler/runninghub.go index ccf8c97b0..a48f13556 100644 --- a/backend/internal/handler/runninghub.go +++ b/backend/internal/handler/runninghub.go @@ -11,11 +11,12 @@ import ( // RegisterRunningHubRoutes 是独立工作流 Provider 的管理代理,不复用 ModelChannel。 func RegisterRunningHubRoutes(r *gin.RouterGroup, svc *service.Service) { fetch := func(c *gin.Context, app bool) { - if _, err := currentUser(c, svc); err != nil { + user, err := currentUser(c, svc) + if err != nil { failService(c, err) return } - if err := svc.RequireWorkflowPluginForInterface("runninghub-workflow-image"); err != nil { + if err := svc.RequireWorkflowPluginForUser(user.ID, "runninghub-workflow-image"); err != nil { failService(c, err) return } @@ -26,7 +27,6 @@ func RegisterRunningHubRoutes(r *gin.RouterGroup, svc *service.Service) { return } var result map[string]any - var err error if app { result, err = svc.FetchRunningHubAppInfo(c.Request.Context(), req) } else { diff --git a/backend/internal/model/models_plugin.go b/backend/internal/model/models_plugin.go new file mode 100644 index 000000000..12b135952 --- /dev/null +++ b/backend/internal/model/models_plugin.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// PluginPlatformState stores the administrator-controlled availability of a +// plugin. User activation can never make an unavailable plugin effective. +type PluginPlatformState struct { + PluginID string `json:"pluginId" gorm:"primaryKey;size:120"` + Available bool `json:"available" gorm:"index"` + UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// UserPluginState stores one user's activation choice for an application +// plugin. System-scoped protocol and uploaded plugins do not use this table. +type UserPluginState struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_plugin_state_user_plugin,priority:1"` + PluginID string `json:"pluginId" gorm:"size:120;index;uniqueIndex:idx_user_plugin_state_user_plugin,priority:2"` + Enabled bool `json:"enabled" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/repository/plugin_states.go b/backend/internal/repository/plugin_states.go new file mode 100644 index 000000000..d18e4ef59 --- /dev/null +++ b/backend/internal/repository/plugin_states.go @@ -0,0 +1,86 @@ +package repository + +import ( + "errors" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (r *Repository) PluginPlatformState(pluginID string) (*model.PluginPlatformState, error) { + var state model.PluginPlatformState + if err := r.db.First(&state, "plugin_id = ?", pluginID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &state, nil +} + +func (r *Repository) PluginPlatformStates() ([]model.PluginPlatformState, error) { + var states []model.PluginPlatformState + err := r.db.Order("plugin_id asc").Find(&states).Error + return states, err +} + +func (r *Repository) SavePluginPlatformState(state *model.PluginPlatformState) error { + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "plugin_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"available", "updated_by", "updated_at"}), + }).Create(state).Error +} + +func (r *Repository) DeletePluginPlatformState(pluginID string) error { + return r.db.Delete(&model.PluginPlatformState{}, "plugin_id = ?", pluginID).Error +} + +func (r *Repository) UserPluginState(userID string, pluginID string) (*model.UserPluginState, error) { + var state model.UserPluginState + if err := r.db.First(&state, "user_id = ? AND plugin_id = ?", userID, pluginID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &state, nil +} + +func (r *Repository) UserPluginStates(userID string) ([]model.UserPluginState, error) { + var states []model.UserPluginState + err := r.db.Where("user_id = ?", userID).Order("plugin_id asc").Find(&states).Error + return states, err +} + +func (r *Repository) SaveUserPluginState(state *model.UserPluginState) error { + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "plugin_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"enabled", "updated_at"}), + }).Create(state).Error +} + +func (r *Repository) DeleteUserPluginStates(pluginID string) error { + return r.db.Delete(&model.UserPluginState{}, "plugin_id = ?", pluginID).Error +} + +func (r *Repository) EnabledPluginUserCounts() (map[string]int64, error) { + type countRow struct { + PluginID string + Count int64 + } + var rows []countRow + if err := r.db.Model(&model.UserPluginState{}). + Select("plugin_id, COUNT(*) AS count"). + Where("enabled = ?", true). + Group("plugin_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + result := make(map[string]int64, len(rows)) + for _, row := range rows { + result[row.PluginID] = row.Count + } + return result, nil +} diff --git a/backend/internal/service/feature_availability_test.go b/backend/internal/service/feature_availability_test.go index 3ecfa0b31..8cb239275 100644 --- a/backend/internal/service/feature_availability_test.go +++ b/backend/internal/service/feature_availability_test.go @@ -72,7 +72,7 @@ func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) { } } -func TestPluginsForUserHidesBundledPluginsOnlyForOrdinaryUsers(t *testing.T) { +func TestPluginsForUserKeepsOfficialApplicationsAndHidesManagedPlugins(t *testing.T) { svc, _ := newFeatureAvailabilityTestService(t) admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} if _, err := svc.UpdateFeatureAvailability(admin, FeatureAvailability{ @@ -83,21 +83,22 @@ func TestPluginsForUserHidesBundledPluginsOnlyForOrdinaryUsers(t *testing.T) { } svc.pluginRuntime = &pluginRuntime{plugins: map[string]pluginRecord{ "bundled": {Source: "bundled", Metadata: protocol.Metadata{ID: "bundled", Name: "系统协议", Version: "1"}}, - "uploaded": {Source: "uploaded", Metadata: protocol.Metadata{ID: "uploaded", Name: "用户协议", Version: "1"}}, + "uploaded": {Source: "uploaded", Metadata: protocol.Metadata{ID: "uploaded", Name: "自定义协议", Version: "1"}}, + "app": {Source: "bundled", Metadata: protocol.Metadata{ID: WorkflowPluginRunningHub, Name: "官方应用", Version: "1"}}, }} visibleToUser, err := svc.PluginsForUser(&model.User{ID: "user-1", Role: model.UserRoleUser}) if err != nil { t.Fatal(err) } - if len(visibleToUser) != 1 || visibleToUser[0].Source != "uploaded" { + if len(visibleToUser) != 1 || visibleToUser[0].Manifest.ID != WorkflowPluginRunningHub { t.Fatalf("PluginsForUser(user) = %#v", visibleToUser) } visibleToAdmin, err := svc.PluginsForUser(admin) if err != nil { t.Fatal(err) } - if len(visibleToAdmin) != 2 { + if len(visibleToAdmin) != 3 { t.Fatalf("PluginsForUser(admin) = %#v", visibleToAdmin) } } diff --git a/backend/internal/service/plugin_management.go b/backend/internal/service/plugin_management.go new file mode 100644 index 000000000..e55321037 --- /dev/null +++ b/backend/internal/service/plugin_management.go @@ -0,0 +1,303 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +const ( + PluginEagleAssetConnector = "eagle-asset-connector" + PluginPromptOptimizer = "prompt-optimizer" + PluginPortraitClearance = "portrait-clearance" + + PluginOriginOfficial = "official" + PluginOriginUploaded = "uploaded" + + PluginKindProtocol = "protocol" + PluginKindApplication = "application" + + PluginScopeSystem = "system" + PluginScopeUser = "user" + + PluginConfigurationNone = "none" + PluginConfigurationSystem = "system" + PluginConfigurationUser = "user" +) + +// PluginManagementView is computed by the host. Uploaded manifests cannot +// choose their own privilege or activation scope. +type PluginManagementView struct { + Origin string `json:"origin"` + Kind string `json:"kind"` + ActivationScope string `json:"activationScope"` + ConfigurationScope string `json:"configurationScope"` +} + +type PluginStateView struct { + PluginID string `json:"pluginId"` + PlatformAvailable bool `json:"platformAvailable"` + UserEnabled bool `json:"userEnabled"` + UserConfigured bool `json:"userConfigured"` + EffectiveEnabled bool `json:"effectiveEnabled"` + CanToggle bool `json:"canToggle"` + CanConfigure bool `json:"canConfigure"` + BlockedReason string `json:"blockedReason,omitempty"` +} + +type AdminPluginStateView struct { + PluginStateView + EnabledUserCount int64 `json:"enabledUserCount"` +} + +var officialApplicationPolicies = map[string]PluginManagementView{ + WorkflowPluginRunningHub: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser, + }, + WorkflowPluginComfyUI: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser, + }, + PluginEagleAssetConnector: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser, + }, + PluginPromptOptimizer: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone, + }, + PluginPortraitClearance: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone, + }, +} + +func pluginManagement(pluginID string, source string) PluginManagementView { + if strings.TrimSpace(source) == PluginOriginUploaded { + return PluginManagementView{ + Origin: PluginOriginUploaded, Kind: PluginKindProtocol, + ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem, + } + } + if policy, ok := officialApplicationPolicies[strings.TrimSpace(pluginID)]; ok { + return policy + } + return PluginManagementView{ + Origin: PluginOriginOfficial, Kind: PluginKindProtocol, + ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem, + } +} + +func knownPluginIDs(items []PluginView) []string { + seen := make(map[string]struct{}, len(items)+len(officialApplicationPolicies)) + ids := make([]string, 0, len(items)+len(officialApplicationPolicies)) + for id := range officialApplicationPolicies { + seen[id] = struct{}{} + ids = append(ids, id) + } + for _, item := range items { + if _, exists := seen[item.Manifest.ID]; exists { + continue + } + seen[item.Manifest.ID] = struct{}{} + ids = append(ids, item.Manifest.ID) + } + return ids +} + +func runtimePluginByID(items []PluginView, pluginID string) (PluginView, bool) { + for _, item := range items { + if item.Manifest.ID == pluginID { + return item, true + } + } + return PluginView{}, false +} + +func (s *Service) PluginStatesForUser(actor *model.User) (map[string]PluginStateView, error) { + items := s.Plugins() + result := make(map[string]PluginStateView, len(items)+len(officialApplicationPolicies)) + for _, pluginID := range knownPluginIDs(items) { + state, err := s.pluginStateForUser(actor, pluginID, items) + if err != nil { + return nil, err + } + result[pluginID] = state + } + return result, nil +} + +func (s *Service) pluginStateForUser(actor *model.User, pluginID string, items []PluginView) (PluginStateView, error) { + runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID) + source := "bundled" + if hasRuntime { + source = runtimePlugin.Source + } else if _, known := officialApplicationPolicies[pluginID]; !known { + return PluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID) + } + policy := pluginManagement(pluginID, source) + platformAvailable := policy.Kind == PluginKindApplication + if hasRuntime && (policy.ActivationScope == PluginScopeSystem || s.repo == nil) { + platformAvailable = runtimePlugin.Status == "enabled" + } + if s.repo != nil { + platformState, err := s.repo.PluginPlatformState(pluginID) + if err != nil { + return PluginStateView{}, fmt.Errorf("读取插件平台状态:%w", err) + } + if platformState != nil { + platformAvailable = platformState.Available + } + } + + userEnabled := false + userConfigured := false + if policy.ActivationScope == PluginScopeUser && actor != nil { + if s.repo != nil { + userState, err := s.repo.UserPluginState(actor.ID, pluginID) + if err != nil { + return PluginStateView{}, fmt.Errorf("读取用户插件状态:%w", err) + } + if userState != nil { + userEnabled, userConfigured = userState.Enabled, true + } + } + // Preserve the old globally-enabled workflow behavior until each user + // explicitly saves a personal choice. Other official applications were + // already controlled by each user's local installation state. + if !userConfigured && hasRuntime && isLegacyWorkflowPlugin(pluginID) { + userEnabled = runtimePlugin.Status == "enabled" + } + } + effective := platformAvailable + if policy.ActivationScope == PluginScopeUser { + effective = platformAvailable && userEnabled + } + state := PluginStateView{ + PluginID: pluginID, PlatformAvailable: platformAvailable, + UserEnabled: userEnabled, UserConfigured: userConfigured, + EffectiveEnabled: effective, + CanToggle: policy.ActivationScope == PluginScopeUser && platformAvailable, + CanConfigure: policy.ConfigurationScope == PluginConfigurationUser && platformAvailable, + } + if !platformAvailable { + state.BlockedReason = "管理员已停用该插件" + } else if policy.ActivationScope == PluginScopeSystem { + state.BlockedReason = "系统插件由管理员统一管理" + } + return state, nil +} + +func isLegacyWorkflowPlugin(pluginID string) bool { + return pluginID == WorkflowPluginRunningHub || pluginID == WorkflowPluginComfyUI +} + +func (s *Service) SetUserPluginEnabled(actor *model.User, pluginID string, enabled bool) (PluginStateView, error) { + if actor == nil || strings.TrimSpace(actor.ID) == "" { + return PluginStateView{}, Forbidden("请先登录") + } + items := s.Plugins() + current, err := s.pluginStateForUser(actor, pluginID, items) + if err != nil { + return PluginStateView{}, err + } + runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID) + source := "bundled" + if hasRuntime { + source = runtimePlugin.Source + } + if pluginManagement(pluginID, source).ActivationScope != PluginScopeUser { + return PluginStateView{}, Forbidden("系统插件只能由管理员统一管理") + } + if !current.PlatformAvailable { + return PluginStateView{}, Forbidden("管理员已停用该插件") + } + if s.repo == nil { + return PluginStateView{}, fmt.Errorf("插件状态存储未初始化") + } + now := time.Now() + state := &model.UserPluginState{ID: newID(), UserID: actor.ID, PluginID: pluginID, Enabled: enabled, CreatedAt: now, UpdatedAt: now} + if err := s.repo.SaveUserPluginState(state); err != nil { + return PluginStateView{}, fmt.Errorf("保存用户插件状态:%w", err) + } + return s.pluginStateForUser(actor, pluginID, items) +} + +func (s *Service) AdminPluginStates(actor *model.User) (map[string]AdminPluginStateView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + states, err := s.PluginStatesForUser(actor) + if err != nil { + return nil, err + } + counts, err := s.repo.EnabledPluginUserCounts() + if err != nil { + return nil, fmt.Errorf("统计插件启用用户数:%w", err) + } + result := make(map[string]AdminPluginStateView, len(states)) + for pluginID, state := range states { + result[pluginID] = AdminPluginStateView{PluginStateView: state, EnabledUserCount: counts[pluginID]} + } + return result, nil +} + +func (s *Service) SetPluginPlatformAvailability(actor *model.User, pluginID string, available bool) (AdminPluginStateView, error) { + if err := s.RequireAdmin(actor); err != nil { + return AdminPluginStateView{}, err + } + items := s.Plugins() + runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID) + source := "bundled" + if hasRuntime { + source = runtimePlugin.Source + } else if _, known := officialApplicationPolicies[pluginID]; !known { + return AdminPluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID) + } + policy := pluginManagement(pluginID, source) + previousRuntimeEnabled := false + runtimeChanged := false + if policy.ActivationScope == PluginScopeSystem { + if !hasRuntime { + return AdminPluginStateView{}, fmt.Errorf("插件 %q 缺少运行时", pluginID) + } + previousRuntimeEnabled = runtimePlugin.Status == "enabled" + if _, err := s.SetPluginEnabled(pluginID, available); err != nil { + return AdminPluginStateView{}, err + } + runtimeChanged = previousRuntimeEnabled != available + } + now := time.Now() + platformState := &model.PluginPlatformState{PluginID: pluginID, Available: available, UpdatedBy: actor.ID, CreatedAt: now, UpdatedAt: now} + if err := s.repo.SavePluginPlatformState(platformState); err != nil { + if runtimeChanged { + _, _ = s.SetPluginEnabled(pluginID, previousRuntimeEnabled) + } + return AdminPluginStateView{}, fmt.Errorf("保存插件平台状态:%w", err) + } + if err := s.appendAdminAudit(actor, "plugin.availability.update", "plugin", pluginID, "更新插件平台可用状态", map[string]any{"available": available, "kind": policy.Kind, "origin": policy.Origin}); err != nil { + return AdminPluginStateView{}, err + } + states, err := s.AdminPluginStates(actor) + if err != nil { + return AdminPluginStateView{}, err + } + return states[pluginID], nil +} + +func (s *Service) RequirePluginForUser(userID string, pluginID string) error { + state, err := s.pluginStateForUser(&model.User{ID: strings.TrimSpace(userID)}, pluginID, s.Plugins()) + if err != nil { + return err + } + if !state.EffectiveEnabled { + if state.BlockedReason != "" { + return Forbidden(state.BlockedReason) + } + return Forbidden("插件未启用") + } + return nil +} diff --git a/backend/internal/service/plugin_management_test.go b/backend/internal/service/plugin_management_test.go new file mode 100644 index 000000000..86588b51e --- /dev/null +++ b/backend/internal/service/plugin_management_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestApplicationPluginUsesUserStateUnderPlatformAvailability(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.PluginPlatformState{}, &model.UserPluginState{}, &model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db), pluginRuntime: center} + user := &model.User{ID: "user-1", Role: model.UserRoleUser} + admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + + states, err := svc.PluginStatesForUser(user) + if err != nil { + t.Fatal(err) + } + initial := states[WorkflowPluginRunningHub] + if !initial.PlatformAvailable || initial.UserEnabled || initial.EffectiveEnabled || !initial.CanToggle { + t.Fatalf("initial RunningHub state = %#v", initial) + } + + enabled, err := svc.SetUserPluginEnabled(user, WorkflowPluginRunningHub, true) + if err != nil { + t.Fatal(err) + } + if !enabled.UserConfigured || !enabled.UserEnabled || !enabled.EffectiveEnabled { + t.Fatalf("enabled RunningHub state = %#v", enabled) + } + if _, err := svc.SetPluginPlatformAvailability(user, WorkflowPluginRunningHub, false); err == nil { + t.Fatal("ordinary user changed platform plugin availability") + } + disabled, err := svc.SetPluginPlatformAvailability(admin, WorkflowPluginRunningHub, false) + if err != nil { + t.Fatal(err) + } + if disabled.PlatformAvailable || disabled.EffectiveEnabled || disabled.EnabledUserCount != 1 { + t.Fatalf("platform-disabled RunningHub state = %#v", disabled) + } + if err := svc.RequireWorkflowPluginForUser(user.ID, "runninghub-workflow-image"); err == nil { + t.Fatal("platform-disabled workflow was accepted for a new user request") + } + if _, err := svc.SetPluginPlatformAvailability(admin, WorkflowPluginRunningHub, true); err != nil { + t.Fatal(err) + } + if err := svc.RequireWorkflowPluginForUser(user.ID, "runninghub-workflow-video"); err != nil { + t.Fatalf("restored user workflow rejected: %v", err) + } +} + +func TestUploadedManifestCannotClaimUserActivationScope(t *testing.T) { + policy := pluginManagement(PluginPromptOptimizer, PluginOriginUploaded) + if policy.Origin != PluginOriginUploaded || policy.ActivationScope != PluginScopeSystem || policy.ConfigurationScope != PluginConfigurationSystem { + t.Fatalf("uploaded plugin policy = %#v", policy) + } +} diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index bd25ad737..b8ee60c30 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -23,15 +23,16 @@ const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes // plugin center. Protocol-specific runtime data is nested under Protocol so // the public plugin contract can grow without adding another center API. type PluginView struct { - Manifest PluginManifestView `json:"manifest"` - Source string `json:"source"` - FileName string `json:"fileName"` - Package string `json:"package"` - SHA256 string `json:"sha256"` - InstalledAt time.Time `json:"installedAt"` - UpdatedAt time.Time `json:"updatedAt"` - Status string `json:"status"` - Error string `json:"error,omitempty"` + Manifest PluginManifestView `json:"manifest"` + Source string `json:"source"` + FileName string `json:"fileName"` + Package string `json:"package"` + SHA256 string `json:"sha256"` + InstalledAt time.Time `json:"installedAt"` + UpdatedAt time.Time `json:"updatedAt"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Management PluginManagementView `json:"management"` } type PluginManifestView struct { diff --git a/backend/internal/service/protocol_registry.go b/backend/internal/service/protocol_registry.go index a2818032c..cf0014ffe 100644 --- a/backend/internal/service/protocol_registry.go +++ b/backend/internal/service/protocol_registry.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/protocol" @@ -168,7 +169,11 @@ func (s *Service) Plugins() []PluginView { if s.pluginRuntime == nil { return []PluginView{} } - return s.pluginRuntime.list() + items := s.pluginRuntime.list() + for index := range items { + items[index].Management = pluginManagement(items[index].Manifest.ID, items[index].Source) + } + return items } // PluginsForUser keeps the plugin center response aligned with the public @@ -188,7 +193,7 @@ func (s *Service) PluginsForUser(actor *model.User) ([]PluginView, error) { } filtered := make([]PluginView, 0, len(items)) for _, item := range items { - if item.Source != "bundled" { + if item.Management.ActivationScope == PluginScopeUser { filtered = append(filtered, item) } } @@ -202,6 +207,33 @@ func (s *Service) InstallPlugin(data []byte, fileName string) (PluginView, error return s.pluginRuntime.install(data, fileName) } +func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName string) (PluginView, error) { + if err := s.RequireAdmin(actor); err != nil { + return PluginView{}, err + } + parsed, err := protocol.ParsePluginPackage(data) + if err != nil { + return PluginView{}, err + } + if _, reserved := officialApplicationPolicies[parsed.Manifest.Metadata.ID]; reserved { + return PluginView{}, fmt.Errorf("插件 ID %q 由官方应用保留", parsed.Manifest.Metadata.ID) + } + plugin, err := s.InstallPlugin(data, fileName) + if err != nil { + return PluginView{}, err + } + plugin.Management = pluginManagement(plugin.Manifest.ID, plugin.Source) + now := time.Now() + state := &model.PluginPlatformState{PluginID: plugin.Manifest.ID, Available: plugin.Status == "enabled", UpdatedBy: actor.ID, CreatedAt: now, UpdatedAt: now} + if err := s.repo.SavePluginPlatformState(state); err != nil { + return PluginView{}, fmt.Errorf("保存插件平台状态:%w", err) + } + if err := s.appendAdminAudit(actor, "plugin.install", "plugin", plugin.Manifest.ID, "安装自定义插件", map[string]any{"fileName": fileName, "sha256": plugin.SHA256}); err != nil { + return PluginView{}, err + } + return plugin, nil +} + func (s *Service) PluginPackage(id string) ([]byte, string, error) { if s.pluginRuntime == nil { return nil, "", fmt.Errorf("插件运行时未初始化") @@ -235,3 +267,19 @@ func (s *Service) UninstallPlugin(id string) error { } return s.pluginRuntime.uninstall(id) } + +func (s *Service) UninstallPluginForAdmin(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + if err := s.UninstallPlugin(id); err != nil { + return err + } + if err := s.repo.DeleteUserPluginStates(id); err != nil { + return fmt.Errorf("清理用户插件状态:%w", err) + } + if err := s.repo.DeletePluginPlatformState(id); err != nil { + return fmt.Errorf("清理插件平台状态:%w", err) + } + return s.appendAdminAudit(actor, "plugin.uninstall", "plugin", id, "卸载自定义插件", nil) +} diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index c9869bcd0..1a95f7d23 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -32,7 +32,7 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, frontendEnabled := false if workflowProviderTask { config, _ := normalizedInput["config"].(map[string]any) - if err := s.RequireWorkflowPluginForInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil { + if err := s.RequireWorkflowPluginForUser(userID, strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil { return nil, err } } else { diff --git a/backend/internal/service/workflow_plugins.go b/backend/internal/service/workflow_plugins.go index 088539b67..f697033ad 100644 --- a/backend/internal/service/workflow_plugins.go +++ b/backend/internal/service/workflow_plugins.go @@ -3,6 +3,7 @@ package service import ( "strings" + "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/protocol" ) @@ -66,14 +67,34 @@ func workflowPluginIDForInterface(value string) (string, bool) { func (s *Service) WorkflowPluginStatuses() map[string]string { statuses := make(map[string]string, 2) - for _, plugin := range s.Plugins() { - if plugin.Manifest.ID == WorkflowPluginRunningHub || plugin.Manifest.ID == WorkflowPluginComfyUI { - statuses[plugin.Manifest.ID] = plugin.Status + for _, pluginID := range []string{WorkflowPluginRunningHub, WorkflowPluginComfyUI} { + state, err := s.pluginStateForUser(nil, pluginID, s.Plugins()) + if err == nil && state.PlatformAvailable { + statuses[pluginID] = "enabled" + } else { + statuses[pluginID] = "disabled" } } return statuses } +func (s *Service) WorkflowPluginStatusesForUser(userID string) (map[string]string, error) { + statuses := make(map[string]string, 2) + actor := &model.User{ID: strings.TrimSpace(userID)} + for _, pluginID := range []string{WorkflowPluginRunningHub, WorkflowPluginComfyUI} { + state, err := s.pluginStateForUser(actor, pluginID, s.Plugins()) + if err != nil { + return nil, err + } + if state.EffectiveEnabled { + statuses[pluginID] = "enabled" + } else { + statuses[pluginID] = "disabled" + } + } + return statuses, nil +} + func (s *Service) RequireWorkflowPluginForInterface(interfaceType string) error { pluginID, ok := workflowPluginIDForInterface(normalizeWorkflowInterfaceType(interfaceType)) if !ok { @@ -89,6 +110,20 @@ func (s *Service) RequireWorkflowPluginForInterface(interfaceType string) error return nil } +func (s *Service) RequireWorkflowPluginForUser(userID string, interfaceType string) error { + pluginID, ok := workflowPluginIDForInterface(normalizeWorkflowInterfaceType(interfaceType)) + if !ok { + return Forbidden("未知工作流插件") + } + if err := s.RequirePluginForUser(userID, pluginID); err != nil { + if pluginID == WorkflowPluginRunningHub { + return Forbidden("RunningHub 工作流插件未启用") + } + return Forbidden("ComfyUI Bridge 工作流插件未启用") + } + return nil +} + func normalizeWorkflowInterfaceType(value string) string { value = strings.ToLower(strings.TrimSpace(value)) return value diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 592d623c1..2839b504a 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -7,6 +7,15 @@ description: 影策后端核心持久化表与状态边界 后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 +## 插件管理 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `plugin_platform_states` | 保存管理员设置的平台可用状态 | `plugin_id` 为主键;平台停用是所有用户的硬上限,管理员变更写入审计日志 | +| `user_plugin_states` | 保存用户对官方应用插件的个人启用状态 | `(user_id, plugin_id)` 唯一;系统协议和管理员上传插件不得写入用户级状态 | + +有效状态由服务端计算为 `platform_available AND user_enabled`。官方应用插件没有平台状态记录时默认可用;用户没有个人记录时默认停用,历史上全局启用的工作流插件在用户首次保存个人选择前沿用旧状态。管理员上传包的来源和作用域由宿主覆盖,不能通过 Manifest 提权。 + ## ComfyUI Bridge | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/plugins/plugin-system.mdx b/docs/content/docs/plugins/plugin-system.mdx index 7d7a95831..f3c9889cc 100644 --- a/docs/content/docs/plugins/plugin-system.mdx +++ b/docs/content/docs/plugins/plugin-system.mdx @@ -18,7 +18,7 @@ description: 影策统一插件系统的扩展边界、Manifest 合同与 Eagle ## 2. 设计目标 - 第三方开发者可以独立开发、构建和发布插件。 -- 用户可以查看插件信息、安装、启用、停用、更新和卸载插件。 +- 普通用户可以查看并启用官方应用插件;管理员统一安装、更新、停用和卸载系统协议及自定义插件。 - 插件不直接依赖页面内部 React 组件和 Zustand store。 - 宿主可以通过 `apiVersion` 和权限声明控制兼容性与风险。 - 插件的错误、超时、权限拒绝和版本不兼容必须可见,不能静默降级。 @@ -41,6 +41,18 @@ description: 影策统一插件系统的扩展边界、Manifest 合同与 Eagle 插件分类用于权限、市场筛选和能力发现,不直接决定代码目录结构。 +### 4.0 管理分类与权限 + +插件的管理分类由宿主根据插件 ID 和安装来源计算,不能由上传包自行声明: + +| 分类 | 典型插件 | 平台可用性 | 用户启用状态 | 配置范围 | +| --- | --- | --- | --- | --- | +| 系统插件 | OpenAI、Gemini、即梦等协议 Provider | 管理员 | 不适用 | 管理员 | +| 官方插件 | RunningHub、ComfyUI Bridge、Eagle、提示词优化器、肖像排查 | 管理员作为硬上限 | 每个用户独立 | 按插件为用户级或无配置 | +| 自定义插件 | 管理员上传的 `.yingce-plugin` | 管理员 | 不适用 | 管理员 | + +`/plugins` 是用户发现、个人启用和个人配置入口;`/admin/plugins` 是平台可用性、上传、卸载和启用人数入口。普通用户是否能看到只读的系统/自定义插件由“向普通用户显示系统插件”功能开关控制,但官方应用插件始终保留个人开关。 + ### 4.1 画布节点插件 新增自定义节点类型、节点内容渲染、节点详情面板、节点工具栏和节点输入输出资源。 @@ -179,6 +191,8 @@ export interface PluginSetupApi { 5. 卸载阶段清理插件存储、事件订阅、注册命令和可选的插件节点数据。 6. 更新失败时保留旧版本,不能留下半启用状态。 +平台状态与用户状态分开保存:`effectiveEnabled = platformAvailable && userEnabled`。协议和自定义插件只有平台状态。用户停用工作流插件后禁止新任务进入,但已经接收的任务和 Bridge 结果回传继续完成;管理员停用属于平台硬上限,同样不强行破坏已进入执行阶段的任务。 + ## 8. Eagle 外部素材来源方案 Eagle 插件的主分类是“素材与数据连接器”,可以附带一个 `EagleAsset` 画布节点。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 5791eef7f..d4e415dbc 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 插件分层管理 + +- [ ] 管理员在 `/admin/plugins` 停用官方应用插件后,普通用户的有效状态和新请求同时被阻止;重新开放后保留原个人启用选择。 +- [ ] 两个普通用户分别启用和停用同一官方应用插件,确认状态、Eagle 配置和工作流入口互不串号。 +- [ ] 普通用户不能切换系统协议或管理员上传插件;关闭“向普通用户显示系统插件”后,两类插件均不可见。 +- [ ] 用户停用 RunningHub / ComfyUI 后不能提交新任务,但已接收任务、Bridge 轮询、心跳和结果回传可以完成。 +- [ ] 管理员上传、停用、启用和卸载自定义插件后,插件包、平台状态、用户状态与审计日志一致。 +- [ ] 验证 `/plugins` 与 `/admin/plugins` 的浅色/深色主题、窄屏、空态、筛选、上传弹窗和开关失败回滚。 + ## RunningHub 与 ComfyUI Bridge 工作流 - [x] 后端全量测试、前端类型检查、Canvas Agent 构建和原生 Bridge Go 测试通过。 diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 9914ee7fe..ceadf21e6 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -262,11 +262,12 @@ export function CanvasNodeContextMenu({ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const installations = usePluginStore((state) => state.installations); + const pluginStates = usePluginStore((state) => state.pluginStates); const left = getSubmenuLeft(parentPosition.left); const createContext: AddNodeMenuContext = { workspaceMode, isProjectLinked, - enabledPluginIds: new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id)), + enabledPluginIds: new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)), handlers: { onAddText: () => onAddNode(CanvasNodeType.Text), onAddImage: () => onAddNode(CanvasNodeType.Image), diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index f5f512e6b..3c70ee31b 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -59,6 +59,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const theme = canvasThemes[themeName]; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); + const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); const simpleMode = workspaceMode === "simple"; const mode = defaultMode(node.type); const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); @@ -102,9 +103,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const promptOptimizerProvider = useMemo(() => { - if (!promptOptimizerInstallation?.enabled || !promptOptimizerPlugin.createPromptOptimizer) return null; + if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig)); - }, [globalConfig, promptOptimizerInstallation]); + }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]); const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); const credits = requestCreditCost({ diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index d2ba71204..9ea3be52a 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -85,6 +85,7 @@ export function CanvasToolbar({ const dockRef = useRef(null); const colorTheme = useThemeStore((state) => state.theme); const installations = usePluginStore((state) => state.installations); + const pluginStates = usePluginStore((state) => state.pluginStates); const setTheme = useThemeStore((state) => state.setTheme); const theme = canvasThemes[colorTheme]; const [addOpen, setAddOpen] = useState(false); @@ -173,7 +174,7 @@ export function CanvasToolbar({ handlers, }; - const enabledPluginIds = new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id)); + const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)); const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main")); diff --git a/web/src/components/canvas/nodes/portrait-clearance-node.tsx b/web/src/components/canvas/nodes/portrait-clearance-node.tsx index e9338a4d7..6eba1eba9 100644 --- a/web/src/components/canvas/nodes/portrait-clearance-node.tsx +++ b/web/src/components/canvas/nodes/portrait-clearance-node.tsx @@ -23,11 +23,10 @@ const roleLabels: Record = { export function PortraitClearanceNodeContent({ node }: PortraitClearanceNodeProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const installations = usePluginStore((state) => state.installations); + const enabled = usePluginStore((state) => state.pluginStates[PORTRAIT_CLEARANCE_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled)); const { openPortraitClearance } = useCanvasNodeActions(); const upstream = useUpstreamNodes(node.id).filter((item) => isPortraitImageInput(item)); const state = node.metadata?.portraitClearance; - const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled); const bindings = state?.inputBindings || []; const roleByNodeId = new Map(bindings.map((binding) => [binding.nodeId, binding.role])); const query = upstream.find((item) => roleByNodeId.get(item.id) === "query") || upstream[0]; diff --git a/web/src/components/canvas/portrait-clearance/portrait-clearance-modal.tsx b/web/src/components/canvas/portrait-clearance/portrait-clearance-modal.tsx index a018a6d53..0f841d04a 100644 --- a/web/src/components/canvas/portrait-clearance/portrait-clearance-modal.tsx +++ b/web/src/components/canvas/portrait-clearance/portrait-clearance-modal.tsx @@ -22,9 +22,8 @@ type PortraitClearanceModalProps = { }; export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, onClose, onUpdateState, onAddCandidate }: PortraitClearanceModalProps) { - const installations = usePluginStore((state) => state.installations); const effectiveConfig = useEffectiveConfig(); - const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled); + const enabled = usePluginStore((state) => state.pluginStates[PORTRAIT_CLEARANCE_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled)); const state = node?.metadata?.portraitClearance || createDefaultPortraitClearanceState(); const [starting, setStarting] = useState(false); const [installingModels, setInstallingModels] = useState(false); diff --git a/web/src/components/layout/app-providers.tsx b/web/src/components/layout/app-providers.tsx index ce464268f..420e2961a 100644 --- a/web/src/components/layout/app-providers.tsx +++ b/web/src/components/layout/app-providers.tsx @@ -11,7 +11,7 @@ import { appQueryClient } from "@/lib/query-client"; import { useThemeStore } from "@/stores/use-theme-store"; import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry"; import { usePluginStore } from "@/stores/use-plugin-store"; -import { fetchWorkflowPluginStatuses } from "@/services/api/plugins"; +import { fetchPluginRuntimeState, setUserPluginEnabled } from "@/services/api/plugins"; import { useUserStore } from "@/stores/use-user-store"; export function AppProviders({ children }: { children: ReactNode }) { @@ -19,29 +19,52 @@ export function AppProviders({ children }: { children: ReactNode }) { const dark = theme === "dark"; const ensurePlugin = usePluginStore((state) => state.ensurePlugin); const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses); + const setPluginStates = usePluginStore((state) => state.setPluginStates); + const pluginStoreHydrated = usePluginStore((state) => state.hydrated); const userId = useUserStore((state) => state.user?.id); useEffect(() => { + if (!pluginStoreHydrated) return; for (const plugin of listRegisteredPlugins()) ensurePlugin(plugin.manifest); - }, [ensurePlugin]); + }, [ensurePlugin, pluginStoreHydrated, userId]); useEffect(() => { if (!userId) { setRuntimeStatuses({}); + setPluginStates({}); return; } + if (!pluginStoreHydrated) return; let cancelled = false; - void fetchWorkflowPluginStatuses() - .then((statuses) => { - if (!cancelled) setRuntimeStatuses(statuses); + void fetchPluginRuntimeState() + .then(async ({ statuses, states }) => { + const legacyEnabledIds = usePluginStore.getState().installations + .filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured) + .map((installation) => installation.manifest.id); + if (legacyEnabledIds.length) { + try { + const migrated = await Promise.all(legacyEnabledIds.map((pluginId) => setUserPluginEnabled(pluginId, true))); + for (const state of migrated) states[state.pluginId] = state; + for (const pluginId of legacyEnabledIds) statuses[pluginId] = states[pluginId]?.effectiveEnabled ? "enabled" : "disabled"; + } catch (error) { + console.warn("迁移用户插件启用状态失败,已保留服务端状态", error); + } + } + if (!cancelled) { + setRuntimeStatuses(statuses); + setPluginStates(states); + } }) .catch(() => { - if (!cancelled) setRuntimeStatuses({}); + if (!cancelled) { + setRuntimeStatuses({}); + setPluginStates({}); + } }); return () => { cancelled = true; }; - }, [setRuntimeStatuses, userId]); + }, [pluginStoreHydrated, setPluginStates, setRuntimeStatuses, userId]); useEffect(() => { document.documentElement.classList.toggle("dark", dark); diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 3ef80a8dc..900d0570a 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -8,6 +8,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/st import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { useUserStore } from "@/stores/use-user-store"; +import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store"; import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync"; import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle"; @@ -29,16 +30,18 @@ export async function applyUserSession(payload: AuthSessionPayload) { // Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。 if (previousUserId !== nextUserId) appQueryClient.clear(); await switchUserStorageScope(payload.user?.id); - const [persistedCanvas, persistedAssets] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY)]); + const [persistedCanvas, persistedAssets, persistedPlugins] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY), localForageStorage.getItem(PLUGIN_STORE_KEY)]); const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY); + usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} }); useUserStore.getState().setUser(payload.user); useUserStore.getState().setRuntimeLimits(payload.runtimeLimits); useUserStore.getState().setDrawingEngine(payload.drawingEngine); useUserStore.getState().setFeatures(payload.features); - await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]); + await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]); // Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。 if (!persistedCanvas) useCanvasStore.setState({ projects: [] }); if (!persistedAssets) useAssetStore.setState({ assets: [] }); + if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} }); if (!persistedConfig) { // 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。 // 使用统一模型目录接口 diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index c19b2703e..27ad1b3e0 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -14,6 +14,7 @@ import { Mail, MessageSquareText, Paintbrush, + PlugZap, PanelLeftClose, PanelLeftOpen, RadioTower, @@ -54,6 +55,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: }, { path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: }, { path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: , requireFeature: "frontendModelsEnabled" }, + { path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: }, { path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: }, ], }, diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx index 42a152caf..e13d1a792 100644 --- a/web/src/pages/admin/components/feature-availability-panel.tsx +++ b/web/src/pages/admin/components/feature-availability-panel.tsx @@ -14,7 +14,7 @@ const userFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; des { key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: }, { key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: }, { key: "pluginCenterEnabled", title: "插件中心", menu: "/plugins", description: "关闭后普通用户无法进入插件中心或调用插件中心相关接口;管理员仍可进入后台恢复开关。已有插件不会删除。", icon: }, - { key: "systemPluginsVisibleToUsers", title: "向普通用户显示系统插件(不可编辑)", menu: "/plugins", description: "仅在插件中心开启时生效。关闭后普通用户看不到系统插件,用户插件不受影响。", icon: }, + { key: "systemPluginsVisibleToUsers", title: "向普通用户显示系统插件(只读)", menu: "/plugins", description: "仅在插件中心开启时生效。关闭后普通用户看不到协议类系统插件和管理员上传的自定义插件;官方应用插件仍可由用户自行启用。", icon: }, ]; const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [ diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx new file mode 100644 index 000000000..ab6588c78 --- /dev/null +++ b/web/src/pages/admin/plugins/plugins-page.tsx @@ -0,0 +1,245 @@ +import { App, Button, Input, Select, Switch, Tag } from "antd"; +import { CloudUpload, PlugZap, RefreshCw, Search, ShieldCheck, Trash2, UsersRound } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import "@/lib/plugins/builtin"; +import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; +import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; +import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; +import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry"; +import type { PluginManifest } from "@/lib/plugins/plugin-types"; +import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uploadPlugin, type AdminPluginState, type BackendPlugin, type PluginManagement } from "@/services/api/plugins"; +import { UploadPluginModal } from "@/pages/plugins/plugin-documentation-modals"; + +import { AdminPageFrame } from "../components/admin-shell"; + +type AdminPluginItem = { + manifest: PluginManifest; + source: string; + management: PluginManagement; + status?: string; + error?: string; +}; + +const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance"]); + +export default function AdminPluginsPage() { + const { message, modal } = App.useApp(); + const [plugins, setPlugins] = useState([]); + const [states, setStates] = useState>({}); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(""); + const [uploadOpen, setUploadOpen] = useState(false); + const [search, setSearch] = useState(""); + const [kind, setKind] = useState<"all" | "application" | "protocol" | "uploaded">("all"); + + const reload = async () => { + setLoading(true); + try { + const result = await fetchAdminPlugins(); + setPlugins(result.plugins); + setStates(result.states); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取插件管理数据失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void reload(); + }, []); + + const items = useMemo(() => mergePlugins(plugins), [plugins]); + const filtered = useMemo(() => { + const keyword = search.trim().toLocaleLowerCase(); + return items.filter((item) => { + if (kind === "uploaded" && item.management.origin !== "uploaded") return false; + if (kind !== "all" && kind !== "uploaded" && item.management.kind !== kind) return false; + if (!keyword) return true; + return [item.manifest.name, item.manifest.id, item.manifest.description, item.manifest.author].filter(Boolean).join(" ").toLocaleLowerCase().includes(keyword); + }); + }, [items, kind, search]); + + const changeAvailability = async (item: AdminPluginItem, available: boolean) => { + setSavingId(item.manifest.id); + try { + const state = await setPluginPlatformAvailability(item.manifest.id, available); + setStates((current) => ({ ...current, [item.manifest.id]: state })); + message.success(`${item.manifest.name}${available ? "已开放" : "已停用"}`); + } catch (error) { + message.error(error instanceof Error ? error.message : "更新插件可用状态失败"); + } finally { + setSavingId(""); + } + }; + + const upload = async (file: File) => { + try { + await uploadPlugin(file); + setUploadOpen(false); + message.success("自定义插件已安装"); + await reload(); + } catch (error) { + message.error(error instanceof Error ? error.message : "安装插件失败"); + } + }; + + const remove = (item: AdminPluginItem) => { + modal.confirm({ + title: `卸载 ${item.manifest.name}?`, + content: "插件包、平台状态和所有用户的启用记录都会删除。此操作不可撤销。", + okText: "确认卸载", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: async () => { + try { + await uninstallPlugin(item.manifest.id); + message.success("自定义插件已卸载"); + await reload(); + } catch (error) { + message.error(error instanceof Error ? error.message : "卸载插件失败"); + throw error; + } + }, + }); + }; + + const applicationCount = items.filter((item) => item.management.kind === "application").length; + const protocolCount = items.filter((item) => item.management.kind === "protocol").length; + const unavailableCount = items.filter((item) => states[item.manifest.id] && !states[item.manifest.id].platformAvailable).length; + + return ( + + + + + } + > +
+ + + +
+ +
+ } value={search} placeholder="搜索插件名称、ID 或作者" onChange={(event) => setSearch(event.target.value)} /> +