feat(platform): 模型平台能力 - 整合模型目录与 Agent 路由并补齐归档重试兼容

This commit is contained in:
ddCat
2026-08-20 13:25:15 +08:00
21 changed files with 752 additions and 59 deletions

View File

@@ -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 {

View File

@@ -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 固化一次可发布的前台能力合同。默认值属于前台模型,不能由供应商隐式决定。

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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

View File

@@ -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":

View File

@@ -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

View File

@@ -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"`

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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(",") || "工具调用";
}

View File

@@ -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)}>
移除

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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;
}

View File

@@ -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>();