From 7f9ff18743d7ed0fb31b620ef3ba0cf0187f8acb Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 28 Jul 2026 10:26:10 +0800 Subject: [PATCH] refactor: update AI agent handling to maintain active published revision and improve toast messages --- internal/services/agent_revision_service.go | 12 ++- .../services/agent_revision_service_test.go | 58 +++++++++++++- internal/services/ai_agent_service.go | 1 - internal/services/ai_agent_service_test.go | 80 +++++++++++++++++++ .../config-workbench-copy.test.mjs | 5 ++ .../_components/config-workbench.tsx | 10 ++- 6 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 internal/services/ai_agent_service_test.go diff --git a/internal/services/agent_revision_service.go b/internal/services/agent_revision_service.go index bee8e9e..91e5e99 100644 --- a/internal/services/agent_revision_service.go +++ b/internal/services/agent_revision_service.go @@ -113,8 +113,16 @@ func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, co if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil { return nil, errorsx.InvalidParam("published Agent revision is invalid") } - if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID { - return nil, errorsx.InvalidParam("published agent model config no longer matches") + publishedConfigID := definition.Agent.AIConfigID + if publishedConfigID <= 0 { + publishedConfigID = definition.Model.ConfigID + } + if publishedConfigID > 0 && publishedConfigID != config.ID { + publishedConfig := repositories.AIConfigRepository.Get(sqls.DB(), publishedConfigID) + if publishedConfig == nil || publishedConfig.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("published agent model config is unavailable") + } + snapshot.AIConfig = *publishedConfig } applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent) snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...) diff --git a/internal/services/agent_revision_service_test.go b/internal/services/agent_revision_service_test.go index 335fe04..0fe53b3 100644 --- a/internal/services/agent_revision_service_test.go +++ b/internal/services/agent_revision_service_test.go @@ -18,7 +18,7 @@ func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing. if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&models.AgentRevision{}); err != nil { + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil { t.Fatalf("auto migrate: %v", err) } sqls.SetDB(db) @@ -48,3 +48,59 @@ func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing. t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig) } } + +func TestAgentRevisionServiceUsesPublishedModelConfigAfterDraftConfigChanges(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + + publishedConfig := &models.AIConfig{ + Name: "published", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, ModelName: "current-published-model", APIKey: "published-secret", + } + draftConfig := &models.AIConfig{ + Name: "draft", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, ModelName: "draft-model", APIKey: "draft-secret", + } + if err := db.Create(publishedConfig).Error; err != nil { + t.Fatalf("create published config: %v", err) + } + if err := db.Create(draftConfig).Error; err != nil { + t.Fatalf("create draft config: %v", err) + } + + definition := agentRevisionDefinition{ + Agent: agentRevisionAgent{AIConfigID: publishedConfig.ID, SystemPrompt: "published instruction"}, + Model: agentRevisionModel{ + ConfigID: publishedConfig.ID, Provider: string(enums.AIProviderOpenAI), + ModelType: string(enums.AIModelTypeLLM), ModelName: "snapshotted-published-model", + }, + } + data, err := json.Marshal(definition) + if err != nil { + t.Fatalf("marshal definition: %v", err) + } + revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + + snapshot, err := AgentRevisionService.ResolvePublishedSnapshot( + models.AIAgent{ID: 7, AIConfigID: draftConfig.ID, PublishedRevisionID: revision.ID}, + *draftConfig, + ) + if err != nil { + t.Fatalf("ResolvePublishedSnapshot: %v", err) + } + if snapshot.AIConfig.ID != publishedConfig.ID { + t.Fatalf("model config id = %d, want published config %d", snapshot.AIConfig.ID, publishedConfig.ID) + } + if snapshot.AIConfig.ModelName != "snapshotted-published-model" || snapshot.AIConfig.APIKey != "published-secret" { + t.Fatalf("published model snapshot not restored safely: %#v", snapshot.AIConfig) + } +} diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index bfe3fc8..9718148 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -123,7 +123,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato "knowledge_ids": item.KnowledgeIDs, "skill_ids": item.SkillIDs, "allowed_mcp_tools": item.AllowedMCPTools, - "published_revision_id": 0, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), diff --git a/internal/services/ai_agent_service_test.go b/internal/services/ai_agent_service_test.go new file mode 100644 index 0000000..9b77f0a --- /dev/null +++ b/internal/services/ai_agent_service_test.go @@ -0,0 +1,80 @@ +package services + +import ( + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) { + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.AIAgentWorkflowBinding{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + + config := &models.AIConfig{ + Name: "test", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI, + ModelType: enums.AIModelTypeLLM, ModelName: "test-model", + } + if err := db.Create(config).Error; err != nil { + t.Fatalf("create ai config: %v", err) + } + agent := &models.AIAgent{ + Name: "published agent", Status: enums.StatusOk, AIConfigID: config.ID, + ServiceMode: enums.IMConversationServiceModeAIFirst, HandoffMode: enums.AIAgentHandoffModeWaitPool, + FallbackMode: enums.AIAgentFallbackModeNoAnswer, RolloutPercent: 100, PublishedRevisionID: 18, + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + err = AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ + ID: agent.ID, + CreateAIAgentRequest: request.CreateAIAgentRequest{ + Name: "updated draft", AIConfigID: config.ID, + ServiceMode: enums.IMConversationServiceModeAIFirst, + HandoffMode: enums.AIAgentHandoffModeWaitPool, + FallbackMode: enums.AIAgentFallbackModeNoAnswer, + RolloutPercent: 100, + }, + }, &dto.AuthPrincipal{UserID: 1, Username: "admin"}) + if err != nil { + t.Fatalf("UpdateAIAgent: %v", err) + } + + var updated models.AIAgent + if err := db.First(&updated, agent.ID).Error; err != nil { + t.Fatalf("get updated ai agent: %v", err) + } + if updated.PublishedRevisionID != agent.PublishedRevisionID { + t.Fatalf("published revision id = %d, want %d", updated.PublishedRevisionID, agent.PublishedRevisionID) + } + if updated.Name != "updated draft" { + t.Fatalf("draft name = %q, want updated draft", updated.Name) + } +} diff --git a/web/app/dashboard/ai-agents/_components/config-workbench-copy.test.mjs b/web/app/dashboard/ai-agents/_components/config-workbench-copy.test.mjs index 6011509..8063d0e 100644 --- a/web/app/dashboard/ai-agents/_components/config-workbench-copy.test.mjs +++ b/web/app/dashboard/ai-agents/_components/config-workbench-copy.test.mjs @@ -58,3 +58,8 @@ test("publishing an AI Agent saves the current form before publishing", () => { assert.ok(publishIndex > saveIndex) assert.match(publishFunction, /Agent 配置已保存并发布/) }) + +test("saving a published AI Agent keeps the active revision online", () => { + assert.match(configWorkbenchSource, /配置已保存,当前已发布版本继续生效/) + assert.match(configWorkbenchSource, /已发布版本正在生效;再次发布后应用当前配置/) +}) diff --git a/web/app/dashboard/ai-agents/_components/config-workbench.tsx b/web/app/dashboard/ai-agents/_components/config-workbench.tsx index 8f0832e..13b44eb 100644 --- a/web/app/dashboard/ai-agents/_components/config-workbench.tsx +++ b/web/app/dashboard/ai-agents/_components/config-workbench.tsx @@ -390,7 +390,11 @@ export function AIAgentConfigWorkbench({ const payload = buildPayload() if (agent) { await updateAIAgent({ id: agent.id, ...payload }) - toast.success("Agent 配置已保存") + toast.success( + agent.publishedRevisionId > 0 + ? "配置已保存,当前已发布版本继续生效" + : "Agent 配置已保存", + ) await loadData() } else { const created = await createAIAgent(payload) @@ -828,7 +832,9 @@ export function AIAgentConfigWorkbench({ )} /> - {agentPublished ? "Agent 已发布;再次发布会保存当前配置" : "发布时会先保存当前配置"} + {agentPublished + ? "已发布版本正在生效;再次发布后应用当前配置" + : "发布时会先保存当前配置"}