diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index e7a177d08..6a5c26454 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -58,6 +58,18 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) { r.PATCH("/admin/logical-models/:id", func(c *gin.Context) { saveAdminLogicalModel(c, svc, c.Param("id")) }) + r.DELETE("/admin/logical-models/:id", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteAdminLogicalModel(actor, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) r.POST("/admin/logical-models/:id/simulate", func(c *gin.Context) { actor, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 7adf9b209..762f7c1c5 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -20,16 +20,18 @@ type LogicalModel struct { Enabled bool `json:"enabled" gorm:"index"` SortOrder int `json:"sortOrder" gorm:"index"` // RevisionSequence 记录已经分配的 revision 版本号,由数据库原子递增。 - RevisionSequence int `json:"-" gorm:"not null;default:0"` - ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` - PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"` - BillingMode string `json:"billingMode" gorm:"size:32"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` - OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` - CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + RevisionSequence int `json:"-" gorm:"not null;default:0"` + ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` + PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` + OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` + CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` + // ArchivedAt 仅从可选目录隐藏模型;历史任务、计费和审计仍需读取主体及不可变 revision。 + ArchivedAt *time.Time `json:"-" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // LogicalModelRevision 固化一次可发布的前台能力合同。默认值属于前台模型,不能由供应商隐式决定。 diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 6436a3869..337bff5ea 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -211,6 +211,9 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.requireActiveLogicalModelForTask(tx, task); err != nil { + return err + } if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } @@ -223,6 +226,9 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.requireActiveLogicalModelForTask(tx, task); err != nil { + return err + } if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index e8e7bc797..34c6fc09a 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -7,6 +7,7 @@ import ( "infinite-canvas/backend/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type LogicalModelGraph struct { @@ -16,9 +17,12 @@ type LogicalModelGraph struct { ChannelModels []model.ChannelModel } +var ErrLogicalModelInUse = errors.New("logical model is in use") +var ErrLogicalModelUnavailable = errors.New("logical model is unavailable") + func (r *Repository) LogicalModels(includeDisabled bool) ([]model.LogicalModel, error) { var items []model.LogicalModel - query := r.db.Order("sort_order asc, created_at asc") + query := r.db.Where("archived_at IS NULL").Order("sort_order asc, created_at asc") if !includeDisabled { query = query.Where("enabled = ?", true) } @@ -401,6 +405,68 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * }) } +func (r *Repository) ArchiveLogicalModel(id string, audit *model.AdminAuditEvent, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // PostgreSQL 下锁定主体,使任务创建与归档在同一行上串行,避免检查后仍写入新任务。 + var item model.LogicalModel + query := tx.Where("id = ? AND archived_at IS NULL", id) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.First(&item).Error; err != nil { + return err + } + var activeTasks int64 + if err := tx.Model(&model.Task{}). + Where("logical_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeTasks).Error; err != nil { + return err + } + if activeTasks > 0 { + return ErrLogicalModelInUse + } + archived := tx.Model(&model.LogicalModel{}). + Where("id = ? AND archived_at IS NULL", id). + Updates(map[string]any{"enabled": false, "archived_at": now, "updated_at": now}) + if archived.Error != nil { + return archived.Error + } + if archived.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if audit != nil { + if err := tx.Create(audit).Error; err != nil { + return err + } + } + return nil + }) +} + +// requireActiveLogicalModelForTask 与归档共用主体行锁,保证新任务只引用仍在目录中的当前 revision。 +func (r *Repository) requireActiveLogicalModelForTask(tx *gorm.DB, task *model.Task) error { + if task == nil || task.LogicalModelID == "" { + return nil + } + var item model.LogicalModel + query := tx.Select("id").Where( + "id = ? AND enabled = ? AND archived_at IS NULL AND active_revision_id = ?", + task.LogicalModelID, + true, + task.LogicalModelRevisionID, + ) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrLogicalModelUnavailable + } + return err + } + return nil +} + func (r *Repository) DeleteLogicalModelRoute(id string) error { return r.db.Model(&model.LogicalModelRoute{}).Where("id = ?", id).Updates(map[string]any{"enabled": false, "weight": 0, "updated_at": time.Now()}).Error } diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index b064f5823..cd1faee1e 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -1,6 +1,7 @@ package repository import ( + "errors" "testing" "time" @@ -48,3 +49,95 @@ func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) t.Fatalf("updated model state = %#v", updated) } } + +func TestArchiveLogicalModelPreservesPublishedHistory(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-history?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + + item := &model.LogicalModel{ID: "LMODEL_DELETE", Code: "delete-demo", Name: "Delete Demo", Capability: "text", Enabled: true, PricePolicy: "unified", BillingMode: "fixed_request", CreatedAt: time.Now(), UpdatedAt: time.Now()} + revision := &model.LogicalModelRevision{ID: "REVISION_DELETE", LogicalModelID: item.ID, CapabilitySpecJSON: `{"version":1,"capability":"text"}`, DefaultOptionsJSON: `{}`, CreatedAt: time.Now()} + routes := []model.LogicalModelRoute{{ID: "ROUTE_DELETE", ChannelModelID: "CMODEL_DELETE", Enabled: true, Weight: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}} + if err := repo.SaveLogicalModelBundle(item, revision, routes, true); err != nil { + t.Fatal(err) + } + + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); err != nil { + t.Fatal(err) + } + var archived model.LogicalModel + if err := db.First(&archived, "id = ?", item.ID).Error; err != nil { + t.Fatalf("logical model lookup error = %v", err) + } + if archived.ArchivedAt == nil || archived.Enabled { + t.Fatalf("archived logical model = %#v", archived) + } + items, err := repo.LogicalModels(true) + if err != nil { + t.Fatalf("LogicalModels() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("LogicalModels() = %#v, want archived model hidden", items) + } + for label, target := range map[string]any{"revision": &model.LogicalModelRevision{}, "route": &model.LogicalModelRoute{}} { + if err := db.First(target).Error; err != nil { + t.Fatalf("%s history was not preserved: %v", label, err) + } + } +} + +func TestArchiveLogicalModelRejectsActiveTask(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-active-task?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + item := model.LogicalModel{ID: "LMODEL_ACTIVE", Code: "active-demo", Name: "Active Demo", Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Task{ID: "TASK_ACTIVE", LogicalModelID: item.ID, Status: model.TaskStatusRunning, CreatedAt: time.Now(), UpdatedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { + t.Fatalf("ArchiveLogicalModel() error = %v, want ErrLogicalModelInUse", err) + } + var preserved model.LogicalModel + if err := db.First(&preserved, "id = ?", item.ID).Error; err != nil { + t.Fatalf("active logical model was removed: %v", err) + } + if !preserved.Enabled { + t.Fatal("active logical model disable was not rolled back") + } +} + +func TestCreateTaskRejectsArchivedLogicalModel(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-archived-task?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + item := model.LogicalModel{ID: "LMODEL_ARCHIVED_TASK", Code: "archived-task", Name: "Archived Task", Enabled: true, ActiveRevisionID: "REVISION_ARCHIVED_TASK", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); err != nil { + t.Fatal(err) + } + task := &model.Task{ID: "TASK_ARCHIVED_MODEL", UserID: "USER_1", LogicalModelID: item.ID, LogicalModelRevisionID: item.ActiveRevisionID, Status: model.TaskStatusQueued, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := repo.CreateTaskWithActiveLimit(task, 5); !errors.Is(err, ErrLogicalModelUnavailable) { + t.Fatalf("CreateTaskWithActiveLimit() error = %v, want ErrLogicalModelUnavailable", err) + } +} diff --git a/backend/internal/service/admin_audit.go b/backend/internal/service/admin_audit.go index 454075d0b..3db2f0fb4 100644 --- a/backend/internal/service/admin_audit.go +++ b/backend/internal/service/admin_audit.go @@ -34,21 +34,29 @@ type AdminAuditPage struct { } func (s *Service) appendAdminAudit(actor *model.User, action string, targetType string, targetID string, summary string, metadata any) error { + event, err := newAdminAuditEvent(actor, action, targetType, targetID, summary, metadata) + if err != nil { + return err + } + return s.repo.AppendAdminAudit(event) +} + +func newAdminAuditEvent(actor *model.User, action string, targetType string, targetID string, summary string, metadata any) (*model.AdminAuditEvent, error) { if actor == nil { - return Unauthorized("请先登录") + return nil, Unauthorized("请先登录") } encoded := "" if metadata != nil { data, err := json.Marshal(metadata) if err != nil { - return err + return nil, err } encoded = string(data) } - return s.repo.AppendAdminAudit(&model.AdminAuditEvent{ + return &model.AdminAuditEvent{ ID: newID(), ActorUserID: actor.ID, Action: strings.TrimSpace(action), TargetType: strings.TrimSpace(targetType), TargetID: strings.TrimSpace(targetID), Summary: truncateRunes(strings.TrimSpace(summary), 500), MetadataJSON: encoded, CreatedAt: time.Now(), - }) + }, nil } func (s *Service) AdminUserDetail(actor *model.User, userID string) (*AdminUserDetail, error) { diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 59d1138f5..44dce5b9c 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -457,7 +457,7 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) { logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil || !logicalModel.Enabled || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID { + if err != nil || (!logicalModel.Enabled && logicalModel.ArchivedAt == nil) || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID { return nil, BadAuthRequest("所选模型计费配置已失效,请重新选择") } route, err := s.repo.LogicalModelRoute(task.RouteID) diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 39ccc9270..8be0840da 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -408,6 +408,37 @@ func (s *Service) SaveAdminLogicalModel(actor *model.User, id string, req Logica return s.buildAdminLogicalModel(*item, graph, systemChannelByID) } +func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + item, err := s.repo.LogicalModel(strings.TrimSpace(id)) + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + if err != nil { + return err + } + if item.ArchivedAt != nil { + return BadAuthRequest("前台模型不存在或已删除") + } + audit, err := newAdminAuditEvent(actor, "logical_model.archive", "logical_model", item.ID, "归档前台模型", map[string]any{"code": item.Code, "name": item.Name}) + if err != nil { + return err + } + if err := s.repo.ArchiveLogicalModel(item.ID, audit, time.Now()); err != nil { + if errors.Is(err, repository.ErrLogicalModelInUse) { + return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再归档") + } + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + return err + } + s.invalidateRouteCatalog() + return nil +} + func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalModelRequest) (*model.LogicalModel, *model.LogicalModelRevision, []model.LogicalModelRoute, bool, error) { code := strings.ToLower(strings.TrimSpace(req.Code)) name := strings.TrimSpace(req.Name) @@ -464,6 +495,9 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if err != nil { return nil, nil, nil, false, err } + if item.ArchivedAt != nil { + return nil, nil, nil, false, BadAuthRequest("前台模型不存在或已删除") + } } item.Code, item.Name, item.Icon, item.Description, item.Capability = code, name, strings.TrimSpace(req.Icon), strings.TrimSpace(req.Description), capability item.Enabled, item.SortOrder, item.PricePolicy, item.BillingMode = req.Enabled, req.SortOrder, pricePolicy, billingMode diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index ccc76f59a..a663eab52 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -286,7 +286,16 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa return spec, nil } if config == nil { - return spec, BadAuthRequest("渠道模型尚未配置能力参数") + switch capability { + case "text": + return spec, BadAuthRequest("渠道文本模型尚未配置能力参数") + case "image": + return spec, BadAuthRequest("渠道图片模型尚未配置能力参数") + case "video": + return spec, BadAuthRequest("渠道视频模型尚未配置能力参数") + default: + return spec, BadAuthRequest("渠道模型尚未配置能力参数") + } } switch capability { case "text": diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 9ed0b7e33..624576157 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -1018,7 +1018,17 @@ func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any return nil } intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - routed, err := s.ResolveLogicalModel(task.LogicalModelID, intent) + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return err + } + var routed *RoutedModel + if logicalModel.ArchivedAt != nil { + // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 + routed, err = s.resolveArchivedTaskRoute(task, intent) + } else { + routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) + } if err != nil { return err } @@ -1042,6 +1052,56 @@ func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any return nil } +// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 +// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 +func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { + if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { + return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + if logicalModel.ArchivedAt == nil { + return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") + } + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { + return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil || !channelModel.Enabled { + return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") + } + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil +} + func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { if attempt == nil { return diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index fe43e9ff9..c96c65e31 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -35,11 +35,17 @@ type canvasGenerationInput struct { TextHistory []providerTextMessage `json:"textHistory"` Mask *providerMedia `json:"mask"` Metadata map[string]interface{} `json:"metadata"` + AgentRequests *agentToolRequests `json:"agentRequests"` ImageCapability *ImageCapabilityConfig `json:"-"` StreamText bool `json:"-"` // 分镜请求使用上游 SSE 保活;最终结构仍在流结束后统一校验。 VideoCapability *VideoCapabilityConfig `json:"-"` } +type agentToolRequests struct { + Responses map[string]interface{} `json:"responses"` + ChatCompletion map[string]interface{} `json:"chatCompletion"` +} + type providerTextMessage struct { Role string `json:"role"` Content string `json:"content"` @@ -247,6 +253,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string case "image": return runImageTask(ctx, input) case "text": + if input.AgentRequests != nil { + return runAgentToolTask(ctx, input) + } result, taskErr := runTextTask(ctx, input) if taskErr == nil && promptTemplateOperation != "" { taskErr = validatePromptTemplateResult(promptTemplateOperation, result) @@ -261,6 +270,128 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + request := input.AgentRequests.ChatCompletion + path := "/chat/completions" + protocol := "chat-completion" + if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) { + request = input.AgentRequests.Responses + path = "/responses" + protocol = "responses" + } + if request == nil { + return nil, errors.New("画布 Agent 工具请求缺少协议参数") + } + body := cloneStringAnyMap(request) + body["model"] = input.Config.Model + var payload map[string]interface{} + err := postJSON(ctx, input.Config, path, body, &payload) + if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) { + if !isAutoAgentToolChoice(body["tool_choice"]) { + autoBody := cloneStringAnyMap(body) + autoBody["tool_choice"] = "auto" + payload = nil + err = postJSON(ctx, input.Config, path, autoBody, &payload) + } + if isAgentToolChoiceCompatibilityError(err) { + withoutToolChoice := cloneStringAnyMap(body) + delete(withoutToolChoice, "tool_choice") + payload = nil + err = postJSON(ctx, input.Config, path, withoutToolChoice, &payload) + } + } + if err != nil { + return nil, err + } + return parseAgentToolPayload(payload, protocol) +} + +func isAgentToolChoiceCompatibilityError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode") +} + +func isAutoAgentToolChoice(value interface{}) bool { + choice, ok := value.(string) + return ok && strings.EqualFold(strings.TrimSpace(choice), "auto") +} + +func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) { + if err := validateTextPayload(payload); err != nil { + return nil, err + } + result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}} + if protocol == "responses" { + result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload)) + if reasoning := extractResponseReasoning(payload); reasoning != "" { + result["reasoning"] = reasoning + } + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "function_call" { + continue + } + calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil + } + choices := interfaceSlice(payload["choices"]) + if len(choices) == 0 { + return nil, errors.New("画布 Agent 接口没有返回 choices") + } + choice, _ := choices[0].(map[string]interface{}) + message, _ := choice["message"].(map[string]interface{}) + result["text"] = stringField(message, "content") + if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" { + result["reasoning"] = reasoning + } + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(message["tool_calls"]) { + item, _ := value.(map[string]interface{}) + function, _ := item["function"].(map[string]interface{}) + calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil +} + +func extractResponseReasoning(payload map[string]interface{}) string { + var chunks []string + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "reasoning" { + continue + } + for _, key := range []string{"summary", "content"} { + for _, part := range interfaceSlice(item[key]) { + record, _ := part.(map[string]interface{}) + if text := strings.TrimSpace(stringField(record, "text")); text != "" { + chunks = append(chunks, text) + } + } + } + } + return strings.Join(chunks, "\n") +} + +func interfaceSlice(value interface{}) []interface{} { + items, _ := value.([]interface{}) + return items +} + +func cloneStringAnyMap(value map[string]interface{}) map[string]interface{} { + cloned := make(map[string]interface{}, len(value)+1) + for key, item := range value { + cloned[key] = item + } + return cloned +} + type styleExecutionPlanDocument struct { SchemaVersion int `json:"schemaVersion"` ProfilePresetID string `json:"profilePresetId"` diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 13190b4ab..6ed8aba79 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -107,6 +107,102 @@ data: [DONE] } } +func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { + result, err := parseAgentToolPayload(map[string]interface{}{ + "choices": []interface{}{map[string]interface{}{ + "message": map[string]interface{}{ + "content": "准备读取画布", + "tool_calls": []interface{}{map[string]interface{}{ + "id": "call-1", + "function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`}, + }}, + }, + }}, + }, "chat-completion") + if err != nil { + t.Fatalf("parseAgentToolPayload() error = %v", err) + } + if result["text"] != "准备读取画布" { + t.Fatalf("text = %v", result["text"]) + } + calls, _ := result["toolCalls"].([]interface{}) + if len(calls) != 1 { + t.Fatalf("toolCalls = %#v", result["toolCalls"]) + } + call, _ := calls[0].(map[string]interface{}) + function, _ := call["function"].(map[string]interface{}) + if call["id"] != "call-1" || function["name"] != "canvas_get_state" || function["arguments"] != `{}` { + t.Fatalf("tool call = %#v", call) + } +} + +func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { + result, err := parseAgentToolPayload(map[string]interface{}{ + "output": []interface{}{ + map[string]interface{}{"type": "reasoning", "summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "先读取画布,再决定操作"}}}, + map[string]interface{}{"type": "message", "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "开始操作"}}}, + map[string]interface{}{"type": "function_call", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`}, + }, + }, "responses") + if err != nil { + t.Fatalf("parseAgentToolPayload() error = %v", err) + } + if result["text"] != "开始操作" { + t.Fatalf("text = %v", result["text"]) + } + if result["reasoning"] != "先读取画布,再决定操作" { + t.Fatalf("reasoning = %v", result["reasoning"]) + } + calls, _ := result["toolCalls"].([]interface{}) + if len(calls) != 1 { + t.Fatalf("toolCalls = %#v", result["toolCalls"]) + } + call, _ := calls[0].(map[string]interface{}) + function, _ := call["function"].(map[string]interface{}) + if call["id"] != "call-2" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` { + t.Fatalf("tool call = %#v", call) + } +} + +func TestRunAgentToolTaskFallsBackToolChoice(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + var choices []interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + choice, exists := body["tool_choice"] + if exists { + choices = append(choices, choice) + } else { + choices = append(choices, nil) + } + w.Header().Set("Content-Type", "application/json") + if len(choices) < 3 { + _, _ = w.Write([]byte(`{"error":{"message":"tool_choice is incompatible with thinking mode"}}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`)) + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model", AllowLocalChannel: true} + result, err := runAgentToolTask(withProviderOutboundPolicy(context.Background(), config), canvasGenerationInput{ + Config: config, + AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "required"}}, + }) + if err != nil { + t.Fatalf("runAgentToolTask() error = %v", err) + } + if result["text"] != "完成" { + t.Fatalf("text = %v", result["text"]) + } + if len(choices) != 3 || choices[0] != "required" || choices[1] != "auto" || choices[2] != nil { + t.Fatalf("tool choices = %#v", choices) + } +} + func TestPostStreamingTextSetsStreamHeaders(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index b9097073c..d9c76237f 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -407,6 +407,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("积分不足,请先使用兑换码充值") } + if errors.Is(err, repository.ErrLogicalModelUnavailable) { + return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择") + } if err != nil { return nil, err } diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index b930b1539..6c045a8f2 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -8,6 +8,7 @@ import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelCha import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image"; +import { logicalModelIDForConfig, runBackendToolGenerationTask } from "@/services/api/generation-task"; import { imageToDataUrl } from "@/services/image-storage"; import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskAgent } from "@/services/project-asset-sync"; @@ -376,6 +377,7 @@ export function CanvasAssistantPanel({ const hasMessages = messages.length > 0; const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending"); const activeModel = effectiveConfig.textModel || effectiveConfig.model; + const activeModelName = activeModel ? modelDisplayName(effectiveConfig, activeModel) : ""; const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); @@ -606,17 +608,10 @@ export function CanvasAssistantPanel({ const messages = await buildToolAgentMessages(snapshotRef.current, history, userMessage); addOnlineLog(`Agent Tool Loop ${loop.step} 开始`, { toolChoice: "required" }); let streamed = ""; - const result = await requestToolResponse( - { ...requestConfig, systemPrompt: "" }, - messages, - ONLINE_AGENT_TOOLS, - "required", - (text) => { - streamed = text; - if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }, - { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }, - ); + const result = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, messages, "required", userMessage.text, (text) => { + streamed = text; + if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); + }, canvasAgentPromptCacheKey(sessionId)); addOnlineLog("模型工具回复", result); if (result.toolCalls.length) { const writableCalls = result.toolCalls.filter(isWritableToolCall); @@ -672,17 +667,10 @@ export function CanvasAssistantPanel({ } const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; let streamed = ""; - const next = await requestToolResponse( - { ...requestConfig, systemPrompt: "" }, - nextMessages, - ONLINE_AGENT_TOOLS, - "auto", - (text) => { - streamed = text; - if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }, - { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }, - ); + const next = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, nextMessages, "auto", "继续处理画布工具结果", (text) => { + streamed = text; + if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); + }, canvasAgentPromptCacheKey(sessionId)); addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next); if (next.toolCalls.length) { const writableCalls = next.toolCalls.filter(isWritableToolCall); @@ -1019,7 +1007,7 @@ export function CanvasAssistantPanel({ /> {view === "setup" ? ( - navigateToSettings({ continueCreation: true })} /> + navigateToSettings({ continueCreation: true })} /> ) : (
{view === "history" ? ( @@ -1036,7 +1024,7 @@ export function CanvasAssistantPanel({ setOnlineLogs([])} /> ) : messages.length ? ( @@ -1590,6 +1578,15 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage { return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) }; } +async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void, promptCacheKey: string) { + if (logicalModelIDForConfig(config)) { + const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice }); + if (result.content.trim()) onDelta(result.content); + return result; + } + return requestToolResponse(config, messages, ONLINE_AGENT_TOOLS, toolChoice, onDelta, { promptCacheKey }); +} + function summarizeToolCalls(calls: ResponseToolCall[]) { return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用"; } diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 98b039933..9319f1aad 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -1,7 +1,7 @@ import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; +import { Archive, FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -14,6 +14,7 @@ import { listAdminChannels } from "@/services/api/auth"; import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet"; import { createAdminLogicalModel, + deleteAdminLogicalModel, listAdminLogicalModels, simulateAdminLogicalModel, updateAdminLogicalModel, @@ -71,6 +72,7 @@ export default function LogicalModelsPage() { const [channelEnabled, setChannelEnabled] = useState>({}); const [editingModel, setEditingModel] = useState(); const [saving, setSaving] = useState(false); + const [deletingModelId, setDeletingModelId] = useState(); const [simulatingModel, setSimulatingModel] = useState(); const [simulationIntent, setSimulationIntent] = useState(); const [simulationResult, setSimulationResult] = useState(); @@ -84,9 +86,7 @@ export default function LogicalModelsPage() { setLoading(true); try { const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]); - const remainingChannelPages = await Promise.all( - Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })), - ); + const remainingChannelPages = await Promise.all(Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit }))); const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels); const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id))); setModels(modelResult.models); @@ -180,6 +180,21 @@ export default function LogicalModelsPage() { } }; + const removeModel = async (item: AdminLogicalModel) => { + setDeletingModelId(item.id); + try { + await deleteAdminLogicalModel(item.id); + setModels((current) => current.filter((model) => model.id !== item.id)); + if (paginatedModels.length === 1 && page > 1) setPage(page - 1); + message.success("前台模型已归档"); + } catch (error) { + message.error(error instanceof Error ? error.message : "归档前台模型失败"); + throw error; + } finally { + setDeletingModelId(undefined); + } + }; + const openSimulation = (item: AdminLogicalModel) => { setSimulationIntent({ capability: item.capability, @@ -238,9 +253,23 @@ export default function LogicalModelsPage() { render: (_, item) => ( , onClick: () => openModel(item) }} + visibleActionCount={1} actions={[ { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, + { + key: "archive", + label: "归档模型", + icon: , + danger: true, + disabled: deletingModelId === item.id, + confirm: { + title: `归档前台模型“${item.name}”?`, + description: "归档后模型将从公开目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法归档。", + okText: "确认归档", + }, + onClick: () => removeModel(item), + }, ]} /> ), @@ -257,13 +286,49 @@ export default function LogicalModelsPage() { } > } allowClear value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索模型名称、代码或能力" className="app-list-search" />} - toolbarActiveFilters={keyword ? { setKeyword(""); setPage(1); }} /> : null} + toolbar={ + } + allowClear + value={keyword} + onChange={(event) => { + setKeyword(event.target.value); + setPage(1); + }} + placeholder="搜索模型名称、代码或能力" + className="app-list-search" + /> + } + toolbarActiveFilters={ + keyword ? ( + { + setKeyword(""); + setPage(1); + }} + /> + ) : null + } toolbarActive={Boolean(keyword)} - onReset={() => { setKeyword(""); setPage(1); }} - table={{ rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }} + onReset={() => { + setKeyword(""); + setPage(1); + }} + table={{ className: "admin-logical-model-table", rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }} empty={} - footer={ { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />} + footer={ + { + setPage(nextPageSize !== pageSize ? 1 : nextPage); + setPageSize(nextPageSize); + }} + /> + } /> !saving && setEditingModel(undefined)} rootClassName="admin-drawer" - footer={
} + footer={ +
+ + +
+ } >
setSimulatingModel(undefined)} styles={{ body: { maxHeight: "min(72vh, 720px)", overflowY: "auto" } }} footer={[ - , + , , @@ -465,9 +541,7 @@ function RouteFields({
供应线路 {fields.indexOf(field) + 1}
-
- {selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"} -
+
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}