From a5bac9ec78b7db29f21316f1ed5b808a28294b97 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 20 Aug 2026 13:24:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(platform):=20=E6=A8=A1=E5=9E=8B=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=20-=20=E5=90=88=E5=B9=B6PR=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E9=87=8D=E8=AF=95=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/model/models_logical_model.go | 22 ++--- backend/internal/repository/finance.go | 6 ++ backend/internal/repository/logical_models.go | 63 ++++++++++---- .../repository/logical_models_test.go | 47 ++++++++-- backend/internal/service/admin_audit.go | 16 +++- backend/internal/service/finance.go | 2 +- backend/internal/service/logical_models.go | 15 +++- backend/internal/service/model_capability.go | 11 ++- backend/internal/service/model_router.go | 62 +++++++++++++- backend/internal/service/provider.go | 52 +++++++++++- backend/internal/service/provider_test.go | 45 +++++++++- backend/internal/service/service.go | 3 + .../logical-models/logical-models-page.tsx | 85 ++++++++++++++----- .../generation-task-durability.node.test.ts | 6 +- 14 files changed, 365 insertions(+), 70 deletions(-) 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 96b81c94d..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 { @@ -17,10 +18,11 @@ type LogicalModelGraph struct { } 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) } @@ -403,19 +405,17 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * }) } -func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { +func (r *Repository) ArchiveLogicalModel(id string, audit *model.AdminAuditEvent, now time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { - // 先停用并锁定目录主体,避免删除期间仍被新的数据库查询选中。 - result := tx.Model(&model.LogicalModel{}). - Where("id = ?", id). - Updates(map[string]any{"enabled": false, "updated_at": now}) - if result.Error != nil { - return result.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 result.RowsAffected != 1 { - return gorm.ErrRecordNotFound + 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}). @@ -425,19 +425,48 @@ func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { if activeTasks > 0 { return ErrLogicalModelInUse } - - // revision 与 route 是不可变的任务快照;删除目录主体时保留它们供历史记录追溯。 - deleted := tx.Where("id = ?", id).Delete(&model.LogicalModel{}) - if deleted.Error != nil { - return deleted.Error + 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 deleted.RowsAffected != 1 { + 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 8b8c1f968..cd1faee1e 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -50,7 +50,7 @@ func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) } } -func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { +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) @@ -67,11 +67,22 @@ func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { t.Fatal(err) } - if err := repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); err != nil { t.Fatal(err) } - if err := db.First(&model.LogicalModel{}, "id = ?", item.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("logical model lookup error = %v, want record not found", 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 { @@ -80,7 +91,7 @@ func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { } } -func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { +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) @@ -97,8 +108,8 @@ func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { t.Fatal(err) } - if err := repo.DeleteLogicalModel(item.ID, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { - t.Fatalf("DeleteLogicalModel() error = %v, want ErrLogicalModelInUse", 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 { @@ -108,3 +119,25 @@ func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { 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 af42060b4..8be0840da 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -419,9 +419,16 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { if err != nil { return err } - if err := s.repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + 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("前台模型仍被排队中或进行中任务使用,请等待任务结束后再删除") + return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再归档") } if logicalModelNotFound(err) { return BadAuthRequest("前台模型不存在或已删除") @@ -429,7 +436,6 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { return err } s.invalidateRouteCatalog() - _ = s.appendAdminAudit(actor, "logical_model.delete", "logical_model", item.ID, "删除前台模型目录主体", map[string]any{"code": item.Code, "name": item.Name}) return nil } @@ -489,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 088ea900b..c96c65e31 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -285,12 +285,40 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str body := cloneStringAnyMap(request) body["model"] = input.Config.Model var payload map[string]interface{} - if err := postJSON(ctx, input.Config, path, body, &payload); err != nil { + 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 @@ -298,6 +326,9 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map 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{}) @@ -329,6 +360,25 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map 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 diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index b20741ce1..6ed8aba79 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -113,7 +113,7 @@ func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { "message": map[string]interface{}{ "content": "准备读取画布", "tool_calls": []interface{}{map[string]interface{}{ - "id": "call-1", + "id": "call-1", "function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`}, }}, }, @@ -139,6 +139,7 @@ func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { 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":[]}`}, }, @@ -149,6 +150,9 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { 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"]) @@ -160,6 +164,45 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { } } +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/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index afac0e355..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, Trash2 } 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"; @@ -86,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); @@ -188,9 +186,9 @@ export default function LogicalModelsPage() { await deleteAdminLogicalModel(item.id); setModels((current) => current.filter((model) => model.id !== item.id)); if (paginatedModels.length === 1 && page > 1) setPage(page - 1); - message.success("前台模型已删除"); + message.success("前台模型已归档"); } catch (error) { - message.error(error instanceof Error ? error.message : "删除前台模型失败"); + message.error(error instanceof Error ? error.message : "归档前台模型失败"); throw error; } finally { setDeletingModelId(undefined); @@ -260,15 +258,15 @@ export default function LogicalModelsPage() { { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, { - key: "delete", - label: "删除模型", - icon: , + key: "archive", + label: "归档模型", + icon: , danger: true, disabled: deletingModelId === item.id, confirm: { - title: `删除前台模型“${item.name}”?`, - description: "删除后模型将从目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法删除。", - okText: "确认删除", + title: `归档前台模型“${item.name}”?`, + description: "归档后模型将从公开目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法归档。", + okText: "确认归档", }, onClick: () => removeModel(item), }, @@ -288,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); }} + 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={[ - , + , , @@ -496,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}` : "选择一个可承接请求的渠道模型"}