feat(platform): 模型平台能力 - 整合模型目录与 Agent 路由并补齐归档重试兼容
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 固化一次可发布的前台能力合同。默认值属于前台模型,不能由供应商隐式决定。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
<OnlineAgentSetupView theme={theme} activeModel={activeModel} onOpenConfig={() => navigateToSettings({ continueCreation: true })} />
|
||||
<OnlineAgentSetupView theme={theme} activeModel={activeModelName} onOpenConfig={() => navigateToSettings({ continueCreation: true })} />
|
||||
) : (
|
||||
<div ref={chatListRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{view === "history" ? (
|
||||
@@ -1036,7 +1024,7 @@ export function CanvasAssistantPanel({
|
||||
<OnlineAgentLogView
|
||||
logs={onlineLogs}
|
||||
theme={theme}
|
||||
context={{ model: activeModel, running: agentBusy, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }}
|
||||
context={{ model: activeModelName, running: agentBusy, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }}
|
||||
onClear={() => 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(",") || "工具调用";
|
||||
}
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string>();
|
||||
const [simulatingModel, setSimulatingModel] = useState<AdminLogicalModel>();
|
||||
const [simulationIntent, setSimulationIntent] = useState<ModelRequestIntent>();
|
||||
const [simulationResult, setSimulationResult] = useState<RouteSimulationResult>();
|
||||
@@ -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) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openModel(item) }}
|
||||
visibleActionCount={1}
|
||||
actions={[
|
||||
{ key: "simulate", label: "模拟供应线路匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
|
||||
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
|
||||
{
|
||||
key: "archive",
|
||||
label: "归档模型",
|
||||
icon: <Archive className="size-3.5" />,
|
||||
danger: true,
|
||||
disabled: deletingModelId === item.id,
|
||||
confirm: {
|
||||
title: `归档前台模型“${item.name}”?`,
|
||||
description: "归档后模型将从公开目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法归档。",
|
||||
okText: "确认归档",
|
||||
},
|
||||
onClick: () => removeModel(item),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
@@ -257,13 +286,49 @@ export default function LogicalModelsPage() {
|
||||
}
|
||||
>
|
||||
<AdminDataTable
|
||||
toolbar={<Input prefix={<Search className="size-4 text-foreground/40" />} allowClear value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索模型名称、代码或能力" className="app-list-search" />}
|
||||
toolbarActiveFilters={keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}
|
||||
toolbar={
|
||||
<Input
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索模型名称、代码或能力"
|
||||
className="app-list-search"
|
||||
/>
|
||||
}
|
||||
toolbarActiveFilters={
|
||||
keyword ? (
|
||||
<AdminFilterChip
|
||||
label={`搜索:${keyword}`}
|
||||
onRemove={() => {
|
||||
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={<AdminTableEmpty filtered={Boolean(deferredKeyword)} title="暂无模型" />}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filteredModels.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
footer={
|
||||
<PaginationBar
|
||||
alwaysShow
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredModels.length}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
@@ -274,7 +339,16 @@ export default function LogicalModelsPage() {
|
||||
maskClosable={!saving}
|
||||
onClose={() => !saving && setEditingModel(undefined)}
|
||||
rootClassName="admin-drawer"
|
||||
footer={<div className="flex justify-end gap-2"><Button disabled={saving} onClick={() => setEditingModel(undefined)}>取消</Button><Button type="primary" loading={saving} onClick={() => void saveModel()}>保存</Button></div>}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button disabled={saving} onClick={() => setEditingModel(undefined)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveModel()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={modelForm}
|
||||
@@ -357,7 +431,9 @@ export default function LogicalModelsPage() {
|
||||
onCancel={() => setSimulatingModel(undefined)}
|
||||
styles={{ body: { maxHeight: "min(72vh, 720px)", overflowY: "auto" } }}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setSimulatingModel(undefined)}>关闭</Button>,
|
||||
<Button key="cancel" onClick={() => setSimulatingModel(undefined)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" icon={<FlaskConical className="size-4" />} loading={simulating} onClick={() => void runSimulation()}>
|
||||
模拟匹配
|
||||
</Button>,
|
||||
@@ -465,9 +541,7 @@ function RouteFields({
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold">供应线路 {fields.indexOf(field) + 1}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground/50">
|
||||
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground/50">{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}</div>
|
||||
</div>
|
||||
<Button type="text" size="small" danger onClick={() => remove(field.name)}>
|
||||
移除
|
||||
|
||||
@@ -439,11 +439,11 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-wrap gap-1">
|
||||
{labels.slice(0, 4).map((label) => (
|
||||
<Tag key={label} className="max-w-full whitespace-normal break-all text-left leading-5">
|
||||
<Tag key={label} className="admin-capability-summary-tag max-w-full whitespace-normal break-all text-left leading-5">
|
||||
{label}
|
||||
</Tag>
|
||||
))}
|
||||
{labels.length > 4 ? <Tag className="shrink-0">+{labels.length - 4}</Tag> : null}
|
||||
{labels.length > 4 ? <Tag className="admin-capability-summary-tag shrink-0">+{labels.length - 4}</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type A
|
||||
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInputMessage, type ToolChoice, type ToolResponseResult } from "@/services/api/image";
|
||||
|
||||
export type BackendGenerationMode = "text" | "image" | "video" | "audio";
|
||||
|
||||
@@ -19,6 +20,8 @@ export type BackendGenerationResult = {
|
||||
video?: { dataUrl: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string };
|
||||
audio?: { dataUrl: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; format?: string };
|
||||
text?: string;
|
||||
toolCalls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string }; thoughtSignature?: string }>;
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
type BackendGenerationTaskOptions = {
|
||||
@@ -105,6 +108,40 @@ export async function runBackendGenerationTask(
|
||||
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies);
|
||||
}
|
||||
|
||||
export async function runBackendToolGenerationTask(options: {
|
||||
prompt: string;
|
||||
config: AiConfig;
|
||||
messages: ResponseInputMessage[];
|
||||
tools: ResponseFunctionTool[];
|
||||
toolChoice: ToolChoice;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ToolResponseResult> {
|
||||
throwIfAborted(options.signal);
|
||||
const logicalModelId = logicalModelIDForConfig(options.config);
|
||||
if (!logicalModelId) throw new Error("当前模型不是平台系统模型");
|
||||
const task = await createGenerationTask({
|
||||
type: "canvas_text",
|
||||
operation: "text",
|
||||
prompt: options.prompt,
|
||||
model: options.config.model,
|
||||
logicalModelId,
|
||||
input: {
|
||||
mode: "text",
|
||||
prompt: options.prompt,
|
||||
config: backendProviderConfig(options.config),
|
||||
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice),
|
||||
metadata: { source: "canvas-online-agent" },
|
||||
},
|
||||
});
|
||||
const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task });
|
||||
const result = parseBackendGenerationResult(completed);
|
||||
return {
|
||||
content: result.text || "",
|
||||
toolCalls: result.toolCalls || [],
|
||||
...(result.reasoning ? { reasoning: result.reasoning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }, dependencies: GenerationTaskDependencies = defaultDependencies) {
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1));
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
@@ -46,7 +46,11 @@ export type ToolResponseResult = {
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
type ToolChoice = "auto" | "required" | { type: "function"; name: string };
|
||||
export type ToolChoice = "auto" | "required" | { type: "function"; name: string };
|
||||
export type BackendToolRequests = {
|
||||
responses: Record<string, unknown>;
|
||||
chatCompletion: Record<string, unknown>;
|
||||
};
|
||||
type ResponseMessageContent = AiTextMessage["content"] | string;
|
||||
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string };
|
||||
type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string };
|
||||
@@ -407,6 +411,23 @@ function toChatCompletionToolChoice(toolChoice: ToolChoice) {
|
||||
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
|
||||
}
|
||||
|
||||
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice): BackendToolRequests {
|
||||
return {
|
||||
responses: {
|
||||
input: toResponseInput(messages),
|
||||
tools: tools.map(toResponseTool),
|
||||
tool_choice: toolChoice,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
chatCompletion: {
|
||||
messages: toChatCompletionMessages(messages),
|
||||
tools,
|
||||
tool_choice: toChatCompletionToolChoice(toolChoice),
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isToolChoiceCompatibilityError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return /tool[_\s-]?choice|thinking\s+mode/i.test(message);
|
||||
|
||||
@@ -103,6 +103,10 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation)
|
||||
return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function deleteAdminLogicalModel(id: string) {
|
||||
return request<{ ok: boolean }>(apiClient.delete(`/admin/logical-models/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) {
|
||||
return request<RouteSimulationResult>(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent));
|
||||
}
|
||||
|
||||
@@ -7859,6 +7859,21 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.admin-shell .admin-logical-model-table .ant-table-tbody > tr > td {
|
||||
height: auto;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-shell .admin-logical-model-table .admin-capability-summary-tag {
|
||||
max-width: 100%;
|
||||
margin-inline-end: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, var(--admin-layer-1)) !important;
|
||||
color: color-mix(in srgb, var(--foreground) 72%, transparent) !important;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.admin-shell .admin-table-clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,31 @@ import { test } from "node:test";
|
||||
import type { GenerationTask } from "../src/services/api/task-center";
|
||||
import { applyGenerationConsumerEffect } from "../src/services/generation-consumer-dedupe";
|
||||
import { createGenerationTaskMaterializer, type GenerationTaskEffectResult, type GenerationTaskEffectStore } from "../src/services/generation-task-materializer";
|
||||
import { buildBackendToolRequests } from "../src/services/api/image";
|
||||
|
||||
test("managed canvas agent task keeps tool definitions and tool results", () => {
|
||||
const requests = buildBackendToolRequests(
|
||||
[
|
||||
{ role: "user", content: "读取画布" },
|
||||
{ type: "function_call", call_id: "call-1", name: "canvas_get_state", arguments: "{}" },
|
||||
{ role: "tool", tool_call_id: "call-1", content: '{"nodes":[]}' },
|
||||
],
|
||||
[{ type: "function", function: { name: "canvas_get_state", parameters: { type: "object" } } }],
|
||||
"required",
|
||||
);
|
||||
|
||||
assert.equal(requests.responses.tool_choice, "required");
|
||||
assert.deepEqual(requests.responses.input, [
|
||||
{ role: "user", content: "读取画布" },
|
||||
{ type: "function_call", call_id: "call-1", name: "canvas_get_state", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call-1", output: '{"nodes":[]}' },
|
||||
]);
|
||||
assert.deepEqual(requests.chatCompletion.messages, [
|
||||
{ role: "user", content: "读取画布" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "call-1", type: "function", function: { name: "canvas_get_state", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: "call-1", content: '{"nodes":[]}' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("node message and agent consumers receive stable effect keys across three replays", async () => {
|
||||
const completed = new Map<string, GenerationTaskEffectResult>();
|
||||
|
||||
Reference in New Issue
Block a user